"""Network tools for scanning and managing devices on a network."""

import ipaddress
import logging
import platform
import re
import socket
import struct
import subprocess
import traceback
from time import sleep
from typing import List, Dict, Optional

import psutil
from scapy.sendrecv import srp
from scapy.layers.l2 import ARP, Ether
from scapy.error import Scapy_Exception

from pydantic import BaseModel, PrivateAttr
try:
    from pydantic import ConfigDict, computed_field, model_serializer  # pydantic v2
    _PYD_V2 = True
except Exception:  # pragma: no cover
    CONFIG_DICT = None  # type: ignore  # pylint: disable=invalid-name
    COMPUTED_FIELD = None  # type: ignore  # pylint: disable=invalid-name
    MODEL_SERIALIZER = None  # type: ignore  # pylint: disable=invalid-name
    _PYD_V2 = False
else:
    CONFIG_DICT = ConfigDict  # pylint: disable=invalid-name
    COMPUTED_FIELD = computed_field  # pylint: disable=invalid-name
    MODEL_SERIALIZER = model_serializer  # pylint: disable=invalid-name

from lanscape.core.service_scan import scan_service, ServiceScanResult
from lanscape.core.mac_lookup import MacLookup, get_macs
from lanscape.core.ip_parser import get_address_count, MAX_IPS_ALLOWED, parse_ip_input
from lanscape.core.errors import DeviceError
from lanscape.core.decorators import job_tracker, run_once, timeout_enforcer
from lanscape.core.scan_config import ServiceScanConfig, PortScanConfig, ScanType
from lanscape.core.models import (
    DeviceResult, DeviceErrorInfo, DeviceStage, ServiceInfo, ProbeResponseInfo
)

log = logging.getLogger('NetTools')
mac_lookup = MacLookup()


# ---------------------------------------------------------------------------
# Pure-Python hostname resolution helpers (no external tools required)
# ---------------------------------------------------------------------------

def _dns_name_decode(data: bytes, offset: int) -> tuple:
    """Decode a DNS-encoded name from *data* starting at *offset*.

    Handles label-compression pointers (RFC 1035 §4.1.4).
    Returns ``(name_str, next_offset)`` where *next_offset* is the byte
    immediately after the name field in the original (non-jumped) stream.
    """
    labels: list = []
    max_offset = offset
    jumped = False
    seen: set = set()

    while offset < len(data):
        if offset in seen:
            break  # prevent infinite pointer loops
        seen.add(offset)

        length = data[offset]
        if length == 0:
            if not jumped:
                max_offset = offset + 1
            break

        if (length & 0xC0) == 0xC0:  # pointer
            if not jumped:
                max_offset = offset + 2
            pointer = ((length & 0x3F) << 8) | data[offset + 1]
            offset = pointer
            jumped = True
            continue

        offset += 1
        labels.append(data[offset:offset + length].decode('ascii', errors='ignore'))
        offset += length

    return '.'.join(labels), max_offset


def _parse_mdns_ptr_response(data: bytes) -> Optional[str]:
    """Extract the hostname from an mDNS PTR response packet.

    Returns the first PTR RDATA value with any trailing dot stripped,
    or ``None`` when the packet is not a valid PTR response.
    """
    if len(data) < 12:
        return None

    flags = struct.unpack('>H', data[2:4])[0]
    if not flags & 0x8000:  # QR bit must be set (response)
        return None

    qdcount = struct.unpack('>H', data[4:6])[0]
    ancount = struct.unpack('>H', data[6:8])[0]
    if ancount == 0:
        return None

    # Skip question section
    offset = 12
    for _ in range(qdcount):
        _, offset = _dns_name_decode(data, offset)
        offset += 4  # QTYPE + QCLASS

    # Walk answer records looking for a PTR
    for _ in range(ancount):
        _, offset = _dns_name_decode(data, offset)
        if offset + 10 > len(data):
            return None
        rtype = struct.unpack('>H', data[offset:offset + 2])[0]
        offset += 8  # TYPE(2) + CLASS(2) + TTL(4)
        rdlength = struct.unpack('>H', data[offset:offset + 2])[0]
        offset += 2

        if rtype == 12:  # PTR
            hostname, _ = _dns_name_decode(data, offset)
            return hostname.rstrip('.') if hostname else None
        offset += rdlength

    return None


