"""A2AService coverage — agent cards, task ↔ A2A conversion, conversations."""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import AsyncMock, patch
from unittest.mock import MagicMock as _MM
from uuid import UUID, uuid4
from uuid import uuid4 as _u

import pytest
import pytest_asyncio
from roboco.agents_config import A2A_ALLOWED_PAIRS
from roboco.db.tables import (
    A2AConversationTable,
    A2AMessageTable,
    AgentTable,
    ProjectTable,
    TaskTable,
)
from roboco.enforcement.a2a_access import A2AAccessDeniedError
from roboco.models import AgentRole, AgentStatus, Team
from roboco.models.a2a import (
    A2AConversationStatus,
    A2AMessage,
    SendMessageRequest,
    TextPart,
)
from roboco.models.base import (
    TaskNature,
    TaskStatus,
    TaskType,
)
from roboco.models.events import EventType
from roboco.services.a2a import _LIVE_VIEW_EXCERPT_CHARS, A2AService
from roboco.services.gateway.evidence_repo import EvidenceRepo
from sqlalchemy import select
from sqlalchemy import select as _sel
from sqlalchemy.sql.dml import Update

if TYPE_CHECKING:
    from collections.abc import AsyncIterator

    from sqlalchemy.ext.asyncio import AsyncSession


@pytest_asyncio.fixture
async def a2a_setup(
    db_session: AsyncSession,
) -> AsyncIterator[dict]:
    # Use the canonical seed slugs so the A2A policy matrix recognizes
    # role + team and lets same-cell pairs talk. Random suffixes would
    # leave them with role="unknown" → policy denies everything.
    dev = AgentTable(
        id=uuid4(),
        name="Dev",
        slug="be-dev-1",
        role=AgentRole.DEVELOPER,
        team=Team.BACKEND,
        status=AgentStatus.ACTIVE,
        model_config={},
        system_prompt="dev",
        capabilities=[],
        permissions={},
        metrics={},
    )
    qa = AgentTable(
        id=uuid4(),
        name="QA",
        slug="be-qa",
        role=AgentRole.QA,
        team=Team.BACKEND,
        status=AgentStatus.ACTIVE,
        model_config={},
        system_prompt="qa",
        capabilities=[],
        permissions={},
        metrics={},
    )
    db_session.add_all([dev, qa])
    await db_session.flush()
    project = ProjectTable(
        id=uuid4(),
        name="A-Proj",
        slug=f"a-proj-{uuid4().hex[:8]}",
        git_url="https://example.com/r.git",
        assigned_cell=Team.BACKEND,
        created_by=dev.id,
    )
    db_session.add(project)
    await db_session.flush()
    task = TaskTable(
        id=uuid4(),
        title="t",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=project.id,
        created_by=dev.id,
        team=Team.BACKEND,
    )
    db_session.add(task)
    await db_session.flush()
    yield {
        "svc": A2AService(db_session),
        "dev": dev,
        "qa": qa,
        "task_id": task.id,
        "db": db_session,
    }


# ---------------------------------------------------------------------------
# Agent cards
# ---------------------------------------------------------------------------


def test_get_service_endpoint_returns_url() -> None:
    url = A2AService.get_service_endpoint()
    assert url.startswith("http://")


def test_build_system_agent_card() -> None:
    card = A2AService.build_system_agent_card()
    assert card.id == "roboco-system"
    assert len(card.skills) >= 1


