"""Signaling-only relay for a self-hosted WebRTC mesh — Phase C of
[[my-sea-invite-voice-blueprint]] (per disco-voice-mesh.pdf).

Media (the actual audio) never touches the server — only SDP offers/answers
and ICE candidates are relayed. The server is room-agnostic: `room_id` is a
STRING url kwarg — `mysea-<owner_id>` for my-sea today, an epic room UUID
later (the `_can_join` gate is the only room-type-aware piece, so epic rooms
reuse this consumer unchanged).

Mesh handshake (newcomer offers to each existing peer):
    client → hello                         (after `welcome`)
    server → peer.hello broadcast → each existing peer replies `present`
    newcomer ← present (per existing peer) → newcomer sends `offer` to each
    offer / answer / ice are relayed point-to-point by `target` peer id,
    tagged with the `source` peer id.
    On disconnect the server broadcasts `left` so peers tear down their
    RTCPeerConnection.
"""

import uuid as uuidlib

from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer

# Max live connections per room — the owner (seat 1C) + up to 5 visitors
# (2C–6C), mirroring `gameboard.models.MY_SEA_MAX_VISITORS`. The deposit gate
# already caps *presence* at 5 visitors, so this is defense-in-depth: it also
# refuses extra connections from multi-tab / stale-presence edge cases.
VOICE_MAX_MEMBERS = 6
_SLOT_TTL = 26 * 3600   # > the 24h voice window, so a leaked slot self-clears


class RoomVoiceConsumer(AsyncJsonWebsocketConsumer):
    async def connect(self):
        self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
        self.user = self.scope.get("user")
        self.room_group = None
        self.peer_group = None
        self.peer_id = None
        self._claimed = False
        if not (self.user and getattr(self.user, "is_authenticated", False)):
            await self.close()
            return
        if not await self._can_join(self.user, self.room_id):
            await self.close()
            return
        # Member cap — claim a slot or refuse (the room is full).
        if not await self._claim_slot():
            await self.close()
            return
        self._claimed = True
        self.peer_id = uuidlib.uuid4().hex
        self.room_group = f"voice.{self.room_id}"
        self.peer_group = f"peer.{self.peer_id}"
        await self.channel_layer.group_add(self.room_group, self.channel_name)
        await self.channel_layer.group_add(self.peer_group, self.channel_name)
        await self.accept()
        # Hand the client its own peer id; it sends `hello` next.
        await self.send_json({"type": "welcome", "peer_id": self.peer_id})

    async def disconnect(self, code):
        if self.room_group:
            await self.channel_layer.group_send(
                self.room_group, {"type": "peer.left", "source": self.peer_id},
            )
            await self.channel_layer.group_discard(self.room_group, self.channel_name)
        if self.peer_group:
            await self.channel_layer.group_discard(self.peer_group, self.channel_name)
        if self._claimed:
            await self._release_slot()
            self._claimed = False

    async def receive_json(self, content):
        mtype = content.get("type")
        if mtype == "hello":
            await self.channel_layer.group_send(
                self.room_group, {"type": "peer.hello", "source": self.peer_id},
            )
        elif mtype in ("offer", "answer", "ice"):
            target = content.get("target")
            if target:
                await self.channel_layer.group_send(
                    f"peer.{target}",
                    {
                        "type": f"relay.{mtype}",
                        "kind": mtype,
                        "source": self.peer_id,
                        "payload": content.get("payload"),
                    },
                )

    # ── room-group fan-out handlers ─────────────────────────────────────
    async def peer_hello(self, event):
        src = event.get("source")
        if src == self.peer_id:
            return  # don't echo our own hello
        # Tell the newcomer we're already present (they'll offer to us)…
        await self.channel_layer.group_send(
            f"peer.{src}", {"type": "peer.present", "source": self.peer_id},
        )
        # …and note the newcomer locally so we're ready to answer their offer.
        await self.send_json({"type": "hello", "source": src})

    async def peer_present(self, event):
        await self.send_json({"type": "present", "source": event["source"]})

    async def peer_left(self, event):
        if event.get("source") == self.peer_id:
            return
        await self.send_json({"type": "left", "source": event["source"]})

    # ── point-to-point signaling relay ──────────────────────────────────
    async def relay_offer(self, event):
        await self._relay(event)

    async def relay_answer(self, event):
        await self._relay(event)

    async def relay_ice(self, event):
        await self._relay(event)

    async def _relay(self, event):
        await self.send_json({
            "type": event["kind"],
            "source": event["source"],
            "payload": event["payload"],
        })

    # ── member-cap slot counter (atomic, cache-backed) ──────────────────
    def _slot_key(self):
        return f"voice_slot:{self.room_id}"

    @database_sync_to_async
    def _claim_slot(self):
        """Atomically take a connection slot; False if the room is already
        full (VOICE_MAX_MEMBERS). `cache.incr` is atomic on Redis + LocMem, so
        concurrent connects can't oversubscribe past the cap."""
        from django.core.cache import cache
        key = self._slot_key()
        cache.add(key, 0, _SLOT_TTL)
        try:
            n = cache.incr(key)
        except ValueError:                 # key expired between add + incr
            cache.set(key, 1, _SLOT_TTL)
            n = 1
        if n > VOICE_MAX_MEMBERS:
            try:
                cache.decr(key)
            except ValueError:
                pass
            return False
        return True

    @database_sync_to_async
    def _release_slot(self):
        from django.core.cache import cache
        try:
            cache.decr(self._slot_key())
        except ValueError:                 # already 0 / expired
            pass

    # ── membership gate ─────────────────────────────────────────────────
    @database_sync_to_async
    def _can_join(self, user, room_id):
        """`mysea-<owner_id>` → the owner OR a present invitee (token
        deposited, not left). An epic room UUID → a seated gamer (wired in a
        later sprint; the consumer itself needs no change)."""
        if room_id.startswith("mysea-"):
            owner_id = room_id[len("mysea-"):]
            if str(user.id) == owner_id:
                return True
            from apps.gameboard.models import SeaInvite
            return SeaInvite.objects.filter(
                owner_id=owner_id, invitee=user, status=SeaInvite.ACCEPTED,
                token_deposited_at__isnull=False, left_at__isnull=True,
            ).exists()
        from apps.epic.models import TableSeat
        return TableSeat.objects.filter(room_id=room_id, gamer=user).exists()
