"""
Find the LAN address this machine is actually reachable on, live.

The dashboard publishes `http://<ip>:8770`, so picking the wrong address means
handing someone a link that cannot work. That is easy to get wrong: this very
machine has a *disconnected* Ethernet adapter still holding 192.168.123.222, and
a local HTTP fetch to that address SUCCEEDS - Windows routes traffic to any of
its own addresses through loopback, skipping the adapter entirely. It even
answers faster than the real one. There is therefore no connect, bind or fetch
test that can tell a reachable address from a dead one; the decision has to come
from adapter metadata.

The rule below was chosen by running candidate rules against 23 synthetic
adapter tables (VPNs of three different shapes, Hyper-V bridges, mobile hotspot,
docked Ethernet, duplicate-IP detection, campus public addressing, renamed
adapters). Two obvious-looking rules broke 9 times each; this one breaks once,
on a case that is genuinely undecidable (two Wi-Fi radios, nothing in the
adapter table says which one the phone is associated to).

Deliberate non-decisions, each of which was tried and rejected:

* No `is_private` test. It over-rejects campus and CGNAT (100.64/10) addresses
  that phones reach perfectly well, and under-rejects Hyper-V's 172.x.
* No adapter *name* matching. "Wi-Fi" is user-renameable and every ipconfig
  label is translated on non-English Windows.
* Interface metric is the LAST tiebreak, never a gate. Corporate VPNs
  deliberately set a low metric, and on this machine four *disconnected*
  adapters sit at metric 25 while the one working NIC is at 30.
"""

from __future__ import annotations

import ipaddress
import json
import logging
import os
import platform
import subprocess
import threading
import time

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

# Re-read this often at most. At ~2 ms a detection the cost is irrelevant; the
# TTL exists so two requests in one page load cannot disagree mid-roam, and so a
# stale address never outlives a single human retry.
TTL_SECONDS = 2.0

IF_TYPE_ETHERNET = 6
IF_TYPE_LOOPBACK = 24
IF_TYPE_WIFI = 71
IF_TYPE_TUNNEL = 131

OPER_STATUS_UP = 1
DAD_STATE_PREFERRED = 4
PREFIX_ORIGIN_WELLKNOWN = 2
PREFIX_ORIGIN_DHCP = 3


# --------------------------------------------------------------------------
# Ranking - platform independent, operating on normalised records
# --------------------------------------------------------------------------

def _class_of(record: dict) -> int:
    """Higher is a better candidate to hand to a phone."""
    hardware = record.get("hardware")
    iftype = record.get("iftype")
    if hardware and iftype == IF_TYPE_WIFI:
        return 3                      # a real Wi-Fi radio
    if hardware and iftype == IF_TYPE_ETHERNET:
        return 2                      # a real wired NIC
    if iftype == IF_TYPE_WIFI:
        return 1                      # hotspot / Wi-Fi Direct - still a radio
    return 0                          # bridge or unknown


def _rejected(record: dict) -> str | None:
    """Why this address must not be published, or None if it is usable."""
    iftype = record.get("iftype")
    if iftype in (IF_TYPE_LOOPBACK, IF_TYPE_TUNNEL):
        return "loopback or tunnel"
    if record.get("oper_status") != OPER_STATUS_UP:
        return "adapter is down"
    if record.get("connected") is False:
        return "no link"
    if record.get("dad_state") not in (None, DAD_STATE_PREFERRED):
        # Deprecated = the stale address on a disconnected NIC.
        # Tentative   = still running duplicate-address detection.
        # Duplicate   = the stack itself refuses to send from it.
        return "address not preferred"
    if record.get("prefix_origin") == PREFIX_ORIGIN_WELLKNOWN:
        return "self-assigned (APIPA)"

    try:
        ip = ipaddress.IPv4Address(record["ip"])
    except (ipaddress.AddressValueError, KeyError):
        return "not an IPv4 address"
    if ip.is_loopback or ip.is_link_local:
        return "loopback or link-local"

    # Virtual adapters (VPN NDIS miniports, Hyper-V, WSL, docker) are not a path
    # to a phone. Two exceptions: any Wi-Fi-class radio, which covers the mobile
    # hotspot case; and a bridge that has taken over a live physical NIC's
    # address, which is what a Hyper-V *external* switch does.
    if not record.get("hardware", True):
        is_radio = iftype == IF_TYPE_WIFI
        is_external_bridge = (
            record.get("physical_link_present")
            and record.get("has_gateway")
            and record.get("prefix_origin") == PREFIX_ORIGIN_DHCP
        )
        if not (is_radio or is_external_bridge):
            return "virtual adapter"
    return None


