"""System-level detection: interfaces, CIDRs, DNS, architecture."""

from __future__ import annotations

import ipaddress
import os
import platform
import re
import socket
import subprocess
from dataclasses import dataclass, field
from pathlib import Path


# Data types

@dataclass
class NetworkInterface:
    name: str
    addresses: list[str] = field(default_factory=list)   # e.g. ["192.168.1.5"]
    cidrs: list[str] = field(default_factory=list)       # e.g. ["192.168.1.0/24"]
    netmasks: list[str] = field(default_factory=list)

    def display(self) -> str:
        cidrs_str = ", ".join(self.cidrs) if self.cidrs else "no IPv4"
        return f"{self.name}  [{cidrs_str}]"


# Network interfaces

_SKIP_PREFIXES = ("lo", "docker", "br-", "virbr", "veth", "tun", "wg")
_CGNAT = ipaddress.IPv4Network("100.64.0.0/10")
_RFC1918 = (
    ipaddress.IPv4Network("10.0.0.0/8"),
    ipaddress.IPv4Network("172.16.0.0/12"),
    ipaddress.IPv4Network("192.168.0.0/16"),
)


def _suggested_cidr(ip: str, net: ipaddress.IPv4Network) -> str | None:
    """Return the CIDR to suggest for a detected interface address, or None for public IPs.

    For CGNAT (100.64.0.0/10) addresses the interface netmask is typically /8,
    but the API-supported block is 100.64.0.0/10, so we always suggest that.
    Public (non-private, non-CGNAT) addresses are excluded.
    """
    try:
        ip_addr = ipaddress.IPv4Address(ip)
        if ip_addr in _CGNAT:
            return str(_CGNAT)
        if any(ip_addr in r for r in _RFC1918):
            return str(net)
        return None  # public IP
    except ValueError:
        return None


def get_interfaces(include_virtual: bool = False) -> list[NetworkInterface]:
    """Return network interfaces with their IPv4 addresses and CIDRs."""
    try:
        import psutil

        result: dict[str, NetworkInterface] = {}
        for iface, addrs in psutil.net_if_addrs().items():
            if not include_virtual and _is_virtual(iface):
                continue
            iface_obj = NetworkInterface(name=iface)
            for addr in addrs:
                if addr.family != socket.AF_INET:
                    continue
                try:
                    if addr.netmask:
                        net = ipaddress.IPv4Network(
                            f"{addr.address}/{addr.netmask}", strict=False
                        )
                        cidr = _suggested_cidr(addr.address, net)
                    elif ipaddress.IPv4Address(addr.address) in _CGNAT:
                        # macOS returns netmask=None for point-to-point (utun)
                        # interfaces; still expose the CGNAT block.
                        cidr = str(_CGNAT)
                    else:
                        continue
                    if cidr is None:
                        continue
                    iface_obj.addresses.append(addr.address)
                    iface_obj.cidrs.append(cidr)
                    if addr.netmask:
                        iface_obj.netmasks.append(addr.netmask)
                except ValueError:
                    pass
            if iface_obj.addresses:
                result[iface] = iface_obj
        return list(result.values())
    except ImportError:
        return _get_interfaces_fallback(include_virtual)


def _is_virtual(name: str) -> bool:
    return any(name.startswith(p) for p in _SKIP_PREFIXES)


_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")


def validate_name_input(name: str) -> bool | str:
    """Validate a beacon name for interactive prompts and flag values.

    Returns ``True`` if valid, or an error message string otherwise.
    """
    if not name.strip():
        return "Name cannot be empty"
    if not _NAME_RE.match(name.strip()):
        return "Name may only contain letters, digits, hyphens (-), and underscores (_)"
    return True


def validate_cidrs_input(raw: str) -> bool | str:
    """Validate a comma-separated CIDR string for interactive prompts and flag values.

    Returns ``True`` if all CIDRs are valid private networks, or an error
    message string describing the first problem found.
    """
    parts = [c.strip() for c in raw.split(",") if c.strip()]
    if not parts:
        return "At least one CIDR is required"
    for cidr in parts:
        if "/" not in cidr:
            return f"'{cidr}' is not a valid CIDR - add a prefix length (e.g. {cidr}/24)"
        try:
            net = ipaddress.IPv4Network(cidr, strict=False)
        except ValueError:
            return f"'{cidr}' is not a valid CIDR"
        if not (net.is_private or net.subnet_of(ipaddress.IPv4Network("100.64.0.0/10"))):
            return f"'{cidr}' is not a private CIDR"
    return True


