"""
Advertise the dashboard on the LAN under its own name, e.g. `agibot.local`.

Why this exists
---------------
The link people type should be about the robot, not about whatever the PC
happens to be called. Renaming the computer would do it, but that needs
administrator rights and a reboot and changes the machine's identity for
everything else. Publishing an extra name over mDNS is additive, reversible and
needs neither: the computer keeps its own name, and the advertised name exists
only while the dashboard is running.

Why stdlib and not the `zeroconf` package
-----------------------------------------
Two measured constraints on this machine, either of which would have made a
separate helper process silently useless:

  * Windows' Public firewall profile is BlockInbound and every inbound rule for
    UDP 5353 is *program*-scoped, not port-scoped. The built-in "mDNS (UDP-In)"
    rule is scoped to svchost.exe/dnscache and grants a Python process nothing.
    The only interpreter with a blanket inbound allow is the base Python that
    already serves the dashboard - so the responder has to live *inside* that
    process to be reachable from another device.
  * A same-host HTTP fetch to the PC's own LAN address is routed via loopback
    and skips inbound firewall filtering entirely, so a helper running under the
    wrong interpreter passes every local test while remaining invisible to a
    phone. Running in-process removes that whole failure mode.

Being stdlib-only also means no extra dependency to install in the interpreter
that matters.

Scope
-----
Only the `<name>.local` form is published. A bare single-label `http://agibot:`
appears to work on Windows because Windows appends `.local` to single-label
names, but it is unreliable (its negative DNS cache can poison the name for
minutes) and no iPhone, Android, Mac or Linux client resolves it. Publishing it
would be advertising something that only works on one machine.
"""

from __future__ import annotations

import logging
import socket
import struct
import threading
import time

log = logging.getLogger("x2.announce")

MDNS_ADDR = "224.0.0.251"
MDNS_PORT = 5353
TTL = 120                    # seconds a client may cache the record
QTYPE_A = 1
QTYPE_ANY = 255
CLASS_IN = 1
CACHE_FLUSH = 0x8000         # tells clients this is the authoritative answer
UNICAST_RESPONSE = 0x8000    # the "QU" bit on a question's class


def _encode_name(name: str) -> bytes:
    out = bytearray()
    for label in name.rstrip(".").split("."):
        raw = label.encode("utf-8")
        out.append(len(raw))
        out += raw
    out.append(0)
    return bytes(out)


def _decode_name(data: bytes, offset: int) -> tuple[str, int]:
    """Read a DNS name, following compression pointers. Returns (name, next_offset)."""
    labels: list[str] = []
    jumped = False
    end = offset
    hops = 0

    while True:
        if offset >= len(data) or hops > 20:
            break
        length = data[offset]

        if length & 0xC0 == 0xC0:                      # compression pointer
            if offset + 1 >= len(data):
                break
            pointer = struct.unpack_from("!H", data, offset)[0] & 0x3FFF
            if not jumped:
                end = offset + 2
            offset = pointer
            jumped = True
            hops += 1
            continue

        offset += 1
        if length == 0:
            if not jumped:
                end = offset
            break
        labels.append(data[offset:offset + length].decode("utf-8", "replace"))
        offset += length

    return ".".join(labels), end


def _build_response(name: str, ip: str) -> bytes:
    header = struct.pack(
        "!HHHHHH",
        0,          # ID - always 0 in mDNS
        0x8400,     # QR=1 (response), AA=1 (authoritative)
        0,          # no questions echoed
        1,          # one answer
        0, 0,
    )
    answer = (
        _encode_name(name)
        + struct.pack("!HHIH", QTYPE_A, CLASS_IN | CACHE_FLUSH, TTL, 4)
        + socket.inet_aton(ip)
    )
    return header + answer


def _build_query(name: str) -> bytes:
    header = struct.pack("!HHHHHH", 0, 0x0000, 1, 0, 0, 0)
    return header + _encode_name(name) + struct.pack("!HH", QTYPE_A, CLASS_IN)


