"""Find VoxCast Servers on the local network.

Counterpart of compute_server/app/discovery.py: broadcasts a UDP probe on
every local IPv4 interface and collects the JSON replies for ~2 seconds. Used
by the "Find server" button in Settings so nobody has to type an IP:port.

CRITICAL: the app event loop is QtAsyncio, which implements NO asyncio network
primitives (see http_exec.py — the license-bug root cause). All socket work
here is blocking and runs in the default executor.
"""
from __future__ import annotations

import asyncio
import json
import select
import socket
import time

from loguru import logger

DISCOVERY_PORT = 8590
PROBE = b"VOXCAST_DISCOVER_V1"
_MAX_RESULTS = 16
_MAX_REPLY_BYTES = 512


def _parse_reply(data: bytes, sender_ip: str) -> dict | None:
    """Validate one reply datagram (untrusted LAN input). Returns
    {name, ip, port, url, version} or None."""
    if len(data) > _MAX_REPLY_BYTES:
        return None
    try:
        obj = json.loads(data.decode("utf-8"))
    except (UnicodeDecodeError, ValueError):
        return None
    if not isinstance(obj, dict):
        return None
    magic = obj.get("voxcast_server")
    # bool is an int subclass: json `true` == 1 would pass a plain != 1 check.
    if not isinstance(magic, int) or isinstance(magic, bool) or magic != 1:
        return None
    port = obj.get("port")
    if not isinstance(port, int) or isinstance(port, bool) \
            or not (1 <= port <= 65535):
        return None
    name = obj.get("name")
    if not isinstance(name, str):
        name = ""
    # Strip control characters AND markup delimiters: the name is untrusted
    # LAN data shown in the UI (QML side additionally forces PlainText).
    name = "".join(c for c in name
                   if c.isprintable() and c not in "<>&").strip()[:64]
    version = obj.get("version")
    if not isinstance(version, str):
        version = ""
    version = "".join(c for c in version if c.isprintable()).strip()[:32]
    # Servers with a SPECIFIC (non-wildcard) bind advertise that address. It
    # must be a literal PRIVATE IPv4, else it is ignored: a discovery reply
    # must never be able to point the client at a public/arbitrary host (the
    # sender-IP fallback keeps replies pinned to their own source).
    ip = sender_ip
    adv = obj.get("host")
    if isinstance(adv, str):
        import ipaddress
        try:
            parsed = ipaddress.IPv4Address(adv)
            # is_private alone also admits 0.0.0.0/8 and 255.255.255.255;
            # exclude every non-routable class explicitly.
            if parsed.is_private and not (
                    parsed.is_loopback or parsed.is_unspecified
                    or parsed.is_reserved or parsed.is_multicast):
                ip = adv
        except ValueError:
            pass
    return {
        "name": name or ip,
        "ip": ip,
        "port": port,
        "url": f"http://{ip}:{port}",
        "version": version,
    }


def _local_ipv4s() -> list[str]:
    """Local interface addresses. Broadcasting from a socket bound to each
    address forces one probe out of every interface (a single unbound socket
    only broadcasts via the default route)."""
    ips: list[str] = []
    try:
        for info in socket.getaddrinfo(socket.gethostname(), None,
                                       socket.AF_INET):
            ip = info[4][0]
            if not ip.startswith("127.") and ip not in ips:
                ips.append(ip)
    except OSError:
        pass
    return ips


def _open_probe_sockets(targets: list[tuple[str, int]] | None
                        ) -> list[socket.socket]:
    """One broadcast socket per local interface (plus an unbound fallback),
    probe already sent. With explicit `targets` (tests): a single unicast
    socket probing those addresses."""
    socks: list[socket.socket] = []
    if targets is not None:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.setblocking(False)
        for addr in targets:
            try:
                s.sendto(PROBE, addr)
            except OSError:
                pass
        return [s]

    bind_ips: list[str | None] = list(_local_ipv4s()) or [None]
    for ip in bind_ips:
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        except OSError as e:
            logger.debug("Discovery: no socket for {}: {}", ip or "default", e)
            continue
        try:
            s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
            s.setblocking(False)
            if ip is not None:
                s.bind((ip, 0))
            s.sendto(PROBE, ("255.255.255.255", DISCOVERY_PORT))
            socks.append(s)
        except OSError as e:
            # Close THIS interface's socket only; sockets already in `socks`
            # stay alive so the scan continues on the surviving interfaces.
            logger.debug("Discovery probe failed on {}: {}", ip or "default", e)
            try:
                s.close()
            except OSError:
                pass
    return socks


def _scan_blocking(timeout: float = 2.0,
                   targets: list[tuple[str, int]] | None = None) -> list[dict]:
    """Broadcast the probe and collect replies until the deadline. Runs in an
    executor thread — must never touch the event loop."""
    socks = _open_probe_sockets(targets)
    if not socks:
        return []
    found: dict[tuple[str, int], dict] = {}
    deadline = time.monotonic() + timeout
    try:
        while True:
            remaining = deadline - time.monotonic()
            if remaining <= 0 or len(found) >= _MAX_RESULTS:
                break
            readable, _, _ = select.select(socks, [], [], remaining)
            for s in readable:
                try:
                    data, addr = s.recvfrom(_MAX_REPLY_BYTES + 1)
                except OSError:
                    continue
                entry = _parse_reply(data, addr[0])
                if entry is None:
                    continue
                found.setdefault((entry["ip"], entry["port"]), entry)
    finally:
        for s in socks:
            try:
                s.close()
            except OSError:
                pass
    return sorted(found.values(), key=lambda e: (e["name"].lower(), e["ip"]))


async def discover_servers(timeout: float = 2.0) -> list[dict]:
    """Async entry point for the bridge. Blocking scan in the executor
    (QtAsyncio rule), event loop stays free."""
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(None, _scan_blocking, timeout)
