"""Tests for the gateway-hosted ``groups.*`` JSON-RPC contract."""

from __future__ import annotations

from types import SimpleNamespace

import pytest

from gateway.hosted_rooms import default_db_path as hosted_rooms_default_db_path
import tui_gateway.server as srv
from tui_gateway import methods_groups


@pytest.fixture
def home(tmp_path, monkeypatch):
    class DurableRunStore:
        durable = True

    path = tmp_path / ".hermes"
    path.mkdir()
    (path / "profiles" / "ops").mkdir(parents=True)
    (path / "profiles" / "ops" / "config.yaml").write_text("{}\n")  # identity marker: local roster
    monkeypatch.setenv("HERMES_HOME", str(path))
    monkeypatch.setattr(srv, "_run_idempotency_store", DurableRunStore(), raising=False)
    methods_groups.stop_hosted_room_service(timeout=1.0)
    methods_groups.start_hosted_room_service()
    yield path
    methods_groups.stop_hosted_room_service(timeout=1.0)


def _result(envelope):
    assert "error" not in envelope, envelope
    return envelope["result"]


def _server_authority():
    from gateway.hosted_rooms import local_authority_gateway_id

    return local_authority_gateway_id()


def _create_room():
    return _result(
        srv._methods["groups.create"](
            1,
            {
                "room_id": "room-1",
                "name": "Release room",
                "members": [
                    {
                        "member_id": "default",
                        "profile": "default",
                        "handle": "hermes",
                    },
                    {"member_id": "ops", "profile": "ops", "handle": "ops"},
                ],
                "authority_gateway_id": "gateway-a",
            },
        )
    )["room"]




def test_capabilities_and_invitation_advertise_scoped_roomlink(home, monkeypatch):
    monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890")
    monkeypatch.setenv("HERMES_PROFILE", "reviewer")
    # The advertised policy is the SERVED profile's own config, so the profile must exist (#116900).
    (home / "profiles" / "reviewer").mkdir(parents=True)
    (home / "profiles" / "reviewer" / "config.yaml").write_text("approvals:\n  mode: manual\n")
    result = _result(srv._methods["groups.capabilities"](1, {}))
    assert result["room_link"]["enabled"] is True
    assert result["room_link"]["profile"] == "reviewer"
    assert result["room_link"]["catalog"]["text"] is True
    assert "groups.peer.invite" in result["methods"]
    assert "groups.peer.register" in result["methods"]

    invitation = _result(
        srv._methods["groups.peer.invite"](
            2,
            {
                "room_id": "room-1",
                "home_install_id": "install-home",
                "authority_gateway_id": "install-home",
                "authority_epoch": 1,
                "member_id": "member-peer",
                "grant_id": "grant-room-1",
            },
        )
    )
    assert invitation["target_profile"] == "reviewer"
    assert invitation["catalog"] == result["room_link"]["catalog"]
    assert "." in invitation["grant"]
    from gateway import hosted_rooms

    assert hosted_rooms.peer_room_is_reserved(
        hosted_rooms.default_db_path(),
        room_id="room-1",
        target_profile="reviewer",
    )


def test_capabilities_disable_roomlink_when_run_replay_is_not_durable(
    home, monkeypatch
):
    import tui_gateway.methods_groups as groups_methods

    class VolatileRunStore:
        durable = False

    class BoundServer:
        _run_idempotency_store = VolatileRunStore()

    monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890")
    monkeypatch.setattr(groups_methods, "_bound_server", BoundServer())
    result = _result(srv._methods["groups.capabilities"](1, {}))
    assert result["room_link"] == {
        "enabled": False,
        "reason": "durable_run_storage_required",
    }
    invitation = srv._methods["groups.peer.invite"](
        2,
        {
            "room_id": "room-volatile",
            "home_install_id": "install-home",
            "authority_gateway_id": "install-home",
            "authority_epoch": 1,
            "member_id": "member-peer",
        },
    )
    assert invitation["error"]["code"] == 4120


def test_capabilities_open_shared_durable_run_store_without_test_injection(
    home, monkeypatch
):
    """The production dashboard server must not depend on fixture injection."""

    monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890")
    monkeypatch.delattr(srv, "_run_idempotency_store", raising=False)

    result = _result(srv._methods["groups.capabilities"](1, {}))
    store = srv._run_idempotency_store
    try:
        assert store.durable is True
        assert result["room_link"]["enabled"] is True
    finally:
        store.close()


