"""LAN discovery responder.

The VoxCast desktop client broadcasts a small UDP probe; every server on the
subnet answers with a tiny JSON datagram naming its HTTP endpoint. The client
then shows a pick-list ("Find server") instead of making the user type an
IP:port. The reply carries NO secrets — connecting still requires the API key.

Protocol v1 (all UTF-8):
  probe:  b"VOXCAST_DISCOVER_V1"                    → UDP port 8590 (broadcast)
  reply:  {"voxcast_server": 1, "name": "<hostname>", "port": <http_port>,
           "scheme": "http", "version": "<server version>"}   → back to sender

The discovery port is FIXED (8590/udp) even when the HTTP port is customized —
the reply carries the real HTTP port, so changing it never breaks discovery.
The responder only runs when the server is bound to a non-loopback host: a
127.0.0.1 server is unreachable for LAN clients and must not advertise itself.
"""
from __future__ import annotations

import json
import socket
import threading
import time

from loguru import logger

DISCOVERY_PORT = 8590
PROBE = b"VOXCAST_DISCOVER_V1"
# Amplification guard: the reply (~130 B) is ~7x the probe (19 B), so cap the
# reply rate. A human clicking "Find server" needs a handful per second at most.
_MAX_REPLIES_PER_SEC = 20


def build_reply(http_port: int, version: str, host: str = "",
                scheme: str = "http") -> bytes:
    reply = {
        "voxcast_server": 1,
        "name": socket.gethostname()[:64],
        "port": int(http_port),
        "scheme": scheme if scheme in ("http", "https") else "http",
        "version": version,
    }
    # Specific (non-wildcard) bind: advertise that address explicitly. The
    # responder listens on all interfaces (broadcasts only reach wildcard
    # binds), so on a multi-homed host the sender-derived IP could name an
    # interface uvicorn is not listening on.
    if host and host not in ("0.0.0.0", "", "::"):
        reply["host"] = host
    return json.dumps(reply).encode("utf-8")


class DiscoveryResponder:
    """Answers discovery probes on a background thread.

    Failure to bind (port taken by another instance) is non-fatal: the server
    still works, it is just not discoverable, and we log why.
    """

    def __init__(self, http_port: int, version: str, http_host: str = "",
                 scheme: str = "http",
                 discovery_port: int = DISCOVERY_PORT) -> None:
        self._http_port = http_port
        self._version = version
        self._http_host = http_host
        self._scheme = scheme
        self._discovery_port = discovery_port
        self._sock: socket.socket | None = None
        self._thread: threading.Thread | None = None
        self._stop = threading.Event()
        self._reply_times: list[float] = []

    @property
    def bound_port(self) -> int | None:
        """Actual UDP port after start() (useful with discovery_port=0 in tests)."""
        if self._sock is None:
            return None
        try:
            return self._sock.getsockname()[1]
        except OSError:
            return None

    def start(self) -> bool:
        """Bind + start answering. Returns False when the port is unavailable."""
        if self._thread is not None and self._thread.is_alive():
            return True  # already running; never leak a second thread/socket
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            # Exclusive bind: with SO_REUSEADDR, Windows lets a second UDP
            # socket bind an occupied port and datagrams get split
            # nondeterministically between the owners — the documented
            # "port taken -> False + warning" contract would silently break.
            if hasattr(socket, "SO_EXCLUSIVEADDRUSE"):
                sock.setsockopt(socket.SOL_SOCKET,
                                socket.SO_EXCLUSIVEADDRUSE, 1)
            sock.bind(("", self._discovery_port))
            sock.settimeout(0.5)
        except OSError as e:
            logger.warning("Discovery disabled: cannot bind UDP {}: {}",
                           self._discovery_port, e)
            return False
        self._sock = sock
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, name="discovery",
                                        daemon=True)
        self._thread.start()
        logger.info("Discovery responder listening on UDP {}",
                    self.bound_port)
        return True

    def stop(self) -> None:
        self._stop.set()
        if self._thread is not None:
            self._thread.join(timeout=2.0)
            self._thread = None
        if self._sock is not None:
            self._sock.close()
            self._sock = None

    def _allow_reply(self) -> bool:
        now = time.monotonic()
        self._reply_times = [t for t in self._reply_times if now - t < 1.0]
        if len(self._reply_times) >= _MAX_REPLIES_PER_SEC:
            return False
        self._reply_times.append(now)
        return True

    def _run(self) -> None:
        # Local reference: stop() may null out self._sock after a join
        # timeout; the loop must keep a stable handle until it exits itself.
        sock = self._sock
        assert sock is not None
        reply = build_reply(self._http_port, self._version, self._http_host,
                            self._scheme)
        while not self._stop.is_set():
            try:
                data, addr = sock.recvfrom(64)
            except socket.timeout:
                continue
            except OSError as e:
                # Windows raises WSAEMSGSIZE (10040) when a datagram exceeds
                # the buffer — that is hostile/foreign traffic, not a reason
                # to stop answering. Anything else means the socket is gone.
                if getattr(e, "winerror", None) == 10040:
                    continue
                break  # socket closed during shutdown
            if data != PROBE:
                continue  # not for us; stay silent
            if not self._allow_reply():
                continue
            try:
                sock.sendto(reply, addr)
            except OSError:
                continue
