import asyncio
import json

import httpx
import pytest

from src import gitea_proxy, main
from src.idempotency import IdempotencyLedger


@pytest.mark.anyio
async def test_repository_issue_templates_are_bounded_and_normalized():
    async def handler(request):
        assert request.url.path == "/api/v1/repos/stackchain/api/issue_templates"
        return httpx.Response(200, json=[
            {
                "name": " Bug report ", "about": " Reproduce a defect ",
                "title": "[Bug] ", "content": "## Steps\n1. ",
                "labels": ["bug", " needs-triage ", "bug", 42],
            },
            {"name": "", "content": "ignored"},
            "malformed",
        ] + [{"name": f"Template {index}", "content": "x"} for index in range(30)])

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        templates = await gitea_proxy.repo_issue_templates("stackchain/api")
    finally:
        await gitea_proxy.stop_client()

    assert templates[0] == {
        "id": "bug-report",
        "name": "Bug report",
        "about": "Reproduce a defect",
        "title": "[Bug] ",
        "body": "## Steps\n1.",
        "labels": ["bug", "needs-triage"],
    }
    assert len(templates) == 20


@pytest.mark.anyio
async def test_repository_issue_templates_route_checks_access_and_returns_blank_fallback(monkeypatch):
    calls = []

    async def access(repository):
        calls.append(("access", repository))
        return {"full_name": repository}

    async def templates(repository):
        calls.append(("templates", repository))
        return []

    monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
    monkeypatch.setattr(main.gitea_proxy, "repo_issue_templates", templates, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/api/v1/repos/stackchain/api/issue-templates")

    assert response.status_code == 200
    assert response.json() == []
    assert calls == [("access", "stackchain/api"), ("templates", "stackchain/api")]


@pytest.mark.anyio
async def test_issue_filing_metadata_authorizes_once_and_isolates_concurrent_section_failures(monkeypatch):
    calls = []
    started = set()
    all_started = asyncio.Event()

    async def access(repository):
        calls.append(("access", repository))
        return {"full_name": repository}

    async def section(name, result=None, error=None):
        calls.append((name, "stackchain/api"))
        started.add(name)
        if len(started) == 3:
            all_started.set()
        await asyncio.wait_for(all_started.wait(), timeout=0.2)
        if error:
            raise error
        return result

    monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
    monkeypatch.setattr(
        main.gitea_proxy, "repo_labels",
        lambda repository: section("labels", [{"id": 3, "name": "P0"}]),
    )
    monkeypatch.setattr(
        main.gitea_proxy, "repo_milestones",
        lambda repository: section("milestones", [{"id": 9, "title": "August RC"}]),
    )
    monkeypatch.setattr(
        main.gitea_proxy, "repo_issue_templates",
        lambda repository: section("templates", error=RuntimeError("templates unavailable")),
    )

    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            "/api/v1/repos/stackchain/api/issue-filing-metadata"
        )

    assert response.status_code == 200
    assert response.json() == {
        "labels": {"available": True, "items": [{"id": 3, "name": "P0"}]},
        "milestones": {
            "available": True,
            "items": [{"id": 9, "title": "August RC"}],
        },
        "templates": {
            "available": False,
            "items": [],
            "error": "Issue types could not be loaded.",
        },
    }
    assert calls.count(("access", "stackchain/api")) == 1
    assert set(calls[1:]) == {
        ("labels", "stackchain/api"),
        ("milestones", "stackchain/api"),
        ("templates", "stackchain/api"),
    }


@pytest.mark.anyio
async def test_owned_comment_mutations_verify_thread_and_author_before_writing():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.method == "GET" and request.url.path.endswith("/issues/comments/42"):
            return httpx.Response(200, json={
                "id": 42,
                "body": "Original",
                "user": {"login": "timmy"},
                "issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/17",
                "html_url": f"{gitea_proxy.GITEA_URL}/stackchain/api/issues/17#issuecomment-42",
            })
        if request.method == "PATCH":
            return httpx.Response(200, json={
                "id": 42, "body": "Corrected", "user": {"login": "timmy"},
                "html_url": "https://gitea.example/stackchain/api/issues/17#issuecomment-42",
            })
        return httpx.Response(204)

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        edited = await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Corrected")
        deleted = await gitea_proxy.delete_owned_comment("stackchain/api", 17, 42)
    finally:
        await gitea_proxy.stop_client()

    assert edited["body"] == "Corrected"
    assert deleted == {"id": 42, "deleted": True}
    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/comments/42"),
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/comments/42"),
        ("DELETE", "/api/v1/repos/stackchain/api/issues/comments/42"),
    ]
    assert json.loads(requests[2].content) == {"body": "Corrected"}


@pytest.mark.anyio
@pytest.mark.parametrize(
    "comment",
    [
        {"id": 42, "user": {"login": "alex"}, "issue_url": "https://gitea.example/api/v1/repos/stackchain/api/issues/17"},
        {"id": 42, "user": {"login": "timmy"}, "issue_url": f"{gitea_proxy.GITEA_URL}/api/v1/repos/stackchain/api/issues/99"},
        {"id": 42, "user": {"login": "timmy"}, "issue_url": "https://evil.example/api/v1/repos/stackchain/api/issues/17"},
    ],
)
async def test_owned_comment_mutations_reject_other_author_or_thread_without_writing(comment):
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json=comment)

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(gitea_proxy.CommentMutationForbiddenError):
            await gitea_proxy.edit_owned_comment("stackchain/api", 17, 42, "Nope")
    finally:
        await gitea_proxy.stop_client()

    assert [request.method for request in requests] == ["GET", "GET"]


@pytest.mark.anyio
@pytest.mark.parametrize(
    ("base_path", "availability_call"),
    [
        ("/api/v1/repos/stackchain/api/issues/17", "issue"),
        ("/api/v1/repos/stackchain/api/pulls/17", "pull"),
        ("/api/v1/notifications/91", "notification"),
    ],
)
async def test_comment_mutation_routes_cover_issue_pull_and_unread_update(
    monkeypatch, base_path, availability_call
):
    calls = []

    async def available_issue(repository, number):
        calls.append(("issue", repository, number))
        return True

    async def available_pull(repository, number):
        calls.append(("pull", repository, number))
        return True

    async def notification_target(thread_id):
        calls.append(("notification", thread_id))
        return "stackchain/api", 17

    async def edit(repository, number, comment_id, body):
        calls.append(("edit", repository, number, comment_id, body))
        return {"id": comment_id, "author": "timmy", "body": body}

    async def delete(repository, number, comment_id):
        calls.append(("delete", repository, number, comment_id))
        return {"id": comment_id, "deleted": True}

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available_issue)
    monkeypatch.setattr(main.gitea_proxy, "is_assigned_pull", available_pull)
    monkeypatch.setattr(
        main.gitea_proxy, "notification_conversation_target", notification_target,
        raising=False,
    )
    monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", edit)
    monkeypatch.setattr(main.gitea_proxy, "delete_owned_comment", delete)

    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        edited = await client.patch(base_path + "/comments/42", json={"body": "  Corrected  "})
        deleted = await client.delete(base_path + "/comments/42")

    assert edited.status_code == 200
    assert edited.json()["body"] == "Corrected"
    assert deleted.status_code == 200
    assert deleted.json() == {"id": 42, "deleted": True}
    if availability_call == "notification":
        assert calls == [
            ("notification", 91), ("edit", "stackchain/api", 17, 42, "Corrected"),
            ("notification", 91), ("delete", "stackchain/api", 17, 42),
        ]
    else:
        assert calls == [
            (availability_call, "stackchain/api", 17),
            ("edit", "stackchain/api", 17, 42, "Corrected"),
            (availability_call, "stackchain/api", 17),
            ("delete", "stackchain/api", 17, 42),
        ]


@pytest.mark.anyio
async def test_comment_mutation_route_returns_forbidden_without_hiding_saved_edit(monkeypatch):
    async def available(repository, number):
        return True

    async def forbidden(*args):
        raise gitea_proxy.CommentMutationForbiddenError("not owned")

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", available)
    monkeypatch.setattr(main.gitea_proxy, "edit_owned_comment", forbidden)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/comments/42",
            json={"body": "Keep this draft"},
        )
    assert response.status_code == 403
    assert response.json()["detail"] == "You can only change your own comments"


async def repository_access_from(loader, repository):
    repositories = await loader()
    return next(
        (item for item in repositories if item.get("full_name") == repository), None
    )