def _parse_nbstat_response(data: bytes) -> Optional[str]:
    """Extract the machine name from a NetBIOS NBSTAT response.

    Scans the name table for a ``<00>`` suffix entry with the UNIQUE flag
    (GROUP bit 0x8000 clear) and returns the trimmed ASCII name.
    """
    if len(data) < 43:  # minimum viable NBSTAT response (header + ptr + RR meta + 1 name)
        return None

    # Skip header (12 bytes)
    offset = 12

    # Skip answer RR name (may be a label or a compression pointer)
    while offset < len(data):
        length = data[offset]
        if length == 0:
            offset += 1
            break
        if (length & 0xC0) == 0xC0:
            offset += 2
            break
        offset += 1 + length

    # TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = 10
    if offset + 10 > len(data):
        return None
    offset += 10

    if offset >= len(data):
        return None
    num_names = data[offset]
    offset += 1

    # Each entry: 15-byte name + 1-byte suffix + 2-byte flags = 18 bytes
    for _ in range(num_names):
        if offset + 18 > len(data):
            break
        name_bytes = data[offset:offset + 15]
        suffix = data[offset + 15]
        flags = struct.unpack('>H', data[offset + 16:offset + 18])[0]
        offset += 18

        # Suffix 0x00 = Workstation Service; GROUP bit clear = unique name
        if suffix == 0x00 and not flags & 0x8000:
            name = name_bytes.decode('ascii', errors='ignore').strip()
            if name and name != '*':
                return name

    return None