def _sort_key(record: dict):
    try:
        numeric = int(ipaddress.IPv4Address(record["ip"]))
    except Exception:
        numeric = 0
    return (
        1 if record.get("has_gateway") else 0,
        _class_of(record),
        1 if record.get("prefix_origin") == PREFIX_ORIGIN_DHCP else 0,
        -(record.get("metric") or 0),
        -numeric,
    )


def rank(records: list[dict]) -> tuple[list[dict], list[dict]]:
    """Split candidates into (usable, best first) and (rejected, with reasons)."""
    usable, rejected = [], []
    for record in records:
        why = _rejected(record)
        if why:
            rejected.append({**record, "rejected": why})
        else:
            usable.append(record)
    usable.sort(key=_sort_key, reverse=True)
    return usable, rejected


def _reason_for_nothing(rejected: list[dict]) -> str:
    """Explain an empty result honestly instead of guessing an address."""
    if not rejected:
        return "no_network_interface"
    kinds = {r.get("rejected") for r in rejected}
    wifi = [r for r in rejected if r.get("iftype") == IF_TYPE_WIFI and r.get("hardware")]
    if wifi and all(r.get("oper_status") != OPER_STATUS_UP or r.get("connected") is False
                    for r in wifi):
        return "wifi_down"
    if "address not preferred" in kinds or "self-assigned (APIPA)" in kinds:
        return "awaiting_dhcp"
    if all(not r.get("has_gateway") for r in rejected):
        return "no_router_on_this_network"
    return "no_usable_address"


# --------------------------------------------------------------------------
# Windows collector - GetAdaptersAddresses + GetIfTable2 via ctypes
# --------------------------------------------------------------------------