@pytest.mark.anyio
async def test_gitea_milestone_update_revalidates_assignment_and_open_repository_milestone():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.method == "GET" and request.url.path.endswith("/issues/17"):
            return httpx.Response(200, json={
                "number": 17, "state": "open", "pull_request": None,
                "assignees": [{"login": "timmy"}],
            })
        if request.method == "GET" and request.url.path.endswith("/milestones"):
            return httpx.Response(200, json=[
                {"id": 9, "title": "August RC", "state": "open"},
                {"id": 10, "title": "Old release", "state": "closed"},
                {"id": "bad", "title": "Invalid", "state": "open"},
            ])
        return httpx.Response(200, json={
            "number": 17, "state": "open",
            "milestone": {"id": 9, "title": "August RC"},
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        options = await gitea_proxy.repo_milestones("stackchain/api")
        result = await gitea_proxy.update_assigned_issue_milestone(
            "stackchain/api", 17, 9
        )
    finally:
        await gitea_proxy.stop_client()

    assert options == [{"id": 9, "title": "August RC"}]
    patch_request = next(request for request in requests if request.method == "PATCH")
    assert patch_request.url.path == "/api/v1/repos/stackchain/api/issues/17"
    assert json.loads(patch_request.content) == {"milestone": 9}
    assert result == {
        "repository": "stackchain/api", "number": 17, "state": "open",
        "milestone": {"id": 9, "title": "August RC"},
    }


@pytest.mark.anyio
@pytest.mark.parametrize("due_date", ["2026-08-31T23:59:59Z", None])
async def test_release_plan_applies_milestone_and_optional_deadline_in_one_patch(due_date):
    requests = []
    existing_due_date = "2026-08-20T23:59:59Z"

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.method == "GET" and request.url.path.endswith("/issues/17"):
            return httpx.Response(200, json={
                "number": 17, "state": "open", "pull_request": None,
                "assignees": [{"login": "timmy"}], "due_date": existing_due_date,
            })
        if request.method == "GET" and request.url.path.endswith("/milestones"):
            return httpx.Response(200, json=[
                {"id": 9, "title": "August RC", "state": "open"},
            ])
        return httpx.Response(200, json={
            "number": 17, "state": "open",
            "milestone": {"id": 9, "title": "August RC"},
            "due_date": due_date or existing_due_date,
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.update_assigned_issue_release_plan(
            "stackchain/api", 17, 9, due_date
        )
    finally:
        await gitea_proxy.stop_client()

    patch_requests = [request for request in requests if request.method == "PATCH"]
    assert len(patch_requests) == 1
    expected_payload = {"milestone": 9}
    if due_date:
        expected_payload["due_date"] = due_date
    assert json.loads(patch_requests[0].content) == expected_payload
    assert result == {
        "repository": "stackchain/api", "number": 17, "state": "open",
        "milestone": {"id": 9, "title": "August RC"},
        "due_date": due_date or existing_due_date,
    }


@pytest.mark.anyio
async def test_release_plan_endpoint_is_bounded_and_returns_confirmed_plan(monkeypatch):
    calls = []

    async def update(repository, number, milestone_id, due_date):
        calls.append((repository, number, milestone_id, due_date))
        return {
            "repository": repository, "number": number, "state": "open",
            "milestone": {"id": milestone_id, "title": "August RC"},
            "due_date": due_date,
        }

    monkeypatch.setattr(
        main.gitea_proxy, "update_assigned_issue_release_plan", update, raising=False
    )
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/release-plan",
            json={"milestone_id": 9, "due_date": "2026-08-31T23:59:59Z"},
        )

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
    assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
    assert calls == [("stackchain/api", 17, 9, "2026-08-31T23:59:59Z")]


@pytest.mark.anyio
async def test_milestone_routes_are_repository_bounded_and_no_store(monkeypatch):
    calls = []

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def milestones(repository):
        calls.append(("list", repository))
        return [{"id": 9, "title": "August RC"}]

    async def update(repository, number, milestone_id):
        calls.append(("update", repository, number, milestone_id))
        return {
            "repository": repository, "number": number, "state": "open",
            "milestone": {"id": milestone_id, "title": "August RC"} if milestone_id else None,
        }

    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones, raising=False)
    monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue_milestone", update, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        listed = await client.get("/api/v1/repos/stackchain/api/milestones")
        updated = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/milestone", json={"milestone_id": 9}
        )
        missing = await client.get("/api/v1/repos/other/private/milestones")

    assert listed.status_code == 200
    assert listed.headers["cache-control"] == "no-store"
    assert listed.json() == [{"id": 9, "title": "August RC"}]
    assert updated.status_code == 200
    assert updated.headers["cache-control"] == "no-store"
    assert updated.json()["milestone"] == {"id": 9, "title": "August RC"}
    assert missing.status_code == 404
    assert calls == [
        ("list", "stackchain/api"),
        ("update", "stackchain/api", 17, 9),
    ]


@pytest.mark.anyio
@pytest.mark.parametrize(
    ("due_date", "expected_payload", "confirmed_due_date"),
    [
        ("2026-08-09T23:59:59Z", {"due_date": "2026-08-09T23:59:59Z"}, "2026-08-09T23:59:59Z"),
        (None, {"unset_due_date": True}, None),
    ],
)
async def test_gitea_due_date_update_revalidates_assignment_and_confirms_result(
    due_date, expected_payload, confirmed_due_date
):
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.method == "GET":
            return httpx.Response(200, json={
                "number": 17, "state": "open", "pull_request": None,
                "assignees": [{"login": "timmy"}],
            })
        return httpx.Response(200, json={
            "number": 17, "state": "open", "due_date": confirmed_due_date,
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.update_assigned_issue_due_date(
            "stackchain/api", 17, due_date
        )
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
    ]
    assert json.loads(requests[2].content) == expected_payload
    assert result == {
        "repository": "stackchain/api", "number": 17,
        "state": "open", "due_date": confirmed_due_date,
    }


@pytest.mark.anyio
async def test_due_date_endpoint_sets_or_clears_assigned_issue(monkeypatch):
    calls = []

    async def update(repository, number, due_date):
        calls.append((repository, number, due_date))
        return {
            "repository": repository, "number": number, "state": "open",
            "due_date": due_date,
        }

    monkeypatch.setattr(
        main.gitea_proxy, "update_assigned_issue_due_date", update, raising=False
    )
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        set_response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/due-date",
            json={"due_date": "2026-08-09T23:59:59Z"},
        )
        clear_response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/due-date",
            json={"due_date": None},
        )

    assert [set_response.status_code, clear_response.status_code] == [200, 200]
    assert set_response.headers["cache-control"] == "no-store"
    assert set_response.json()["due_date"] == "2026-08-09T23:59:59Z"
    assert clear_response.json()["due_date"] is None
    assert calls == [
        ("stackchain/api", 17, "2026-08-09T23:59:59Z"),
        ("stackchain/api", 17, None),
    ]


@pytest.mark.anyio
async def test_edit_assigned_issue_updates_title_and_body_at_expected_revision(monkeypatch):
    calls = []

    async def update(repository, number, title, body, expected_updated_at):
        calls.append((repository, number, title, body, expected_updated_at))
        return {
            "repository": repository,
            "number": number,
            "title": title,
            "body": body,
            "state": "open",
            "updated_at": "2026-08-07T10:01:00Z",
        }

    monkeypatch.setattr(
        main.gitea_proxy, "update_assigned_issue", update, raising=False
    )
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/content",
            json={
                "title": "  Clarified scope  ",
                "body": "  Updated acceptance criteria  ",
                "expected_updated_at": "2026-08-07T10:00:00Z",
            },
        )

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["title"] == "Clarified scope"
    assert calls == [(
        "stackchain/api", 17, "Clarified scope", "Updated acceptance criteria",
        "2026-08-07T10:00:00Z",
    )]


@pytest.mark.anyio
async def test_edit_filed_issue_uses_open_author_authorization(monkeypatch):
    calls = []

    async def update(repository, number, title, body, expected_updated_at):
        calls.append((repository, number, title, body, expected_updated_at))
        return {
            "repository": repository, "number": number, "title": title, "body": body,
            "state": "open", "updated_at": "2026-08-07T10:01:00Z",
        }

    monkeypatch.setattr(main.gitea_proxy, "update_authored_issue", update, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/content?access=filed",
            json={
                "title": "Clarified filing", "body": "Canonical criteria",
                "expected_updated_at": "2026-08-07T10:00:00Z",
            },
        )

    assert response.status_code == 200
    assert calls == [(
        "stackchain/api", 17, "Clarified filing", "Canonical criteria",
        "2026-08-07T10:00:00Z",
    )]


@pytest.mark.anyio
@pytest.mark.parametrize("target", [
    {"state": "closed", "user": {"login": "timmy"}, "pull_request": None},
    {"state": "open", "user": {"login": "alex"}, "pull_request": None},
    {"state": "open", "user": {"login": "timmy"}, "pull_request": {}},
])
async def test_gitea_edit_filed_issue_rejects_ineligible_target_without_patch(target):
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "number": 17, "title": "Old", "body": "Old body",
            "updated_at": "2026-08-07T10:00:00Z", **target,
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(gitea_proxy.IssueNotAvailableError):
            await gitea_proxy.update_authored_issue(
                "stackchain/api", 17, "Clarified", "New body",
                "2026-08-07T10:00:00Z",
            )
    finally:
        await gitea_proxy.stop_client()

    assert [request.method for request in requests] == ["GET", "GET"]


@pytest.mark.anyio
async def test_gitea_edit_issue_rejects_stale_revision_without_patch():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "number": 17,
            "title": "Changed upstream",
            "body": "Newer body",
            "state": "open",
            "updated_at": "2026-08-07T10:02:00Z",
            "assignees": [{"login": "timmy"}],
            "pull_request": None,
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(gitea_proxy.IssueEditConflictError):
            await gitea_proxy.update_assigned_issue(
                "stackchain/api", 17, "My draft", "Draft body",
                "2026-08-07T10:00:00Z",
            )
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
    ]


@pytest.mark.anyio
async def test_gitea_edit_issue_confirms_already_applied_content_after_lost_response():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "number": 17,
            "title": "My draft",
            "body": "Draft body",
            "state": "open",
            "updated_at": "2026-08-07T10:02:00Z",
            "assignees": [{"login": "timmy"}],
            "pull_request": None,
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.update_assigned_issue(
            "stackchain/api", 17, "My draft", "Draft body",
            "2026-08-07T10:00:00Z",
        )
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
    ]
    assert result == {
        "repository": "stackchain/api", "number": 17, "title": "My draft",
        "body": "Draft body", "state": "open", "updated_at": "2026-08-07T10:02:00Z",
        "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
    }


@pytest.mark.anyio
async def test_gitea_edit_issue_revalidates_assignment_and_confirms_content():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.method == "GET":
            return httpx.Response(200, json={
                "number": 17, "title": "Old", "body": "Old body", "state": "open",
                "updated_at": "2026-08-07T10:00:00Z",
                "assignees": [{"login": "timmy"}], "pull_request": None,
            })
        return httpx.Response(200, json={
            "number": 17, "title": "Clarified", "body": "New body", "state": "open",
            "updated_at": "2026-08-07T10:01:00Z",
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.update_assigned_issue(
            "stackchain/api", 17, "Clarified", "New body", "2026-08-07T10:00:00Z"
        )
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
    ]
    assert requests[2].content == b'{"title":"Clarified","body":"New body"}'
    assert result == {
        "repository": "stackchain/api", "number": 17, "title": "Clarified",
        "body": "New body", "state": "open", "updated_at": "2026-08-07T10:01:00Z",
        "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
    }


@pytest.mark.anyio
async def test_edit_assigned_issue_reports_revision_conflict_without_mutation(monkeypatch):
    async def update(*_args):
        raise gitea_proxy.IssueEditConflictError("changed")

    monkeypatch.setattr(main.gitea_proxy, "update_assigned_issue", update)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/content",
            json={
                "title": "My draft",
                "body": "Still safe",
                "expected_updated_at": "2026-08-07T10:00:00Z",
            },
        )

    assert response.status_code == 409
    assert response.json() == {
        "error": "This issue changed in Gitea. Your draft is safe; reload the latest issue before saving."
    }


