"""
Network discovery and host addressing.

Nothing in this dashboard assumes a fixed IP. Three separate problems are solved
here:

1. Which addresses is this dashboard reachable on?  -> local_addresses()
   The server binds 0.0.0.0, so it answers on every interface. We enumerate them
   so the operator can open the dashboard from a phone on the same Wi-Fi.

2. What subnets are we on?  -> local_networks()
   Derived from the live interface list, so moving between networks just works.

3. Where is the robot right now?  -> scan_subnet() / probe_host()
   A bounded TCP sweep of the current subnet, matching hosts that answer on the
   ports an X2 exposes.
"""

from __future__ import annotations

import asyncio
import ipaddress
import socket
import subprocess
import sys
import time
from typing import Iterable

from . import x2_spec


# --------------------------------------------------------------------------
# Local interface enumeration
# --------------------------------------------------------------------------

def _addresses_from_os() -> set[str]:
    """
    Every IPv4 the OS reports, by asking the OS directly.

    getaddrinfo() and the UDP-connect trick below both under-report: the first
    depends on how the hostname resolves, the second only ever reveals the
    default-route interface. On a machine with both Wi-Fi and Ethernet that
    means the dashboard advertised only one of its addresses.
    """
    found: set[str] = set()
    try:
        if sys.platform == "win32":
            out = subprocess.run(
                ["ipconfig"], capture_output=True, text=True, timeout=8,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            ).stdout
            for line in out.splitlines():
                line = line.strip()
                # Localised Windows uses a translated label, so match on the
                # "IPv4" token rather than the full English phrase.
                if "IPv4" in line and ":" in line:
                    found.add(line.split(":")[-1].strip().replace("(Preferred)", "").strip())
        else:
            out = subprocess.run(
                ["ip", "-o", "-4", "addr", "show"],
                capture_output=True, text=True, timeout=8,
            ).stdout
            for line in out.splitlines():
                for token in line.split():
                    if "/" in token and token[0].isdigit():
                        found.add(token.split("/")[0])
                        break
    except (OSError, subprocess.SubprocessError, ValueError):
        pass
    return found


def _addresses_via_socket() -> set[str]:
    """Addresses this host answers on, without third-party dependencies."""
    found: set[str] = set(_addresses_from_os())

    try:
        for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
            found.add(info[4][0])
    except socket.gaierror:
        pass

    # Opening a UDP socket toward a public address makes the kernel pick the
    # outbound interface and tell us its address - no packet is actually sent.
    for probe in ("8.8.8.8", "1.1.1.1"):
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            sock.connect((probe, 80))
            found.add(sock.getsockname()[0])
        except OSError:
            pass
        finally:
            sock.close()

    cleaned = set()
    for addr in found:
        try:
            ipaddress.IPv4Address(addr)
            cleaned.add(addr)
        except ipaddress.AddressValueError:
            continue
    return cleaned


def _netmask_for(addr: str) -> str | None:
    """Best-effort netmask lookup. Falls back to a /24 assumption."""
    try:
        if sys.platform == "win32":
            out = subprocess.run(
                ["ipconfig"], capture_output=True, text=True, timeout=5,
                creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
            ).stdout
            block: list[str] = []
            for line in out.splitlines():
                block.append(line)
                if addr in line:
                    # The mask is printed on the line after the address.
                    idx = block.index(line)
                    for follow in out.splitlines()[idx + 1: idx + 4]:
                        if "Mask" in follow or "掩码" in follow:
                            return follow.split(":")[-1].strip()
        else:
            out = subprocess.run(
                ["ip", "-o", "-f", "inet", "addr", "show"],
                capture_output=True, text=True, timeout=5,
            ).stdout
            for line in out.splitlines():
                if f"{addr}/" in line:
                    for tok in line.split():
                        if tok.startswith(f"{addr}/"):
                            return tok.split("/")[1]
    except (OSError, subprocess.SubprocessError, ValueError):
        pass
    return None


def local_addresses() -> list[dict]:
    """Every usable IPv4 this machine holds, loopback last."""
    result = []
    for addr in sorted(_addresses_via_socket()):
        try:
            ip = ipaddress.IPv4Address(addr)
        except ipaddress.AddressValueError:
            continue
        result.append({
            "address": addr,
            "loopback": ip.is_loopback,
            "private": ip.is_private,
            "link_local": ip.is_link_local,
        })
    result.sort(key=lambda r: (r["loopback"], r["link_local"], not r["private"], r["address"]))
    return result


def local_networks() -> list[str]:
    """CIDR networks this host sits on, excluding loopback and link-local."""
    nets: list[str] = []
    for entry in local_addresses():
        if entry["loopback"] or entry["link_local"]:
            continue
        addr = entry["address"]
        mask = _netmask_for(addr)
        try:
            iface = ipaddress.IPv4Interface(f"{addr}/{mask}" if mask else f"{addr}/24")
        except (ipaddress.AddressValueError, ipaddress.NetmaskValueError, ValueError):
            iface = ipaddress.IPv4Interface(f"{addr}/24")
        cidr = str(iface.network)
        if cidr not in nets:
            nets.append(cidr)
    return nets


def primary_address() -> str | None:
    """
    The address other devices on the LAN would actually reach us on.

    Delegates to nic.detect(), which ranks real adapters and refuses to return a
    stale one. Enumerating "the first non-loopback address" is not good enough:
    a disconnected adapter keeps its address, answers local connections through
    loopback, and would be published as a link nobody else can open.
    """
    from . import nic

    return nic.primary_address()


