"""Channels ITs for RoomVoiceConsumer — Phase C of
[[my-sea-invite-voice-blueprint]]. Covers the auth + `_can_join` membership
gate and the signaling relay (hello/present handshake + offer routing).

Scope is injected directly (user + url_route) rather than going through
AuthMiddlewareStack, so no session plumbing is needed. `_can_join` queries the
DB, so this is a TransactionTestCase (committed setUp rows are visible to the
consumer's threaded `database_sync_to_async` query) using the in-memory
channel layer. Tagged `channels` (excluded from the default run).
"""

from channels.db import database_sync_to_async
from channels.testing.websocket import WebsocketCommunicator
from django.contrib.auth.models import AnonymousUser
from django.test import TransactionTestCase, override_settings, tag
from django.utils import timezone

from apps.gameboard.models import SeaInvite
from apps.lyric.models import User
from apps.voice.consumers import RoomVoiceConsumer


TEST_CHANNEL_LAYERS = {
    "default": {"BACKEND": "channels.layers.InMemoryChannelLayer"},
}


def _comm(user, room_id):
    comm = WebsocketCommunicator(RoomVoiceConsumer.as_asgi(), f"/ws/voice/{room_id}/")
    comm.scope["user"] = user
    comm.scope["url_route"] = {"kwargs": {"room_id": room_id}}
    return comm


@tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerGateTest(TransactionTestCase):
    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io", username="discoman")
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.room = f"mysea-{self.owner.id}"

    def _accepted_invite(self, present):
        return SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now() if present else None,
        )

    async def test_owner_connects_and_receives_welcome(self):
        comm = _comm(self.owner, self.room)
        connected, _ = await comm.connect()
        self.assertTrue(connected)
        msg = await comm.receive_json_from()
        self.assertEqual(msg["type"], "welcome")
        self.assertIn("peer_id", msg)
        await comm.disconnect()

    async def test_unauthenticated_is_rejected(self):
        comm = _comm(AnonymousUser(), self.room)
        connected, _ = await comm.connect()
        self.assertFalse(connected)

    async def test_stranger_is_rejected(self):
        comm = _comm(self.bud, self.room)  # bud has no invite yet
        connected, _ = await comm.connect()
        self.assertFalse(connected)

    async def test_present_invitee_connects(self):
        # ORM from an async test must be offloaded off the event loop.
        await database_sync_to_async(self._accepted_invite)(present=True)
        comm = _comm(self.bud, self.room)
        connected, _ = await comm.connect()
        self.assertTrue(connected)
        await comm.disconnect()

    async def test_accepted_but_not_present_is_rejected(self):
        await database_sync_to_async(self._accepted_invite)(present=False)
        comm = _comm(self.bud, self.room)
        connected, _ = await comm.connect()
        self.assertFalse(connected)


@tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerCapacityTest(TransactionTestCase):
    """Defense-in-depth member cap (2026-05-29): at most VOICE_MAX_MEMBERS live
    connections per room, even from multi-tab. The slot is freed on disconnect
    so the room reopens. The owner can hold many connections here (the counter
    is per-connection), which keeps the test independent of the deposit gate."""

    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io", username="discoman")
        self.room = f"mysea-{self.owner.id}"   # fresh UUID → fresh slot counter

    async def test_refuses_a_connection_past_the_member_cap(self):
        from apps.voice.consumers import VOICE_MAX_MEMBERS
        comms = []
        for i in range(VOICE_MAX_MEMBERS):
            c = _comm(self.owner, self.room)
            connected, _ = await c.connect()
            self.assertTrue(connected, f"member {i} should fit")
            comms.append(c)
        # One past the cap is refused.
        over = _comm(self.owner, self.room)
        connected, _ = await over.connect()
        self.assertFalse(connected)
        # A disconnect frees a slot → the room reopens.
        await comms.pop().disconnect()
        reopened = _comm(self.owner, self.room)
        connected, _ = await reopened.connect()
        self.assertTrue(connected)
        comms.append(reopened)
        for c in comms:
            await c.disconnect()


@tag("channels")
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
class RoomVoiceConsumerSignalingTest(TransactionTestCase):
    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io", username="discoman")
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
        )
        self.room = f"mysea-{self.owner.id}"

    async def _connect(self, user):
        comm = _comm(user, self.room)
        await comm.connect()
        welcome = await comm.receive_json_from()
        return comm, welcome["peer_id"]

    async def test_hello_triggers_present_and_hello_handshake(self):
        owner_comm, owner_pid = await self._connect(self.owner)
        bud_comm, bud_pid = await self._connect(self.bud)
        # Bud announces arrival.
        await bud_comm.send_json_to({"type": "hello"})
        # Owner is told a newcomer (bud) arrived; bud is told owner is present.
        owner_msg = await owner_comm.receive_json_from()
        self.assertEqual(owner_msg, {"type": "hello", "source": bud_pid})
        bud_msg = await bud_comm.receive_json_from()
        self.assertEqual(bud_msg, {"type": "present", "source": owner_pid})
        await owner_comm.disconnect()
        await bud_comm.disconnect()

    async def test_offer_is_relayed_to_target_peer(self):
        owner_comm, owner_pid = await self._connect(self.owner)
        bud_comm, bud_pid = await self._connect(self.bud)
        await bud_comm.send_json_to({
            "type": "offer", "target": owner_pid, "payload": {"sdp": "X"},
        })
        msg = await owner_comm.receive_json_from()
        self.assertEqual(msg["type"], "offer")
        self.assertEqual(msg["source"], bud_pid)
        self.assertEqual(msg["payload"], {"sdp": "X"})
        await owner_comm.disconnect()
        await bud_comm.disconnect()

    async def test_disconnect_broadcasts_left(self):
        owner_comm, owner_pid = await self._connect(self.owner)
        bud_comm, bud_pid = await self._connect(self.bud)
        await bud_comm.disconnect()
        msg = await owner_comm.receive_json_from()
        self.assertEqual(msg, {"type": "left", "source": bud_pid})
        await owner_comm.disconnect()