@pytest.fixture(autouse=True)
def clear_issue_creation_operations():
    main._authored_action_operations.clear()
    main._idempotency_ledger.clear()
    yield
    main._authored_action_operations.clear()
    main._idempotency_ledger.clear()


@pytest.mark.anyio
async def test_create_issue_endpoint_derives_self_assignment_and_returns_confirmed_issue(monkeypatch):
    calls = []

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def labels(repository):
        assert repository == "stackchain/api"
        return [
            {"id": 3, "name": "P0", "color": "d73a4a"},
            {"id": 8, "name": "frontend", "color": "1d76db"},
        ]

    async def create(repository, title, body, assignee, label_ids):
        calls.append((repository, title, body, assignee, label_ids))
        return {
            "id": 81,
            "number": 17,
            "title": title,
            "state": "open",
            "repository": repository,
            "labels": ["P0"],
            "assignees": [assignee],
            "updated_at": "2026-08-07T03:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels, raising=False)
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={
                "title": "  Capture mobile work  ",
                "body": "  Context  ",
                "label_ids": [3],
            },
        )

    assert response.status_code == 201
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["number"] == 17
    assert response.json()["assignees"] == ["timmy"]
    assert response.json()["labels"] == ["P0"]
    assert calls == [("stackchain/api", "Capture mobile work", "Context", "timmy", [3])]


@pytest.mark.anyio
async def test_create_issue_endpoint_validates_and_assigns_selected_initial_owner(monkeypatch):
    calls = []

    async def user():
        return {"login": "timmy"}

    async def access(repository):
        return {"full_name": repository}

    async def candidates(repository):
        assert repository == "stackchain/api"
        return [{"login": "alex", "name": "Alex"}]

    async def create(repository, title, body, assignee, label_ids):
        calls.append((repository, title, assignee))
        return {"number": 18, "repository": repository, "assignees": [assignee]}

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
    monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Delegate at capture", "assignee": "alex"},
        )

    assert response.status_code == 201
    assert response.json()["assignees"] == ["alex"]
    assert calls == [("stackchain/api", "Delegate at capture", "alex")]


@pytest.mark.anyio
async def test_create_issue_endpoint_preserves_explicit_no_owner(monkeypatch):
    calls = []

    async def user():
        return {"login": "timmy"}

    async def access(repository):
        return {"full_name": repository}

    async def create(repository, title, body, assignee, label_ids):
        calls.append((repository, title, assignee))
        return {"number": 19, "repository": repository, "assignees": []}

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Backlog capture", "unassigned": True},
        )

    assert response.status_code == 201
    assert response.json()["assignees"] == []
    assert calls == [("stackchain/api", "Backlog capture", None)]


@pytest.mark.anyio
async def test_create_issue_endpoint_rejects_named_and_no_owner_together():
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Contradictory owner", "unassigned": True, "assignee": "alex"},
        )

    assert response.status_code == 422
    assert "assignee" in response.text
    assert "unassigned" in response.text


@pytest.mark.anyio
async def test_initial_owner_candidates_require_repository_access_and_are_bounded(monkeypatch):
    async def access(repository):
        return {"full_name": repository} if repository == "stackchain/api" else None

    async def candidates(repository):
        return ([{"login": "bad user", "name": "Unsafe"}] +
                [{"login": f"user-{index}", "name": f"User {index}"} for index in range(40)])

    monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
    monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        listed = await client.get("/api/v1/repos/stackchain/api/issue-assignees")
        missing = await client.get("/api/v1/repos/stackchain/private/issue-assignees")

    assert listed.status_code == 200
    assert listed.headers["cache-control"] == "no-store"
    assert len(listed.json()) == 25
    assert all(item["login"] != "bad user" for item in listed.json())
    assert missing.status_code == 404


@pytest.mark.anyio
async def test_repository_page_reports_more_results_and_targeted_access_uses_repository_route():
    calls = []

    def handler(request: httpx.Request) -> httpx.Response:
        calls.append((request.url.path, dict(request.url.params)))
        if request.url.path.endswith("/user/repos"):
            return httpx.Response(
                200,
                json=[{"id": 51, "name": "later", "full_name": "stackchain/later"}],
                headers={"X-Total-Count": "51"},
            )
        if request.url.path.endswith("/repos/stackchain/later"):
            return httpx.Response(200, json={"id": 51, "full_name": "stackchain/later"})
        return httpx.Response(404)

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        page = await gitea_proxy.repo_page(page=2, limit=50)
        first_page = await gitea_proxy.repos()
        accessible = await gitea_proxy.repository_access("stackchain/later")
        missing = await gitea_proxy.repository_access("stackchain/missing")
    finally:
        await gitea_proxy.stop_client()

    assert page == {
        "items": [{"id": 51, "name": "later", "full_name": "stackchain/later"}],
        "page": 2,
        "total": 51,
        "has_more": False,
    }
    assert accessible["full_name"] == "stackchain/later"
    assert missing is None
    assert first_page.pagination == {"page": 1, "total": 51, "has_more": True}
    assert calls == [
        ("/api/v1/user/repos", {"page": "2", "limit": "50"}),
        ("/api/v1/user/repos", {"page": "1", "limit": "50"}),
        ("/api/v1/repos/stackchain/later", {}),
        ("/api/v1/repos/stackchain/missing", {}),
    ]


@pytest.mark.anyio
async def test_repository_search_finds_visible_repositories_without_loading_pages():
    requests = []

    def handler(request: httpx.Request) -> httpx.Response:
        requests.append((request.url.path, dict(request.url.params)))
        return httpx.Response(200, json={
            "ok": True,
            "data": [
                {"id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True},
                "invalid",
            ],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.search_repositories("  mobile  ", limit=20)
    finally:
        await gitea_proxy.stop_client()

    assert result == [{
        "id": 151, "name": "mobile", "full_name": "stackchain/mobile", "private": True,
    }]
    assert requests == [(
        "/api/v1/repos/search", {"q": "mobile", "limit": "20", "page": "1"},
    )]


@pytest.mark.anyio
async def test_repository_search_endpoint_returns_minimal_visible_matches(monkeypatch):
    calls = []

    async def search(query, limit):
        calls.append((query, limit))
        return [{
            "id": 151,
            "name": "mobile",
            "full_name": "stackchain/mobile",
            "html_url": "http://127.0.0.1:3000/stackchain/mobile",
            "description": "secret context that the picker does not need",
            "private": True,
        }]

    monkeypatch.setattr(main.gitea_proxy, "search_repositories", search)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/api/v1/repositories/search?q=%20mobile%20&limit=20")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {"items": [{
        "id": 151, "name": "mobile", "full_name": "stackchain/mobile",
    }]}
    assert calls == [("mobile", 20)]


@pytest.mark.anyio
async def test_repository_page_endpoint_and_issue_creation_support_later_repository(monkeypatch):
    access_calls = []
    create_calls = []

    async def page(page, limit):
        assert (page, limit) == (2, 50)
        return {
            "items": [{
                "id": 51, "name": "later", "full_name": "stackchain/later",
                "description": "", "html_url": "http://127.0.0.1:3000/stackchain/later",
                "updated_at": "2026-08-09T12:00:00Z",
            }],
            "page": 2, "total": 51, "has_more": False,
        }

    async def access(repository):
        access_calls.append(repository)
        return {"id": 51, "full_name": repository} if repository == "stackchain/later" else None

    async def user():
        return {"login": "timmy"}

    async def create(repository, title, body, assignee, label_ids):
        create_calls.append((repository, title, assignee))
        return {
            "id": 403, "number": 403, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "updated_at": "2026-08-09T12:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/later/issues/403",
        }

    monkeypatch.setattr(main.gitea_proxy, "repo_page", page)
    monkeypatch.setattr(main.gitea_proxy, "repository_access", access)
    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        listed = await client.get("/api/v1/repositories?page=2&limit=50")
        created = await client.post(
            "/api/v1/repos/stackchain/later/issues", json={"title": "Later-page work"}
        )

    assert listed.status_code == 200
    assert listed.json()["items"][0]["full_name"] == "stackchain/later"
    assert listed.json()["has_more"] is False
    assert created.status_code == 201
    assert access_calls == ["stackchain/later"]
    assert create_calls == [("stackchain/later", "Later-page work", "timmy")]


def test_context_exposes_truthful_repository_pagination():
    repositories = gitea_proxy.RepositoryItems(
        [{
            "id": 1, "name": "api", "full_name": "stackchain/api",
            "description": "", "html_url": "http://127.0.0.1:3000/stackchain/api",
            "updated_at": "2026-08-09T12:00:00Z",
        }],
        {"page": 1, "total": 51, "has_more": True},
    )

    payload = main._context_payload(
        {"id": 2, "login": "timmy"}, repositories, [], []
    )

    assert payload["repository_pagination"] == {
        "page": 1, "total": 51, "has_more": True
    }


