"""Managed-relay inbound adapter — the cloud-relay alternative to Socket Mode.

The desktop offers the user two ways to receive Slack:
- **Socket Mode** (`SlackAdapter`): manual bot + app tokens, one workspace, a
  direct WebSocket to Slack. No cloud involved.
- **Managed relay** (`SlackRelayAdapter`, here): "Add to Slack" OAuth, no tokens
  typed, *many* workspaces, events pushed from OpenWorker Cloud over one
  authenticated WebSocket. Replies still go desktop → Slack Web API directly
  with the per-team bot token (the relay is inbound-only).

Both register on the gateway as platform ``slack`` and produce the same
``MessageEvent``/``InteractionEvent`` — downstream code doesn't care which mode
delivered a message. Managed-relay reply handles are **team-qualified**
(``slack:T…/C…``) so multi-workspace replies pick the right token (see
``slack_addr``).

The socket transport is injectable so the frame-handling logic is tested with a
fake relay (no live WebSocket); the default transport is a thin ``websockets``
client, lazy-imported like the Socket-Mode SDK.
"""

from __future__ import annotations

import asyncio
import json
import logging
import os
import re
import time
from typing import Any, Awaitable, Callable, Optional, Protocol

from .adapters import _SLACK_MENTION_RE, slack_event_to_event
from .base import BasePlatformAdapter, InteractionEvent, SendResult, SessionSource
from .senders import _send_slack, _send_slack_interactive
from .slack_addr import qualify

logger = logging.getLogger("coworker.connectors")


class RelayTransport(Protocol):
    """One live connection to the cloud relay. Implementations lazy-import their
    WebSocket library; the frame contract is decoded JSON dicts."""

    async def open(self) -> None: ...
    async def recv(self) -> Optional[dict]:
        """Next frame, or None when the connection has closed."""
        ...

    async def close(self) -> None: ...


TransportFactory = Callable[[], RelayTransport]

# Slack errors that mean the BOT TOKEN is dead (uninstalled/revoked/suspended) —
# distinct from transient network or method errors, which say nothing about it.
_TOKEN_ERRORS = frozenset({"invalid_auth", "account_inactive", "token_revoked"})
TokenProvider = Callable[[], str]  # returns the current cloud sign-in JWT
# team_id, channel, count -> list of raw Slack message dicts (newest last)
HistoryFetcher = Callable[[str, str, int], Awaitable[list[dict]]]