def _collect_windows() -> list[dict]:
    import ctypes
    from ctypes import wintypes

    iphlpapi = ctypes.WinDLL("iphlpapi")

    class SOCKET_ADDRESS(ctypes.Structure):
        _fields_ = [("lpSockaddr", ctypes.c_void_p), ("iSockaddrLength", ctypes.c_int)]

    class IP_ADAPTER_UNICAST_ADDRESS(ctypes.Structure):
        pass

    IP_ADAPTER_UNICAST_ADDRESS._fields_ = [
        ("Length", wintypes.ULONG),
        ("Flags", wintypes.DWORD),
        ("Next", ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
        ("Address", SOCKET_ADDRESS),
        ("PrefixOrigin", ctypes.c_int),
        ("SuffixOrigin", ctypes.c_int),
        ("DadState", ctypes.c_int),
        ("ValidLifetime", wintypes.ULONG),
        ("PreferredLifetime", wintypes.ULONG),
        ("LeaseLifetime", wintypes.ULONG),
        ("OnLinkPrefixLength", ctypes.c_ubyte),
    ]

    class IP_ADAPTER_GATEWAY_ADDRESS(ctypes.Structure):
        pass

    IP_ADAPTER_GATEWAY_ADDRESS._fields_ = [
        ("Length", wintypes.ULONG),
        ("Reserved", wintypes.DWORD),
        ("Next", ctypes.POINTER(IP_ADAPTER_GATEWAY_ADDRESS)),
        ("Address", SOCKET_ADDRESS),
    ]

    class IP_ADAPTER_ADDRESSES(ctypes.Structure):
        pass

    IP_ADAPTER_ADDRESSES._fields_ = [
        ("Length", wintypes.ULONG),
        ("IfIndex", wintypes.DWORD),
        ("Next", ctypes.POINTER(IP_ADAPTER_ADDRESSES)),
        ("AdapterName", ctypes.c_char_p),
        ("FirstUnicastAddress", ctypes.POINTER(IP_ADAPTER_UNICAST_ADDRESS)),
        ("FirstAnycastAddress", ctypes.c_void_p),
        ("FirstMulticastAddress", ctypes.c_void_p),
        ("FirstDnsServerAddress", ctypes.c_void_p),
        ("DnsSuffix", ctypes.c_wchar_p),
        ("Description", ctypes.c_wchar_p),
        ("FriendlyName", ctypes.c_wchar_p),
        ("PhysicalAddress", ctypes.c_ubyte * 8),
        ("PhysicalAddressLength", wintypes.ULONG),
        ("Flags", wintypes.ULONG),
        ("Mtu", wintypes.ULONG),
        ("IfType", wintypes.ULONG),
        ("OperStatus", ctypes.c_int),
        ("Ipv6IfIndex", wintypes.DWORD),
        ("ZoneIndices", wintypes.ULONG * 16),
        ("FirstPrefix", ctypes.c_void_p),
        ("TransmitLinkSpeed", ctypes.c_uint64),
        ("ReceiveLinkSpeed", ctypes.c_uint64),
        ("FirstWinsServerAddress", ctypes.c_void_p),
        ("FirstGatewayAddress", ctypes.POINTER(IP_ADAPTER_GATEWAY_ADDRESS)),
        ("Ipv4Metric", wintypes.ULONG),
        ("Ipv6Metric", wintypes.ULONG),
        ("Luid", ctypes.c_uint64),
        ("Dhcpv4Server", SOCKET_ADDRESS),
        ("CompartmentId", wintypes.DWORD),
        ("NetworkGuid", ctypes.c_ubyte * 16),
        ("ConnectionType", ctypes.c_int),
        ("TunnelType", ctypes.c_int),
    ]

    AF_INET = 2
    GAA_FLAG_INCLUDE_GATEWAYS = 0x0080
    GAA_FLAG_SKIP_ANYCAST = 0x0002
    GAA_FLAG_SKIP_MULTICAST = 0x0004
    GAA_FLAG_SKIP_DNS_SERVER = 0x0008
    flags = (GAA_FLAG_INCLUDE_GATEWAYS | GAA_FLAG_SKIP_ANYCAST
             | GAA_FLAG_SKIP_MULTICAST | GAA_FLAG_SKIP_DNS_SERVER)

    def sockaddr_to_ip(sa: SOCKET_ADDRESS) -> str | None:
        if not sa.lpSockaddr:
            return None
        raw = ctypes.string_at(sa.lpSockaddr, 8)
        if int.from_bytes(raw[0:2], "little") != AF_INET:
            return None
        return ".".join(str(b) for b in raw[4:8])

    # The buffer is allocated per call; sharing one across threads corrupts it.
    size = wintypes.ULONG(15000)
    buffer = ctypes.create_string_buffer(size.value)
    result = iphlpapi.GetAdaptersAddresses(
        AF_INET, flags, None, ctypes.byref(buffer), ctypes.byref(size))
    if result == 111:                                  # ERROR_BUFFER_OVERFLOW
        buffer = ctypes.create_string_buffer(size.value)
        result = iphlpapi.GetAdaptersAddresses(
            AF_INET, flags, None, ctypes.byref(buffer), ctypes.byref(size))
    if result != 0:
        raise OSError(f"GetAdaptersAddresses failed: {result}")

    # First pass: collect IfType per interface so the GetIfTable2 parse below can
    # be validated against a second, independent source.
    known_types: dict[int, int] = {}
    node = ctypes.cast(buffer, ctypes.POINTER(IP_ADAPTER_ADDRESSES))
    while node:
        known_types[int(node.contents.IfIndex)] = int(node.contents.IfType)
        node = node.contents.Next

    hardware, connected, physical_live = _windows_interface_flags(known_types)

    records: list[dict] = []
    node = ctypes.cast(buffer, ctypes.POINTER(IP_ADAPTER_ADDRESSES))
    while node:
        adapter = node.contents
        gateways = []
        gw = adapter.FirstGatewayAddress
        while gw:
            ip = sockaddr_to_ip(gw.contents.Address)
            if ip:
                gateways.append(ip)
            gw = gw.contents.Next

        unicast = adapter.FirstUnicastAddress
        while unicast:
            entry = unicast.contents
            ip = sockaddr_to_ip(entry.Address)
            if ip:
                index = int(adapter.IfIndex)
                records.append({
                    "ip": ip,
                    "prefix_length": int(entry.OnLinkPrefixLength),
                    "adapter": adapter.FriendlyName or "",
                    "description": adapter.Description or "",
                    "if_index": index,
                    "iftype": int(adapter.IfType),
                    "oper_status": int(adapter.OperStatus),
                    "dad_state": int(entry.DadState),
                    "prefix_origin": int(entry.PrefixOrigin),
                    "metric": int(adapter.Ipv4Metric),
                    "has_gateway": bool(gateways),
                    "gateway": gateways[0] if gateways else None,
                    "hardware": hardware.get(index),
                    "connected": connected.get(index),
                    "physical_link_present": physical_live,
                })
            unicast = entry.Next
        node = adapter.Next

    return records


def _windows_interface_flags(known_types: dict | None = None) -> tuple[dict, dict, bool]:
    """
    Per-interface HardwareInterface / MediaConnectState from GetIfTable2.

    These two gates cannot be derived from GetAdaptersAddresses, and they are
    what separates a real NIC from a VPN miniport or a Hyper-V bridge without
    matching English adapter names.

    MIB_IF_ROW2 must be modelled COMPLETELY, trailing statistics counters and
    all: the rows are read as a contiguous array, so a struct even one field
    short gives the wrong stride and every row after the first is garbage -
    silently, with no error from the API. `known_types` therefore carries the
    IfType values already read from GetAdaptersAddresses so the parse can be
    checked against them; on disagreement we discard the whole table and let the
    ranking fall back to `hardware: None` (unknown), which is treated as "do not
    reject", never as "virtual".
    """
    import ctypes
    from ctypes import wintypes

    try:
        iphlpapi = ctypes.WinDLL("iphlpapi")

        class MIB_IF_ROW2(ctypes.Structure):
            _fields_ = [
                ("InterfaceLuid", ctypes.c_uint64),
                ("InterfaceIndex", wintypes.DWORD),
                ("InterfaceGuid", ctypes.c_ubyte * 16),
                ("Alias", ctypes.c_wchar * 257),
                ("Description", ctypes.c_wchar * 257),
                ("PhysicalAddressLength", wintypes.ULONG),
                ("PhysicalAddress", ctypes.c_ubyte * 32),
                ("PermanentPhysicalAddress", ctypes.c_ubyte * 32),
                ("Mtu", wintypes.ULONG),
                ("Type", wintypes.ULONG),
                ("TunnelType", ctypes.c_int),
                ("MediaType", ctypes.c_int),
                ("PhysicalMediumType", ctypes.c_int),
                ("AccessType", ctypes.c_int),
                ("DirectionType", ctypes.c_int),
                ("InterfaceAndOperStatusFlags", ctypes.c_ubyte),
                ("OperStatus", ctypes.c_int),
                ("AdminStatus", ctypes.c_int),
                ("MediaConnectState", ctypes.c_int),
                ("NetworkGuid", ctypes.c_ubyte * 16),
                ("ConnectionType", ctypes.c_int),
                ("TransmitLinkSpeed", ctypes.c_uint64),
                ("ReceiveLinkSpeed", ctypes.c_uint64),
                # Everything below is unused, but its size decides the stride.
                ("InOctets", ctypes.c_uint64),
                ("InUcastPkts", ctypes.c_uint64),
                ("InNUcastPkts", ctypes.c_uint64),
                ("InDiscards", ctypes.c_uint64),
                ("InErrors", ctypes.c_uint64),
                ("InUnknownProtos", ctypes.c_uint64),
                ("InUcastOctets", ctypes.c_uint64),
                ("InMulticastOctets", ctypes.c_uint64),
                ("InBroadcastOctets", ctypes.c_uint64),
                ("OutOctets", ctypes.c_uint64),
                ("OutUcastPkts", ctypes.c_uint64),
                ("OutNUcastPkts", ctypes.c_uint64),
                ("OutDiscards", ctypes.c_uint64),
                ("OutErrors", ctypes.c_uint64),
                ("OutUcastOctets", ctypes.c_uint64),
                ("OutMulticastOctets", ctypes.c_uint64),
                ("OutBroadcastOctets", ctypes.c_uint64),
                ("OutQLen", ctypes.c_uint64),
            ]

        class MIB_IF_TABLE2(ctypes.Structure):
            # NumEntries then the rows - there is no reserved field here. Adding
            # one shifts Table by 8 bytes and every row parses as garbage.
            _fields_ = [("NumEntries", wintypes.ULONG),
                        ("Table", MIB_IF_ROW2 * 1)]

        iphlpapi.GetIfTable2.argtypes = [ctypes.POINTER(ctypes.POINTER(MIB_IF_TABLE2))]
        iphlpapi.GetIfTable2.restype = ctypes.c_ulong

        table_ptr = ctypes.POINTER(MIB_IF_TABLE2)()
        status = iphlpapi.GetIfTable2(ctypes.byref(table_ptr))
        if status != 0 or not table_ptr:
            log.debug("GetIfTable2 returned %s", status)
            return {}, {}, False

        try:
            count = int(table_ptr.contents.NumEntries)
            if not 0 < count < 4096:
                log.debug("GetIfTable2 gave an implausible row count: %s", count)
                return {}, {}, False

            rows = ctypes.cast(
                ctypes.byref(table_ptr.contents.Table),
                ctypes.POINTER(MIB_IF_ROW2 * count)).contents

            hardware, connected, types = {}, {}, {}
            physical_live = False
            for row in rows:
                index = int(row.InterfaceIndex)
                if not 0 < index < 10_000_000:
                    log.debug("GetIfTable2 row has a bogus index (%s) - bad layout", index)
                    return {}, {}, False
                media = int(row.MediaConnectState)
                is_hardware = bool(row.InterfaceAndOperStatusFlags & 0x01)
                is_connected = media == 1
                hardware[index] = is_hardware
                connected[index] = is_connected if media in (1, 2) else None
                types[index] = int(row.Type)
                if is_hardware and is_connected and int(row.Type) in (
                        IF_TYPE_ETHERNET, IF_TYPE_WIFI):
                    physical_live = True

            # Cross-check against the types GetAdaptersAddresses already gave
            # us. If the two disagree, this parse is not trustworthy.
            for index, iftype in (known_types or {}).items():
                if index in types and types[index] != iftype:
                    log.warning("GetIfTable2 disagrees with GetAdaptersAddresses on if%s "
                                "(%s vs %s) - ignoring hardware flags",
                                index, types[index], iftype)
                    return {}, {}, False

            return hardware, connected, physical_live
        finally:
            iphlpapi.FreeMibTable(table_ptr)
    except Exception as exc:
        log.debug("GetIfTable2 unavailable (%s); ranking without hardware flags", exc)
        return {}, {}, False


# --------------------------------------------------------------------------
# Linux collector - `ip -j addr` enriched from sysfs
# --------------------------------------------------------------------------

def _sysfs(interface: str, leaf: str) -> str | None:
    try:
        with open(f"/sys/class/net/{interface}/{leaf}", "r") as handle:
            return handle.read().strip()
    except OSError:
        return None


def _linux_gateways() -> set[str]:
    """Interfaces owning a default route, from /proc/net/route."""
    owners: set[str] = set()
    try:
        with open("/proc/net/route", "r") as handle:
            next(handle, None)
            for line in handle:
                parts = line.split()
                if len(parts) > 2 and parts[1] == "00000000":
                    owners.add(parts[0])
    except OSError:
        pass
    return owners


def _collect_linux() -> list[dict]:
    try:
        raw = subprocess.run(["ip", "-j", "-4", "addr", "show"],
                             capture_output=True, text=True, timeout=5).stdout
        interfaces = json.loads(raw or "[]")
    except (OSError, subprocess.SubprocessError, ValueError):
        return []

    gateways = _linux_gateways()
    physical_live = False
    records: list[dict] = []

    for iface in interfaces:
        name = iface.get("ifname", "")
        # The `device` symlink exists only when a real PCI/USB/platform device
        # backs the interface - absent for docker0, veth*, tun*, br-*, lo. It is
        # the structural equivalent of Windows' HardwareInterface.
        is_hardware = os.path.exists(f"/sys/class/net/{name}/device")
        is_wireless = os.path.isdir(f"/sys/class/net/{name}/phy80211") or \
            os.path.isdir(f"/sys/class/net/{name}/wireless")
        carrier = _sysfs(name, "carrier")
        operstate = _sysfs(name, "operstate")
        up = (operstate == "up") or ("UP" in (iface.get("flags") or []))

        iftype = IF_TYPE_WIFI if is_wireless else (
            IF_TYPE_LOOPBACK if name == "lo" else IF_TYPE_ETHERNET)
        if is_hardware and carrier == "1" and iftype in (IF_TYPE_ETHERNET, IF_TYPE_WIFI):
            physical_live = True

        for addr in iface.get("addr_info", []):
            if addr.get("family") != "inet":
                continue
            records.append({
                "ip": addr.get("local"),
                "prefix_length": addr.get("prefixlen"),
                "adapter": name,
                "description": name,
                "if_index": iface.get("ifindex"),
                "iftype": iftype,
                "oper_status": OPER_STATUS_UP if up else 2,
                # `scope: global` is the practical stand-in for Preferred; it
                # also drops link-local for free.
                "dad_state": DAD_STATE_PREFERRED if addr.get("scope") == "global" else 1,
                "prefix_origin": PREFIX_ORIGIN_DHCP if addr.get("dynamic") else 1,
                "metric": 0,
                "has_gateway": name in gateways,
                "gateway": None,
                "hardware": is_hardware,
                "connected": None if carrier is None else carrier == "1",
                "physical_link_present": physical_live,
            })

    for record in records:
        record["physical_link_present"] = physical_live
    return records


# --------------------------------------------------------------------------
# Public interface
# --------------------------------------------------------------------------

_lock = threading.Lock()
_cache: dict | None = None
_cache_at = 0.0


def _collect() -> list[dict]:
    if platform.system() == "Windows":
        return _collect_windows()
    return _collect_linux()


def detect(force: bool = False) -> dict:
    """
    The current LAN address, re-read live.

    Returns {primary, alternates, rejected, reason, checked_at}. `primary` is
    None when there is genuinely nothing usable - the caller must show that
    honestly rather than print a stale or invented URL.
    """
    global _cache, _cache_at

    with _lock:
        # monotonic, never time.time(): the wall clock jumps on NTP sync and on
        # resume from sleep, which would freeze or expire the cache wrongly.
        now = time.monotonic()
        if not force and _cache is not None and (now - _cache_at) < TTL_SECONDS:
            return _cache

        try:
            records = _collect()
        except Exception as exc:
            log.warning("address detection failed: %s", exc)
            records = []

        usable, rejected = rank(records)
        result = {
            "primary": usable[0] if usable else None,
            "alternates": usable[1:],
            "rejected": rejected,
            "reason": None if usable else _reason_for_nothing(rejected),
            "checked_at": time.time(),
        }
        _cache, _cache_at = result, now
        return result


def primary_address() -> str | None:
    """Just the address, for callers that do not care why."""
    found = detect()
    return found["primary"]["ip"] if found["primary"] else None


REASON_TEXT = {
    "wifi_down": "Wi-Fi is off or not connected",
    "awaiting_dhcp": "connected, still waiting for an address",
    "no_router_on_this_network": "connected, but this network has no router",
    "no_physical_adapter": "no network adapter found",
    "no_network_interface": "no network interface found",
    "no_usable_address": "no usable network address",
}