@pytest.mark.anyio
async def test_create_issue_atomically_validates_and_sends_release_plan(monkeypatch):
    calls = []

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def milestones(repository):
        assert repository == "stackchain/api"
        return [{"id": 9, "title": "August RC"}]

    async def create(repository, title, body, assignee, label_ids, milestone_id, due_date):
        calls.append((repository, title, body, assignee, label_ids, milestone_id, due_date))
        return {
            "id": 221, "number": 221, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "milestone": {"id": 9, "title": "August RC"},
            "due_date": "2026-08-31T23:59:59Z",
            "updated_at": "2026-08-07T20:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/221",
        }

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "repo_milestones", milestones)
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={
                "title": "Ship mobile plan",
                "milestone_id": 9,
                "due_date": "2026-08-31T23:59:59Z",
            },
        )

    assert response.status_code == 201
    assert response.json()["milestone"] == {"id": 9, "title": "August RC"}
    assert response.json()["due_date"] == "2026-08-31T23:59:59Z"
    assert calls == [(
        "stackchain/api", "Ship mobile plan", "", "timmy", [], 9,
        "2026-08-31T23:59:59Z",
    )]


@pytest.mark.anyio
async def test_create_issue_replays_one_upstream_result_for_concurrent_idempotent_requests(monkeypatch):
    calls = 0
    started = asyncio.Event()
    release = asyncio.Event()

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def create(repository, title, body, assignee, label_ids):
        nonlocal calls
        calls += 1
        started.set()
        await release.wait()
        return {
            "id": 81, "number": 17, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "updated_at": "2026-08-07T03:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    headers = {"Idempotency-Key": "capture-177-concurrent"}
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        first = asyncio.create_task(client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
        ))
        await started.wait()
        second = asyncio.create_task(client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "Capture work"}, headers=headers
        ))
        await asyncio.sleep(0)
        release.set()
        responses = await asyncio.gather(first, second)

    assert [response.status_code for response in responses] == [201, 201]
    assert [response.json()["number"] for response in responses] == [17, 17]
    assert calls == 1


@pytest.mark.anyio
async def test_completed_issue_creation_replays_after_ledger_reconstruction(monkeypatch, tmp_path):
    calls = 0

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def create(repository, title, body, assignee, label_ids):
        nonlocal calls
        calls += 1
        return {
            "id": 201, "number": 201, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "updated_at": "2026-08-07T15:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/201",
        }

    database = tmp_path / "issues.sqlite3"
    monkeypatch.setattr(
        main,
        "_idempotency_ledger",
        IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
    )
    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    headers = {"Idempotency-Key": "restart-create-201"}

    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        created = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Durable capture"},
            headers=headers,
        )
        main._authored_action_operations.clear()
        monkeypatch.setattr(
            main,
            "_idempotency_ledger",
            IdempotencyLedger(database, ttl_seconds=600, max_entries=256),
        )
        replayed = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Durable capture"},
            headers=headers,
        )

    assert [created.status_code, replayed.status_code] == [201, 201]
    assert replayed.json()["number"] == 201
    assert calls == 1


@pytest.mark.anyio
async def test_create_issue_rejects_changed_payload_for_an_existing_idempotency_key(monkeypatch):
    calls = []

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def create(repository, title, body, assignee, label_ids):
        calls.append(title)
        return {
            "id": 81, "number": 17, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "updated_at": "2026-08-07T03:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    headers = {"Idempotency-Key": "capture-177-payload-conflict"}
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        created = await client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "First title"}, headers=headers
        )
        conflict = await client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "Changed title"}, headers=headers
        )

    assert created.status_code == 201
    assert conflict.status_code == 409
    assert conflict.json()["detail"] == "Idempotency key already used"
    assert calls == ["First title"]


@pytest.mark.anyio
async def test_create_issue_retry_recovers_result_after_the_first_request_times_out(monkeypatch):
    calls = 0

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def create(repository, title, body, assignee, label_ids):
        nonlocal calls
        calls += 1
        await asyncio.sleep(0.03)
        return {
            "id": 81, "number": 17, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "updated_at": "2026-08-07T03:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    headers = {"Idempotency-Key": "capture-177-timeout-recovery"}
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        timed_out = await client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
        )
        await asyncio.sleep(0.03)
        recovered = await client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "Slow capture"}, headers=headers
        )

    assert timed_out.status_code == 503
    assert recovered.status_code == 201
    assert recovered.json()["number"] == 17
    assert calls == 1