def interface_exists(name: str) -> bool:
    """Return True if a network interface with this name exists on the system."""
    try:
        socket.if_nametoindex(name)
        return True
    except OSError:
        return False


def get_routes_via_interface(iface_name: str) -> list[str]:
    """Return CIDRs of routes that go through *iface_name* (Linux only).

    Parses ``ip route show`` and extracts non-default, non-loopback routes
    whose ``dev`` field matches the interface.  Useful for suggesting CIDRs
    that are reachable via an interface but whose prefix is not the
    interface's own subnet (e.g. a static route to 10.100.0.0/16 via eth0).
    Returns an empty list on non-Linux systems or when ``ip`` is unavailable.
    """
    try:
        out = subprocess.check_output(
            ["ip", "route", "show"], text=True, stderr=subprocess.DEVNULL
        )
    except (FileNotFoundError, subprocess.CalledProcessError):
        return []

    cidrs: list[str] = []
    seen: set[str] = set()
    skip = {"default", "blackhole", "unreachable", "prohibit"}

    for line in out.splitlines():
        if f"dev {iface_name}" not in line:
            continue
        parts = line.split()
        if not parts or parts[0] in skip or "/" not in parts[0]:
            continue
        try:
            net = ipaddress.IPv4Network(parts[0], strict=False)
            if net.is_loopback:
                continue
            key = str(net)
            if key not in seen:
                seen.add(key)
                cidrs.append(key)
        except ValueError:
            pass
    return cidrs


def _get_interfaces_fallback(include_virtual: bool) -> list[NetworkInterface]:
    """Parse `ip addr show` when psutil is unavailable."""
    try:
        out = subprocess.check_output(["ip", "addr", "show"], text=True)
    except (FileNotFoundError, subprocess.CalledProcessError):
        return []

    interfaces: list[NetworkInterface] = []
    current: NetworkInterface | None = None

    for line in out.splitlines():
        m = re.match(r"^\d+: (\S+):", line)
        if m:
            if current and current.addresses:
                interfaces.append(current)
            name = m.group(1).rstrip("@").split("@")[0]
            if not include_virtual and _is_virtual(name):
                current = None
            else:
                current = NetworkInterface(name=name)
        elif current and "inet " in line:
            m2 = re.search(r"inet (\d+\.\d+\.\d+\.\d+)/(\d+)", line)
            if m2:
                ip, prefix = m2.group(1), m2.group(2)
                net = ipaddress.IPv4Network(f"{ip}/{prefix}", strict=False)
                cidr = _suggested_cidr(ip, net)
                if cidr is None:
                    continue
                current.addresses.append(ip)
                current.cidrs.append(cidr)

    if current and current.addresses:
        interfaces.append(current)
    return interfaces


# DNS detection

_LOOPBACK_RE = re.compile(r"^127\.|^::1$|^0\.0\.0\.0$")


def get_dns_servers() -> list[str]:
    """
    Return a deduplicated list of non-loopback DNS servers discovered on
    this machine, trying multiple strategies.
    """
    servers: list[str] = []
    servers.extend(_dns_from_resolv_conf())
    servers.extend(_dns_from_resolvectl())
    servers.extend(_dns_from_networkmanager())
    servers.extend(_dns_from_scutil())  # macOS

    seen: set[str] = set()
    result: list[str] = []
    for s in servers:
        if s not in seen:
            seen.add(s)
            result.append(s)
    return result



def check_dns_server(ip: str, port: int = 53, timeout: float = 2.0) -> bool:
    """Return True if the DNS server at ip:port responds to a minimal query.

    Sends a bare-minimum DNS query for the root zone and checks whether any
    response arrives.  The answer content is irrelevant - a response means the
    server is reachable and speaking DNS.
    """
    # Minimal valid DNS query: header + question for "." A IN
    # Header: ID=0x1234, RD=1 (recursion desired), QDCOUNT=1
    query = (
        b"\x12\x34"  # transaction ID
        b"\x01\x00"  # flags: recursion desired
        b"\x00\x01"  # QDCOUNT = 1
        b"\x00\x00"  # ANCOUNT = 0
        b"\x00\x00"  # NSCOUNT = 0
        b"\x00\x00"  # ARCOUNT = 0
        b"\x00"      # root label (empty name = ".")
        b"\x00\x01"  # QTYPE = A
        b"\x00\x01"  # QCLASS = IN
    )
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        sock.settimeout(timeout)
        sock.sendto(query, (ip, port))
        data, _ = sock.recvfrom(512)
        return len(data) > 0
    except (socket.timeout, OSError):
        return False
    finally:
        sock.close()