class Device(BaseModel):
    """Represents a network device with metadata and scanning capabilities."""

    ip: str
    alive: Optional[bool] = None
    hostname: Optional[str] = None
    macs: List[str] = []
    manufacturer: Optional[str] = None
    ports: List[int] = []
    stage: str = 'found'
    ports_scanned: int = 0
    services: Dict[str, List[int]] = {}
    service_info: List[ServiceInfo] = []
    caught_errors: List[DeviceError] = []
    job_stats: Optional[Dict] = None

    _log: logging.Logger = PrivateAttr(default_factory=lambda: logging.getLogger('Device'))
    # Support pydantic v1 and v2 configs
    if _PYD_V2 and CONFIG_DICT:
        model_config = CONFIG_DICT(arbitrary_types_allowed=True)  # type: ignore[assignment]
    else:  # pragma: no cover
        class Config:  # pylint: disable=too-few-public-methods
            """Pydantic v1 configuration."""
            arbitrary_types_allowed = True
            extra = 'allow'

    @property
    def log(self) -> logging.Logger:
        """Get the logger instance for this device."""
        return self._log

    # Computed fields for pydantic v2 (included in model_dump)
    if _PYD_V2 and COMPUTED_FIELD:
        @COMPUTED_FIELD(return_type=str)  # type: ignore[misc]
        @property
        def mac_addr(self) -> str:
            """Get the primary MAC address for this device."""
            return self.get_mac() or ""

        @MODEL_SERIALIZER(mode='wrap')  # type: ignore[misc]
        def _serialize(self, serializer):
            """Serialize device data for output."""
            data = serializer(self)
            # Remove internals
            data.pop('job_stats', None)
            # Ensure mac_addr present (computed_field already adds it)
            data['mac_addr'] = data.get('mac_addr') or (self.get_mac() or '')
            # Ensure manufacturer present; prefer explicit model value
            manuf = data.get('manufacturer')
            if not manuf:
                data['manufacturer'] = self._get_manufacturer(
                    data['mac_addr']) if data['mac_addr'] else None
            return data

    def get_metadata(self):
        """Retrieve metadata such as hostname and MAC addresses."""
        if self.alive:
            self.hostname = self._get_hostname()
            self._get_mac_addresses()
            if not self.manufacturer:
                self.manufacturer = self._get_manufacturer(
                    self.get_mac()
                )

    def test_port(self, port: int, port_config: Optional[PortScanConfig] = None) -> bool:
        """Test if a specific port is open on the device."""
        if port_config is None:
            port_config = PortScanConfig()  # Use defaults

        # Calculate timeout enforcer: (timeout * (retries+1) * 1.5)
        enforcer_timeout = port_config.timeout * (port_config.retries + 1) * 1.5

        @timeout_enforcer(enforcer_timeout, False)
        def do_test():
            for attempt in range(port_config.retries + 1):
                sock = None
                try:
                    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                    sock.settimeout(port_config.timeout)
                    result = sock.connect_ex((self.ip, port))
                    if result == 0:
                        if port not in self.ports:
                            self.ports.append(port)
                        return True
                except OSError as e:
                    # Handle socket creation failures (e.g., "Too many open files")
                    # Log and continue to retry if attempts remain
                    log = logging.getLogger('Device.test_port')
                    log.debug(f"OSError on {self.ip}:{port} attempt {attempt + 1}: {e}")
                except Exception:
                    pass  # Connection failed, try again if retries remain
                finally:
                    # Always close socket if it was created
                    if sock is not None:
                        try:
                            sock.close()
                        except Exception:
                            pass  # Ignore errors during cleanup

                # Wait before retry (except on last attempt)
                if attempt < port_config.retries:
                    sleep(port_config.retry_delay)

            return False

        ans = do_test() or False
        self.ports_scanned += 1
        return ans

    @job_tracker
    def scan_service(self, port: int, cfg: ServiceScanConfig):
        """Scan a specific port for services."""
        try:
            result: ServiceScanResult = scan_service(self.ip, port, cfg)
        except Exception as e:
            self.caught_errors.append(DeviceError(e))
            return

        # Record service-scan-level errors on the device
        if result.error:
            try:
                # Raise and catch to ensure the exception has a traceback
                raise RuntimeError(result.error)
            except RuntimeError as err:
                self.caught_errors.append(DeviceError(err))

        # Update the services mapping (service name -> ports)
        service_ports = self.services.get(result.service, [])
        service_ports.append(port)
        self.services[result.service] = service_ports

        # Store detailed service info with response and probe statistics
        self.service_info.append(ServiceInfo(
            port=port,
            service=result.service,
            request=result.request,
            response=result.response,
            probes_sent=result.probes_sent,
            probes_received=result.probes_received,
            is_tls=result.is_tls,
            all_responses=[
                ProbeResponseInfo(
                    request=pr.request,
                    response=pr.response,
                    service=pr.service,
                    weight=pr.weight,
                    is_tls=pr.is_tls,
                ) for pr in result.all_responses
            ],
        ))

    def get_mac(self):
        """Get the primary MAC address of the device."""
        if not self.macs:
            return ''
        return mac_selector.choose_mac(self.macs)

    @job_tracker
    def _get_mac_addresses(self):
        """Get the possible MAC addresses of a network device given its IP address."""
        # job may already be done depending on
        # the strat from isalive
        if not self.macs:
            self.macs = get_macs(self.ip)
        mac_selector.import_macs(self.macs)
        return self.macs

    @job_tracker
    def _get_hostname(self) -> Optional[str]:
        """Get the hostname of a network device given its IP address.

        Tries multiple resolution methods:
        1. Reverse DNS (socket.gethostbyaddr) — works everywhere when PTR records exist
        2. mDNS multicast PTR query (Linux/macOS) — resolves .local hostnames
        3. NetBIOS NBSTAT query (Linux/macOS) — resolves Windows-style hostnames
        """
        # 1. Standard reverse DNS lookup
        try:
            hostname = socket.gethostbyaddr(self.ip)[0]
            if hostname:
                return hostname
        except socket.herror:
            pass

        # On Windows, gethostbyaddr already chains through NetBIOS/LLMNR/mDNS
        if platform.system() == 'Windows':
            return None

        # 2. mDNS multicast PTR query (no avahi-utils dependency)
        hostname = self._resolve_mdns()
        if hostname:
            return hostname

        # 3. NetBIOS NBSTAT query (no samba-common dependency)
        hostname = self._resolve_netbios()
        if hostname:
            return hostname

        return None

    def _resolve_mdns(self) -> Optional[str]:
        """Resolve hostname via mDNS multicast PTR query (pure Python)."""
        reversed_ip = '.'.join(reversed(self.ip.split('.')))
        qname = f"{reversed_ip}.in-addr.arpa"

        # Encode DNS query name
        name_bytes = b''
        for label in qname.split('.'):
            name_bytes += bytes([len(label)]) + label.encode('ascii')
        name_bytes += b'\x00'

        request = (
            b'\x00\x00'  # Transaction ID (0 for mDNS)
            b'\x00\x00'  # Flags: standard query
            b'\x00\x01'  # QDCOUNT: 1
            b'\x00\x00'  # ANCOUNT: 0
            b'\x00\x00'  # NSCOUNT: 0
            b'\x00\x00'  # ARCOUNT: 0
        ) + name_bytes + (
            b'\x00\x0c'  # Type: PTR
            b'\x80\x01'  # Class: IN + unicast-response bit
        )

        sock = None
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.settimeout(2.0)
            sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 255)
            sock.sendto(request, ('224.0.0.251', 5353))
            data, _ = sock.recvfrom(1500)
            return _parse_mdns_ptr_response(data)
        except (socket.timeout, OSError):
            return None
        finally:
            if sock:
                sock.close()

    def _resolve_netbios(self) -> Optional[str]:
        """Resolve hostname via NetBIOS NBSTAT query (pure Python)."""
        # NBSTAT request for wildcard name '*'
        request = (
            b'\xa5\x6c'       # Transaction ID
            b'\x00\x00'       # Flags: standard query
            b'\x00\x01'       # QDCOUNT: 1
            b'\x00\x00'       # ANCOUNT: 0
            b'\x00\x00'       # NSCOUNT: 0
            b'\x00\x00'       # ARCOUNT: 0
            b'\x20'           # Name length: 32
            b'CKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'  # '*' NetBIOS-encoded
            b'\x00'           # Name terminator
            b'\x00\x21'       # Type: NBSTAT
            b'\x00\x01'       # Class: IN
        )

        sock = None
        try:
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.settimeout(2.0)
            sock.sendto(request, (self.ip, 137))
            data, _ = sock.recvfrom(1500)
            return _parse_nbstat_response(data)
        except (socket.timeout, OSError):
            return None
        finally:
            if sock:
                sock.close()

    @job_tracker
    def _get_manufacturer(self, mac_addr=None):
        """Get the manufacturer of a network device given its MAC address."""
        return mac_lookup.lookup_vendor(mac_addr) if mac_addr else None

    def to_result(self) -> DeviceResult:
        """Convert this Device to a DeviceResult for API/WebSocket responses."""
        # Convert DeviceError exceptions to DeviceErrorInfo models
        error_infos = []
        for err in self.caught_errors:
            error_infos.append(DeviceErrorInfo(
                source=err.method if hasattr(err, 'method') else 'unknown',
                message=str(err.base) if hasattr(err, 'base') else str(err),
                traceback=None  # Don't expose traceback in API responses
            ))

        # Map stage string to DeviceStage enum
        stage_map = {
            'found': DeviceStage.FOUND,
            'scanning': DeviceStage.SCANNING,
            'complete': DeviceStage.COMPLETE
        }
        device_stage = stage_map.get(self.stage, DeviceStage.FOUND)

        return DeviceResult(
            ip=self.ip,
            alive=self.alive,
            hostname=self.hostname,
            macs=self.macs,
            manufacturer=self.manufacturer or self._get_manufacturer(self.get_mac()),
            ports=self.ports,
            ports_scanned=self.ports_scanned,
            stage=device_stage,
            services=self.services,
            service_info=self.service_info,
            errors=error_infos
        )