@pytest.mark.anyio
async def test_create_issue_durable_ledger_evicts_completed_entry_at_size_limit(
    monkeypatch, tmp_path
):
    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def create(repository, title, body, assignee, label_ids):
        return {
            "id": 81, "number": 17, "title": title, "state": "open",
            "repository": repository, "labels": [], "assignees": [assignee],
            "updated_at": "2026-08-07T03:00:00Z",
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    ledger = IdempotencyLedger(
        tmp_path / "bounded-issues.sqlite3", ttl_seconds=600, max_entries=2
    )
    monkeypatch.setattr(main, "_idempotency_ledger", ledger)
    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        for index in range(3):
            response = await client.post(
                "/api/v1/repos/stackchain/api/issues",
                json={"title": f"Capture {index}"},
                headers={"Idempotency-Key": f"capture-177-bounded-{index}"},
            )
            assert response.status_code == 201

    replay = ledger.reserve(
        "capture-177-bounded-0",
        ("issue-create", "stackchain/api", "Capture 0", "", ()),
    )
    assert replay.state == "reserved"


@pytest.mark.anyio
async def test_gitea_create_issue_posts_self_assignment_and_normalizes_confirmation():
    requests = []

    async def handler(request):
        requests.append(request)
        return httpx.Response(201, json={
            "id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
            "updated_at": "2026-08-07T03:00:00Z",
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
            "assignees": [{"login": "timmy"}],
            "labels": [{"id": 3, "name": "P0"}],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.create_issue(
            "stackchain/api", "Capture mobile work", "Context", "timmy", [3]
        )
    finally:
        await gitea_proxy.stop_client()

    assert len(requests) == 1
    assert requests[0].method == "POST"
    assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues"
    assert requests[0].content == (
        b'{"title":"Capture mobile work","body":"Context","assignee":"timmy","labels":[3]}'
    )
    assert result["repository"] == "stackchain/api"
    assert result["number"] == 17
    assert result["assignees"] == ["timmy"]
    assert result["labels"] == ["P0"]


@pytest.mark.anyio
async def test_gitea_create_issue_omits_assignment_and_confirms_no_owner():
    requests = []

    async def handler(request):
        requests.append(request)
        return httpx.Response(201, json={
            "id": 83, "number": 19, "title": "Backlog capture", "state": "open",
            "updated_at": "2026-08-07T03:00:00Z",
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/19",
            "assignees": [], "labels": [],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.create_issue(
            "stackchain/api", "Backlog capture", "Context", None, []
        )
    finally:
        await gitea_proxy.stop_client()

    assert json.loads(requests[0].content) == {
        "title": "Backlog capture", "body": "Context"
    }
    assert result["assignees"] == []


@pytest.mark.anyio
async def test_gitea_create_issue_requires_exact_selected_initial_owner():
    async def handler(_request):
        return httpx.Response(201, json={
            "id": 82, "number": 18, "title": "Delegate", "state": "open",
            "assignees": [{"login": "alex"}, {"login": "timmy"}], "labels": [],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(ValueError, match="exact issue assignment"):
            await gitea_proxy.create_issue("stackchain/api", "Delegate", "", "alex", [])
    finally:
        await gitea_proxy.stop_client()


@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_release_plan():
    async def handler(_request):
        return httpx.Response(201, json={
            "id": 221, "number": 221, "title": "Planned work", "state": "open",
            "assignees": [{"login": "timmy"}], "labels": [],
            "milestone": None, "due_date": "2026-09-01T23:59:59Z",
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(ValueError, match="release plan"):
            await gitea_proxy.create_issue(
                "stackchain/api", "Planned work", "", "timmy", [], 9,
                "2026-08-31T23:59:59Z",
            )
    finally:
        await gitea_proxy.stop_client()


@pytest.mark.anyio
async def test_gitea_repo_labels_returns_safe_touch_picker_options():
    requests = []

    async def handler(request):
        requests.append(request)
        return httpx.Response(200, json=[
            {"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"},
            {"id": "bad", "name": "invalid", "color": "000000"},
        ])

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.repo_labels("stackchain/api")
    finally:
        await gitea_proxy.stop_client()

    assert requests[0].url.path == "/api/v1/repos/stackchain/api/labels"
    assert requests[0].url.params["limit"] == "50"
    assert result == [{
        "id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
    }]


@pytest.mark.anyio
async def test_repo_labels_endpoint_only_loads_labels_for_accessible_repository(monkeypatch):
    calls = []

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def labels(repository):
        calls.append(repository)
        return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]

    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/api/v1/repos/stackchain/api/labels")
        missing = await client.get("/api/v1/repos/other/private/labels")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == [{
        "id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"
    }]
    assert missing.status_code == 404
    assert calls == ["stackchain/api"]


@pytest.mark.anyio
async def test_create_issue_rejects_blank_title_before_upstream(monkeypatch):
    called = False

    async def user():
        nonlocal called
        called = True
        return {"login": "timmy"}

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues", json={"title": "   ", "body": "Context"}
        )

    assert response.status_code == 422
    assert response.headers["cache-control"] == "no-store"
    assert called is False


@pytest.mark.anyio
async def test_create_issue_rejects_nonexistent_due_date_before_upstream(monkeypatch):
    called = False

    async def user():
        nonlocal called
        called = True
        return {"login": "timmy"}

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Impossible plan", "due_date": "2026-02-31T23:59:59Z"},
        )

    assert response.status_code == 422
    assert called is False


@pytest.mark.anyio
async def test_create_issue_rejects_label_not_in_target_repository(monkeypatch):
    created = False

    async def user():
        return {"login": "timmy"}

    async def available_repos():
        return [{"full_name": "stackchain/api"}]

    async def labels(_repository):
        return [{"id": 3, "name": "P0"}]

    async def create(*_args):
        nonlocal created
        created = True

    monkeypatch.setattr(main.gitea_proxy, "current_user", user)
    monkeypatch.setattr(main.gitea_proxy, "repos", available_repos)
    monkeypatch.setattr(
        main.gitea_proxy, "repository_access",
        lambda repository: repository_access_from(available_repos, repository),
    )
    monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
    monkeypatch.setattr(main.gitea_proxy, "create_issue", create)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues",
            json={"title": "Urgent work", "label_ids": [999]},
        )

    assert response.status_code == 422
    assert response.json()["detail"] == "Unknown repository label"
    assert created is False


@pytest.mark.anyio
async def test_gitea_create_issue_requires_confirmed_self_assignment():
    async def handler(_request):
        return httpx.Response(201, json={
            "id": 81, "number": 17, "title": "Capture mobile work", "state": "open",
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17", "assignees": [],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(ValueError, match="self-assignment"):
            await gitea_proxy.create_issue(
                "stackchain/api", "Capture mobile work", "", "timmy"
            )
    finally:
        await gitea_proxy.stop_client()


@pytest.mark.anyio
async def test_gitea_claim_available_issue_rechecks_then_confirms_authenticated_assignee():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.method == "GET" and request.url.path.endswith("/issues/17"):
            return httpx.Response(200, json={
                "id": 81, "number": 17, "title": "Available", "state": "open",
                "assignees": None, "pull_request": None, "labels": [],
                "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
            })
        if request.method == "GET" and request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "id": 81, "number": 17, "title": "Available", "state": "open",
            "assignees": [{"login": "timmy"}], "labels": [],
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.claim_available_issue("stackchain/api", 17)
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
        ("GET", "/api/v1/user"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
    ]
    assert requests[2].content == b'{"assignee":"timmy"}'
    assert result["repository"] == "stackchain/api"
    assert result["assignees"] == ["timmy"]


@pytest.mark.anyio
async def test_gitea_reopen_issue_rechecks_closed_state_and_confirms_self_assignment():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.method == "GET" and request.url.path.endswith("/issues/17"):
            return httpx.Response(200, json={
                "id": 81, "number": 17, "title": "Resume work", "state": "closed",
                "assignees": [], "pull_request": None, "labels": [{"name": "P1"}],
                "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
            })
        if request.method == "GET" and request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "id": 81, "number": 17, "title": "Resume work", "state": "open",
            "assignees": [{"login": "timmy"}], "labels": [{"name": "P1"}],
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.reopen_issue("stackchain/api", 17)
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
        ("GET", "/api/v1/user"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
    ]
    assert requests[2].content == b'{"state":"open","assignee":"timmy"}'
    assert result == {
        "id": 81, "number": 17, "title": "Resume work", "state": "open",
        "repository": "stackchain/api", "labels": ["P1"],
        "assignees": ["timmy"], "updated_at": "",
        "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
    }


@pytest.mark.anyio
async def test_gitea_reopen_issue_retry_accepts_already_open_self_assigned_issue():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "id": 81, "number": 17, "title": "Resume work", "state": "open",
            "assignees": [{"login": "timmy"}], "pull_request": None, "labels": [],
            "html_url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.reopen_issue("stackchain/api", 17)
    finally:
        await gitea_proxy.stop_client()

    assert result["state"] == "open"
    assert result["assignees"] == ["timmy"]
    assert all(request.method == "GET" for request in requests)


@pytest.mark.anyio
async def test_claim_available_issue_endpoint_returns_confirmed_work_item(monkeypatch):
    calls = []

    async def claim(repository, number):
        calls.append((repository, number))
        return {
            "id": 81, "number": number, "title": "Available", "state": "open",
            "repository": repository, "labels": [], "assignees": ["timmy"],
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["assignees"] == ["timmy"]
    assert calls == [("stackchain/api", 17)]


@pytest.mark.anyio
async def test_claim_available_issue_endpoint_reports_assignment_race_as_conflict(monkeypatch):
    async def claim(_repository, _number):
        raise gitea_proxy.IssueNotAvailableError("claimed")

    monkeypatch.setattr(main.gitea_proxy, "claim_available_issue", claim)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch("/api/v1/repos/stackchain/api/issues/17/claim")

    assert response.status_code == 409
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {
        "error": "This issue was already claimed or is no longer open."
    }


@pytest.mark.anyio
async def test_reopen_issue_endpoint_returns_confirmed_resumable_work_item(monkeypatch):
    calls = []

    async def reopen(repository, number):
        calls.append((repository, number))
        return {
            "id": 81, "number": number, "title": "Resume work", "state": "open",
            "repository": repository, "labels": ["P1"], "assignees": ["timmy"],
            "url": "http://127.0.0.1:3000/stackchain/api/issues/17",
        }

    monkeypatch.setattr(main.gitea_proxy, "reopen_issue", reopen, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch("/api/v1/repos/stackchain/api/issues/17/reopen")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["state"] == "open"
    assert response.json()["assignees"] == ["timmy"]
    assert calls == [("stackchain/api", 17)]


@pytest.mark.anyio
async def test_gitea_release_issue_removes_only_authenticated_user_and_confirms_peers():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.method == "GET" and request.url.path.endswith("/issues/17"):
            return httpx.Response(200, json={
                "id": 81, "number": 17, "title": "Shared work", "state": "open",
                "pull_request": None,
                "assignees": [{"login": "timmy"}, {"login": "alex"}],
                "labels": [],
            })
        if request.method == "GET" and request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "id": 81, "number": 17, "title": "Shared work", "state": "open",
            "assignees": [{"login": "alex"}], "labels": [],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.release_assigned_issue("stackchain/api", 17)
    finally:
        await gitea_proxy.stop_client()

    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
    ]
    assert requests[2].content == b'{"assignees":["alex"]}'
    assert result["assignees"] == ["alex"]
    assert result["available"] is False


@pytest.mark.anyio
async def test_gitea_handoff_candidates_exclude_current_and_malformed_users():
    async def handler(request):
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
        return httpx.Response(200, json=[
            {"login": "timmy", "full_name": "Timmy"},
            {"login": "alex", "full_name": "Alexander"},
            {"login": "casey", "full_name": ""},
            {"login": ""},
            {"full_name": "Missing login"},
            "malformed",
        ])

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.issue_handoff_candidates("stackchain/api")
    finally:
        await gitea_proxy.stop_client()

    assert result == [
        {"login": "alex", "name": "Alexander"},
        {"login": "casey", "name": "casey"},
    ]


@pytest.mark.anyio
async def test_gitea_mention_candidates_match_login_or_name_and_are_bounded():
    async def handler(request):
        assert request.url.path == "/api/v1/repos/stackchain/api/assignees"
        return httpx.Response(200, json=[
            {"login": "alexa", "full_name": "Alexa Dev"},
            {"login": "buildbot", "full_name": "Alex Builder"},
            {"login": "alexb", "full_name": "Alex Backup"},
            {"login": "casey", "full_name": "Casey"},
            {"login": "AL", "full_name": "Al"},
            {"login": "bad space", "full_name": "Malformed"},
            {"full_name": "Missing login"},
        ])

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.mention_candidates("stackchain/api", "alex", limit=2)
    finally:
        await gitea_proxy.stop_client()

    assert result == [
        {"login": "alexa", "name": "Alexa Dev"},
        {"login": "alexb", "name": "Alex Backup"},
    ]


@pytest.mark.anyio
async def test_gitea_handoff_replaces_operator_and_preserves_coassignees():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.url.path.endswith("/assignees"):
            return httpx.Response(200, json=[
                {"login": "alex", "full_name": "Alexander"},
                {"login": "casey", "full_name": "Casey"},
            ])
        if request.method == "GET":
            return httpx.Response(200, json={
                "number": 17, "state": "open", "pull_request": None,
                "assignees": [{"login": "timmy"}, {"login": "casey"}],
            })
        assert request.content == b'{"assignees":["casey","alex"]}'
        return httpx.Response(200, json={
            "number": 17, "state": "open",
            "assignees": [{"login": "casey"}, {"login": "alex"}],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.handoff_assigned_issue("stackchain/api", 17, "alex")
    finally:
        await gitea_proxy.stop_client()

    assert result == {
        "repository": "stackchain/api", "number": 17, "state": "open",
        "assignees": ["casey", "alex"], "recipient": "alex",
    }
    assert [(request.method, request.url.path) for request in requests] == [
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/issues/17"),
        ("GET", "/api/v1/user"),
        ("GET", "/api/v1/repos/stackchain/api/assignees"),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/17"),
    ]


@pytest.mark.anyio
async def test_gitea_reassigns_authored_issue_only_from_reviewed_delegate():
    requests = []

    async def handler(request):
        requests.append(request)
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.url.path == "/api/v1/repos/stackchain/api/issues/17" and request.method == "GET":
            return httpx.Response(200, json={
                "number": 17, "state": "open", "user": {"login": "timmy"},
                "assignees": [{"login": "alex"}],
            })
        if request.url.path == "/api/v1/repos/stackchain/api/assignees":
            return httpx.Response(200, json=[
                {"login": "timmy", "full_name": "Timmy"},
                {"login": "alex", "full_name": "Alexander"},
                {"login": "casey", "full_name": "Casey"},
            ])
        assert request.method == "PATCH"
        assert json.loads(request.content) == {"assignees": ["casey"]}
        return httpx.Response(200, json={
            "number": 17, "state": "open", "assignees": [{"login": "casey"}],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.reassign_authored_issue(
            "stackchain/api", 17, "casey", ["alex"]
        )
    finally:
        await gitea_proxy.stop_client()

    assert result == {
        "repository": "stackchain/api", "number": 17, "state": "open",
        "assignees": ["casey"], "recipient": "casey", "previous_assignees": ["alex"],
    }


@pytest.mark.anyio
async def test_gitea_reassign_authored_issue_rejects_stale_delegate_without_patch():
    requests = []

    async def handler(request):
        requests.append((request.method, request.url.path))
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        return httpx.Response(200, json={
            "number": 17, "state": "open", "user": {"login": "timmy"},
            "assignees": [{"login": "alex-new"}],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(gitea_proxy.IssueNotAvailableError):
            await gitea_proxy.reassign_authored_issue(
                "stackchain/api", 17, "casey", ["alex"]
            )
    finally:
        await gitea_proxy.stop_client()

    assert not any(method == "PATCH" for method, _path in requests)


@pytest.mark.anyio
async def test_release_issue_endpoint_invalidates_available_work_and_returns_confirmation(monkeypatch):
    calls = []

    async def release(repository, number):
        calls.append((repository, number))
        return {
            "number": number, "repository": repository, "state": "open",
            "assignees": [], "available": True,
        }

    monkeypatch.setattr(main.gitea_proxy, "release_assigned_issue", release, raising=False)
    monkeypatch.setattr(main, "_available_issue_snapshot_value", [{"number": 99}])
    monkeypatch.setattr(main, "_available_issue_snapshot_created_at", 123.0)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch("/api/v1/repos/stackchain/api/issues/17/release")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["available"] is True
    assert calls == [("stackchain/api", 17)]
    assert main._available_issue_snapshot_value is None
    assert main._available_issue_snapshot_created_at is None


@pytest.mark.anyio
async def test_issue_handoff_endpoints_list_candidates_and_confirm_transfer(monkeypatch):
    calls = []

    async def assigned(repository, number):
        calls.append(("assigned", repository, number))
        return True

    async def candidates(repository):
        calls.append(("candidates", repository))
        return [{"login": "alex", "name": "Alexander"}]

    async def handoff(repository, number, recipient):
        calls.append(("handoff", repository, number, recipient))
        return {
            "repository": repository, "number": number, "state": "open",
            "assignees": [recipient], "recipient": recipient,
        }

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
    monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        listed = await client.get(
            "/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
        )
        transferred = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/handoff",
            json={"recipient": "alex"},
        )

    assert listed.status_code == 200
    assert listed.json() == [{"login": "alex", "name": "Alexander"}]
    assert transferred.status_code == 200
    assert transferred.json()["recipient"] == "alex"
    assert listed.headers["cache-control"] == "no-store"
    assert transferred.headers["cache-control"] == "no-store"
    assert calls == [
        ("assigned", "stackchain/api", 17),
        ("candidates", "stackchain/api"),
        ("handoff", "stackchain/api", 17, "alex"),
    ]


@pytest.mark.anyio
async def test_filed_reassignment_endpoints_authorize_author_and_preserve_reviewed_delegate(monkeypatch):
    calls = []

    async def authored(repository, number):
        calls.append(("authored", repository, number))
        return True

    async def candidates(repository):
        calls.append(("candidates", repository))
        return [{"login": "alex", "name": "Alexander"}, {"login": "casey", "name": "Casey"}]

    async def reassign(repository, number, recipient, expected_assignees):
        calls.append(("reassign", repository, number, recipient, expected_assignees))
        return {
            "repository": repository, "number": number, "state": "open",
            "assignees": [recipient], "recipient": recipient,
            "previous_assignees": expected_assignees,
        }

    monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored)
    monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
    monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        listed = await client.get(
            "/api/v1/repos/stackchain/api/issues/17/handoff-candidates?access=filed"
        )
        transferred = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/reassign",
            json={"recipient": "casey", "expected_assignees": ["alex"]},
        )

    assert listed.status_code == 200
    assert listed.json() == [
        {"login": "alex", "name": "Alexander"},
        {"login": "casey", "name": "Casey"},
    ]
    assert transferred.status_code == 200
    assert transferred.json()["previous_assignees"] == ["alex"]
    assert calls == [
        ("authored", "stackchain/api", 17),
        ("candidates", "stackchain/api"),
        ("reassign", "stackchain/api", 17, "casey", ["alex"]),
    ]


@pytest.mark.anyio
async def test_filed_reassignment_returns_conflict_when_delegate_changed(monkeypatch):
    async def reassign(_repository, _number, _recipient, _expected_assignees):
        raise gitea_proxy.IssueNotAvailableError("stale")

    monkeypatch.setattr(main.gitea_proxy, "reassign_authored_issue", reassign)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/reassign",
            json={"recipient": "casey", "expected_assignees": ["alex"]},
        )

    assert response.status_code == 409
    assert response.json() == {
        "error": "The delegate changed. Reload before reassigning."
    }


@pytest.mark.anyio
async def test_mention_candidate_endpoint_bounds_query_and_disables_caching(monkeypatch):
    calls = []

    async def candidates(repository, query, *, limit):
        calls.append((repository, query, limit))
        return [{"login": "alex", "name": "Alexander"}]

    monkeypatch.setattr(main.gitea_proxy, "mention_candidates", candidates, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        too_short = await client.get(
            "/api/v1/repos/stackchain/api/mention-candidates?q=a"
        )
        response = await client.get(
            "/api/v1/repos/stackchain/api/mention-candidates?q=AlEx"
        )

    assert too_short.status_code == 422
    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == [{"login": "alex", "name": "Alexander"}]
    assert calls == [("stackchain/api", "alex", 8)]


@pytest.mark.anyio
async def test_issue_handoff_returns_conflict_when_assignment_or_recipient_changed(monkeypatch):
    async def handoff(_repository, _number, _recipient):
        raise gitea_proxy.IssueNotAvailableError("stale")

    monkeypatch.setattr(main.gitea_proxy, "handoff_assigned_issue", handoff)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/17/handoff",
            json={"recipient": "alex"},
        )

    assert response.status_code == 409
    assert response.json() == {
        "error": "The issue or recipient changed. Reload before handing off."
    }


@pytest.mark.anyio
async def test_issue_handoff_candidates_reject_an_issue_not_assigned_to_operator(monkeypatch):
    called = False

    async def assigned(_repository, _number):
        return False

    async def candidates(_repository):
        nonlocal called
        called = True
        return []

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "issue_handoff_candidates", candidates)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            "/api/v1/repos/stackchain/api/issues/17/handoff-candidates"
        )

    assert response.status_code == 404
    assert called is False


@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_assigned_issue_with_no_store(monkeypatch):
    async def assigned(repository, number):
        return (repository, number) == ("stackchain/api", 7)

    async def detail(repository, number):
        return {"repository": repository, "number": number, "title": "Fix mobile flow"}

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned, raising=False)
    monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/api/v1/repos/stackchain/api/issues/7/detail")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {
        "repository": "stackchain/api", "number": 7, "title": "Fix mobile flow"
    }


@pytest.mark.anyio
async def test_issue_detail_endpoint_returns_authored_filing_in_read_only_mode(monkeypatch):
    calls = []

    async def authored(repository, number):
        calls.append(("authored", repository, number))
        return (repository, number) == ("stackchain/api", 8)

    async def detail(repository, number):
        calls.append(("detail", repository, number))
        return {"repository": repository, "number": number, "title": "Delegated filing"}

    monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
    monkeypatch.setattr(main.gitea_proxy, "issue_detail", detail)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            "/api/v1/repos/stackchain/api/issues/8/detail?access=filed"
        )

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {
        "repository": "stackchain/api",
        "number": 8,
        "title": "Delegated filing",
        "read_only": True,
    }
    assert calls == [
        ("authored", "stackchain/api", 8),
        ("detail", "stackchain/api", 8),
    ]


@pytest.mark.anyio
async def test_assigned_issue_conversation_endpoint_is_authorized_bounded_and_no_store(monkeypatch):
    calls = []

    async def assigned(repository, number):
        calls.append(("assigned", repository, number))
        return repository == "stackchain/api"

    async def conversation(repository, number, page, limit):
        calls.append(("conversation", repository, number, page, limit))
        return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get(
            "/api/v1/repos/stackchain/api/issues/7/comments?page=2&limit=20"
        )
        missing = await client.get(
            "/api/v1/repos/private/secret/issues/7/comments?page=2&limit=20"
        )
        invalid = await client.get(
            "/api/v1/repos/stackchain/api/issues/7/comments?page=999&limit=999"
        )

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["older_page"] == 1
    assert missing.status_code == 404
    assert invalid.status_code == 422
    assert calls == [
        ("assigned", "stackchain/api", 7),
        ("conversation", "stackchain/api", 7, 2, 20),
        ("assigned", "private/secret", 7),
    ]


@pytest.mark.anyio
async def test_filed_issue_author_can_read_and_reply_but_other_issues_stay_hidden(monkeypatch):
    calls = []

    async def authored(repository, number):
        calls.append(("authored", repository, number))
        return (repository, number) == ("stackchain/api", 8)

    async def conversation(repository, number, page, limit):
        calls.append(("conversation", repository, number, page, limit))
        return {"comments": [{"id": 21}], "page": 2, "older_page": 1, "total": 21}

    async def comment(repository, number, body):
        calls.append(("comment", repository, number, body))
        return {"id": 22, "author": "timmy", "body": body}

    monkeypatch.setattr(main.gitea_proxy, "is_authored_issue", authored, raising=False)
    monkeypatch.setattr(main.gitea_proxy, "issue_conversation_page", conversation)
    monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        loaded = await client.get(
            "/api/v1/repos/stackchain/api/issues/8/comments?access=filed&page=2&limit=20"
        )
        posted = await client.post(
            "/api/v1/repos/stackchain/api/issues/8/comments?access=filed",
            json={"body": "  One clarification  "},
            headers={"Idempotency-Key": "filed-followup-876"},
        )
        hidden = await client.post(
            "/api/v1/repos/private/secret/issues/8/comments?access=filed",
            json={"body": "Do not post"},
        )

    assert loaded.status_code == 200
    assert loaded.json()["older_page"] == 1
    assert posted.status_code == 201
    assert posted.json() == {"id": 22, "author": "timmy", "body": "One clarification"}
    assert hidden.status_code == 404
    assert calls == [
        ("authored", "stackchain/api", 8),
        ("conversation", "stackchain/api", 8, 2, 20),
        ("authored", "stackchain/api", 8),
        ("comment", "stackchain/api", 8, "One clarification"),
        ("authored", "private/secret", 8),
    ]


@pytest.mark.anyio
async def test_issue_detail_deadline_is_retryable_sanitized_and_cancels_work(monkeypatch):
    cancelled = asyncio.Event()

    async def assigned(repository, number):
        try:
            await asyncio.Event().wait()
        finally:
            cancelled.set()

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main, "ISSUE_ACTION_TIMEOUT_SECONDS", 0.01)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/api/v1/repos/stackchain/api/issues/7/detail")

    assert response.status_code == 503
    assert response.headers["cache-control"] == "no-store"
    assert response.headers["retry-after"] == "1"
    assert response.json() == {"error": "Loading the issue timed out. Please retry."}
    assert cancelled.is_set()


@pytest.mark.anyio
@pytest.mark.parametrize(
    ("method", "path", "json_body"),
    [
        ("GET", "/api/v1/repos/stackchain/api/issues/7/detail", None),
        ("POST", "/api/v1/repos/stackchain/api/issues/7/comments", {"body": "Hello"}),
        ("PATCH", "/api/v1/repos/stackchain/api/issues/7/close", None),
    ],
)
async def test_issue_actions_reject_items_not_assigned_to_service_user(
    monkeypatch, method, path, json_body
):
    async def assigned(repository, number):
        return False

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.request(method, path, json=json_body)

    assert response.status_code == 404
    assert response.headers["cache-control"] == "no-store"


@pytest.mark.anyio
@pytest.mark.parametrize("body", ["   ", "x" * 10_001])
async def test_issue_comment_rejects_blank_or_oversized_body_before_upstream(
    monkeypatch, body
):
    called = False

    async def assigned(repository, number):
        nonlocal called
        called = True
        return True

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues/7/comments", json={"body": body}
        )

    assert response.status_code == 422
    assert response.headers["cache-control"] == "no-store"
    assert called is False


@pytest.mark.anyio
async def test_issue_comment_endpoint_posts_only_to_assigned_issue(monkeypatch):
    calls = []

    async def assigned(repository, number):
        return True

    async def comment(repository, number, body):
        calls.append((repository, number, body))
        return {"id": 82, "author": "timmy", "body": body}

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "comment_on_issue", comment)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/api/issues/7/comments",
            json={"body": "  Ready to ship  "},
        )

    assert response.status_code == 201
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {"id": 82, "author": "timmy", "body": "Ready to ship"}
    assert calls == [("stackchain/api", 7, "Ready to ship")]


@pytest.mark.anyio
async def test_issue_close_endpoint_mutates_assigned_issue_only_after_confirmation(monkeypatch):
    calls = []

    async def assigned(repository, number):
        return True

    async def close(repository, number):
        calls.append((repository, number))
        return {"number": number, "state": "closed", "closed_at": "now"}

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch("/api/v1/repos/stackchain/api/issues/7/close")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["state"] == "closed"
    assert calls == [("stackchain/api", 7)]


@pytest.mark.anyio
async def test_issue_close_endpoint_withdraws_only_open_authored_filed_issue(monkeypatch):
    checks = []
    calls = []

    async def authored(repository, number):
        checks.append((repository, number))
        return True

    async def close(repository, number):
        calls.append((repository, number))
        return {
            "number": number,
            "state": "closed",
            "updated_at": "2026-08-15T10:00:00Z",
        }

    monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored)
    monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/7/close?access=filed"
        )

    assert response.status_code == 200
    assert response.json()["state"] == "closed"
    assert checks == [("stackchain/api", 7)]
    assert calls == [("stackchain/api", 7)]