@pytest.mark.asyncio
async def test_build_agent_card_by_uuid(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    card = await svc.build_agent_card(str(dev.id))
    assert card is not None
    assert card.name == dev.name


@pytest.mark.asyncio
async def test_build_agent_card_by_slug(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    card = await svc.build_agent_card(dev.slug)
    assert card is not None
    assert card.id == str(dev.id)


@pytest.mark.asyncio
async def test_build_agent_card_unknown_returns_none(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    assert await svc.build_agent_card(str(uuid4())) is None
    assert await svc.build_agent_card("ghost-slug") is None


# ---------------------------------------------------------------------------
# Task ↔ A2A
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_task_by_uuid(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    a2a = await svc.get_task(str(a2a_setup["task_id"]))
    assert a2a is not None
    assert a2a.id == str(a2a_setup["task_id"])


@pytest.mark.asyncio
async def test_get_task_returns_none_for_invalid_id(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    assert await svc.get_task("not-a-uuid") is None
    assert await svc.get_task(str(uuid4())) is None


@pytest.mark.asyncio
async def test_list_tasks(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    tasks, has_more = await svc.list_tasks(page_size=20)
    assert any(t.id == str(a2a_setup["task_id"]) for t in tasks)
    assert isinstance(has_more, bool)


@pytest.mark.asyncio
async def test_list_tasks_ascending(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    tasks, _ = await svc.list_tasks(order_by="created_at asc")
    assert isinstance(tasks, list)


@pytest.mark.asyncio
async def test_cancel_task_invalid_id(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(ValueError, match="Invalid task ID"):
        await svc.cancel_task("not-a-uuid")


@pytest.mark.asyncio
async def test_cancel_task_not_found(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(ValueError, match="Task not found"):
        await svc.cancel_task(str(uuid4()))


@pytest.mark.asyncio
async def test_cancel_task_already_terminal(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    completed = TaskTable(
        id=uuid4(),
        title="done",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.COMPLETED,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=uuid4(),
        created_by=a2a_setup["dev"].id,
        team=Team.BACKEND,
    )
    # FK on project — use existing project
    completed.project_id = (await db.execute(select(ProjectTable))).scalars().first().id
    db.add(completed)
    await db.flush()
    with pytest.raises(ValueError, match="terminal state"):
        await svc.cancel_task(str(completed.id))


# ---------------------------------------------------------------------------
# Discovery
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_discover_agents_no_filters(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    cards = await svc.discover_agents()
    # Setup seeds dev + qa, so at least 2 cards.
    _MIN_SEEDED = 2
    assert len(cards) >= _MIN_SEEDED


@pytest.mark.asyncio
async def test_discover_agents_by_role(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    cards = await svc.discover_agents(role="developer")
    assert all(c.metadata.get("role") == "developer" for c in cards)


@pytest.mark.asyncio
async def test_discover_agents_by_team(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    cards = await svc.discover_agents(team="backend")
    assert all(c.metadata.get("team") == "backend" for c in cards)


@pytest.mark.asyncio
async def test_discover_agents_by_skill_tag(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    cards = await svc.discover_agents(skill_tag="qa")
    # All returned cards have at least one skill tagged 'qa'.
    for card in cards:
        assert any("qa" in skill.tags for skill in card.skills)


# ---------------------------------------------------------------------------
# Canonical pair helper
# ---------------------------------------------------------------------------


def test_canonical_pair_orders_lexically() -> None:
    a, b = A2AService._canonical_pair("z-agent", "a-agent")
    assert (a, b) == ("a-agent", "z-agent")
    a, b = A2AService._canonical_pair("a-agent", "z-agent")
    assert (a, b) == ("a-agent", "z-agent")


# ---------------------------------------------------------------------------
# Conversations
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_or_create_conversation_self_a2a_denied(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(A2AAccessDeniedError):
        await svc.get_or_create_conversation("be-dev-1", "be-dev-1")


@pytest.mark.asyncio
async def test_get_or_create_conversation_creates(a2a_setup: dict) -> None:
    """A2A between two same-cell devs is allowed by the policy."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-dev-2")
    assert conv is not None


@pytest.mark.asyncio
async def test_get_conversation_returns_none_for_missing(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    assert await svc.get_conversation(uuid4(), "be-dev-1") is None


@pytest.mark.asyncio
async def test_list_conversations_empty(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    convs = await svc.list_conversations("be-dev-1")
    assert isinstance(convs, list)


@pytest.mark.asyncio
async def test_list_conversations_with_filters(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    convs = await svc.list_conversations(
        "be-dev-1", status=None, with_agent="be-dev-2", limit=10
    )
    assert isinstance(convs, list)


# ---------------------------------------------------------------------------
# Resolve creator agent
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_resolve_creator_agent_returns_uuid_or_none(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    out = await svc.resolve_creator_agent(dev.slug)
    assert out is not None or out is None  # Smoke test: doesn't raise.


@pytest.mark.asyncio
async def test_resolve_creator_agent_unknown(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    out = await svc.resolve_creator_agent("ghost-agent-slug")
    assert out is None or hasattr(out, "id") or isinstance(out, type(uuid4()))


# ---------------------------------------------------------------------------
# Chat messages
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_chat_message_rejects_nil_uuid(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    nil = UUID(int=0)
    with pytest.raises(ValueError, match="nil UUID"):
        await svc.send_chat_message(nil, "be-dev-1", "hi")


@pytest.mark.asyncio
async def test_send_chat_message_unknown_conversation(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(ValueError, match="not found"):
        await svc.send_chat_message(uuid4(), "be-dev-1", "hi")


@pytest.mark.asyncio
async def test_get_messages_unknown_conversation_returns_empty(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    msgs = await svc.get_messages(uuid4(), "be-dev-1")
    assert msgs == []


@pytest.mark.asyncio
async def test_close_conversation_unknown(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(ValueError, match="not found"):
        await svc.close_conversation(uuid4(), "be-dev-1")


@pytest.mark.asyncio
async def test_mark_read_unknown_returns_none(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    # Returns None silently for unknown conversation.
    await svc.mark_read(uuid4(), "be-dev-1")


# ---------------------------------------------------------------------------
# Inbox + pairs
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_inbox_summary(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    inbox = await svc.get_inbox_summary("be-dev-1")
    assert inbox is not None


@pytest.mark.asyncio
async def test_list_pairs(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    pairs = await svc.list_pairs("be-dev-1")
    assert isinstance(pairs, list)


@pytest.mark.asyncio
async def test_send_a2a_returns_handler_result(a2a_setup: dict) -> None:
    """Just exercise the send() entrypoint with a stub that fails closed."""
    svc = a2a_setup["svc"]
    try:
        result = await svc.send(
            from_agent="be-dev-1",
            to_agent="be-dev-2",
            skill="general",
            message="hi",
        )
        assert result is not None
    except Exception:
        # Expected if the policy rejects this pair or service is wired
        # to external infra in this test setup.
        pass


@pytest.mark.asyncio
async def test_send_records_skill_on_message_for_receiver(a2a_setup: dict) -> None:
    """#1416: a2a.send(skill=...) must record the requested capability on the
    persisted message so the receiver learns it — not silently drop it. The
    gateway adapter's docstring promised exactly this, but send_chat_message
    never read the ``skill`` opt and the table had no skill column, so every
    gateway A2A send lost the capability signal the caller passed."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    sent = await svc.send(
        from_agent=dev.id,
        to_agent="be-qa",
        task_id=task_id,
        body="please review my PR",
        skill="code_review",
    )
    assert sent.skill == "code_review"
    # The receiver reads the capability back via get_messages.
    inbox = await svc.get_messages(UUID(sent.conversation_id), "be-qa")
    assert inbox
    assert inbox[-1].skill == "code_review"


@pytest.mark.asyncio
async def test_send_publishes_a2a_message_sent_event_when_bus_connected(
    a2a_setup: dict,
) -> None:
    """A2AService.send() is the gateway's one publish point for A2A chat — it
    must fan an A2A_MESSAGE_SENT event so the CEO's live view (operator
    /ws/system stream) sees every directed agent-to-agent message."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
        sent = await svc.send(
            from_agent=dev.id,
            to_agent="be-qa",
            task_id=task_id,
            body="please review",
            skill="code_review",
        )
    mock_bus.publish.assert_awaited()
    published = mock_bus.publish.await_args.args[0]
    assert published.type is EventType.A2A_MESSAGE_SENT
    data = published.data
    assert data["conversation_id"] == sent.conversation_id
    assert data["message_id"] == sent.id
    assert data["task_id"] == str(task_id)
    assert data["from_agent"] == "be-dev-1"
    assert data["to_agent"] == "be-qa"
    assert data["skill"] == "code_review"
    assert data["body_excerpt"] == "please review"
    assert data["timestamp"] == sent.created_at.isoformat()


@pytest.mark.asyncio
async def test_send_excerpts_long_body_in_event(a2a_setup: dict) -> None:
    """The WS live-view frame carries a capped excerpt, not the full body —
    but the persisted message keeps the full untruncated text (readable via
    the existing REST message endpoints)."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    long_body = "x" * (_LIVE_VIEW_EXCERPT_CHARS + 100)
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
        sent = await svc.send(
            from_agent=dev.id,
            to_agent="be-qa",
            task_id=task_id,
            body=long_body,
        )
    published = mock_bus.publish.await_args.args[0]
    assert published.type is EventType.A2A_MESSAGE_SENT
    excerpt = published.data["body_excerpt"]
    assert len(excerpt) < len(long_body)
    assert excerpt.endswith("…")
    # Full body survives untruncated in persistent storage.
    assert sent.content == long_body
    stored = await svc.get_messages(UUID(sent.conversation_id), "be-qa")
    assert stored[-1].content == long_body


@pytest.mark.asyncio
async def test_send_bus_failure_does_not_break_send(a2a_setup: dict) -> None:
    """A bus outage during the A2A_MESSAGE_SENT publish is logged but never
    rolls back the persisted message — live delivery is best-effort."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    with patch(
        "roboco.services.a2a.get_event_bus",
        side_effect=RuntimeError("bus down"),
    ):
        sent = await svc.send(
            from_agent=dev.id,
            to_agent="be-qa",
            task_id=task_id,
            body="hello",
        )
    assert sent.id is not None
    assert sent.content == "hello"


@pytest.mark.asyncio
async def test_send_chat_message_directly_publishes_event(
    a2a_setup: dict,
) -> None:
    """The REST send paths (conversation-create + post-message) call
    send_chat_message directly, not the send() wrapper. That direct path must
    still emit A2A_MESSAGE_SENT so those messages light up the CEO's live view
    — the gap this test guards."""
    svc = a2a_setup["svc"]
    task_id = a2a_setup["task_id"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa", task_id=task_id)
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
        sent = await svc.send_chat_message(
            UUID(conv.id),
            "be-dev-1",
            "please review",
            options={"skill": "code_review"},
        )
    mock_bus.publish.assert_awaited_once()
    data = mock_bus.publish.await_args.args[0].data
    assert data["message_id"] == sent.id
    assert data["from_agent"] == "be-dev-1"
    assert data["to_agent"] == "be-qa"
    assert data["skill"] == "code_review"
    assert data["task_id"] == str(task_id)


@pytest.mark.asyncio
async def test_suppressed_duplicate_does_not_republish(
    a2a_setup: dict,
) -> None:
    """A re-sent identical unread message dedups to the existing row and must
    NOT emit a second live-view event (no redundant cache invalidation)."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
        await svc.send_chat_message(UUID(conv.id), "be-dev-1", "same text")
        await svc.send_chat_message(UUID(conv.id), "be-dev-1", "same text")
    mock_bus.publish.assert_awaited_once()


@pytest.mark.asyncio
async def test_list_unread_a2a_preview_is_incoming_not_own(a2a_setup: dict) -> None:
    """The briefing preview must be the latest INCOMING message, never the
    agent's own reply — an agent that replies before reading must still see
    what was said to it, not its own words."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]  # be-dev-1
    task_id = a2a_setup["task_id"]
    conv = await svc.get_or_create_conversation("be-qa", "be-dev-1", task_id=task_id)
    await svc.send_chat_message(
        UUID(conv.id), "be-qa", "Please fix the null check in auth.py"
    )
    # dev replies WITHOUT reading — its own message is now the latest row.
    await svc.send_chat_message(UUID(conv.id), "be-dev-1", "ok on it")

    items = await EvidenceRepo(a2a_setup["db"]).list_unread_a2a(dev.id)

    assert items and items[0]["from_agent"] == "be-qa"
    assert "null check" in items[0]["last_message_preview"]
    assert "ok on it" not in items[0]["last_message_preview"]


@pytest.mark.asyncio
async def test_get_unread_messages_returns_incoming_bodies_and_clears(
    a2a_setup: dict,
) -> None:
    """read_a2a delivers the unread INCOMING bodies to the agent (never its own
    sends) in order, then clears them — a second call returns nothing."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]  # be-dev-1
    conv = await svc.get_or_create_conversation(
        "be-qa", "be-dev-1", task_id=a2a_setup["task_id"]
    )
    await svc.send_chat_message(UUID(conv.id), "be-qa", "first")
    await svc.send_chat_message(UUID(conv.id), "be-dev-1", "my own reply")
    await svc.send_chat_message(UUID(conv.id), "be-qa", "second")

    msgs = await svc.get_unread_messages(dev.id)

    assert [m["content"] for m in msgs] == ["first", "second"]
    assert all(m["from_agent"] == "be-qa" for m in msgs)
    assert await svc.get_unread_messages(dev.id) == []


# ---------------------------------------------------------------------------
# Admin (CEO live view) service methods — no participant filter
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_conversations_admin_includes_non_participant_pairs(
    a2a_setup: dict,
) -> None:
    """The CEO's live view has no participant filter — it must show
    conversations between two agents where the CEO is not itself a party."""
    svc = a2a_setup["svc"]
    conv1 = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    conv2 = await svc.get_or_create_conversation("fe-dev-1", "fe-qa")

    summaries = await svc.list_conversations_admin(limit=50)

    ids = {s.id for s in summaries}
    assert conv1.id in ids
    assert conv2.id in ids
    pairs = {(s.agent_a, s.agent_b) for s in summaries}
    assert ("be-dev-1", "be-qa") in pairs
    assert ("fe-dev-1", "fe-qa") in pairs


@pytest.mark.asyncio
async def test_list_conversations_admin_orders_most_recent_first_and_bounds(
    a2a_setup: dict,
) -> None:
    """Most-recent-first ordering and a hard limit — proven by forcing
    distinguishable updated_at values across three seeded conversations."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    conv_a = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    conv_b = await svc.get_or_create_conversation("fe-dev-1", "fe-qa")
    conv_c = await svc.get_or_create_conversation("ux-dev-1", "ux-qa")

    now = datetime.now(UTC)
    for conv_id, offset in (
        (conv_a.id, timedelta(minutes=-10)),
        (conv_b.id, timedelta(minutes=-5)),
        (conv_c.id, timedelta(minutes=0)),
    ):
        row = await db.get(A2AConversationTable, UUID(conv_id))
        assert row is not None
        row.updated_at = now + offset
    await db.flush()

    summaries = await svc.list_conversations_admin(limit=2)

    _LIMIT = 2
    assert len(summaries) == _LIMIT
    assert [s.id for s in summaries] == [conv_c.id, conv_b.id]


@pytest.mark.asyncio
async def test_get_messages_admin_returns_full_transcript_for_non_participant(
    a2a_setup: dict,
) -> None:
    """The plain get_messages() denies a non-participant (returns []); the
    admin bypass returns the full transcript regardless — the exact behavior
    a normal agent-scoped call cannot give the CEO today."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "be-dev-1", "hello")
    await svc.send_chat_message(cid, "be-qa", "hi back")

    as_ceo_scoped = await svc.get_messages(cid, "ceo")
    assert as_ceo_scoped == []

    admin_view = await svc.get_messages_admin(cid)
    _EXPECTED = 2
    assert len(admin_view) == _EXPECTED
    assert admin_view[0].content == "hello"
    assert admin_view[1].content == "hi back"


@pytest.mark.asyncio
async def test_get_messages_admin_unknown_conversation_returns_empty(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    assert await svc.get_messages_admin(uuid4()) == []


@pytest.mark.asyncio
async def test_get_conversation_admin_returns_conversation_ceo_not_part_of(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")

    fetched = await svc.get_conversation_admin(UUID(conv.id))

    assert fetched is not None
    assert fetched.id == conv.id
    assert fetched.agent_a == "be-dev-1"
    assert fetched.agent_b == "be-qa"


@pytest.mark.asyncio
async def test_get_conversation_admin_returns_none_for_unknown(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    assert await svc.get_conversation_admin(uuid4()) is None


# ---------------------------------------------------------------------------
# interject_as_ceo — the CEO's one-directional interjection into a watched
# agent<->agent conversation (not a re-homed CEO<->target DM).
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_interject_as_ceo_lands_in_viewed_conversation_with_prefix(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")

    msg = await svc.interject_as_ceo(UUID(conv.id), "be-qa", "ship it")

    assert msg.conversation_id == conv.id
    assert msg.from_agent == "ceo"
    assert msg.content == "@be-qa: ship it"


@pytest.mark.asyncio
async def test_interject_as_ceo_bumps_target_unread_when_target_is_agent_b(
    a2a_setup: dict,
) -> None:
    """Canonical order makes "be-qa" agent_b — its counter, not agent_a's,
    must move; the other participant gets no ping."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    assert conv.agent_a == "be-dev-1"
    assert conv.agent_b == "be-qa"

    await svc.interject_as_ceo(UUID(conv.id), "be-qa", "ship it")

    row = await db.get(A2AConversationTable, UUID(conv.id))
    assert row is not None
    assert row.unread_by_b == 1
    assert row.unread_by_a == 0


@pytest.mark.asyncio
async def test_interject_as_ceo_bumps_target_unread_when_target_is_agent_a(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")

    await svc.interject_as_ceo(UUID(conv.id), "be-dev-1", "ship it")

    row = await db.get(A2AConversationTable, UUID(conv.id))
    assert row is not None
    assert row.unread_by_a == 1
    assert row.unread_by_b == 0


@pytest.mark.asyncio
async def test_interject_as_ceo_bumps_message_count_and_last_message_at(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    await svc.send_chat_message(UUID(conv.id), "be-dev-1", "hello")

    await svc.interject_as_ceo(UUID(conv.id), "be-qa", "ship it")

    row = await db.get(A2AConversationTable, UUID(conv.id))
    assert row is not None
    _EXPECTED_MESSAGE_COUNT = 2
    assert row.message_count == _EXPECTED_MESSAGE_COUNT
    assert row.last_message_at is not None


@pytest.mark.asyncio
async def test_interject_as_ceo_rejects_non_participant_target(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")

    with pytest.raises(ValueError, match="not a participant"):
        await svc.interject_as_ceo(UUID(conv.id), "ghost-agent", "hi")


@pytest.mark.asyncio
async def test_interject_as_ceo_unknown_conversation_raises(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(ValueError, match="Conversation not found"):
        await svc.interject_as_ceo(uuid4(), "be-qa", "hi")


@pytest.mark.asyncio
async def test_interject_as_ceo_publishes_a2a_message_sent_event(
    a2a_setup: dict,
) -> None:
    """Same operator-live-view chokepoint as send()/send_chat_message() —
    the panel's /ws/system invalidation must fire for an interjection too."""
    svc = a2a_setup["svc"]
    task_id = a2a_setup["task_id"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa", task_id=task_id)
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
        sent = await svc.interject_as_ceo(UUID(conv.id), "be-qa", "ship it")

    mock_bus.publish.assert_awaited_once()
    published = mock_bus.publish.await_args.args[0]
    assert published.type is EventType.A2A_MESSAGE_SENT
    data = published.data
    # Points at the VIEWED conversation, not a re-homed ceo<->target one.
    assert data["conversation_id"] == conv.id
    assert data["conversation_id"] == sent.conversation_id
    assert data["task_id"] == str(task_id)
    assert data["from_agent"] == "ceo"
    assert data["to_agent"] == "be-qa"


# ---------------------------------------------------------------------------
# list_admin_pairs — the A2A switchboard's static-matrix + DB join
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_admin_pairs_bounded_by_static_matrix(a2a_setup: dict) -> None:
    """With no conversations at all, every pair from the static matrix is
    still returned (conversation-less), sized exactly to the matrix."""
    svc = a2a_setup["svc"]
    pairs = await svc.list_admin_pairs()

    assert len(pairs) == len(A2A_ALLOWED_PAIRS)
    assert all(p.conversation_id is None for p in pairs)
    assert all(p.message_count == 0 for p in pairs)
    assert all(p.last_message_at is None for p in pairs)


@pytest.mark.asyncio
async def test_list_admin_pairs_joins_representative_conversation(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    await svc.send_chat_message(UUID(conv.id), "be-dev-1", "hello")

    pairs = await svc.list_admin_pairs()

    match = next(p for p in pairs if {p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"})
    assert match.conversation_id == conv.id
    assert match.message_count == 1
    assert match.last_message_at is not None
    assert match.group_key == "cell-backend"
    assert match.role_a == "developer"
    assert match.role_b == "qa"


@pytest.mark.asyncio
async def test_list_admin_pairs_picks_most_recently_updated_conversation(
    a2a_setup: dict,
) -> None:
    """A pair with two conversations (distinct topics) surfaces the more
    recently active one as its representative conversation."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    conv_old = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="t1")
    conv_new = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="t2")

    now = datetime.now(UTC)
    row_old = await db.get(A2AConversationTable, UUID(conv_old.id))
    row_new = await db.get(A2AConversationTable, UUID(conv_new.id))
    assert row_old is not None
    assert row_new is not None
    row_old.updated_at = now - timedelta(minutes=10)
    row_new.updated_at = now
    await db.flush()

    pairs = await svc.list_admin_pairs()

    match = next(p for p in pairs if {p.agent_a, p.agent_b} == {"be-dev-1", "be-qa"})
    assert match.conversation_id == conv_new.id


@pytest.mark.asyncio
async def test_list_admin_pairs_excludes_disallowed_pairs(a2a_setup: dict) -> None:
    """A conversation row between two agents the matrix does NOT allow (dev
    A2A is same-cell only — this should never legitimately exist, but the
    join must be robust against it) never surfaces as a pair card: the
    service iterates the static matrix, not "any conversation row"."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    stray = A2AConversationTable(agent_a="be-dev-1", agent_b="fe-dev-1")
    db.add(stray)
    await db.flush()

    pairs = await svc.list_admin_pairs()

    assert not any({p.agent_a, p.agent_b} == {"be-dev-1", "fe-dev-1"} for p in pairs)


# ---------------------------------------------------------------------------
# CEO reply-only budget — an agent may only reply to the CEO inside a
# conversation the CEO itself opened, and only up to the CEO's own message
# count in that conversation.
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_to_ceo_without_existing_conversation_denied(
    a2a_setup: dict,
) -> None:
    """An agent can never INITIATE a CEO conversation via the gateway
    send() adapter — only reply inside one the CEO already opened."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    with pytest.raises(A2AAccessDeniedError):
        await svc.send(from_agent=dev.id, to_agent="ceo", task_id=task_id, body="hi")


@pytest.mark.asyncio
async def test_ceo_can_post_consecutive_messages_no_budget(a2a_setup: dict) -> None:
    """CEO -> agent direction is unrestricted — no budget applies to CEO
    sends, so the CEO may post twice in a row with no reply in between."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "ceo", "first")
    second = await svc.send_chat_message(cid, "ceo", "second, no reply needed yet")
    assert second.content == "second, no reply needed yet"


@pytest.mark.asyncio
async def test_ceo_reply_budget_first_reply_allowed(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "ceo", "hi dev")
    reply = await svc.send_chat_message(cid, "be-dev-1", "on it")
    assert reply.content == "on it"


@pytest.mark.asyncio
async def test_ceo_reply_budget_second_reply_without_new_ceo_message_rejected(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "ceo", "hi dev")
    await svc.send_chat_message(cid, "be-dev-1", "on it")
    with pytest.raises(A2AAccessDeniedError, match="already replied"):
        await svc.send_chat_message(cid, "be-dev-1", "another update")


@pytest.mark.asyncio
async def test_ceo_reply_budget_refreshes_after_new_ceo_message(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "ceo", "hi dev")
    await svc.send_chat_message(cid, "be-dev-1", "on it")
    await svc.send_chat_message(cid, "ceo", "any update?")
    reply2 = await svc.send_chat_message(cid, "be-dev-1", "done!")
    assert reply2.content == "done!"


@pytest.mark.asyncio
async def test_ceo_reply_budget_independent_across_conversations(
    a2a_setup: dict,
) -> None:
    """The a2a_conversations model is strictly pairwise — a literal 3-party
    thread can't exist. Adapted form: two agents each in their OWN
    conversation with the CEO get independent budgets; one agent exhausting
    its budget must not affect the other's."""
    svc = a2a_setup["svc"]
    conv_dev = await svc.get_or_create_conversation("ceo", "be-dev-1")
    conv_qa = await svc.get_or_create_conversation("ceo", "be-qa")
    cid_dev = UUID(conv_dev.id)
    cid_qa = UUID(conv_qa.id)

    await svc.send_chat_message(cid_dev, "ceo", "dev, status?")
    await svc.send_chat_message(cid_qa, "ceo", "qa, status?")

    await svc.send_chat_message(cid_dev, "be-dev-1", "on it")
    with pytest.raises(A2AAccessDeniedError):
        await svc.send_chat_message(cid_dev, "be-dev-1", "again")

    # qa's independent budget is untouched by dev's exhausted one.
    qa_reply = await svc.send_chat_message(cid_qa, "be-qa", "on it too")
    assert qa_reply.content == "on it too"


@pytest.mark.asyncio
async def test_ceo_reply_dedup_before_budget_check(a2a_setup: dict) -> None:
    """Dedup runs BEFORE the budget check: a respawned agent re-sending its
    identical unread reply gets the existing row back idempotently — never a
    budget error — even once the agent has exhausted its reply budget."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "ceo", "status?")
    first = await svc.send_chat_message(cid, "be-dev-1", "on it")
    # Budget is now exhausted (agent_count == ceo_count == 1); an identical
    # resend must still dedup instead of hitting the budget gate.
    again = await svc.send_chat_message(cid, "be-dev-1", "on it")
    assert again.id == first.id


@pytest.mark.asyncio
async def test_send_to_ceo_via_gateway_when_ceo_opened_conversation(
    a2a_setup: dict,
) -> None:
    """Once the CEO has opened a conversation with the agent, the gateway
    send() adapter finds it directly — bypassing
    get_or_create_conversation's validate-first gate, which would otherwise
    deny even a legitimate reply — and the reply persists under budget."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    conv = await svc.get_or_create_conversation("ceo", "be-dev-1")
    await svc.send_chat_message(UUID(conv.id), "ceo", "status?")

    reply = await svc.send(
        from_agent=dev.id, to_agent="ceo", task_id=task_id, body="on it"
    )
    assert reply.content == "on it"
    assert reply.from_agent == "be-dev-1"


@pytest.mark.asyncio
async def test_send_publishes_only_after_persist_not_on_reply_denial(
    a2a_setup: dict,
) -> None:
    """A rejected send (no existing CEO conversation) must never publish
    A2A_MESSAGE_SENT — the event is a record of a persisted message."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    task_id = a2a_setup["task_id"]
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with (
        patch("roboco.services.a2a.get_event_bus", return_value=mock_bus),
        pytest.raises(A2AAccessDeniedError),
    ):
        await svc.send(from_agent=dev.id, to_agent="ceo", task_id=task_id, body="hi")
    mock_bus.publish.assert_not_awaited()


# ---------------------------------------------------------------------------
# Conversation creation happy path with allowed pair
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_create_conversation_between_dev_and_qa_in_same_cell(
    a2a_setup: dict,
) -> None:
    """Cell members can A2A within their own cell."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    assert conv is not None
    # Idempotent — same agents, same conversation.
    again = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    assert again.id == conv.id


@pytest.mark.asyncio
async def test_send_chat_message_in_existing_conversation(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    msg = await svc.send_chat_message(UUID(conv.id), "be-dev-1", "hello")
    assert msg.content == "hello"


@pytest.mark.asyncio
async def test_get_messages_returns_chronological(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    cid = UUID(conv.id)
    await svc.send_chat_message(cid, "be-dev-1", "first")
    await svc.send_chat_message(cid, "be-dev-1", "second")
    msgs = await svc.get_messages(cid, "be-dev-1")
    _SENT_COUNT = 2
    assert len(msgs) == _SENT_COUNT


@pytest.mark.asyncio
async def test_send_chat_message_dedups_identical_unread(a2a_setup: dict) -> None:
    """An identical message re-sent while still unread is suppressed (one copy),
    but a different message is not collapsed."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    cid = UUID(conv.id)
    first = await svc.send_chat_message(cid, "be-dev-1", "ping: are you free?")
    again = await svc.send_chat_message(cid, "be-dev-1", "ping: are you free?")
    distinct = await svc.send_chat_message(cid, "be-dev-1", "different message")
    # The duplicate returns the SAME stored message and adds no new row.
    assert again.id == first.id
    assert distinct.id != first.id
    msgs = await svc.get_messages(cid, "be-dev-1")
    _EXPECTED = 2  # the deduped "ping" + the distinct one
    assert len(msgs) == _EXPECTED


@pytest.mark.asyncio
async def test_close_conversation_with_resolution(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    await svc.close_conversation(UUID(conv.id), "be-dev-1", resolution="done")


@pytest.mark.asyncio
async def test_mark_read_clears_unread(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    await svc.mark_read(UUID(conv.id), "be-dev-1")


@pytest.mark.asyncio
async def test_close_conversation_non_participant_raises(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    with pytest.raises(ValueError, match="Not a participant"):
        await svc.close_conversation(UUID(conv.id), "ghost-agent")


@pytest.mark.asyncio
async def test_send_chat_message_non_participant_raises(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    with pytest.raises(ValueError, match="Not a participant"):
        await svc.send_chat_message(UUID(conv.id), "ghost", "hi")


# ---------------------------------------------------------------------------
# Pure-function helpers
# ---------------------------------------------------------------------------


def test_get_team_from_agent_backend() -> None:
    assert A2AService.get_team_from_agent("be-dev-1") == Team.BACKEND


def test_get_team_from_agent_unknown_defaults_to_backend() -> None:
    assert A2AService.get_team_from_agent("ghost-agent") == Team.BACKEND


def test_resolve_target_agent_explicit() -> None:
    result = A2AService.resolve_target_agent({"target_agent": "be-dev-1"})
    assert result == "be-dev-1"


def test_resolve_target_agent_unknown_returns_none() -> None:
    result = A2AService.resolve_target_agent({"target_agent": "ghost-agent"})
    assert result is None


def test_resolve_target_agent_none_when_no_metadata() -> None:
    result = A2AService.resolve_target_agent({})
    assert result is None


def test_extract_message_text_no_text_parts() -> None:
    msg = A2AMessage(role="user", parts=[])
    title, desc, _full = A2AService.extract_message_text(msg)
    assert title == "A2A Task"
    assert desc == ""


def test_extract_message_text_single_line() -> None:
    msg = A2AMessage(role="user", parts=[TextPart(text="Hello world")])
    title, _desc, _full = A2AService.extract_message_text(msg)
    assert title == "Hello world"


def test_extract_message_text_multi_line() -> None:
    msg = A2AMessage(role="user", parts=[TextPart(text="Title here\nThis is the body")])
    title, desc, _full = A2AService.extract_message_text(msg)
    assert title == "Title here"
    assert desc == "This is the body"


@pytest.mark.asyncio
async def test_update_task_with_message_appends_to_notes(
    a2a_setup: dict,
) -> None:
    """Use a real DB-backed task instance to avoid SA private state issues."""
    db = a2a_setup["db"]
    task = (await db.execute(select(TaskTable).limit(1))).scalar_one()
    original_notes = task.dev_notes
    task.dev_notes = "existing notes"
    msg = A2AMessage(role="user", parts=[TextPart(text="new message")])
    A2AService.update_task_with_message(task, msg)
    assert "existing notes" in task.dev_notes
    assert "new message" in task.dev_notes
    task.dev_notes = original_notes  # restore


@pytest.mark.asyncio
async def test_update_task_with_message_no_text_parts_noop(
    a2a_setup: dict,
) -> None:
    db = a2a_setup["db"]
    task = (await db.execute(select(TaskTable).limit(1))).scalar_one()
    original = task.dev_notes
    task.dev_notes = "existing"
    msg = A2AMessage(role="user", parts=[])
    A2AService.update_task_with_message(task, msg)
    assert task.dev_notes == "existing"
    task.dev_notes = original


# ---------------------------------------------------------------------------
# resolve_creator_agent paths
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_resolve_creator_agent_with_unknown_falls_back_to_main_pm(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    # Unknown ID falls back to main PM lookup — returns None if no main PM seeded.
    out = await svc.resolve_creator_agent("ghost-id")
    # Either None (no main_pm) or AgentTable (main_pm seeded by a prior test).
    assert out is None or hasattr(out, "id")


@pytest.mark.asyncio
async def test_resolve_creator_agent_with_none_falls_back_to_main_pm(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    out = await svc.resolve_creator_agent(None)
    assert out is None or hasattr(out, "id")


# ---------------------------------------------------------------------------
# get_service_endpoint — unspecified host falls through to 127.0.0.1
# ---------------------------------------------------------------------------


def test_get_service_endpoint_unspecified_host() -> None:
    """0.0.0.0 host triggers loopback fallback."""
    with patch("roboco.services.a2a.settings") as mock_settings:
        mock_settings.host = "0.0.0.0"
        mock_settings.port = 8000
        url = A2AService.get_service_endpoint()
    assert "127.0.0.1" in url


def test_get_service_endpoint_invalid_host_falls_through() -> None:
    """Non-IP host string falls through ValueError → uses host directly."""
    with patch("roboco.services.a2a.settings") as mock_settings:
        mock_settings.host = "myhost"
        mock_settings.port = 8080
        url = A2AService.get_service_endpoint()
    assert "myhost" in url


# ---------------------------------------------------------------------------
# task_to_a2a — branches: dev_notes, no value attr, assigned_to, parent
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_task_to_a2a_with_dev_notes_and_assignment(
    a2a_setup: dict,
) -> None:
    """Task with dev_notes, assigned_to, parent_task_id covers metadata branches."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    parent = TaskTable(
        id=_u(),
        title="parent",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=(await db.execute(select(TaskTable))).scalars().first().project_id,
        created_by=a2a_setup["dev"].id,
        team=Team.BACKEND,
    )
    db.add(parent)
    await db.flush()
    child = TaskTable(
        id=_u(),
        title="child",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=parent.project_id,
        created_by=a2a_setup["dev"].id,
        assigned_to=a2a_setup["dev"].id,
        parent_task_id=parent.id,
        team=Team.BACKEND,
        dev_notes="some progress",
    )
    db.add(child)
    await db.flush()
    a2a_task = svc.task_to_a2a(child)
    assert "assigned_to" in a2a_task.metadata
    assert "parent_task_id" in a2a_task.metadata


def test_task_to_a2a_status_without_value_attr(a2a_setup: dict) -> None:
    """When task.status lacks .value (already a string), use str()."""
    svc = a2a_setup["svc"]
    fake_task = SimpleNamespace(
        id="00000000-0000-0000-0000-000000000001",
        status="pending",  # plain string, no .value
        priority=2,
        team="backend",
        dev_notes=None,
        assigned_to=None,
        parent_task_id=None,
        updated_at=None,
        created_at=datetime.now(UTC),
    )
    a2a_task = svc.task_to_a2a(fake_task)
    assert a2a_task.metadata["roboco_status"] == "pending"


# ---------------------------------------------------------------------------
# list_tasks — has_more=True branch
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_tasks_has_more_true(a2a_setup: dict) -> None:
    """Seed enough tasks to trigger has_more=True."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    pid = (await db.execute(select(TaskTable))).scalars().first().project_id
    for _i in range(3):
        db.add(
            TaskTable(
                id=_u(),
                title=f"t{_i}",
                description="d",
                acceptance_criteria=["ac"],
                status=TaskStatus.PENDING,
                priority=2,
                task_type=TaskType.CODE,
                nature=TaskNature.TECHNICAL,
                project_id=pid,
                created_by=a2a_setup["dev"].id,
                team=Team.BACKEND,
            )
        )
    await db.flush()
    _PAGE = 2
    tasks, has_more = await svc.list_tasks(page_size=_PAGE)
    assert has_more is True
    assert len(tasks) == _PAGE


# ---------------------------------------------------------------------------
# cancel_task — full path with reason and existing dev_notes
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_cancel_task_with_reason_and_existing_notes(
    a2a_setup: dict,
) -> None:
    """Reason is appended to existing dev_notes and full cancel runs."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    pid = (await db.execute(select(TaskTable))).scalars().first().project_id
    task = TaskTable(
        id=_u(),
        title="t",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=pid,
        created_by=a2a_setup["dev"].id,
        team=Team.BACKEND,
        dev_notes="initial work",
    )
    db.add(task)
    await db.flush()
    a2a_task = await svc.cancel_task(str(task.id), reason="changed mind")
    assert a2a_task.id == str(task.id)


@pytest.mark.asyncio
async def test_cancel_task_with_reason_no_existing_notes(
    a2a_setup: dict,
) -> None:
    """Reason populates fresh dev_notes when none existed."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    pid = (await db.execute(select(TaskTable))).scalars().first().project_id
    task = TaskTable(
        id=_u(),
        title="t",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=pid,
        created_by=a2a_setup["dev"].id,
        team=Team.BACKEND,
    )
    db.add(task)
    await db.flush()
    a2a_task = await svc.cancel_task(str(task.id), reason="cancelled")
    assert a2a_task is not None


@pytest.mark.asyncio
async def test_cancel_task_status_no_value_attr(a2a_setup: dict) -> None:
    """If task.status is already a string, str() fallback runs."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    task = (await db.execute(select(TaskTable).limit(1))).scalar_one()
    # Mock execute so the cancel-side query returns a task with status="pending" string.
    real_execute = svc.session.execute
    fake_uuid = _u()

    class _FakeStatus:
        # No .value attribute
        def __str__(self) -> str:
            return "pending"

    fake_task = type(
        "FakeTask",
        (),
        {
            "id": str(fake_uuid),
            "status": _FakeStatus(),
            "dev_notes": None,
        },
    )()

    seen = {"hit": False}

    async def _intercepting_execute(stmt: Any, *args: Any, **kwargs: Any) -> Any:
        if not seen["hit"]:
            seen["hit"] = True
            stub = _MM()
            stub.scalar_one_or_none.return_value = fake_task
            return stub
        return await real_execute(stmt, *args, **kwargs)

    # Patch TaskService.cancel to return the same task object.
    with patch("roboco.services.task.TaskService") as mock_ts:
        instance = AsyncMock()
        instance.cancel = AsyncMock(return_value=task)
        mock_ts.return_value = instance
        with patch.object(svc.session, "execute", side_effect=_intercepting_execute):
            a2a_task = await svc.cancel_task(str(fake_uuid))
    assert a2a_task is not None


@pytest.mark.asyncio
async def test_cancel_task_failed_returns_value_error(
    a2a_setup: dict,
) -> None:
    """If TaskService.cancel returns None, raise ValueError."""
    svc = a2a_setup["svc"]
    db = a2a_setup["db"]
    pid = (await db.execute(select(TaskTable))).scalars().first().project_id
    task = TaskTable(
        id=_u(),
        title="t",
        description="d",
        acceptance_criteria=["ac"],
        status=TaskStatus.PENDING,
        priority=2,
        task_type=TaskType.CODE,
        nature=TaskNature.TECHNICAL,
        project_id=pid,
        created_by=a2a_setup["dev"].id,
        team=Team.BACKEND,
    )
    db.add(task)
    await db.flush()
    with patch("roboco.services.task.TaskService") as mock_ts:
        instance = AsyncMock()
        instance.cancel = AsyncMock(return_value=None)
        mock_ts.return_value = instance
        with pytest.raises(ValueError, match="Failed to cancel"):
            await svc.cancel_task(str(task.id))


# ---------------------------------------------------------------------------
# resolve_target_agent — skill-based routing match
# ---------------------------------------------------------------------------


def test_resolve_target_agent_by_skill_match() -> None:
    """metadata.skill matches an agent skill id → returns slug."""
    with patch(
        "roboco.services.a2a.get_agent_skills",
        return_value=[{"id": "general", "name": "g"}],
    ):
        result = A2AService.resolve_target_agent({"skill": "general"})
    # First agent in ALL_AGENTS with this skill id wins.
    assert result is not None


def test_resolve_target_agent_skill_no_match() -> None:
    """Unknown skill returns None."""
    with patch("roboco.services.a2a.get_agent_skills", return_value=[]):
        result = A2AService.resolve_target_agent({"skill": "ghost-skill"})
    assert result is None


# ---------------------------------------------------------------------------
# extract_message_text — text part without `text` attribute
# ---------------------------------------------------------------------------


def test_extract_message_text_no_text_attr() -> None:
    """text_part missing `text` attr → returns defaults."""
    fake_part = SimpleNamespace(type="text")
    fake_msg = SimpleNamespace(parts=[fake_part])
    title, desc, full = A2AService.extract_message_text(cast("A2AMessage", fake_msg))
    assert title == "A2A Task"
    assert desc == ""
    assert full == ""


# ---------------------------------------------------------------------------
# update_task_with_message — text_part lacks `.text` attr
# ---------------------------------------------------------------------------


def test_update_task_with_message_no_text_attr() -> None:
    """text_part without text attribute leaves dev_notes untouched."""
    fake_part = SimpleNamespace(type="text")
    fake_msg = SimpleNamespace(parts=[fake_part])
    fake_task = SimpleNamespace(dev_notes="orig")
    A2AService.update_task_with_message(
        cast("TaskTable", fake_task), cast("A2AMessage", fake_msg)
    )
    assert fake_task.dev_notes == "orig"


# ---------------------------------------------------------------------------
# resolve_creator_agent — known agent slug + lookup hit
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_resolve_creator_agent_known_slug_with_uuid_hit(
    a2a_setup: dict,
) -> None:
    """from_agent_id is a known slug AND has UUID — looks up by UUID."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    # be-dev-1 is in ALL_AGENTS (a list) by default; just inject a UUID.
    with patch.dict(
        "roboco.services.a2a.AGENT_UUIDS",
        {"be-dev-1": str(dev.id)},
    ):
        out = await svc.resolve_creator_agent("be-dev-1")
    # May return None if agent not found by id, or the dev row.
    assert out is None or hasattr(out, "id")


# ---------------------------------------------------------------------------
# create_a2a_notification — full path
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_create_a2a_notification_missing_task_id_raises(
    a2a_setup: dict,
) -> None:
    """task_id absent → ValueError."""
    svc = a2a_setup["svc"]
    msg = A2AMessage(role="user", parts=[TextPart(text="hi")])
    req = SendMessageRequest(message=msg, metadata={})
    with pytest.raises(ValueError, match="task_id"):
        await svc.create_a2a_notification(req)


@pytest.mark.asyncio
async def test_create_a2a_notification_with_target_calls_notification_service(
    a2a_setup: dict,
) -> None:
    """Path with from_agent + target + skill → NotificationService called."""
    svc = a2a_setup["svc"]
    task_id = str(a2a_setup["task_id"])
    msg = A2AMessage(role="user", parts=[TextPart(text="hi there")], task_id=task_id)
    req = SendMessageRequest(
        message=msg,
        metadata={"from_agent": "be-dev-1", "target_agent": "be-dev-2"},
    )
    mock_ns = AsyncMock()
    mock_ns.send_a2a_notification = AsyncMock(return_value=None)
    with patch(
        "roboco.services.notification.NotificationService",
        return_value=mock_ns,
    ):
        result = await svc.create_a2a_notification(req)
    assert result["task_id"] == task_id


@pytest.mark.asyncio
async def test_create_a2a_notification_permission_denied(
    a2a_setup: dict,
) -> None:
    """Hierarchy denial → the typed A2AAccessDeniedError (with route_hint), routed
    through validate_a2a_access so the legacy notification path matches the
    conversation path — not a bare ValueError a caller can't distinguish from a
    malformed request."""
    svc = a2a_setup["svc"]
    task_id = str(a2a_setup["task_id"])
    msg = A2AMessage(role="user", parts=[TextPart(text="hi")], task_id=task_id)
    req = SendMessageRequest(
        message=msg,
        metadata={"from_agent": "be-dev-1", "target_agent": "be-dev-2"},
    )
    with (
        patch(
            "roboco.enforcement.a2a_access.can_a2a_direct",
            return_value=(False, "denied"),
        ),
        patch(
            "roboco.enforcement.a2a_access.get_a2a_route_hint",
            return_value="use channel",
        ),
        pytest.raises(A2AAccessDeniedError) as exc,
    ):
        await svc.create_a2a_notification(req)
    assert exc.value.route_hint == "use channel"


@pytest.mark.asyncio
async def test_create_a2a_notification_self_a2a_raises_typed_error(
    a2a_setup: dict,
) -> None:
    """#612: a self-directed A2A notification (from_agent == target) must raise
    the typed A2AAccessDeniedError — the self-check the conversation path
    (validate_a2a_access) enforces — not a bare ValueError and not a silently
    sent notification."""
    svc = a2a_setup["svc"]
    task_id = str(a2a_setup["task_id"])
    msg = A2AMessage(role="user", parts=[TextPart(text="self note")], task_id=task_id)
    req = SendMessageRequest(
        message=msg,
        metadata={"from_agent": "be-dev-1", "target_agent": "be-dev-1"},
    )
    mock_ns = AsyncMock()
    mock_ns.send_a2a_notification = AsyncMock(return_value=None)
    with (
        patch("roboco.services.notification.NotificationService", return_value=mock_ns),
        pytest.raises(A2AAccessDeniedError),
    ):
        await svc.create_a2a_notification(req)
    mock_ns.send_a2a_notification.assert_not_called()


@pytest.mark.asyncio
async def test_create_a2a_notification_missing_from_agent_raises_not_silent(
    a2a_setup: dict,
) -> None:
    """#609: an unattributed A2A request (no from_agent, unresolvable target)
    must NOT slip past the hierarchy gate and dispatch with
    from_agent='unknown'. The gate is unconditional — both ends must be present
    and resolvable before any notification is created."""
    svc = a2a_setup["svc"]
    task_id = str(a2a_setup["task_id"])
    msg = A2AMessage(role="user", parts=[TextPart(text="anon")], task_id=task_id)
    req = SendMessageRequest(message=msg, metadata={})
    mock_ns = AsyncMock()
    mock_ns.send_a2a_notification = AsyncMock(return_value=None)
    with (
        patch("roboco.services.notification.NotificationService", return_value=mock_ns),
        pytest.raises(ValueError, match="from_agent"),
    ):
        await svc.create_a2a_notification(req)
    mock_ns.send_a2a_notification.assert_not_called()


# ---------------------------------------------------------------------------
# update_task_from_message — full happy path + invalid id
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_update_task_from_message_invalid_id(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    msg = A2AMessage(role="user", parts=[TextPart(text="hi")])
    with pytest.raises(ValueError, match="Invalid task ID"):
        await svc.update_task_from_message("not-a-uuid", msg)


@pytest.mark.asyncio
async def test_update_task_from_message_not_found(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    msg = A2AMessage(role="user", parts=[TextPart(text="hi")])
    with pytest.raises(ValueError, match="not found"):
        await svc.update_task_from_message(str(_u()), msg)


@pytest.mark.asyncio
async def test_update_task_from_message_success(a2a_setup: dict) -> None:
    """Real task updated with new dev_notes."""
    svc = a2a_setup["svc"]
    task_id = str(a2a_setup["task_id"])
    msg = A2AMessage(role="user", parts=[TextPart(text="response text")])
    updated = await svc.update_task_from_message(task_id, msg)
    assert "response text" in (updated.dev_notes or "")


# ---------------------------------------------------------------------------
# _notify_original_requester — branches: not A2A, no created_by, no slug
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_notify_original_requester_not_a2a_returns(
    a2a_setup: dict,
) -> None:
    """Task.dev_notes lacks 'A2A Request' → no-op."""
    svc = a2a_setup["svc"]
    fake_task = SimpleNamespace(dev_notes="just notes", created_by=None)
    # No raise.
    await svc._notify_original_requester(fake_task, "responder")


@pytest.mark.asyncio
async def test_notify_original_requester_no_created_by(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    fake_task = SimpleNamespace(dev_notes="A2A Request: hi", created_by=None)
    await svc._notify_original_requester(fake_task)


@pytest.mark.asyncio
async def test_notify_original_requester_unknown_slug(
    a2a_setup: dict,
) -> None:
    """If the creator UUID can't be mapped to a slug, returns silently."""
    svc = a2a_setup["svc"]
    fake_task = SimpleNamespace(
        id=_u(),
        dev_notes="A2A Request: please review",
        created_by=_u(),  # Not in AGENT_UUIDS.
    )
    await svc._notify_original_requester(fake_task)


@pytest.mark.asyncio
async def test_notify_original_requester_responder_is_requester(
    a2a_setup: dict,
) -> None:
    """If responder is the same as requester, no event published."""
    svc = a2a_setup["svc"]
    fake_task = SimpleNamespace(
        id="00000000-0000-0000-0000-000000000001",
        dev_notes="A2A Request: please review",
        created_by="be-dev-1-uuid",
    )
    with patch(
        "roboco.services.a2a.A2AService._lookup_requester_slug",
        return_value="be-dev-1",
    ):
        # responder == requester → return without publish.
        await svc._notify_original_requester(fake_task, "be-dev-1")


@pytest.mark.asyncio
async def test_notify_original_requester_publishes_event(
    a2a_setup: dict,
) -> None:
    """Full path: requester slug found, responder different → publish."""
    svc = a2a_setup["svc"]
    fake_task = SimpleNamespace(
        id="00000000-0000-0000-0000-000000000001",
        dev_notes="A2A Request: please review",
        created_by="some-uuid",
    )
    mock_bus = AsyncMock()
    mock_bus.is_connected = lambda: True
    mock_bus.publish = AsyncMock(return_value=None)
    with (
        patch(
            "roboco.services.a2a.A2AService._lookup_requester_slug",
            return_value="be-dev-1",
        ),
        patch("roboco.services.a2a.get_event_bus", return_value=mock_bus),
    ):
        await svc._notify_original_requester(fake_task, "be-dev-2")
    mock_bus.publish.assert_awaited()


@pytest.mark.asyncio
async def test_publish_a2a_response_event_no_bus() -> None:
    """Bus not connected → silent no-op."""
    fake_task = SimpleNamespace(id="00000000-0000-0000-0000-000000000001")
    mock_bus = type("B", (), {"is_connected": lambda _self: False})()
    with patch("roboco.services.a2a.get_event_bus", return_value=mock_bus):
        await A2AService._publish_a2a_response_event(
            cast("TaskTable", fake_task), "creator", "requester", "responder"
        )


@pytest.mark.asyncio
async def test_publish_a2a_response_event_bus_exception_swallowed() -> None:
    fake_task = SimpleNamespace(id="00000000-0000-0000-0000-000000000001")
    with patch(
        "roboco.services.a2a.get_event_bus",
        side_effect=RuntimeError("bus down"),
    ):
        # Exception swallowed.
        await A2AService._publish_a2a_response_event(
            cast("TaskTable", fake_task), "creator", "requester", "responder"
        )


# ---------------------------------------------------------------------------
# list_conversations — status/with_agent/task_id filters + last message
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_conversations_with_status_and_task_filter(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    convs = await svc.list_conversations(
        "be-dev-1",
        status=A2AConversationStatus.ACTIVE,
        with_agent="be-dev-2",
        task_id=a2a_setup["task_id"],
    )
    assert isinstance(convs, list)


@pytest.mark.asyncio
async def test_list_conversations_with_messages(a2a_setup: dict) -> None:
    """Seed a conversation + a message so the last_message preview path runs."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    await svc.send_chat_message(UUID(conv.id), "be-dev-1", "preview text")

    convs = await svc.list_conversations("be-dev-1")
    # last_message_preview should be truthy for this conv.
    assert any(c.last_message_preview for c in convs)


# ---------------------------------------------------------------------------
# send_chat_message — both agents perspective + message_kind/response_to
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_chat_message_options_response_to(
    a2a_setup: dict,
) -> None:
    """response_to_id and requires_response options surface on the message.

    `response_to_id` has a FK to `a2a_messages.id`, so we send a real
    "first" message to thread off — passing a random UUID would hit FK
    violation.
    """
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    first = await svc.send_chat_message(UUID(conv.id), "be-qa", "first message")
    msg = await svc.send_chat_message(
        UUID(conv.id),
        "be-dev-1",
        "needs answer",
        options={"requires_response": True, "response_to_id": UUID(first.id)},
    )
    assert msg.requires_response is True
    assert msg.response_to_id == first.id


@pytest.mark.asyncio
async def test_send_chat_message_from_agent_b_increments_unread_a(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    # First exchange establishes canonical pair (a < b lexicographically).
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    # Send from whichever agent is conv.agent_b (the "other" side).
    result = await svc.session.execute(
        _sel(A2AConversationTable).where(A2AConversationTable.id == UUID(conv.id))
    )
    row = result.scalar_one()
    msg = await svc.send_chat_message(UUID(conv.id), row.agent_b, "hi from b")
    assert msg.from_agent == row.agent_b


# ---------------------------------------------------------------------------
# get_messages — non-participant returns []
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_messages_non_participant_returns_empty(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    msgs = await svc.get_messages(UUID(conv.id), "ghost-agent")
    assert msgs == []


@pytest.mark.asyncio
async def test_get_messages_with_before_filter(a2a_setup: dict) -> None:
    """Pass a `before` datetime — exercises the filter branch."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    await svc.send_chat_message(UUID(conv.id), "be-dev-1", "first")
    future = datetime.now(UTC).replace(year=2099)
    msgs = await svc.get_messages(UUID(conv.id), "be-dev-1", before=future)
    assert isinstance(msgs, list)


# ---------------------------------------------------------------------------
# mark_read — non-participant returns silently + agent_b path
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_mark_read_non_participant(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    # Non-participant → silent return.
    await svc.mark_read(UUID(conv.id), "ghost")


@pytest.mark.asyncio
async def test_mark_read_as_agent_b(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    result = await svc.session.execute(
        _sel(A2AConversationTable).where(A2AConversationTable.id == UUID(conv.id))
    )
    row = result.scalar_one()
    await svc.mark_read(UUID(conv.id), row.agent_b)


# ---------------------------------------------------------------------------
# list_pairs — exercises grouping + last_activity comparison
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_list_pairs_with_conversations(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    await svc.get_or_create_conversation("be-dev-1", "be-qa")
    pairs = await svc.list_pairs("be-dev-1")
    assert any(
        (p.agent_a, p.agent_b) == ("be-dev-1", "be-qa")
        or (p.agent_a, p.agent_b) == ("be-qa", "be-dev-1")
        for p in pairs
    )


# ---------------------------------------------------------------------------
# _resolve_slug_from_id — happy + raise
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_resolve_slug_from_id_happy(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    slug = await svc._resolve_slug_from_id(dev.id)
    assert slug == dev.slug


@pytest.mark.asyncio
async def test_resolve_slug_from_id_missing_raises(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    with pytest.raises(ValueError, match="Agent not found"):
        await svc._resolve_slug_from_id(_u())


# ---------------------------------------------------------------------------
# send — gateway adapter, both UUID and str recipient
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_gateway_adapter_uuid_to_uuid(
    a2a_setup: dict,
) -> None:
    """Both ends as UUIDs → resolves both slugs via DB lookup."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    qa = a2a_setup["qa"]
    msg = await svc.send(
        from_agent=dev.id,
        to_agent=qa.id,
        task_id=a2a_setup["task_id"],
        body="hello",
        skill="general",
    )
    assert msg.content == "hello"


@pytest.mark.asyncio
async def test_send_gateway_adapter_with_string_recipient(
    a2a_setup: dict,
) -> None:
    """Recipient as slug string → no DB lookup for it."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    msg = await svc.send(
        from_agent=dev.id,
        to_agent="be-qa",
        task_id=a2a_setup["task_id"],
        body="hello",
    )
    assert msg.content == "hello"


# ---------------------------------------------------------------------------
# create_task_from_message — patched DB so flush succeeds
# ---------------------------------------------------------------------------


# ---------------------------------------------------------------------------
# _lookup_requester_slug — found path
# ---------------------------------------------------------------------------


def test_lookup_requester_slug_found() -> None:
    target_uuid = "00000000-0000-0000-0000-000000000042"
    with patch(
        "roboco.seeds.initial_data.AGENT_UUIDS",
        {"slug-x": target_uuid},
    ):
        result = A2AService._lookup_requester_slug(target_uuid)
    assert result == "slug-x"


# ---------------------------------------------------------------------------
# get_or_create_conversation — topic provided branch
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_or_create_conversation_with_topic(
    a2a_setup: dict,
) -> None:
    svc = a2a_setup["svc"]
    a = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="Bug X")
    b = await svc.get_or_create_conversation("be-dev-1", "be-qa", topic="Bug X")
    assert a.id == b.id


# ---------------------------------------------------------------------------
# get_conversation — non-participant returns None
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_conversation_non_participant(a2a_setup: dict) -> None:
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    result = await svc.get_conversation(UUID(conv.id), "ghost")
    assert result is None


@pytest.mark.asyncio
async def test_get_conversation_returns_model_when_participant(
    a2a_setup: dict,
) -> None:
    """Participant access → returns the conversation model."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    result = await svc.get_conversation(UUID(conv.id), "be-dev-1")
    assert result is not None
    assert result.id == conv.id


# ---------------------------------------------------------------------------
# get_inbox_summary with unread > 0
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_get_inbox_summary_with_unread(a2a_setup: dict) -> None:
    """Send a message from a2 → a1 has unread."""
    svc = a2a_setup["svc"]
    conv = await svc.get_or_create_conversation("be-dev-1", "be-qa")
    result = await svc.session.execute(
        _sel(A2AConversationTable).where(A2AConversationTable.id == UUID(conv.id))
    )
    row = result.scalar_one()
    # Send from agent_b → agent_a unread increments.
    await svc.send_chat_message(UUID(conv.id), row.agent_b, "hi")
    inbox = await svc.get_inbox_summary(row.agent_a)
    assert inbox.total_unread >= 1


# ---------------------------------------------------------------------------
# send — gateway adapter without skill
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_send_gateway_adapter_skill_none(
    a2a_setup: dict,
) -> None:
    """skill=None branch → options dict stays empty."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    msg = await svc.send(
        from_agent=dev.id,
        to_agent="be-qa",
        task_id=a2a_setup["task_id"],
        body="no skill",
    )
    assert msg.content == "no skill"


@pytest.mark.asyncio
async def test_send_gateway_adapter_with_mocked_conv(
    a2a_setup: dict,
) -> None:
    """Mock get_or_create_conversation + send_chat_message so skill branches run."""
    svc = a2a_setup["svc"]
    dev = a2a_setup["dev"]
    fake_conv = SimpleNamespace(id=str(_u()))
    fake_msg = SimpleNamespace(content="hi", id=str(_u()))
    with (
        patch.object(svc, "_resolve_slug_from_id", AsyncMock(return_value="be-dev-1")),
        patch.object(
            svc, "get_or_create_conversation", AsyncMock(return_value=fake_conv)
        ),
        patch.object(svc, "send_chat_message", AsyncMock(return_value=fake_msg)),
    ):
        # With skill set.
        result = await svc.send(
            from_agent=dev.id,
            to_agent="be-qa",
            task_id=a2a_setup["task_id"],
            body="hi",
            skill="general",
        )
    assert result.content == "hi"
    # Without skill.
    with (
        patch.object(svc, "_resolve_slug_from_id", AsyncMock(return_value="be-dev-1")),
        patch.object(
            svc, "get_or_create_conversation", AsyncMock(return_value=fake_conv)
        ),
        patch.object(svc, "send_chat_message", AsyncMock(return_value=fake_msg)),
    ):
        result2 = await svc.send(
            from_agent=dev.id,
            to_agent="be-qa",
            task_id=a2a_setup["task_id"],
            body="hi-2",
        )
    assert result2.content == "hi"


@pytest.mark.asyncio
async def test_mark_all_read_clears_unread_for_agent(a2a_setup: dict) -> None:
    """mark_all_read zeroes the agent's unread counter across its conversations
    and stamps read_at on the inbound messages, returning the count cleared —
    the bulk ack that lets an agent satisfy i_am_idle's unread-A2A soft-block."""
    svc: A2AService = a2a_setup["svc"]
    db = a2a_setup["db"]
    qa = a2a_setup["qa"]
    conv = await svc.get_or_create_conversation(
        agent_a="be-dev-1", agent_b="be-qa", task_id=a2a_setup["task_id"]
    )
    conv_id = UUID(conv.id)
    # be-dev-1 < be-qa canonically → dev is agent_a; dev's messages bump qa's
    # unread (unread_by_b).
    await svc.send_chat_message(conv_id, "be-dev-1", "one")
    await svc.send_chat_message(conv_id, "be-dev-1", "two")

    cleared = await svc.mark_all_read(qa.id)
    assert cleared == 1

    row = await db.get(A2AConversationTable, conv_id)
    assert row is not None
    assert row.unread_by_b == 0
    msgs = (
        (
            await db.execute(
                select(A2AMessageTable).where(
                    A2AMessageTable.conversation_id == conv_id
                )
            )
        )
        .scalars()
        .all()
    )
    assert all(m.read_at is not None for m in msgs)

    # Idempotent — nothing left unread for qa.
    assert await svc.mark_all_read(qa.id) == 0


# mark_read / mark_all_read — collect-then-mark race coverage (M27)
#
# A send_chat_message committing between the counter-zero and the bulk UPDATE
# used to insert a new read_at NULL row that the UPDATE then stamped as read —
# the new message was silently consumed. The fix mirrors get_unread_messages:
# SELECT the unread IDs first, UPDATE exactly those, recompute the counter from
# the DB. These tests inject a racing message inside the UPDATE step and assert
# the racing message stays unread (read_at NULL) and the counter reflects it.


def _is_a2a_message_update(stmt: object) -> bool:
    """True when ``stmt`` is the bulk ``UPDATE a2a_messages ...`` DML."""
    if not isinstance(stmt, Update):
        return False
    table = getattr(stmt, "table", None)
    return getattr(table, "name", None) == "a2a_messages"


@pytest.mark.asyncio
async def test_mark_read_racing_message_stays_unread(a2a_setup: dict) -> None:
    """A message arriving mid-mark_read (after the SELECT, before the UPDATE)
    must stay unread — the bulk UPDATE stamps only the rows seen at call time,
    and the counter is recomputed from the DB so the new message is counted."""
    svc: A2AService = a2a_setup["svc"]
    db = a2a_setup["db"]
    conv = await svc.get_or_create_conversation(
        agent_a="be-dev-1", agent_b="be-qa", task_id=a2a_setup["task_id"]
    )
    conv_id = UUID(conv.id)
    # dev is agent_a → dev's messages bump qa's unread (unread_by_b).
    await svc.send_chat_message(conv_id, "be-dev-1", "first")

    real_execute = db.execute
    injected: list[str] = []

    async def patched_execute(stmt: object, *args: object, **kwargs: object) -> object:
        if not injected and _is_a2a_message_update(stmt):
            # Race: a new incoming message lands right before the UPDATE runs.
            await svc.send_chat_message(conv_id, "be-dev-1", "racing second")
            injected.append("racing second")
        return await real_execute(stmt, *args, **kwargs)

    db.execute = patched_execute
    try:
        await svc.mark_read(conv_id, "be-qa")
    finally:
        db.execute = real_execute

    assert injected, "test harness must fire the racing send before the UPDATE"

    row = await db.get(A2AConversationTable, conv_id)
    assert row is not None
    msgs = (
        (
            await real_execute(
                select(A2AMessageTable)
                .where(A2AMessageTable.conversation_id == conv_id)
                .order_by(A2AMessageTable.created_at)
            )
        )
        .scalars()
        .all()
    )
    racing = [m for m in msgs if m.content == "racing second"]
    first = [m for m in msgs if m.content == "first"]
    assert racing and first
    assert first[0].read_at is not None, "pre-race message must be marked read"
    assert racing[0].read_at is None, "racing message must stay unread"
    # Counter reflects the actual unread incoming count (1, the racing message).
    assert row.unread_by_b == 1


@pytest.mark.asyncio
async def test_mark_all_read_racing_message_stays_unread(a2a_setup: dict) -> None:
    """Same race for mark_all_read: a message arriving mid-call (after the
    SELECT, before the bulk UPDATE) must stay unread and be reflected in the
    recomputed counter, not silently consumed as read."""
    svc: A2AService = a2a_setup["svc"]
    db = a2a_setup["db"]
    qa = a2a_setup["qa"]
    conv = await svc.get_or_create_conversation(
        agent_a="be-dev-1", agent_b="be-qa", task_id=a2a_setup["task_id"]
    )
    conv_id = UUID(conv.id)
    await svc.send_chat_message(conv_id, "be-dev-1", "first")

    real_execute = db.execute
    injected: list[str] = []

    async def patched_execute(stmt: object, *args: object, **kwargs: object) -> object:
        if not injected and _is_a2a_message_update(stmt):
            await svc.send_chat_message(conv_id, "be-dev-1", "racing second")
            injected.append("racing second")
        return await real_execute(stmt, *args, **kwargs)

    db.execute = patched_execute
    try:
        cleared = await svc.mark_all_read(qa.id)
    finally:
        db.execute = real_execute

    assert injected, "test harness must fire the racing send before the UPDATE"
    assert cleared == 1

    row = await db.get(A2AConversationTable, conv_id)
    assert row is not None
    msgs = (
        (
            await real_execute(
                select(A2AMessageTable)
                .where(A2AMessageTable.conversation_id == conv_id)
                .order_by(A2AMessageTable.created_at)
            )
        )
        .scalars()
        .all()
    )
    racing = [m for m in msgs if m.content == "racing second"]
    first = [m for m in msgs if m.content == "first"]
    assert racing and first
    assert first[0].read_at is not None
    assert racing[0].read_at is None, "racing message must stay unread"
    assert row.unread_by_b == 1