def test_app_managed_catalog_and_self_advertised_endpoint_are_consistent(
    home, monkeypatch
):
    monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890")
    monkeypatch.setenv("HERMES_DESKTOP", "1")
    monkeypatch.setenv("HERMES_ROOM_LINK_URL", "https://peer.example.test/hermes")
    capability = _result(srv._methods["groups.capabilities"](1, {}))
    invitation = _result(
        srv._methods["groups.peer.invite"](
            2,
            {
                "room_id": "room-1",
                "home_install_id": "install-home",
                "authority_gateway_id": "install-home",
                "authority_epoch": 1,
                "member_id": "member-peer",
            },
        )
    )
    assert capability["persistent_process"] is False
    assert capability["room_link"]["catalog"] == invitation["catalog"]
    assert capability["room_link"]["endpoint"] == {
        "available": True,
        "url": "https://peer.example.test/hermes",
        "transport_security": "tls",
    }
    assert invitation["endpoint"] == capability["room_link"]["endpoint"]


def test_launch_profile_is_valid_for_roomlink_invitation(home, monkeypatch):
    monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890")
    monkeypatch.setenv("HERMES_PROFILE", "default")

    capability = _result(
        srv._methods["groups.capabilities"](1, {"profile": "default"})
    )
    invitation = _result(
        srv._methods["groups.peer.invite"](
            2,
            {
                "room_id": "room-default",
                "home_install_id": "install-home",
                "authority_gateway_id": "install-home",
                "authority_epoch": 1,
                "member_id": "member-default",
                "profile": "default",
            },
        )
    )

    assert capability["room_link"]["enabled"] is True
    assert capability["room_link"]["profile"] == "default"
    assert invitation["target_profile"] == "default"


def test_roomlink_endpoint_absence_has_machine_reason(home, monkeypatch):
    monkeypatch.setenv("API_SERVER_KEY", "gateway-api-key-1234567890")
    monkeypatch.delenv("HERMES_ROOM_LINK_URL", raising=False)
    result = _result(srv._methods["groups.capabilities"](1, {}))
    assert result["room_link"]["endpoint"] == {
        "available": False,
        "reason": "not_configured",
    }


def test_multiplexed_invitation_uses_exact_profile_secret(home, monkeypatch):
    from gateway.hosted_room_peer import (
        HostedRoomGrantError,
        decode_room_grant,
        derive_room_grant_secret,
        gateway_room_grant_secret,
    )

    reviewer_home = home / "profiles" / "reviewer"
    reviewer_home.mkdir(parents=True)
    (reviewer_home / "config.yaml").write_text("{}\n")  # identity marker
    reviewer_key = "reviewer-api-key-1234567890"
    default_key = "default-api-key-1234567890"
    (reviewer_home / ".env").write_text(
        f"API_SERVER_KEY={reviewer_key}\n", encoding="utf-8"
    )
    monkeypatch.setenv("API_SERVER_KEY", default_key)
    invitation = _result(
        srv._methods["groups.peer.invite"](
            3,
            {
                "room_id": "room-1",
                "home_install_id": "install-home",
                "authority_gateway_id": "gateway-home",
                "authority_epoch": 1,
                "member_id": "member-reviewer",
                "profile": "reviewer",
            },
        )
    )
    claims = decode_room_grant(
        gateway_room_grant_secret(home),
        invitation["grant"],
        permission="status",
    )
    assert claims["target_profile"] == "reviewer"
    with pytest.raises(HostedRoomGrantError, match="signature"):
        decode_room_grant(
            derive_room_grant_secret(default_key),
            invitation["grant"],
            permission="status",
        )