def address_detail() -> dict:
    """Full detection result - which adapter won, what was rejected and why."""
    from . import nic

    found = nic.detect()
    primary = found["primary"]
    return {
        "ip": primary["ip"] if primary else None,
        "adapter": primary["adapter"] if primary else None,
        "kind": ("wifi" if primary and primary.get("iftype") == nic.IF_TYPE_WIFI
                 else "wired" if primary else None),
        "gateway": primary.get("gateway") if primary else None,
        "reason": found["reason"],
        "reason_text": nic.REASON_TEXT.get(found["reason"] or "", ""),
        "alternates": [
            {"ip": a["ip"], "adapter": a["adapter"],
             "kind": "wifi" if a.get("iftype") == nic.IF_TYPE_WIFI else "wired"}
            for a in found["alternates"]
        ],
        "rejected": [
            {"ip": r["ip"], "adapter": r["adapter"], "why": r["rejected"]}
            for r in found["rejected"]
            if not str(r["ip"]).startswith("127.")
        ],
        "checked_at": found["checked_at"],
    }


def hostname_urls(port: int, advertised: str | None = None) -> list[str]:
    """
    Name-based URLs for the dashboard.

    Only the name the dashboard advertises for itself is offered. The PC's own
    computer name is deliberately not published: this is a robot dashboard, and
    the link should say so. The bare single-label form is left out too - it
    resolves on Windows from local configuration without a packet reaching the
    network, and no phone resolves it at all.
    """
    if not advertised:
        return []
    return [f"http://{advertised.strip().strip('.').lower()}.local:{port}"]


def dashboard_urls(port: int, advertised: str | None = None) -> list[str]:
    """
    URLs for the dashboard, best first.

    The live Wi-Fi address leads: it is what a phone can always reach, needing
    no mDNS, no multicast and no DNS. Names come after it, because on many
    access points `.local` resolution is blocked between wireless clients.
    """
    urls: list[str] = []
    detail = address_detail()
    if detail["ip"]:
        urls.append(f"http://{detail['ip']}:{port}")
    for alternate in detail["alternates"]:
        urls.append(f"http://{alternate['ip']}:{port}")

    for url in hostname_urls(port, advertised):
        if url not in urls:
            urls.append(url)

    urls.append(f"http://localhost:{port}")
    return urls


# --------------------------------------------------------------------------
# Robot probing
# --------------------------------------------------------------------------

async def _tcp_open(host: str, port: int, timeout: float) -> bool:
    try:
        fut = asyncio.open_connection(host, port)
        reader, writer = await asyncio.wait_for(fut, timeout=timeout)
        writer.close()
        try:
            await writer.wait_closed()
        except (ConnectionError, OSError):
            pass
        return True
    except (asyncio.TimeoutError, OSError):
        return False


async def probe_host(host: str, ports: Iterable[int] | None = None,
                     timeout: float = 0.6) -> dict:
    """Check which of the X2's known ports a host answers on."""
    ports = list(ports or x2_spec.DISCOVERY_PORTS)
    started = time.monotonic()
    results = await asyncio.gather(*[_tcp_open(host, p, timeout) for p in ports])
    open_ports = [p for p, ok in zip(ports, results) if ok]

    hostname = None
    if open_ports:
        try:
            hostname = await asyncio.get_running_loop().run_in_executor(
                None, lambda: socket.gethostbyaddr(host)[0]
            )
        except (OSError, socket.herror):
            hostname = None

    return {
        "host": host,
        "reachable": bool(open_ports),
        "open_ports": open_ports,
        "hostname": hostname,
        "latency_ms": round((time.monotonic() - started) * 1000, 1),
        "is_pc1": host == x2_spec.PC1_MOTION_CONTROL_IP,
    }


async def scan_subnet(cidr: str, ports: Iterable[int] | None = None,
                      timeout: float = 0.4, concurrency: int = 128,
                      progress=None) -> list[dict]:
    """
    Sweep a subnet for hosts answering on X2 ports.

    Refuses networks larger than /22 - a /16 sweep is 65k hosts and would hang
    for minutes rather than seconds.
    """
    network = ipaddress.IPv4Network(cidr, strict=False)
    if network.num_addresses > 1024:
        raise ValueError(
            f"{cidr} has {network.num_addresses} addresses; "
            "narrow the range to /22 or smaller before scanning."
        )

    hosts = list(network.hosts())
    semaphore = asyncio.Semaphore(concurrency)
    done = 0
    total = len(hosts)

    async def one(ip):
        nonlocal done
        async with semaphore:
            res = await probe_host(str(ip), ports, timeout)
        done += 1
        if progress and done % 16 == 0:
            await progress(done, total)
        return res

    results = await asyncio.gather(*[one(ip) for ip in hosts])
    if progress:
        await progress(total, total)
    return [r for r in results if r["reachable"]]


async def resolve(host: str) -> str | None:
    """Resolve a hostname to an IPv4 address, or return it unchanged if already one."""
    try:
        ipaddress.IPv4Address(host)
        return host
    except ipaddress.AddressValueError:
        pass
    try:
        loop = asyncio.get_running_loop()
        info = await loop.getaddrinfo(host, None, family=socket.AF_INET)
        return info[0][4][0]
    except (socket.gaierror, OSError, IndexError):
        return None