class RelayHub:
    """The ONE desktop↔cloud relay socket, shared by every provider adapter.

    The cloud pushes all of a user's events down a single authenticated WS;
    frames fan out here by their `provider` tag (slack / github / …). Owns the
    transport, the read loop, and the reconnect watchdog — adapters own only
    their provider's frame handling. Extracted from SlackRelayAdapter when
    GitHub became the second relay provider (github-relay-spec §8)."""

    _RECONNECT_DELAY = 2.0

    def __init__(
        self,
        relay_url: str,
        token_provider: TokenProvider,
        *,
        transport_factory: Optional[TransportFactory] = None,
        reconnect_delay: Optional[float] = None,
    ) -> None:
        self.relay_url = relay_url
        self._token_provider = token_provider
        self._transport_factory = transport_factory or self._default_transport_factory
        self._reconnect_delay = (
            reconnect_delay if reconnect_delay is not None else self._RECONNECT_DELAY
        )
        self._handlers: dict[str, Callable[[dict], Awaitable[None]]] = {}
        self._transport: Optional[RelayTransport] = None
        self._task: Optional[asyncio.Task] = None
        self._closing = False
        self._connections = 0  # total successful opens; reconnects == connections-1
        self._connected = False  # the desktop↔relay socket is open RIGHT NOW
        self._dispatched = 0  # frames dispatched (observable for tests)
        self.last_error: str = ""  # last connect/reconnect failure ("" once healthy)
        self._progress = asyncio.Event()

    def register(
        self, provider: str, handler: Callable[[dict], Awaitable[None]]
    ) -> None:
        self._handlers[provider] = handler

    async def release(self, provider: str) -> None:
        """An adapter is done; the socket closes when the last one leaves."""
        self._handlers.pop(provider, None)
        if not self._handlers:
            await self.stop()

    # -- lifecycle -----------------------------------------------------------
    async def start(self) -> bool:
        """Open the socket (idempotent — the second adapter joins the running
        loop). True when the socket is up or already running."""
        if self._task is not None and not self._task.done():
            return True
        self._closing = False
        self._transport = self._transport_factory()
        try:
            await self._transport.open()
        except Exception as exc:
            logger.exception("relay connect failed")
            self.last_error = str(exc) or type(exc).__name__
            return False
        self._connections = 1
        self._connected = True
        self.last_error = ""
        self._task = asyncio.create_task(self._run())
        return True

    async def _run(self) -> None:
        """Read frames; on a dropped connection, reconnect (fresh transport) —
        the relay's own watchdog analogue on the desktop side."""
        while not self._closing:
            try:
                frame = await self._transport.recv() if self._transport else None
            except Exception:
                logger.exception("relay recv error")
                frame = None
            if frame is not None:
                handler = self._handlers.get(frame.get("provider") or "slack")
                if handler is not None:
                    try:
                        await handler(frame)
                    except Exception:
                        logger.exception("relay frame dispatch failed")
                self._dispatched += 1
                self._progress.set()
                continue
            # Connection closed → reconnect unless we're shutting down.
            self._connected = False
            if self._closing:
                break
            await self._reconnect()

    async def _reconnect(self) -> None:
        try:
            await asyncio.sleep(self._reconnect_delay)
        except asyncio.CancelledError:
            return
        if self._closing:
            return
        self._transport = self._transport_factory()
        try:
            await self._transport.open()
            self._connections += 1
            self._connected = True
            self.last_error = ""
            logger.info("relay reconnected (#%d)", self._connections - 1)
        except Exception as exc:
            self.last_error = str(exc) or type(exc).__name__
            logger.exception("relay reconnect failed — will retry")

    async def stop(self) -> None:
        self._closing = True
        self._connected = False
        if self._transport is not None:
            try:
                await self._transport.close()
            except Exception:
                pass
        if self._task is not None:
            self._task.cancel()
            self._task = None

    @property
    def reconnects(self) -> int:
        return max(0, self._connections - 1)

    def state(self) -> str:
        if self._connected:
            return "live"
        if self._task is not None and not self._closing:
            return "reconnecting"
        return "offline"

    async def wait_dispatched(self, at_least: int, timeout: float = 2.0) -> None:
        """Test helper: wait until at least N frames have been dispatched."""
        loop = asyncio.get_event_loop()
        deadline = loop.time() + timeout
        while self._dispatched < at_least:
            self._progress.clear()
            remaining = deadline - loop.time()
            if remaining <= 0:
                raise TimeoutError(
                    f"only {self._dispatched} frames dispatched (< {at_least})"
                )
            try:
                await asyncio.wait_for(self._progress.wait(), timeout=remaining)
            except asyncio.TimeoutError:
                raise TimeoutError(
                    f"only {self._dispatched} frames dispatched (< {at_least})"
                )

    # -- default transport ---------------------------------------------------
    def _default_transport_factory(self) -> RelayTransport:
        return _WebSocketsTransport(self.relay_url, self._token_provider)