def test_named_profile_needs_no_copied_api_key_for_roomlink(home, monkeypatch):
    from gateway.hosted_room_peer import (
        HostedRoomGrantError,
        decode_room_grant,
        derive_room_grant_secret,
        gateway_room_grant_secret,
    )

    reviewer_home = home / "profiles" / "reviewer"
    reviewer_home.mkdir(parents=True)
    (reviewer_home / "config.yaml").write_text("{}\n")  # identity marker
    gateway_key = "gateway-api-key-1234567890"
    monkeypatch.setenv("API_SERVER_KEY", gateway_key)

    invitation = _result(
        srv._methods["groups.peer.invite"](
            4,
            {
                "room_id": "room-named-bot",
                "home_install_id": "install-home",
                "authority_gateway_id": "gateway-home",
                "authority_epoch": 1,
                "member_id": "member-reviewer",
                "profile": "reviewer",
            },
        )
    )

    claims = decode_room_grant(
        gateway_room_grant_secret(home),
        invitation["grant"],
        permission="status",
    )
    assert claims["target_profile"] == "reviewer"
    with pytest.raises(HostedRoomGrantError, match="signature"):
        decode_room_grant(
            derive_room_grant_secret(gateway_key),
            invitation["grant"],
            permission="status",
        )


def test_register_peer_route_probes_scope_and_persists_via_service(home, monkeypatch):
    from gateway.hosted_room_peer import catalog_mapping
    from gateway.hosted_rooms import local_authority_gateway_id

    catalog = catalog_mapping(
            target_profile="default",
        installation_id="install-peer",
        persistent_process=True,
    )
    captured = {}
    room = _create_room()

    class FakeClient:
        def __init__(self, *, base_url, api_key, **kwargs):
            captured["base_url"] = base_url
            captured["api_key"] = api_key

        def probe(self, *, grant):
            captured["grant"] = grant
            return {
                "room_id": "room-1",
                "home_install_id": local_authority_gateway_id(),
                "authority_gateway_id": room["authority_gateway_id"],
                "authority_epoch": room["authority_epoch"],
                "member_id": "member-peer",
                "target_profile": "reviewer",
                "catalog": catalog,
            }

    class FakeService:
        db_path = hosted_rooms_default_db_path()

        def register_peer_route(self, **kwargs):
            captured["registered"] = kwargs

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    monkeypatch.setattr(
        "tui_gateway.hosted_room_peer_http.PeerRunsHTTPClient",
        FakeClient,
    )
    result = _result(
        srv._methods["groups.peer.register"](
            3,
            {
                "room_id": "room-1",
                "member_id": "member-peer",
                "target_url": "https://peer.example.test",
                "target_profile": "reviewer",
                "grant": "signed.room.grant",
                "catalog": catalog,
            },
        )
    )
    assert result["registered"] is True
    assert captured["api_key"] == ""
    assert captured["registered"]["target_url"] == ("https://peer.example.test")


def test_register_rejects_plaintext_non_loopback(home, monkeypatch):
    class FakeService:
        db_path = hosted_rooms_default_db_path()

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    response = srv._methods["groups.peer.register"](
        4,
        {
            "room_id": "room-1",
            "member_id": "member-peer",
            "target_url": "http://peer.example.test:8377",
            "target_profile": "reviewer",
            "grant": "signed.room.grant",
            "catalog": {},
        },
    )
    assert response["error"]["code"] == 5120
    assert "https outside" in response["error"]["message"]


def test_register_requires_roomlink_protocol_v2(home, monkeypatch):
    from gateway.hosted_room_peer import catalog_mapping

    class FakeService:
        db_path = hosted_rooms_default_db_path()

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    response = srv._methods["groups.peer.register"](
        5,
        {
            "room_id": "room-1",
            "member_id": "member-peer",
            "target_url": "https://peer.example.test",
            "target_profile": "reviewer",
            "grant": "signed.room.grant",
            "catalog": catalog_mapping(
            target_profile="default",
                installation_id="install-peer",
                protocol_versions=(1,),
                persistent_process=True,
            ),
        },
    )
    assert response["error"]["code"] == 5120
    assert "protocol v2" in response["error"]["message"]