@pytest.mark.anyio
async def test_issue_close_endpoint_does_not_withdraw_non_authored_or_closed_filed_issue(monkeypatch):
    close_calls = []

    async def authored(_repository, _number):
        return False

    async def close(repository, number):
        close_calls.append((repository, number))
        return {"number": number, "state": "closed"}

    monkeypatch.setattr(main.gitea_proxy, "is_open_authored_issue", authored)
    monkeypatch.setattr(main.gitea_proxy, "close_issue", close)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/7/close?access=filed"
        )

    assert response.status_code == 404
    assert close_calls == []


@pytest.mark.anyio
async def test_gitea_close_issue_patches_state_and_confirms_closed_response():
    requests = []

    async def handler(request):
        requests.append(request)
        return httpx.Response(
            200,
            json={"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"},
        )

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.close_issue("stackchain/api", 7)
    finally:
        await gitea_proxy.stop_client()

    assert len(requests) == 1
    assert requests[0].method == "PATCH"
    assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
    assert requests[0].content == b'{"state":"closed"}'
    assert result == {"number": 7, "state": "closed", "closed_at": "2026-08-07T10:20:00Z"}


@pytest.mark.anyio
async def test_gitea_issue_comment_posts_body_and_returns_safe_identity():
    requests = []

    async def handler(request):
        requests.append(request)
        return httpx.Response(
            201,
            json={
                "id": 82,
                "body": "Ready to ship",
                "created_at": "2026-08-07T10:10:00Z",
                "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
                "user": {"login": "timmy"},
            },
        )

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.comment_on_issue("stackchain/api", 7, "Ready to ship")
    finally:
        await gitea_proxy.stop_client()

    assert len(requests) == 1
    assert requests[0].method == "POST"
    assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7/comments"
    assert requests[0].content == b'{"body":"Ready to ship"}'
    assert result == {
        "id": 82,
        "author": "timmy",
        "body": "Ready to ship",
        "created_at": "2026-08-07T10:10:00Z",
        "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-82",
    }