def _dns_from_resolv_conf() -> list[str]:
    try:
        text = Path("/etc/resolv.conf").read_text()
        servers = []
        for line in text.splitlines():
            line = line.strip()
            if line.startswith("nameserver"):
                parts = line.split()
                if len(parts) >= 2:
                    ip = parts[1]
                    if not _LOOPBACK_RE.match(ip):
                        servers.append(ip)
        return servers
    except OSError:
        return []


def _dns_from_resolvectl() -> list[str]:
    """systemd-resolved upstream DNS - avoids 127.0.0.53."""
    try:
        out = subprocess.check_output(
            ["resolvectl", "status"], text=True, stderr=subprocess.DEVNULL
        )
        servers = []
        for line in out.splitlines():
            if "DNS Servers:" in line or "Current DNS Server:" in line:
                parts = line.split(":", 1)
                if len(parts) == 2:
                    for token in parts[1].split():
                        if not _LOOPBACK_RE.match(token):
                            servers.append(token)
        return servers
    except (FileNotFoundError, subprocess.CalledProcessError):
        return []


def _dns_from_networkmanager() -> list[str]:
    try:
        out = subprocess.check_output(
            ["nmcli", "-t", "-f", "IP4.DNS", "device", "show"],
            text=True,
            stderr=subprocess.DEVNULL,
        )
        servers = []
        for line in out.splitlines():
            if line.startswith("IP4.DNS"):
                ip = line.split(":", 1)[-1].strip()
                if ip and not _LOOPBACK_RE.match(ip):
                    servers.append(ip)
        return servers
    except (FileNotFoundError, subprocess.CalledProcessError):
        return []


def _dns_from_scutil() -> list[str]:
    """macOS: read DNS from scutil --dns."""
    if platform.system() != "Darwin":
        return []
    try:
        out = subprocess.check_output(
            ["scutil", "--dns"], text=True, stderr=subprocess.DEVNULL
        )
        servers = []
        for line in out.splitlines():
            m = re.search(r"nameserver\[\d+\]\s*:\s*(\S+)", line)
            if m:
                ip = m.group(1)
                if not _LOOPBACK_RE.match(ip):
                    servers.append(ip)
        return servers
    except (FileNotFoundError, subprocess.CalledProcessError):
        return []


# System info


def get_hostname() -> str:
    return socket.gethostname()


def get_suggested_beacon_name() -> str:
    """Return a beacon-name-safe version of the hostname, or empty string if unusable."""
    hostname = get_hostname()
    # Replace invalid chars (e.g. dots in FQDN hostnames) with hyphens
    sanitized = re.sub(r"[^a-zA-Z0-9_-]", "-", hostname)
    # Collapse runs of hyphens and strip leading/trailing ones
    sanitized = re.sub(r"-+", "-", sanitized).strip("-")
    return sanitized


def get_architecture() -> str:
    machine = platform.machine().lower()
    arch_map = {
        "x86_64": "x86_64",
        "amd64": "x86_64",
        "aarch64": "aarch64",
        "arm64": "aarch64",
        "armv7l": "arm",
        "armv6l": "arm",
    }
    return arch_map.get(machine, machine)


def get_os() -> str:
    system = platform.system().lower()
    os_map = {"linux": "linux", "darwin": "darwin"}
    return os_map.get(system, system)


def is_root() -> bool:
    return os.geteuid() == 0


def check_wireguard() -> bool:
    for cmd in ("wg", "wg-quick"):
        try:
            subprocess.check_output(["which", cmd], stderr=subprocess.DEVNULL)
        except subprocess.CalledProcessError:
            return False
    return True


def find_free_udp_port(start: int = 51820, end: int = 51920) -> int:
    """Find a free UDP port in the given range."""
    for port in range(start, end + 1):
        with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
            try:
                s.bind(("", port))
                return port
            except OSError:
                continue
    return start  # fallback