class MacSelector:
    """
    Essentially filters out bad mac addresses
    you send in a list of macs,
    it will return the one that has been seen the least
    (ideally meaning it is the most likely to be the correct one)
    this was added because some lookups return multiple macs,
    usually the hwid of a vpn tunnel etc
    """

    def __init__(self):
        self.macs = {}

    def choose_mac(self, macs: List[str]) -> str:
        """
        Choose the most appropriate MAC address from a list.
        The mac address that has been seen the least is returned.
        """
        if len(macs) == 1:
            return macs[0]
        lowest = 9999
        lowest_i = -1
        for mac in macs:
            if self.macs[mac] < lowest:
                lowest = self.macs[mac]
                lowest_i = macs.index(mac)
        return macs[lowest_i] if lowest_i != -1 else None

    def import_macs(self, macs: List[str]):
        """
        Import a list of MAC addresses associated with a device.
        """
        for mac in macs:
            self.macs[mac] = self.macs.get(mac, 0) + 1

    def clear(self):
        """Clear the stored MAC addresses."""
        self.macs = {}


mac_selector = MacSelector()


def get_ip_address(interface: str):
    """
    Get the IP address of a network interface on Windows, Linux, or macOS.
    """
    def unix_like():  # Combined Linux and macOS
        try:
            # pylint: disable=import-outside-toplevel, import-error
            import fcntl
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            ip_address = socket.inet_ntoa(fcntl.ioctl(
                sock.fileno(),
                0x8915,  # SIOCGIFADDRf
                struct.pack('256s', interface[:15].encode('utf-8'))
            )[20:24])
            return ip_address
        except IOError:
            return None

    def windows():
        # Get network interfaces and IP addresses using psutil
        net_if_addrs = psutil.net_if_addrs()
        if interface in net_if_addrs:
            for addr in net_if_addrs[interface]:
                if addr.family == socket.AF_INET:  # Check for IPv4
                    return addr.address
        return None

    # Call the appropriate function based on the platform
    if psutil.WINDOWS:
        return windows()

    # Linux, macOS, and other Unix-like systems
    return unix_like()