def test_create_list_send_and_log_roundtrip(home):
    room = _create_room()
    assert room["idempotent"] is False

    listed = _result(srv._methods["groups.list"](2, {}))
    assert [item["room_id"] for item in listed["rooms"]] == ["room-1"]
    assert listed["next_offset"] is None
    state = _result(srv._methods["groups.state"](3, {"room_id": "room-1"}))
    assert state["room"]["authority_gateway_id"] == _server_authority()
    assert state["room"]["authority_epoch"] == 1
    assert state["room"]["latest_seq"] == 0

    sent = _result(
        srv._methods["groups.send"](
            4,
            {
                "room_id": "room-1",
                "event_id": "event-1",
                "actor": {"kind": "user", "id": "desktop-user"},
                "payload": {"text": "hello", "thread_id": "thread-1"},
            },
        )
    )
    assert sent["accepted"] is True
    assert sent["driver_started"] is True
    assert sent["event"]["seq"] == 1
    assert sent["event"]["kind"] == "message.user"
    assert sent["event"]["actor"] == {"kind": "user", "id": "desktop"}

    replay = _result(
        srv._methods["groups.log"](
            5,
            {"room_id": "room-1", "since_seq": 0},
        )
    )
    assert replay["latest_seq"] == replay["cursor"] == 1
    assert replay["events"][0]["payload"] == {
        "text": "hello",
        "thread_id": "thread-1",
    }


def test_groups_list_returns_bounded_pages(home):
    _create_room()
    _result(
        srv._methods["groups.create"](
            2,
            {
                "room_id": "room-2",
                "name": "Second room",
                "members": [
                    {
                        "member_id": "default",
                        "profile": "default",
                        "handle": "hermes",
                    },
                    {"member_id": "ops", "profile": "ops", "handle": "ops"},
                ],
            },
        )
    )

    first = _result(srv._methods["groups.list"](3, {"limit": 1}))
    second = _result(
        srv._methods["groups.list"](
            4,
            {"limit": 1, "offset": first["next_offset"]},
        )
    )

    assert first["next_offset"] == 1
    assert second["next_offset"] == 2
    assert {first["rooms"][0]["room_id"], second["rooms"][0]["room_id"]} == {
        "room-1",
        "room-2",
    }
    final = _result(srv._methods["groups.list"](5, {"limit": 1, "offset": 2}))
    assert final["rooms"] == []
    assert final["next_offset"] is None


def test_rpc_retry_is_idempotent_and_conflict_is_visible(home):
    _create_room()
    params = {
        "room_id": "room-1",
        "event_id": "event-1",
        "actor": {"kind": "user", "id": "desktop-user"},
        "payload": {"text": "hello", "thread_id": "thread-1"},
    }
    first = _result(srv._methods["groups.send"](2, params))
    repeated = _result(srv._methods["groups.send"](3, params))

    assert first["event"]["seq"] == repeated["event"]["seq"] == 1
    assert first["client_event_id"] == repeated["client_event_id"] == "event-1"
    assert first["event"]["event_id"].startswith("user:")
    assert repeated["event"]["idempotent"] is True

    conflict = srv._methods["groups.send"](
        4,
        {
            **params,
            "payload": {"text": "different", "thread_id": "thread-1"},
        },
    )
    assert conflict["error"]["code"] == 4111


def test_foreign_authority_cannot_send_or_disband(home):
    from gateway.hosted_rooms import (
        claim_authority,
        default_db_path,
        list_rooms,
        read_events,
    )

    _create_room()
    claim_authority(
        default_db_path(),
        room_id="room-1",
        expected_gateway_id=_server_authority(),
        expected_epoch=1,
        new_gateway_id="foreign-gateway",
        event_id="claim-foreign",
    )
    before = read_events(default_db_path(), room_id="room-1")

    sent = srv._methods["groups.send"](
        2,
        {
            "room_id": "room-1",
            "event_id": "stale-send",
            "payload": {"text": "must not land", "thread_id": "thread-1"},
        },
    )
    disbanded = srv._methods["groups.disband"](3, {"room_id": "room-1"})

    assert sent["error"]["code"] == 4111
    assert sent["error"]["data"] == {"reason": "authority_conflict"}
    assert disbanded["error"]["code"] == 4113
    assert disbanded["error"]["data"] == {"reason": "authority_conflict"}
    assert read_events(default_db_path(), room_id="room-1") == before
    assert list_rooms(default_db_path())[0]["room_id"] == "room-1"


