"""LAN-scope hostname leak lockdown.

Windows by default broadcasts the local hostname on every adapter via:
  - LLMNR  (UDP 5355)        — link-local multicast name resolution
  - NetBIOS over TCP/IP      — broadcast name registration on the subnet
  - mDNS   (UDP 5353)        — Bonjour-style name advertising on newer Windows

Anyone on the same Wi-Fi / VLAN can grab the hostname and tie it to the MAC
address. Killing these is a paranoid-mode no-brainer.

All three are reversible. We snapshot original state on engage and restore on
disengage.
"""
from __future__ import annotations

import json
import logging
import subprocess
from dataclasses import dataclass, field

import winreg

from .firewall import is_admin
from .win_compat import probe as _win_probe

log = logging.getLogger(__name__)

_LLMNR_POLICY_PATH = r"SOFTWARE\Policies\Microsoft\Windows NT\DNSClient"
_LLMNR_VALUE = "EnableMulticast"
_MDNS_DNSCLIENT = r"SYSTEM\CurrentControlSet\Services\Dnscache\Parameters"
_MDNS_VALUE = "EnableMDNS"


@dataclass
class LanSnapshot:
    netbios_per_interface: dict[str, int] = field(default_factory=dict)
    llmnr_present: bool = False
    llmnr_prev_value: int | None = None
    mdns_present: bool = False
    mdns_prev_value: int | None = None


def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]:
    try:
        r = subprocess.run(
            args,
            capture_output=True,
            text=True,
            timeout=timeout,
            creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
        )
        return r.returncode, r.stdout or "", r.stderr or ""
    except Exception as e:
        return 1, "", str(e)


def _list_netbios_via_wmic() -> dict[str, int]:
    """Per-NIC NetBIOS setting via wmic (works back to Server 2008 R2).

    Returns: { settings_id : tcpip_netbios_options }
        0 = use DHCP, 1 = enabled, 2 = disabled
    """
    code, out, _ = _run([
        "wmic", "nicconfig", "where", "IPEnabled=true",
        "get", "SettingID,TcpipNetbiosOptions", "/format:list",
    ])
    if code != 0 or not out:
        return {}
    settings: dict[str, int] = {}
    cur_id = ""
    cur_val: int | None = None
    for raw in out.splitlines():
        line = raw.strip()
        if not line:
            if cur_id and cur_val is not None:
                settings[cur_id] = cur_val
            cur_id, cur_val = "", None
            continue
        if line.startswith("SettingID="):
            cur_id = line.split("=", 1)[1].strip()
        elif line.startswith("TcpipNetbiosOptions="):
            try:
                cur_val = int(line.split("=", 1)[1].strip())
            except ValueError:
                cur_val = None
    if cur_id and cur_val is not None:
        settings[cur_id] = cur_val
    return settings


def _set_netbios_via_wmic(settings_id: str, mode: int) -> bool:
    """mode: 0=DHCP, 1=enabled, 2=disabled."""
    code, _, _ = _run([
        "wmic", "nicconfig", "where",
        f"SettingID='{settings_id}'",
        "call", "SetTcpipNetbios", str(mode),
    ])
    return code == 0


def _llmnr_read() -> tuple[bool, int | None]:
    try:
        with winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_QUERY_VALUE
        ) as key:
            val, _ = winreg.QueryValueEx(key, _LLMNR_VALUE)
            return True, int(val)
    except OSError:
        return False, None


def _llmnr_write(value: int) -> bool:
    try:
        with winreg.CreateKeyEx(
            winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
        ) as key:
            winreg.SetValueEx(key, _LLMNR_VALUE, 0, winreg.REG_DWORD, value)
        return True
    except OSError:
        return False


def _llmnr_delete() -> None:
    try:
        with winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
        ) as key:
            winreg.DeleteValue(key, _LLMNR_VALUE)
    except OSError:
        pass


def _mdns_read() -> tuple[bool, int | None]:
    try:
        with winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_QUERY_VALUE
        ) as key:
            val, _ = winreg.QueryValueEx(key, _MDNS_VALUE)
            return True, int(val)
    except OSError:
        return False, None


def _mdns_write(value: int) -> bool:
    try:
        with winreg.OpenKey(
            winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
        ) as key:
            winreg.SetValueEx(key, _MDNS_VALUE, 0, winreg.REG_DWORD, value)
        return True
    except OSError:
        return False


def _restart_dnscache() -> None:
    """Bounce the DNS Client service so the LLMNR/mDNS toggles take effect.

    Server hardening baselines sometimes mark this service "denied" — failure
    is logged and ignored; the change still applies on next reboot.
    """
    _run(["net", "stop", "Dnscache", "/y"], timeout=20)
    _run(["net", "start", "Dnscache"], timeout=20)