def get_netmask(interface: str):
    """
    Get the netmask of a network interface.
    """
    def unix_like():  # Combined Linux and macOS
        try:
            # pylint: disable=import-outside-toplevel, import-error
            import fcntl
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            netmask = socket.inet_ntoa(fcntl.ioctl(
                sock.fileno(),
                0x891b,  # SIOCGIFNETMASK
                struct.pack('256s', interface[:15].encode('utf-8'))
            )[20:24])
            return netmask
        except IOError:
            return None

    def windows():
        output = subprocess.check_output("ipconfig", shell=True).decode()
        # Use a regular expression to match both interface and subnet mask
        interface_section_pattern = rf"{interface}.*?Subnet Mask.*?:\s+(\d+\.\d+\.\d+\.\d+)"
        # Use re.S to allow dot to match newline
        match = re.search(interface_section_pattern, output, re.S)
        if match:
            return match.group(1)
        return None

    if psutil.WINDOWS:
        return windows()

    # Linux, macOS, and other Unix-like systems
    return unix_like()


def get_cidr_from_netmask(netmask: str):
    """
    Get the CIDR notation of a netmask.
    """
    binary_str = ''.join([bin(int(x)).lstrip('0b').zfill(8)
                         for x in netmask.split('.')])
    return str(len(binary_str.rstrip('0')))


def _find_interface_by_default_gateway_windows():
    """Find the network interface with the default gateway on Windows."""
    try:
        output = subprocess.check_output(
            "route print 0.0.0.0", shell=True, text=True)
        return _parse_windows_route_output(output)
    except Exception as e:
        log.debug(f"Error finding Windows interface by gateway: {e}")
    return None


def _parse_windows_route_output(output):
    """Parse the output of Windows route command to extract interface index."""
    lines = output.strip().split('\n')
    interface_idx = None

    # First find the interface index from the routing table
    for line in lines:
        if '0.0.0.0' in line and 'Gateway' not in line:  # Skip header
            parts = [p for p in line.split() if p]
            if len(parts) >= 4:
                interface_idx = parts[3]
                break

    # If we found an index, find the corresponding interface name
    if interface_idx:
        for iface_name in psutil.net_if_addrs():
            if str(interface_idx) in iface_name:
                return iface_name

    return None


def _find_interface_by_default_gateway_unix():
    """Find the network interface with the default gateway on Unix-like systems."""
    try:
        cmd = "ip route show default 2>/dev/null || netstat -rn | grep default"
        output = subprocess.check_output(cmd, shell=True, text=True)
        return _parse_unix_route_output(output)
    except Exception as e:
        log.debug(f"Error finding Unix interface by gateway: {e}")
    return None


def _parse_unix_route_output(output):
    """Parse the output of Unix route commands to extract interface name."""
    for line in output.split('\n'):
        # Parse lines with 'default via ... dev ...'
        if 'default via' in line and 'dev' in line:
            return line.split('dev')[1].split()[0]

        # Parse simpler 'default ...' lines
        if 'default' in line:
            parts = line.split()
            if len(parts) > 3:
                # Interface is usually the last column
                return parts[-1]
    return None