def test_client_event_id_cannot_squat_disband_receipt(home, monkeypatch):
    from gateway import hosted_rooms

    _create_room()
    service = methods_groups.get_hosted_room_service()
    assert service is not None

    def append_without_starting_work(*, room_id, event_id, payload):
        room = hosted_rooms.room_state(service.db_path, room_id=room_id)
        return hosted_rooms.append_event(
            service.db_path,
            room_id=room_id,
            event_id=event_id,
            kind="message.user",
            actor={"kind": "user", "id": "desktop"},
            payload=payload,
            authority_gateway_id=str(room["authority_gateway_id"]),
            authority_epoch=int(room["authority_epoch"]),
        )

    monkeypatch.setattr(service, "send", append_without_starting_work)
    monkeypatch.setattr(service, "stop_room", lambda *_args, **_kwargs: 0)
    sent = _result(
        srv._methods["groups.send"](
            2,
            {
                "room_id": "room-1",
                "event_id": "system:room-disbanded",
                "payload": {"text": "still a user message", "thread_id": "thread-1"},
            },
        )
    )

    assert sent["client_event_id"] == "system:room-disbanded"
    assert sent["event"]["event_id"].startswith("user:")
    assert sent["event"]["event_id"] != "system:room-disbanded"
    first = _result(srv._methods["groups.disband"](3, {"room_id": "room-1"}))
    repeated = _result(srv._methods["groups.disband"](4, {"room_id": "room-1"}))
    assert first["tombstone"]["event"]["event_id"] == "system:room-disbanded"
    assert repeated["tombstone"]["idempotent"] is True

    replay = _result(
        srv._methods["groups.log"](
            5,
            {"room_id": "room-1", "include_disbanded": True},
        )
    )
    kinds = [event["kind"] for event in replay["events"]]
    assert kinds[0] == "message.user"
    assert kinds[-1] == "room.disbanded"
    assert kinds.count("room.disbanded") == 1


def test_send_does_not_trust_client_supplied_actor_identity(home):
    _create_room()
    sent = _result(
        srv._methods["groups.send"](
            2,
            {
                "room_id": "room-1",
                "event_id": "event-1",
                "actor": {"kind": "user", "id": "spoofed-user"},
                "payload": {"text": "hello", "thread_id": "thread-1"},
            },
        )
    )

    assert sent["event"]["actor"] == {"kind": "user", "id": "desktop"}


def test_create_ignores_client_supplied_authority_identity(home):
    members = [
        {"member_id": "default", "profile": "default", "handle": "hermes"},
        {"member_id": "ops", "profile": "ops", "handle": "ops"},
    ]
    created = _result(
        srv._methods["groups.create"](
            1,
            {"room_id": "legacy-room", "name": "Legacy", "members": members},
        )
    )["room"]
    retried = _result(
        srv._methods["groups.create"](
            2,
            {
                "room_id": "legacy-room",
                "name": "Legacy",
                "members": members,
                "authority_gateway_id": "spoofed-gateway",
            },
        )
    )["room"]

    assert created["authority_gateway_id"] == _server_authority()
    assert retried["authority_gateway_id"] == _server_authority()
    assert retried["idempotent"] is True


def test_legacy_room_adoption_emits_one_lineage_receipt(home):
    from gateway.hosted_rooms import create_room, default_db_path

    members = [
        {"member_id": "default", "profile": "default", "handle": "hermes"},
        {"member_id": "ops", "profile": "ops", "handle": "ops"},
    ]
    create_room(
        default_db_path(),
        room_id="legacy-room",
        name="Legacy",
        members=members,
        authority_gateway_id="legacy",
        now=1,
    )

    adopted = _result(
        srv._methods["groups.create"](
            2,
            {"room_id": "legacy-room", "name": "Legacy", "members": members},
        )
    )["room"]
    state = _result(srv._methods["groups.state"](3, {"room_id": "legacy-room"}))["room"]

    assert adopted["adopted"] is True
    assert adopted["authority_gateway_id"] == _server_authority()
    assert adopted["authority_epoch"] == 2
    assert adopted["claim_event"]["payload"] == {
        "previous_gateway_id": "legacy",
        "authority_gateway_id": _server_authority(),
        "authority_epoch": 2,
    }
    assert state["authority_claim"]["event_id"] == "system:authority-adopted"
    assert state["latest_seq"] == 1


@pytest.mark.parametrize(
    ("method_name", "params"),
    [
        (
            "groups.create",
            {
                "room_id": "",
                "name": "x",
                "members": [],
                "authority_gateway_id": "gateway-a",
            },
        ),
        (
            "groups.send",
            {
                "room_id": "missing",
                "event_id": "event-1",
                "actor": {"kind": "user", "id": "desktop-user"},
                "payload": {},
            },
        ),
        ("groups.log", {"room_id": "missing", "since_seq": 0}),
    ],
)
def test_invalid_or_unknown_room_returns_contract_error(home, method_name, params):
    result = srv._methods[method_name](1, params)
    assert result["error"]["code"] in {4110, 4111, 4112, 5111, 5112}