def lan_status() -> dict[str, str]:
    """Human-readable current state. Returned even when not Admin."""
    nb = _list_netbios_via_wmic()
    if nb:
        disabled = sum(1 for v in nb.values() if v == 2)
        nb_summary = f"{disabled}/{len(nb)} NICs with NetBIOS disabled"
    else:
        nb_summary = "unavailable"
    llmnr_present, llmnr_v = _llmnr_read()
    if not llmnr_present:
        llmnr_summary = "default (ON)"
    else:
        llmnr_summary = "OFF" if llmnr_v == 0 else f"ON (policy={llmnr_v})"
    mdns_present, mdns_v = _mdns_read()
    if not mdns_present:
        mdns_summary = "default (ON on Win10 1903+)"
    else:
        mdns_summary = "OFF" if mdns_v == 0 else f"ON (EnableMDNS={mdns_v})"
    return {
        "netbios": nb_summary,
        "llmnr": llmnr_summary,
        "mdns": mdns_summary,
    }


def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
    """Disable LLMNR / NetBIOS / mDNS. Returns (snapshot for restore, log lines).

    snapshot is None when the operation was refused (not Admin, etc.).
    """
    logs: list[str] = []
    if not is_admin():
        return None, ["LAN privacy lockdown skipped — needs Administrator."]

    snap = LanSnapshot()
    snap.netbios_per_interface = _list_netbios_via_wmic()
    snap.llmnr_present, snap.llmnr_prev_value = _llmnr_read()
    snap.mdns_present, snap.mdns_prev_value = _mdns_read()

    if snap.netbios_per_interface:
        changed = 0
        for sid in snap.netbios_per_interface:
            if _set_netbios_via_wmic(sid, 2):
                changed += 1
        logs.append(f"NetBIOS over TCP/IP disabled on {changed}/{len(snap.netbios_per_interface)} NICs.")
    else:
        logs.append("NetBIOS: could not enumerate adapters (wmic missing?).")

    if _llmnr_write(0):
        logs.append("LLMNR (UDP 5355) disabled via DNSClient policy.")
    else:
        logs.append("LLMNR disable failed (policy write rejected).")

    if _mdns_write(0):
        logs.append("mDNS (UDP 5353) disabled via Dnscache parameters.")
    else:
        logs.append("mDNS disable failed (Dnscache parameters not writable).")

    _restart_dnscache()
    logs.append("DNS Client service bounced — LAN lockdown active.")
    return snap, logs


def restore_lan(snap: LanSnapshot | None) -> list[str]:
    """Roll back to the snapshot captured by engage_lan_lockdown."""
    if snap is None or not is_admin():
        return []
    logs: list[str] = []

    if snap.netbios_per_interface:
        restored = 0
        for sid, prev in snap.netbios_per_interface.items():
            if _set_netbios_via_wmic(sid, prev):
                restored += 1
        logs.append(f"NetBIOS restored on {restored}/{len(snap.netbios_per_interface)} NICs.")

    if snap.llmnr_present and snap.llmnr_prev_value is not None:
        _llmnr_write(snap.llmnr_prev_value)
        logs.append(f"LLMNR policy restored to {snap.llmnr_prev_value}.")
    else:
        _llmnr_delete()
        logs.append("LLMNR policy removed (was default).")

    if snap.mdns_present and snap.mdns_prev_value is not None:
        _mdns_write(snap.mdns_prev_value)
        logs.append(f"mDNS restored (EnableMDNS={snap.mdns_prev_value}).")
    else:
        # If mDNS key was absent originally, delete the value we created.
        try:
            with winreg.OpenKey(
                winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
            ) as key:
                winreg.DeleteValue(key, _MDNS_VALUE)
            logs.append("mDNS toggle removed (was default).")
        except OSError:
            pass

    _restart_dnscache()
    return logs


def snapshot_to_json(snap: LanSnapshot | None) -> str:
    if snap is None:
        return ""
    return json.dumps({
        "netbios": snap.netbios_per_interface,
        "llmnr_present": snap.llmnr_present,
        "llmnr_prev_value": snap.llmnr_prev_value,
        "mdns_present": snap.mdns_present,
        "mdns_prev_value": snap.mdns_prev_value,
    })


def snapshot_from_json(raw: str) -> LanSnapshot | None:
    if not raw:
        return None
    try:
        d = json.loads(raw)
        return LanSnapshot(
            netbios_per_interface={k: int(v) for k, v in (d.get("netbios") or {}).items()},
            llmnr_present=bool(d.get("llmnr_present")),
            llmnr_prev_value=d.get("llmnr_prev_value"),
            mdns_present=bool(d.get("mdns_present")),
            mdns_prev_value=d.get("mdns_prev_value"),
        )
    except Exception:
        return None