@pytest.mark.anyio
async def test_gitea_issue_detail_returns_normalized_context_and_recent_comments():
    requests = []

    async def handler(request):
        requests.append((request.method, request.url.path, request.url.query.decode()))
        if request.url.path.endswith("/dependencies"):
            return httpx.Response(
                200,
                json=[
                    {
                        "number": 3,
                        "title": "Restore signing service",
                        "state": "open",
                        "html_url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
                        "repository": {"full_name": "stackchain/platform"},
                    },
                    {
                        "number": 2,
                        "title": "Completed prerequisite",
                        "state": "closed",
                        "repository": {"full_name": "stackchain/platform"},
                    },
                ],
            )
        if request.url.path.endswith("/comments"):
            return httpx.Response(
                200,
                json=[
                    {
                        "id": 81,
                        "body": "Latest update",
                        "created_at": "2026-08-07T10:00:00Z",
                        "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
                        "user": {"login": "sam"},
                    }
                ],
            )
        return httpx.Response(
            200,
            json={
                "number": 7,
                "title": "Fix mobile flow",
                "state": "open",
                "body": "Full issue context",
                "updated_at": "2026-08-07T09:59:00Z",
                "milestone": {"id": 9, "title": "August RC", "description": "not exposed"},
                "html_url": "http://127.0.0.1:3000/stackchain/api/issues/7",
                "labels": [{"name": "P1"}],
                "assignees": [{"login": "timmy"}],
            },
        )

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.issue_detail("stackchain/api", 7)
    finally:
        await gitea_proxy.stop_client()

    assert requests == [
        ("GET", "/api/v1/repos/stackchain/api/issues/7", ""),
        ("GET", "/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
        ("GET", "/api/v1/repos/stackchain/api/issues/7/dependencies", "limit=20"),
    ]
    assert result == {
        "repository": "stackchain/api",
        "number": 7,
        "title": "Fix mobile flow",
        "state": "open",
        "body": "Full issue context",
        "updated_at": "2026-08-07T09:59:00Z",
        "due_date": None,
        "milestone": {"id": 9, "title": "August RC"},
        "url": "http://127.0.0.1:3000/stackchain/api/issues/7",
        "labels": ["P1"],
        "assignees": ["timmy"],
        "dependencies_available": True,
        "dependencies": [
            {
                "repository": "stackchain/platform",
                "number": 3,
                "title": "Restore signing service",
                "state": "open",
                "url": "http://127.0.0.1:3000/stackchain/platform/issues/3",
            }
        ],
        "comments": [
            {
                "id": 81,
                "author": "sam",
                "body": "Latest update",
                "created_at": "2026-08-07T10:00:00Z",
                "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
            }
        ],
        "conversation": {
            "comments": [
                {
                    "id": 81,
                    "author": "sam",
                    "body": "Latest update",
                    "created_at": "2026-08-07T10:00:00Z",
                    "url": "http://127.0.0.1:3000/stackchain/api/issues/7#issuecomment-81",
                }
            ],
            "page": 1,
            "older_page": None,
            "total": 1,
        },
    }


@pytest.mark.anyio
async def test_gitea_issue_detail_keeps_planning_usable_when_dependencies_are_unavailable():
    async def handler(request):
        if request.url.path.endswith("/dependencies"):
            return httpx.Response(503, json={"message": "upstream timeout"})
        if request.url.path.endswith("/comments"):
            return httpx.Response(200, json=[])
        return httpx.Response(200, json={
            "number": 7,
            "title": "Fix mobile flow",
            "state": "open",
            "assignees": [{"login": "timmy"}],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.issue_detail("stackchain/api", 7)
    finally:
        await gitea_proxy.stop_client()

    assert result["title"] == "Fix mobile flow"
    assert result["dependencies_available"] is False
    assert result["dependencies"] == []


@pytest.mark.anyio
async def test_gitea_conversation_page_opens_newest_page_and_reports_older_history():
    requests = []

    async def handler(request):
        requests.append((request.url.path, request.url.query.decode()))
        page = request.url.params.get("page")
        comments = {
            "1": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
                  for value in range(1, 21)],
            "3": [{"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
                  for value in range(41, 48)],
        }[page]
        return httpx.Response(200, json=comments, headers={"X-Total-Count": "47"})

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
    finally:
        await gitea_proxy.stop_client()

    assert requests == [
        ("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=1"),
        ("/api/v1/repos/stackchain/api/issues/7/comments", "limit=20&page=3"),
    ]
    assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
    assert result["page"] == 3
    assert result["older_page"] == 2
    assert result["total"] == 47


@pytest.mark.anyio
async def test_gitea_conversation_page_locally_bounds_unpaginated_gitea_comments():
    requests = []
    all_comments = [
        {"id": value, "body": f"Comment {value}", "user": {"login": "sam"}}
        for value in range(1, 48)
    ]

    async def handler(request):
        requests.append(request.url.query.decode())
        return httpx.Response(200, json=all_comments, headers={"X-Total-Count": "47"})

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.issue_conversation_page("stackchain/api", 7)
    finally:
        await gitea_proxy.stop_client()

    assert requests == ["limit=20&page=1"]
    assert [comment["id"] for comment in result["comments"]] == list(range(41, 48))
    assert result["page"] == 3
    assert result["older_page"] == 2
    assert result["total"] == 47


@pytest.mark.anyio
async def test_update_assigned_issue_labels_validates_and_returns_confirmed_labels(monkeypatch):
    calls = []

    async def assigned(repository, number):
        return (repository, number) == ("stackchain/api", 7)

    async def labels(repository):
        assert repository == "stackchain/api"
        return [
            {"id": 3, "name": "P0", "color": "d73a4a"},
            {"id": 8, "name": "frontend", "color": "1d76db"},
        ]

    async def update(repository, number, label_ids):
        calls.append((repository, number, label_ids))
        return {"number": number, "labels": ["P0", "frontend"]}

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
    monkeypatch.setattr(main.gitea_proxy, "update_issue_labels", update, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/api/issues/7/labels",
            json={"label_ids": [3, 8]},
        )

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json() == {"number": 7, "labels": ["P0", "frontend"]}
    assert calls == [("stackchain/api", 7, [3, 8])]


@pytest.mark.anyio
async def test_assigned_issue_label_options_do_not_depend_on_repository_listing(monkeypatch):
    calls = []

    async def assigned(repository, number):
        calls.append(("assigned", repository, number))
        return True

    async def labels(repository):
        calls.append(("labels", repository))
        return [{"id": 3, "name": "P0", "color": "d73a4a", "description": "Urgent"}]

    monkeypatch.setattr(main.gitea_proxy, "is_assigned_issue", assigned)
    monkeypatch.setattr(main.gitea_proxy, "repo_labels", labels)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.get("/api/v1/repos/stackchain/api/issues/7/labels")

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()[0]["name"] == "P0"
    assert calls == [
        ("assigned", "stackchain/api", 7),
        ("labels", "stackchain/api"),
    ]


@pytest.mark.anyio
async def test_gitea_update_issue_labels_patches_ids_and_normalizes_confirmation():
    requests = []

    async def handler(request):
        requests.append(request)
        return httpx.Response(200, json={
            "number": 7,
            "labels": [
                {"id": 3, "name": "P0"},
                {"id": 8, "name": "frontend"},
            ],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.update_issue_labels("stackchain/api", 7, [3, 8])
    finally:
        await gitea_proxy.stop_client()

    assert len(requests) == 1
    assert requests[0].method == "PATCH"
    assert requests[0].url.path == "/api/v1/repos/stackchain/api/issues/7"
    assert requests[0].content == b'{"labels":[3,8]}'
    assert result == {"number": 7, "labels": ["P0", "frontend"]}


@pytest.mark.anyio
async def test_gitea_update_issue_labels_rejects_unconfirmed_label_set():
    async def handler(_request):
        return httpx.Response(200, json={
            "number": 7,
            "labels": [{"id": 8, "name": "frontend"}],
        })

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(ValueError, match="confirm"):
            await gitea_proxy.update_issue_labels("stackchain/api", 7, [3])
    finally:
        await gitea_proxy.stop_client()


@pytest.mark.anyio
async def test_assigned_issue_blocker_route_returns_canonical_dependencies(monkeypatch):
    calls = []

    async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
        calls.append((repository, number, blocker_repository, blocker_number, remove))
        return {
            "repository": repository, "number": number, "dependencies_available": True,
            "dependencies": [] if remove else [{
                "repository": blocker_repository, "number": blocker_number,
                "title": "Restore API", "state": "open",
            }],
        }

    monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        added = await client.post(
            "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
            json={"repository": "stackchain/api", "number": 9},
        )
        removed = await client.request(
            "DELETE", "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
            json={"repository": "stackchain/api", "number": 9},
        )

    assert [added.status_code, removed.status_code] == [200, 200]
    assert added.headers["cache-control"] == "no-store"
    assert added.json()["dependencies"][0]["number"] == 9
    assert removed.json()["dependencies"] == []
    assert calls == [
        ("stackchain/dashboard", 17, "stackchain/api", 9, False),
        ("stackchain/dashboard", 17, "stackchain/api", 9, True),
    ]


@pytest.mark.anyio
async def test_assigned_issue_blocker_patch_converges_to_desired_state(monkeypatch):
    calls = []

    async def mutate(repository, number, blocker_repository, blocker_number, remove=False):
        calls.append((repository, number, blocker_repository, blocker_number, remove))
        return {
            "repository": repository, "number": number, "dependencies_available": True,
            "dependencies": [] if remove else [{"repository": blocker_repository, "number": blocker_number}],
        }

    monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.patch(
            "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
            headers={"Idempotency-Key": "offline-blocker-1"},
            json={"repository": "stackchain/api", "number": 9, "present": False},
        )

    assert response.status_code == 200
    assert response.headers["cache-control"] == "no-store"
    assert response.json()["dependencies"] == []
    assert calls == [("stackchain/dashboard", 17, "stackchain/api", 9, True)]


@pytest.mark.anyio
async def test_assigned_issue_blocker_route_rejects_invalid_candidate(monkeypatch):
    async def mutate(*_args, **_kwargs):
        raise gitea_proxy.IssueDependencyInvalidError("blocker must be an open issue")

    monkeypatch.setattr(main.gitea_proxy, "mutate_assigned_issue_dependency", mutate, raising=False)
    transport = httpx.ASGITransport(app=main.app)
    async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
        response = await client.post(
            "/api/v1/repos/stackchain/dashboard/issues/17/blockers",
            json={"repository": "stackchain/api", "number": 9},
        )

    assert response.status_code == 422
    assert response.json() == {"error": "blocker must be an open issue"}


@pytest.mark.anyio
async def test_gitea_add_dependency_validates_assignment_candidate_and_confirmation():
    requests = []

    async def handler(request):
        requests.append(request)
        path = request.url.path
        if path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if path == "/api/v1/repos/stackchain/dashboard/issues/17":
            return httpx.Response(200, json={
                "number": 17, "state": "open", "assignees": [{"login": "timmy"}],
            })
        if path == "/api/v1/repos/stackchain/api/issues/9":
            return httpx.Response(200, json={"number": 9, "state": "open"})
        if request.method == "GET" and path.endswith("/dependencies"):
            mutated = any(r.method == "POST" for r in requests)
            return httpx.Response(200, json=[{
                "number": 9, "state": "open", "title": "Restore API",
                "repository": {"full_name": "stackchain/api"},
                "html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
            }] if mutated else [])
        if request.method == "POST" and path.endswith("/dependencies"):
            return httpx.Response(201, json={})
        raise AssertionError((request.method, path))

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.mutate_assigned_issue_dependency(
            "stackchain/dashboard", 17, "stackchain/api", 9
        )
    finally:
        await gitea_proxy.stop_client()

    mutation = next(request for request in requests if request.method == "POST")
    assert mutation.url.path == "/api/v1/repos/stackchain/dashboard/issues/17/dependencies"
    assert json.loads(mutation.content) == {"owner": "stackchain", "repo": "api", "index": 9}
    assert result["dependencies"] == [{
        "repository": "stackchain/api", "number": 9, "title": "Restore API",
        "state": "open", "url": "http://127.0.0.1:3000/stackchain/api/issues/9",
    }]


@pytest.mark.anyio
async def test_gitea_dependency_retry_returns_canonical_state_without_duplicate_mutation():
    mutations = []
    dependency = {
        "number": 9, "state": "open", "title": "Restore API",
        "repository": {"full_name": "stackchain/api"},
        "html_url": "http://127.0.0.1:3000/stackchain/api/issues/9",
    }

    async def handler(request):
        path = request.url.path
        if path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if path == "/api/v1/repos/stackchain/dashboard/issues/17":
            return httpx.Response(200, json={
                "number": 17, "state": "open", "assignees": [{"login": "timmy"}],
            })
        if path == "/api/v1/repos/stackchain/api/issues/9":
            return httpx.Response(200, json={"number": 9, "state": "open"})
        if request.method == "GET" and path.endswith("/dependencies"):
            return httpx.Response(200, json=[dependency])
        if path.endswith("/dependencies"):
            mutations.append(request.method)
            return httpx.Response(201, json={})
        raise AssertionError((request.method, path))

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        result = await gitea_proxy.mutate_assigned_issue_dependency(
            "stackchain/dashboard", 17, "stackchain/api", 9
        )
    finally:
        await gitea_proxy.stop_client()

    assert mutations == []
    assert result["dependencies"][0]["number"] == 9


@pytest.mark.anyio
@pytest.mark.parametrize("candidate,blocker_repository,blocker_number,existing", [
    ({"number": 17, "state": "open"}, "stackchain/dashboard", 17, []),
    ({"number": 9, "state": "closed"}, "stackchain/api", 9, []),
    ({"number": 9, "state": "open", "pull_request": {}}, "stackchain/api", 9, []),
])
async def test_gitea_dependency_rejects_invalid_candidates(
    candidate, blocker_repository, blocker_number, existing
):
    async def handler(request):
        if request.url.path == "/api/v1/user":
            return httpx.Response(200, json={"login": "timmy"})
        if request.url.path == "/api/v1/repos/stackchain/dashboard/issues/17":
            return httpx.Response(200, json={
                "number": 17, "state": "open", "assignees": [{"login": "timmy"}],
            })
        if request.url.path.endswith("/dependencies"):
            return httpx.Response(200, json=existing)
        return httpx.Response(200, json=candidate)

    gitea_proxy.start_client(transport=httpx.MockTransport(handler))
    try:
        with pytest.raises(gitea_proxy.IssueDependencyInvalidError):
            await gitea_proxy.mutate_assigned_issue_dependency(
                "stackchain/dashboard", 17, blocker_repository, blocker_number
            )
    finally:
        await gitea_proxy.stop_client()