def test_retry_and_approval_controls_forward_only_exact_local_coordinates(
    home, monkeypatch
):
    calls = []
    identity = SimpleNamespace(
        room_id="room-1",
        task_id="task-1",
        thread_id="thread-1",
        turn_id="turn-1",
    )
    service = SimpleNamespace(
        retry_room_task=lambda room_id, task_id: (
            calls.append(("retry", room_id, task_id))
            or {
                "identity": identity,
                "status": "queued",
                "execution_generation": 1,
                "cancel_generation": 0,
            }
        ),
        approve_room_task=lambda room_id, **kwargs: (
            calls.append(("approve", room_id, kwargs)) or {"resolved": 1}
        ),
    )
    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: service)

    retried = _result(
        srv._methods["groups.retry"](
            1,
            {"room_id": "room-1", "task_id": "task-1"},
        )
    )
    approved = _result(
        srv._methods["groups.approve"](
            2,
            {
                "room_id": "room-1",
                "member_id": "ops",
                "task_id": "task-1",
                "execution_generation": 1,
                "request_id": "approval-1",
                "choice": "once",
            },
        )
    )

    assert retried["task"] == {
        "room_id": "room-1",
        "task_id": "task-1",
        "thread_id": "thread-1",
        "turn_id": "turn-1",
        "status": "queued",
        "execution_generation": 1,
        "cancel_generation": 0,
    }
    assert approved == {"approved": True, "result": {"resolved": 1}}
    assert calls == [
        ("retry", "room-1", "task-1"),
        (
            "approve",
            "room-1",
            {
                "member_id": "ops",
                "task_id": "task-1",
                "execution_generation": 1,
                "choice": "once",
                "request_id": "approval-1",
            },
        ),
    ]


@pytest.mark.parametrize(
    ("method_name", "params"),
    [
        ("groups.create", {"room_id": "room-1", "name": "Room", "members": []}),
        ("groups.send", {"room_id": "room-1", "event_id": "event-1", "payload": {}}),
        ("groups.disband", {"room_id": "room-1"}),
        ("groups.stop", {"room_id": "room-1"}),
        ("groups.retry", {"room_id": "room-1", "task_id": "task-1"}),
        (
            "groups.approve",
            {
                "room_id": "room-1",
                "member_id": "ops",
                "task_id": "task-1",
                "execution_generation": 1,
                "request_id": "approval-1",
                "choice": "once",
            },
        ),
    ],
)
def test_mutating_controls_fail_closed_without_a_supervised_worker(
    home, monkeypatch, method_name, params
):
    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: None)

    result = srv._methods[method_name](1, params)

    assert result["error"]["code"] in {4115, 4123}


def test_disband_tombstones_room(home):
    _create_room()
    first = _result(srv._methods["groups.disband"](3, {"room_id": "room-1"}))
    repeated = _result(srv._methods["groups.disband"](4, {"room_id": "room-1"}))
    assert first["tombstone"]["idempotent"] is False
    assert repeated["tombstone"]["idempotent"] is True
    assert _result(srv._methods["groups.list"](5, {}))["rooms"] == []
    deleted = _result(srv._methods["groups.list"](6, {"include_disbanded": True}))[
        "rooms"
    ]
    assert deleted[0]["disbanded_at"] == first["tombstone"]["disbanded_at"]
    replay = _result(
        srv._methods["groups.log"](
            7,
            {"room_id": "room-1", "include_disbanded": True},
        )
    )
    assert [event["kind"] for event in replay["events"]] == [
        "room.stop_requested",
        "room.disbanded",
    ]


