"""LAN discovery responder tests (real UDP sockets on loopback)."""
from __future__ import annotations

import json
import socket
import time

from app.discovery import PROBE, DiscoveryResponder, build_reply


def _probe(port: int, payload: bytes = PROBE, timeout: float = 1.0) -> bytes | None:
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
        s.settimeout(timeout)
        s.sendto(payload, ("127.0.0.1", port))
        try:
            data, _ = s.recvfrom(1024)
            return data
        except socket.timeout:
            return None


def _start(http_port: int = 8590) -> DiscoveryResponder:
    # discovery_port=0 → ephemeral port, no clash with a real server or
    # parallel test runs.
    r = DiscoveryResponder(http_port, "1.0.0", discovery_port=0)
    assert r.start()
    return r


def test_probe_gets_json_reply_with_http_port():
    r = _start(http_port=9123)
    try:
        raw = _probe(r.bound_port)
        assert raw is not None
        reply = json.loads(raw.decode("utf-8"))
        assert reply["voxcast_server"] == 1
        assert reply["port"] == 9123
        assert reply["scheme"] == "http"
        assert reply["version"] == "1.0.0"
        assert isinstance(reply["name"], str) and reply["name"]
    finally:
        r.stop()


def test_garbage_and_oversized_packets_get_no_reply():
    r = _start()
    try:
        assert _probe(r.bound_port, b"hello?", timeout=0.4) is None
        assert _probe(r.bound_port, b"X" * 512, timeout=0.4) is None
        # Still alive and answering after garbage:
        assert _probe(r.bound_port) is not None
    finally:
        r.stop()


def test_reply_carries_no_secrets():
    reply = json.loads(build_reply(8590, "1.0.0").decode("utf-8"))
    text = json.dumps(reply).lower()
    assert "key" not in text
    assert "vxc_" not in text
    assert set(reply) == {"voxcast_server", "name", "port", "scheme", "version"}


def test_reply_advertises_specific_host_only():
    wildcard = json.loads(build_reply(8590, "1.0.0", "0.0.0.0").decode())
    assert "host" not in wildcard
    specific = json.loads(build_reply(8590, "1.0.0", "192.168.1.5").decode())
    assert specific["host"] == "192.168.1.5"


def test_double_start_is_idempotent():
    r = _start()
    port = r.bound_port
    try:
        assert r.start() is True  # no second thread/socket, same port
        assert r.bound_port == port
    finally:
        r.stop()


def test_stop_terminates_thread_and_frees_socket():
    r = _start()
    port = r.bound_port
    r.stop()
    assert r._thread is None
    # Port is free again — a fresh bind must succeed.
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.bind(("", port))
    finally:
        s.close()


def test_rate_limit_caps_replies_per_second():
    r = _start()
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            s.settimeout(0.05)
            for _ in range(60):
                s.sendto(PROBE, ("127.0.0.1", r.bound_port))
            time.sleep(0.5)
            got = 0
            while True:
                try:
                    s.recvfrom(1024)
                    got += 1
                except socket.timeout:
                    break
        assert got <= 25  # cap is 20/s; allow slack for timing
    finally:
        r.stop()


def test_bind_conflict_is_nonfatal():
    # Plain blocker WITHOUT exclusive-use: the responder itself binds
    # exclusively, so even this realistic conflict must be detected (with
    # SO_REUSEADDR the second bind would silently succeed on Windows and
    # datagrams would be split between the owners).
    blocker = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    blocker.bind(("", 0))
    port = blocker.getsockname()[1]
    try:
        r = DiscoveryResponder(8590, "1.0.0", discovery_port=port)
        assert r.start() is False  # port taken → False, no exception
    finally:
        blocker.close()