class Announcer:
    """Answers mDNS A queries for one name, for as long as it runs."""

    def __init__(self, name: str, port: int, address_provider):
        self.name = f"{name.strip().strip('.').lower()}.local"
        self.port = port
        self._address_of = address_provider
        self._sock: socket.socket | None = None
        self._thread: threading.Thread | None = None
        self._stop = threading.Event()
        self.ip: str | None = None
        self.answered = 0
        self.error = ""
        self.conflict = False

    # -- lifecycle ----------------------------------------------------------

    def start(self) -> bool:
        self.ip = self._address_of()
        if not self.ip:
            self.error = "No LAN address to advertise"
            log.warning("mDNS: %s", self.error)
            return False

        try:
            self._sock = self._open_socket()
        except OSError as exc:
            self.error = f"Could not open UDP {MDNS_PORT}: {exc}"
            log.warning("mDNS: %s", self.error)
            return False

        # RFC 6762 probing: if something else already owns the name, do not
        # fight it - two hosts answering for one name gives whichever reply
        # arrives first, which is worse than not advertising at all.
        if self._name_taken():
            self.conflict = True
            self.error = f"{self.name} is already claimed by another device on this network"
            log.warning("mDNS: %s", self.error)
            self._close()
            return False

        self._stop.clear()
        self._thread = threading.Thread(target=self._serve, name="mdns-announce", daemon=True)
        self._thread.start()
        log.info("mDNS: advertising %s -> %s:%s", self.name, self.ip, self.port)
        return True

    def stop(self) -> None:
        self._stop.set()
        self._close()
        if self._thread:
            self._thread.join(timeout=3.0)
            self._thread = None

    def _close(self) -> None:
        if self._sock is not None:
            try:
                self._sock.close()
            except OSError:
                pass
            self._sock = None

    # -- socket -------------------------------------------------------------

    def _open_socket(self) -> socket.socket:
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        # Several responders normally share 5353 (Windows' own, Bonjour, and
        # anything Chrome is doing); SO_REUSEPORT where available lets us join
        # them rather than fail.
        if hasattr(socket, "SO_REUSEPORT"):
            try:
                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1)
            except OSError:
                pass
        sock.bind(("", MDNS_PORT))

        membership = socket.inet_aton(MDNS_ADDR) + socket.inet_aton(self.ip)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, membership)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(self.ip))
        sock.settimeout(1.0)
        return sock

    def _name_taken(self) -> bool:
        """Ask whether anyone else already answers for our name."""
        probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            probe.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
            probe.settimeout(0.4)
            probe.sendto(_build_query(self.name), (MDNS_ADDR, MDNS_PORT))

            deadline = time.time() + 0.9
            while time.time() < deadline:
                try:
                    data, addr = probe.recvfrom(4096)
                except socket.timeout:
                    continue
                except OSError:
                    break
                if addr[0] == self.ip:
                    continue                       # our own machine, ignore
                if self._is_answer_for_us(data):
                    log.warning("mDNS: %s already answered by %s", self.name, addr[0])
                    return True
        except OSError:
            pass
        finally:
            probe.close()
        return False

    def _is_answer_for_us(self, data: bytes) -> bool:
        try:
            _, flags, qd, an, _, _ = struct.unpack_from("!HHHHHH", data, 0)
        except struct.error:
            return False
        if not (flags & 0x8000) or an == 0:
            return False

        offset = 12
        for _ in range(qd):
            _, offset = _decode_name(data, offset)
            offset += 4
        for _ in range(an):
            name, offset = _decode_name(data, offset)
            try:
                rtype, _, _, rdlen = struct.unpack_from("!HHIH", data, offset)
            except struct.error:
                return False
            offset += 10 + rdlen
            if rtype == QTYPE_A and name.lower() == self.name:
                return True
        return False

    # -- serving ------------------------------------------------------------

    def _serve(self) -> None:
        last_ip_check = time.time()

        while not self._stop.is_set():
            sock = self._sock
            if sock is None:
                return
            try:
                data, addr = sock.recvfrom(4096)
            except socket.timeout:
                data = None
            except OSError:
                if not self._stop.is_set():
                    log.debug("mDNS: socket closed")
                return

            # DHCP leases change; an advertiser pinned to a stale address will
            # confidently point the name at nothing.
            if time.time() - last_ip_check > 30:
                last_ip_check = time.time()
                current = self._address_of()
                if current and current != self.ip:
                    log.info("mDNS: address changed %s -> %s, re-advertising", self.ip, current)
                    self.ip = current

            if not data:
                continue

            try:
                self._handle(data, addr)
            except Exception as exc:            # never let one packet stop us
                log.debug("mDNS: bad packet from %s: %s", addr, exc)

    def _handle(self, data: bytes, addr) -> None:
        try:
            _, flags, qdcount, _, _, _ = struct.unpack_from("!HHHHHH", data, 0)
        except struct.error:
            return
        if flags & 0x8000 or qdcount == 0:      # a response, not a question
            return

        offset = 12
        for _ in range(qdcount):
            name, offset = _decode_name(data, offset)
            try:
                qtype, qclass = struct.unpack_from("!HH", data, offset)
            except struct.error:
                return
            offset += 4

            if name.lower() != self.name:
                continue
            if qtype not in (QTYPE_A, QTYPE_ANY):
                continue

            response = _build_response(self.name, self.ip)
            wants_unicast = bool(qclass & UNICAST_RESPONSE)
            sock = self._sock
            if sock is None:
                return
            try:
                if wants_unicast:
                    sock.sendto(response, addr)
                else:
                    # Multicast so every listener refreshes its cache, and
                    # unicast too because some stacks only accept the direct
                    # reply.
                    sock.sendto(response, (MDNS_ADDR, MDNS_PORT))
                    sock.sendto(response, addr)
                self.answered += 1
            except OSError:
                pass

    # -- introspection ------------------------------------------------------

    def status(self) -> dict:
        return {
            "name": self.name,
            "url": f"http://{self.name}:{self.port}",
            "ip": self.ip,
            "running": bool(self._thread and self._thread.is_alive()),
            "answered": self.answered,
            "conflict": self.conflict,
            "error": self.error,
        }
