"""LAN discovery via mDNS/Bonjour."""
from __future__ import annotations

import hashlib
import os
import socket
import subprocess
import sys
import time
from typing import Callable


def mesh_mdns_instance_name(hostname: str, fingerprint: str) -> str:
    """Build a unique DNS-SD instance label (before ``._mugi._tcp.local.``).

    Using only ``socket.gethostname()`` collides when two Macs share the same
    default name (e.g. ``MacBook-Pro``), which causes one peer's advertisement
    to win and the other's to disappear — asymmetric discovery.
    """
    h = (hostname or "").strip().lower() or "mugi"
    slug_chars: list[str] = []
    for c in h:
        if c.isalnum() or c == "-":
            slug_chars.append(c)
        elif c in " ._":
            slug_chars.append("-")
    slug = "".join(slug_chars).strip("-")[:28] or "mugi"
    digest = hashlib.sha256((fingerprint or "").encode("utf-8")).hexdigest()[:8]
    return f"{slug}-{digest}"


class MeshDiscovery:
    """Best-effort zeroconf discovery.

    If zeroconf is unavailable, this class becomes a no-op.
    """

    def __init__(
        self,
        *,
        port: int,
        hostname: str,
        fingerprint: str,
        on_peer_up: Callable[[str, str, int, dict], None] | None = None,
        on_peer_down: Callable[[str], None] | None = None,
    ):
        self.port = int(port or 0)
        self.hostname = hostname
        self.fingerprint = fingerprint
        self.on_peer_up = on_peer_up
        self.on_peer_down = on_peer_down
        self._zc = None
        self._browser = None
        self._service_info = None
        self._running = False
        self._listener = None
        self._last_error = ""
        self._events: list[dict] = []
        self._advertised_addresses: list[str] = []
        self._service_name = ""
        self._install_attempted = False

    def _log_event(self, kind: str, **data) -> None:
        row = {"kind": str(kind), **data}
        self._events.append(row)
        if len(self._events) > 200:
            self._events = self._events[-200:]

    def _primary_outbound_addresses(self) -> list[str]:
        """Best-effort local addresses chosen by routing table."""
        out: list[str] = []
        probes = [
            (socket.AF_INET, ("8.8.8.8", 80)),
            (socket.AF_INET6, ("2001:4860:4860::8888", 80, 0, 0)),
        ]
        for family, target in probes:
            try:
                s = socket.socket(family, socket.SOCK_DGRAM)
                try:
                    s.connect(target)  # no payload sent; used to infer local address
                    ip = str(s.getsockname()[0] or "").strip()
                    if ip and ip not in {"127.0.0.1", "::1"} and not ip.startswith("fe80:"):
                        out.append(ip)
                finally:
                    s.close()
            except Exception:
                continue
        return out

    def _maybe_install_zeroconf(self) -> bool:
        """Best-effort dependency self-heal for stale backend environments."""
        if self._install_attempted:
            return False
        self._install_attempted = True
        if os.environ.get("PYTEST_CURRENT_TEST"):
            return False
        py = str(sys.executable or "").strip()
        if not py or not os.path.isfile(py):
            return False
        cmd = [
            py,
            "-m",
            "pip",
            "install",
            "--disable-pip-version-check",
            "zeroconf>=0.131,<1.0",
        ]
        try:
            result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
            if result.returncode == 0:
                self._log_event("install_zeroconf_ok")
                return True
            err = (result.stderr or result.stdout or "").strip()
            self._log_event("install_zeroconf_failed", error=err[:500])
            return False
        except Exception as e:
            self._log_event("install_zeroconf_failed", error=str(e))
            return False

    def _collect_local_addresses(self) -> list[bytes]:
        """Collect non-loopback local interface addresses (IPv4/IPv6) for mDNS."""
        packed: list[bytes] = []
        seen: set[bytes] = set()
        hostnames = {socket.gethostname(), socket.getfqdn(), "localhost"}
        for host in hostnames:
            if not host:
                continue
            try:
                infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
            except Exception:
                continue
            for family, _socktype, _proto, _canonname, sockaddr in infos:
                try:
                    ip = sockaddr[0]
                except Exception:
                    continue
                if not ip:
                    continue
                if ip.startswith("127.") or ip == "::1" or ip.startswith("fe80:"):
                    continue
                try:
                    if family == socket.AF_INET:
                        raw = socket.inet_aton(ip)
                    elif family == socket.AF_INET6:
                        raw = socket.inet_pton(socket.AF_INET6, ip)
                    else:
                        continue
                except Exception:
                    continue
                if raw in seen:
                    continue
                seen.add(raw)
                packed.append(raw)
        for ip in self._primary_outbound_addresses():
            try:
                raw = socket.inet_aton(ip)
            except OSError:
                try:
                    raw = socket.inet_pton(socket.AF_INET6, ip)
                except OSError:
                    continue
            if raw in seen:
                continue
            seen.add(raw)
            packed.append(raw)
        if not packed:
            try:
                packed.append(socket.inet_aton("127.0.0.1"))
            except Exception:
                pass
        return packed

    def _decode_address(self, raw: bytes) -> str:
        try:
            if len(raw) == 4:
                return socket.inet_ntoa(raw)
            if len(raw) == 16:
                return socket.inet_ntop(socket.AF_INET6, raw)
        except Exception:
            return ""
        return ""

    def _best_address(self, candidates: list[str]) -> str:
        """Pick the best reachable address from a list, preferring same-subnet."""
        if not candidates:
            return ""
        if len(candidates) == 1:
            return candidates[0]
        my_addrs = self._advertised_addresses or []
        for candidate in candidates:
            prefix = candidate.rsplit(".", 1)[0] if "." in candidate else ""
            if prefix and any(a.startswith(prefix + ".") for a in my_addrs):
                return candidate
        return candidates[0]

    def start(self) -> None:
        if self._running:
            return
        self._last_error = ""
        try:
            from zeroconf import IPVersion, ServiceBrowser, ServiceInfo, Zeroconf
        except Exception as e:
            self._last_error = f"zeroconf import failed: {e}"
            self._log_event("start_error", error=self._last_error)
            if self._maybe_install_zeroconf():
                try:
                    from zeroconf import IPVersion, ServiceBrowser, ServiceInfo, Zeroconf
                    self._last_error = ""
                    self._log_event("import_zeroconf_ok_after_install")
                except Exception as e2:
                    self._last_error = f"zeroconf import failed after install: {e2}"
                    self._log_event("start_error", error=self._last_error)
                    return
            else:
                return

        class _Listener:
            def __init__(self, outer: "MeshDiscovery"):
                self.outer = outer

            def add_service(self, zc, service_type, name):
                self.outer._log_event("add_service", name=str(name), service_type=str(service_type))
                self.outer._resolve_peer(zc, service_type, name)

            def update_service(self, zc, service_type, name):
                self.outer._log_event("update_service", name=str(name), service_type=str(service_type))
                self.outer._resolve_peer(zc, service_type, name)

            def remove_service(self, _zc, _service_type, name):
                self.outer._log_event("remove_service", name=str(name))
                if self.outer.on_peer_down:
                    self.outer.on_peer_down(name)

        try:
            self._zc = Zeroconf(ip_version=IPVersion.All)
            service_type = "_mugi._tcp.local."
            instance = mesh_mdns_instance_name(self.hostname, self.fingerprint)
            service_name = f"{instance}._mugi._tcp.local."
            self._service_name = service_name
            props = {
                b"hostname": self.hostname.encode("utf-8"),
                b"fingerprint": self.fingerprint.encode("utf-8"),
                b"version": b"1",
            }
            addresses = self._collect_local_addresses()
            self._advertised_addresses = [self._decode_address(raw) for raw in addresses]
            self._service_info = ServiceInfo(
                type_=service_type,
                name=service_name,
                addresses=addresses,
                port=self.port,
                properties=props,
            )
            self._zc.register_service(self._service_info)
            self._listener = _Listener(self)
            self._browser = ServiceBrowser(self._zc, service_type, self._listener)
            self._running = True
            self._log_event(
                "started",
                service_name=service_name,
                advertised_addresses=self._advertised_addresses,
                port=int(self.port),
            )
        except Exception as e:
            self._last_error = f"discovery start failed: {e}"
            self._log_event("start_error", error=self._last_error)
            self.stop()

    def _resolve_peer(self, zc, service_type: str, name: str) -> None:
        if self.on_peer_up is None:
            return
        try:
            # 500ms was too tight on some Wi‑Fi / wake-from-sleep paths; mDNS SRV+TXT+addresses need time.
            info = zc.get_service_info(service_type, name, timeout=3000)
            if not info:
                return
            props = {}
            for k, v in (info.properties or {}).items():
                try:
                    props[k.decode("utf-8")] = v.decode("utf-8")
                except Exception:
                    pass
            hostname = str(props.get("hostname") or name).strip()
            fp = str(props.get("fingerprint") or "").strip()
            address = ""
            if info.addresses:
                ipv4 = [self._decode_address(raw) for raw in info.addresses if len(raw) == 4]
                ipv6 = [self._decode_address(raw) for raw in info.addresses if len(raw) == 16]
                ipv4 = [a for a in ipv4 if a]
                ipv6 = [a for a in ipv6 if a]
                address = self._best_address(ipv4) or self._best_address(ipv6) or ""
            self._log_event(
                "peer_resolved",
                name=str(name),
                hostname=hostname,
                address=address,
                port=int(info.port or 0),
                fingerprint=fp,
            )
            self.on_peer_up(hostname, address, int(info.port or 0), {"fingerprint": fp, "service_name": name, **props})
        except Exception as e:
            self._log_event("resolve_error", name=str(name), error=str(e))
            return

    def debug_state(self) -> dict:
        return {
            "running": bool(self._running),
            "service_name": self._service_name,
            "hostname": self.hostname,
            "port": int(self.port),
            "advertised_addresses": list(self._advertised_addresses),
            "last_error": self._last_error,
            "events": list(self._events[-100:]),
        }

    def stop(self) -> None:
        try:
            if self._zc and self._service_info:
                self._zc.unregister_service(self._service_info)
        except Exception:
            pass
        try:
            if self._zc:
                self._zc.close()
        except Exception:
            pass
        self._browser = None
        self._listener = None
        self._zc = None
        self._service_info = None
        self._running = False