def _get_candidate_interfaces():
    """Get a list of candidate network interfaces."""
    candidates = []
    for interface, addrs in psutil.net_if_addrs().items():
        stats = psutil.net_if_stats().get(interface)
        if not stats or not stats.isup:
            continue

        ipv4_addrs = [addr for addr in addrs if addr.family == socket.AF_INET]
        if not ipv4_addrs:
            continue

        # Skip loopback and common virtual interfaces
        is_loopback = any(addr.address.startswith('127.')
                          for addr in ipv4_addrs)
        if is_loopback:
            continue

        virtual_names = ['loop', 'vmnet', 'vbox', 'docker', 'virtual', 'veth']
        is_virtual = any(name in interface.lower() for name in virtual_names)
        if is_virtual:
            continue

        candidates.append(interface)
    return candidates


def get_primary_interface():
    """
    Get the primary network interface that is likely handling internet traffic.
    Uses heuristics to identify the most probable interface.
    """
    # Try to find the interface with the default gateway
    if psutil.WINDOWS:
        interface = _find_interface_by_default_gateway_windows()
        if interface:
            return interface
    else:
        interface = _find_interface_by_default_gateway_unix()
        if interface:
            return interface

    # Fallback: Identify likely candidates based on heuristics
    candidates = _get_candidate_interfaces()
    if not candidates:
        return None

    # Prioritize interfaces with names typically used for physical connections
    physical_prefixes = ['eth', 'en', 'wlan', 'wifi', 'wl', 'wi']
    for prefix in physical_prefixes:
        for interface in candidates:
            if interface.lower().startswith(prefix):
                return interface

    # Otherwise return the first candidate
    return candidates[0]


def get_host_ip_mask(ip_with_cidr: str):
    """
    Get the IP address and netmask of a network interface.
    """
    cidr = ip_with_cidr.split('/')[1]
    network = ipaddress.ip_network(ip_with_cidr, strict=False)
    return f'{network.network_address}/{cidr}'


def get_network_subnet(interface=None):
    """
    Get the network subnet for a given interface.
    Uses network_from_snicaddr for conversion.
    Default is primary interface.
    """
    interface = interface or get_primary_interface()

    try:
        addrs = psutil.net_if_addrs()
        if interface in addrs:
            for snicaddr in addrs[interface]:
                if snicaddr.family == socket.AF_INET and snicaddr.address and snicaddr.netmask:
                    subnet = network_from_snicaddr(snicaddr)
                    if subnet:
                        return subnet
    except Exception:
        log.info(f'Unable to parse subnet for interface: {interface}')
        log.debug(traceback.format_exc())
    return None


def get_all_network_subnets():
    """
    Get the primary network interface.
    """
    addrs = psutil.net_if_addrs()
    gateways = psutil.net_if_stats()
    subnets = []

    for interface, snicaddrs in addrs.items():
        for snicaddr in snicaddrs:
            if snicaddr.family == socket.AF_INET and gateways[interface].isup:

                subnet = network_from_snicaddr(snicaddr)

                if subnet:
                    subnets.append({
                        'subnet': subnet,
                        'address_cnt': get_address_count(subnet)
                    })

    return subnets


def network_from_snicaddr(snicaddr: psutil._common.snicaddr) -> str:
    """
    Convert a psutil snicaddr object to a human-readable string.
    """
    if not snicaddr.address or not snicaddr.netmask:
        return None

    if snicaddr.family == socket.AF_INET:
        addr = f"{snicaddr.address}/{get_cidr_from_netmask(snicaddr.netmask)}"
        return get_host_ip_mask(addr)

    if snicaddr.family == socket.AF_INET6:
        addr = f"{snicaddr.address}/{snicaddr.netmask}"
        return get_host_ip_mask(addr)

    return f"{snicaddr.address}"