def test_pruned_room_send_and_log_report_expired_history(home, monkeypatch):
    from gateway import hosted_rooms

    members = [
        {"member_id": "default", "profile": "default", "handle": "hermes"},
        {"member_id": "ops", "profile": "ops", "handle": "ops"},
    ]
    _create_room()
    monkeypatch.setattr(hosted_rooms, "MAX_DISBANDED_ROOM_TOMBSTONES", 0)
    _result(srv._methods["groups.disband"](2, {"room_id": "room-1"}))
    repeated = _result(
        srv._methods["groups.disband"](3, {"room_id": "room-1"})
    )["tombstone"]
    assert repeated["idempotent"] is True
    assert repeated["history_expired"] is True

    sent = srv._methods["groups.send"](
        4,
        {
            "room_id": "room-1",
            "event_id": "stale-send",
            "payload": {"text": "stale", "thread_id": "thread-1"},
        },
    )
    logged = srv._methods["groups.log"](
        5,
        {"room_id": "room-1", "include_disbanded": True},
    )
    renamed = srv._methods["groups.rename"](
        6,
        {"room_id": "room-1", "event_id": "stale-rename", "name": "Stale"},
    )

    assert sent["error"]["code"] == 4111, sent
    assert logged["error"]["code"] == 4112, logged
    assert renamed["error"]["code"] == 4117, renamed
    assert sent["error"]["data"] == {"reason": "room_history_expired"}
    assert logged["error"]["data"] == {"reason": "room_history_expired"}
    assert renamed["error"]["data"] == {"reason": "room_history_expired"}

    recreated = srv._methods["groups.create"](
        7,
        {"room_id": "room-1", "name": "Replacement", "members": members},
    )
    assert recreated["error"]["code"] == 4110
    created = _result(
        srv._methods["groups.create"](
            8,
            {"room_id": "room-new", "name": "Fresh", "members": members},
        )
    )
    assert created["room"]["room_id"] == "room-new"


def test_disband_stops_and_revokes_before_tombstoning(home, monkeypatch):
    _create_room()
    calls = []

    class FakeService:
        db_path = hosted_rooms_default_db_path()

        def stop_room(self, room_id, **_kwargs):
            calls.append(("stop", room_id))

        def revoke_room_routes(self, room_id):
            calls.append(("revoke", room_id))

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    _result(srv._methods["groups.disband"](9, {"room_id": "room-1"}))

    assert calls == [("stop", "room-1"), ("revoke", "room-1")]
    assert _result(srv._methods["groups.list"](10, {}))["rooms"] == []


def test_failed_remote_revocation_keeps_room_recoverable(home, monkeypatch):
    _create_room()

    class FakeService:
        db_path = hosted_rooms_default_db_path()

        def stop_room(self, _room_id, **_kwargs):
            return 1

        def revoke_room_routes(self, _room_id):
            raise RuntimeError("peer is offline")

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    result = srv._methods["groups.disband"](11, {"room_id": "room-1"})

    assert result["error"]["code"] == 5114
    assert [
        room["room_id"]
        for room in _result(srv._methods["groups.list"](12, {}))["rooms"]
    ] == ["room-1"]


def test_disband_does_not_revoke_routes_while_stop_is_unacknowledged(
    home, monkeypatch
):
    _create_room()
    calls = []

    class FakeService:
        db_path = hosted_rooms_default_db_path()

        def stop_room(self, _room_id, **kwargs):
            calls.append(("stop", kwargs["require_acknowledged"]))
            raise RuntimeError("room work is still stopping")

        def revoke_room_routes(self, _room_id):
            calls.append(("revoke", True))

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    result = srv._methods["groups.disband"](13, {"room_id": "room-1"})

    assert result["error"]["code"] == 5114
    assert calls == [("stop", True)]
    assert [
        room["room_id"]
        for room in _result(srv._methods["groups.list"](14, {}))["rooms"]
    ] == ["room-1"]


def test_approve_routes_one_exact_peer_action(home, monkeypatch):
    captured = {}

    class FakeService:
        def approve_room_task(self, room_id, **kwargs):
            captured["room_id"] = room_id
            captured.update(kwargs)
            return {"resolved": 1}

    monkeypatch.setattr(srv, "get_hosted_room_service", lambda: FakeService())
    result = _result(
        srv._methods["groups.approve"](
            8,
            {
                "room_id": "room-1",
                "member_id": "member-peer",
                "task_id": "task-1",
                "execution_generation": 2,
                "request_id": "approval-1",
                "choice": "once",
            },
        )
    )

    assert result == {"approved": True, "result": {"resolved": 1}}
    assert captured == {
        "room_id": "room-1",
        "member_id": "member-peer",
        "task_id": "task-1",
        "execution_generation": 2,
        "request_id": "approval-1",
        "choice": "once",
    }