class SlackRelayAdapter(BasePlatformAdapter):
    platform = "slack"

    def __init__(
        self,
        relay_url: str,
        token_provider: TokenProvider,
        *,
        teams: Optional[dict[str, dict[str, Any]]] = None,
        transport_factory: Optional[TransportFactory] = None,
        history_fetcher: Optional[HistoryFetcher] = None,
        reconnect_delay: Optional[float] = None,
        hub: Optional[RelayHub] = None,
    ) -> None:
        super().__init__()
        self.relay_url = relay_url
        # A shared hub arrives when several relay providers coexist; standalone
        # construction (tests, single-provider setups) builds its own.
        self._hub = hub or RelayHub(
            relay_url,
            token_provider,
            transport_factory=transport_factory,
            reconnect_delay=reconnect_delay,
        )
        # team_id -> {"bot_token", "bot_user_id"}. Mutable: a `revoked` frame or a
        # new install updates it.
        self._teams: dict[str, dict[str, Any]] = dict(teams or {})
        self._history_fetcher = history_fetcher
        self.last_event_at: Optional[float] = None  # last Slack event delivered
        # Name resolution caches, keyed PER WORKSPACE — a U…/C… id only means
        # something inside its team, and resolution uses that team's bot token.
        self._names: dict[str, dict[str, str]] = {}  # team_id -> {uid: name}
        self._channels: dict[str, dict[str, str]] = {}  # team_id -> {cid: name}

    # -- lifecycle -----------------------------------------------------------
    async def connect(self) -> bool:
        self._hub.register(self.platform, self._dispatch)
        ok = await self._hub.start()
        if ok:
            logger.info(
                "slack adapter connected (managed relay), %d team(s)", len(self._teams)
            )
        return ok

    async def disconnect(self) -> None:
        await self._hub.release(self.platform)

    @property
    def reconnects(self) -> int:
        return self._hub.reconnects

    @property
    def last_error(self) -> str:
        return self._hub.last_error

    def status(self) -> dict[str, Any]:
        """Health snapshot for the GUI: the desktop↔relay socket state plus each
        workspace's bot-token health. Says nothing about Slack↔cloud — the desktop
        can't observe that leg, and event silence is not an outage."""
        return {
            "state": self._hub.state(),
            "reconnects": self._hub.reconnects,
            "last_event_at": self.last_event_at,
            "last_error": self._hub.last_error,
            "teams": {
                tid: {"token_ok": bool(info.get("token_ok", True))}
                for tid, info in self._teams.items()
            },
        }

    async def wait_dispatched(self, at_least: int, timeout: float = 2.0) -> None:
        await self._hub.wait_dispatched(at_least, timeout)

    # -- team registry -------------------------------------------------------
    def set_team(
        self, team_id: str, bot_token: str, bot_user_id: Optional[str] = None
    ) -> None:
        self._teams[team_id] = {"bot_token": bot_token, "bot_user_id": bot_user_id}

    def _bot_user_id(self, team_id: str) -> Optional[str]:
        return (self._teams.get(team_id) or {}).get("bot_user_id")

    def _bot_token(self, team_id: str) -> Optional[str]:
        return (self._teams.get(team_id) or {}).get("bot_token")

    # -- frame dispatch ------------------------------------------------------
    async def _dispatch(self, frame: dict) -> None:
        kind = frame.get("kind")
        if kind == "missed":
            await self._on_missed(frame)
            return
        if kind == "revoked":
            self._teams.pop(frame.get("team_id", ""), None)
            logger.info("slack relay team %s revoked — dropped", frame.get("team_id"))
            return
        if kind == "interactivity":
            await self._on_interactivity(frame)
            return
        # A routed Slack event.
        await self._on_event(frame)

    async def _on_event(self, frame: dict) -> None:
        await self._dispatch_slack_event(
            frame.get("team_id", ""), frame.get("event") or {}
        )

    async def _dispatch_slack_event(self, team_id: str, event: dict) -> None:
        """Map a raw Slack event → MessageEvent, resolve display names via the
        per-team bot token, team-qualify the reply handle, and dispatch."""
        self.last_event_at = time.time()
        mapped = slack_event_to_event(event, self._bot_user_id(team_id))
        if mapped is None:
            return
        channel = mapped.source.chat_id  # bare channel id before qualification
        # Resolve friendly names with THIS workspace's bot token (cached per team),
        # mirroring the Socket-Mode adapter — so cards read "@OpenWorker"/"Rohit"/"#ocw-test"
        # not raw U…/C… ids. Best-effort: ids fall through on failure.
        if not mapped.source.user_name:
            mapped.source.user_name = await self._display_name(
                team_id, mapped.source.user_id
            )
        if not mapped.source.chat_name:
            mapped.source.chat_name = await self._channel_name(team_id, channel)
        mapped.text = await self._resolve_mentions(team_id, mapped.text)
        # Team-qualify the reply handle so multi-workspace replies pick the right
        # per-team token.
        mapped.source.chat_id = qualify(team_id, channel)
        mapped.source.team_id = team_id
        await self.handle_message(mapped)

    async def _on_interactivity(self, frame: dict) -> None:
        interaction = frame.get("interaction") or {}
        actions = interaction.get("actions") or [{}]
        value = actions[0].get("value", "")
        user = interaction.get("user") or {}
        team_id = frame.get("team_id", "")
        channel = (interaction.get("channel") or {}).get("id", "")
        ts = (interaction.get("message") or {}).get("ts")
        await self.handle_interaction(
            InteractionEvent(
                platform="slack",
                chat_id=qualify(team_id, channel),
                message_id=ts,
                value=str(value),
                user_id=user.get("id"),
                user_name=user.get("username") or user.get("name"),
                team_id=team_id,
                response_url=interaction.get("response_url"),
            )
        )

    async def _on_missed(self, frame: dict) -> None:
        """A nudge: content was dropped (offline > TTL / overflow). Pull the
        recent channel history ourselves via the per-team bot token and replay
        the missed messages (spec §7 channel-context / nudge)."""
        team_id = frame.get("team_id", "")
        channel = frame.get("channel", "")
        count = int(frame.get("count", 0)) or 1
        if self._history_fetcher is None or not channel:
            return
        try:
            messages = await self._history_fetcher(team_id, channel, count)
        except Exception:
            logger.exception("relay nudge history fetch failed")
            return
        for raw in messages:
            await self._dispatch_slack_event(team_id, {**raw, "channel": channel})

    def _note_token_health(self, team_id: str, error: Optional[str]) -> None:
        """Record what a Web API call said about the team's bot token: success
        proves it live; a token-class error marks it dead; anything else —
        network trouble, channel_not_found — says nothing, so changes nothing."""
        info = self._teams.get(team_id)
        if info is None:
            return
        if error is None:
            info["token_ok"] = True
        elif error in _TOKEN_ERRORS:
            info["token_ok"] = False

    # -- name resolution (per workspace, via that team's bot token) ----------
    async def _slack_get(
        self, team_id: str, method: str, params: dict
    ) -> Optional[dict]:
        """Call a Slack Web API read method with the team's bot token. Best-effort
        (None on any failure). `SLACK_API_URL` redirects to the fake in tests."""
        import httpx

        token = self._bot_token(team_id)
        if not token:
            return None
        base = os.environ.get("SLACK_API_URL", "https://slack.com/api/")
        try:
            async with httpx.AsyncClient(timeout=15) as http:
                resp = await http.get(
                    base + method,
                    params=params,
                    headers={"Authorization": f"Bearer {token}"},
                )
            data = resp.json()
        except Exception:
            return None
        self._note_token_health(team_id, None if data.get("ok") else data.get("error"))
        return data if data.get("ok") else None

    async def _display_name(self, team_id: str, uid: Optional[str]) -> Optional[str]:
        if not uid:
            return None
        cache = self._names.setdefault(team_id, {})
        if uid in cache:
            return cache[uid]
        data = await self._slack_get(team_id, "users.info", {"user": uid})
        u = (data or {}).get("user") or {}
        prof = u.get("profile") or {}
        name = (
            prof.get("display_name")
            or prof.get("real_name")
            or u.get("real_name")
            or u.get("name")
        )
        if name:
            cache[uid] = name
        return name

    async def _channel_name(self, team_id: str, cid: Optional[str]) -> Optional[str]:
        if not cid:
            return None
        cache = self._channels.setdefault(team_id, {})
        if cid in cache:
            return cache[cid]
        data = await self._slack_get(team_id, "conversations.info", {"channel": cid})
        chan = (data or {}).get("channel") or {}
        name = chan.get("name") or chan.get("name_normalized")
        if name:
            cache[cid] = name
        return name

    async def _resolve_mentions(self, team_id: str, text: str) -> str:
        """Rewrite `<@U…>` tokens to `@display-name` (cached). Best-effort."""
        out = text
        for uid in set(_SLACK_MENTION_RE.findall(text or "")):
            name = await self._display_name(team_id, uid)
            if name:
                out = re.sub(rf"<@{re.escape(uid)}(?:\|[^>]*)?>", f"@{name}", out)
        return out

    # -- outbound ------------------------------------------------------------
    async def send(
        self, chat_id: str, text: str, *, thread_id: Optional[str] = None
    ) -> SendResult:
        """Reply directly via the Slack Web API with the per-team bot token."""
        from .slack_addr import split

        team_id, _channel = split(chat_id)
        token = self._bot_token(team_id or "")
        if not token:
            return SendResult(False, error=f"no bot token for team {team_id}")
        result = await asyncio.to_thread(_send_slack, token, chat_id, text, thread_id)
        self._note_token_health(team_id or "", None if result.ok else result.error)
        return result

    async def send_interactive(
        self, chat_id: str, text: str, buttons, *, thread_id: Optional[str] = None
    ) -> SendResult:
        from .slack_addr import split

        team_id, _channel = split(chat_id)
        token = self._bot_token(team_id or "")
        if not token:
            return SendResult(False, error=f"no bot token for team {team_id}")
        result = await asyncio.to_thread(
            _send_slack_interactive, token, chat_id, text, buttons, thread_id
        )
        self._note_token_health(team_id or "", None if result.ok else result.error)
        return result


class _WebSocketsTransport:
    """Real transport: an authenticated `websockets` client. Sends the cloud
    sign-in JWT in the Authorization header (the relay's $connect authorizer)."""

    def __init__(self, url: str, token_provider: TokenProvider) -> None:
        self._url = url
        self._token_provider = token_provider
        self._ws = None

    async def open(self) -> None:
        import websockets  # lazy: optional extra

        token = self._token_provider()
        self._ws = await websockets.connect(
            self._url, additional_headers={"Authorization": f"Bearer {token}"}
        )

    async def recv(self) -> Optional[dict]:
        import websockets

        if self._ws is None:
            return None
        try:
            raw = await self._ws.recv()
        except websockets.ConnectionClosed:
            return None
        try:
            return json.loads(raw)
        except (ValueError, TypeError):
            return None

    async def close(self) -> None:
        if self._ws is not None:
            try:
                await self._ws.close()
            except Exception:
                pass
            self._ws = None