def smart_select_primary_subnet(subnets: List[dict] = None) -> str:
    """
    Intelligently select the primary subnet that is most likely handling internet traffic.

    Selection priority:
    1. Subnet associated with the primary interface (with default gateway)
    2. Largest non-deprioritized subnet within maximum allowed IP range
    3. Largest deprioritized subnet as fallback
    4. First subnet in the list as final fallback

    Deprioritized subnets (virtual/system networks):
    - 127.0.0.0/8 (loopback)
    - 172.27.64.0/20 (WSL default)
    - 172.17.0.0/16 (Docker default)

    Returns an empty string if no subnets are available.
    """
    subnets = subnets or get_all_network_subnets()

    if not subnets:
        return ""

    # First priority: Get subnet for the primary interface
    primary_if = get_primary_interface()
    if primary_if:
        primary_subnet = get_network_subnet(primary_if)
        if primary_subnet:
            # Return this subnet if it's within our list
            for subnet in subnets:
                if subnet["subnet"] == primary_subnet:
                    return primary_subnet

    # Second priority: Find a reasonable sized subnet, avoiding deprioritized ones
    selected = {}
    deprioritized_selected = {}

    for subnet in subnets:
        subnet_str = subnet.get("subnet", "")
        address_cnt = subnet.get("address_cnt", 0)

        if address_cnt >= MAX_IPS_ALLOWED:
            continue

        if _is_deprioritized_subnet(subnet_str):
            if address_cnt > deprioritized_selected.get("address_cnt", 0):
                deprioritized_selected = subnet
        else:
            if address_cnt > selected.get("address_cnt", 0):
                selected = subnet

    # Prefer non-deprioritized, fall back to deprioritized
    if not selected:
        selected = deprioritized_selected

    # Third priority: Just take the first subnet if nothing else matched
    if not selected and subnets:
        selected = subnets[0]

    return selected.get("subnet", "")


def _is_deprioritized_subnet(subnet: str) -> bool:
    """
    Check if a subnet should be deprioritized in auto-selection.

    These are typically virtual/system networks that aren't the user's
    primary LAN, such as WSL, Docker, or loopback networks.
    """
    try:
        network = ipaddress.ip_network(subnet, strict=False)

        # Loopback (127.0.0.0/8)
        loopback = ipaddress.ip_network('127.0.0.0/8')
        if network.overlaps(loopback):
            return True

        # WSL default network (172.27.64.0/20)
        wsl_network = ipaddress.ip_network('172.27.64.0/20')
        if network.overlaps(wsl_network):
            return True

        # Docker default bridge (172.17.0.0/16)
        docker_network = ipaddress.ip_network('172.17.0.0/16')
        if network.overlaps(docker_network):
            return True

        return False
    except ValueError:
        return False


def is_internal_block(subnet: str) -> bool:
    """
    Check if a subnet contains only internal/private IP addresses.

    Supports CIDR notation, IP ranges, comma-separated lists, and single IPs.
    For ranges and complex inputs, samples representative IPs for efficiency.

    Args:
        subnet: IP subnet string in various formats

    Returns:
        bool: True if all sampled IPs are private/internal, False otherwise
    """
    try:
        # Handle comma-separated subnets recursively
        if ',' in subnet:
            return all(is_internal_block(part.strip()) for part in subnet.split(','))

        # Handle CIDR notation directly
        if '/' in subnet:
            return ipaddress.IPv4Network(subnet, strict=False).is_private

        # Handle ranges and single IPs by parsing and sampling
        ip_list = parse_ip_input(subnet)
        sample_ips = ([ip_list[0], ip_list[-1]] if len(ip_list) > 1 else ip_list)
        return all(ipaddress.IPv4Address(ip).is_private for ip in sample_ips)

    except (ValueError, ipaddress.AddressValueError):
        return False  # Assume external for unparseable input


def scan_config_uses_arp(config) -> bool:
    """
    Check if a scan configuration uses ARP-based scanning methods.

    Args:
        config: ScanConfig instance

    Returns:
        bool: True if the configuration uses ARP scanning, False otherwise
    """
    arp_scan_types = {
        ScanType.ARP_LOOKUP,
        ScanType.POKE_THEN_ARP,
        ScanType.ICMP_THEN_ARP
    }

    return any(scan_type in arp_scan_types for scan_type in config.lookup_type)


@run_once
def is_arp_supported():
    """
    Check if ARP requests are supported on the current system.
    Only runs the check once.
    """
    try:
        arp_request = ARP(pdst='0.0.0.0')
        broadcast = Ether(dst="ff:ff:ff:ff:ff:ff")
        packet = broadcast / arp_request
        srp(packet, timeout=0, verbose=False)
        return True
    except (Scapy_Exception, PermissionError, RuntimeError):
        return False
