"""Extra helpers for the netifaces backend."""
import asyncio
import platform
import re
import socket
from ...utility.utils import fstr, log, log_exception
from ...utility.cmd_tools import cmd
from ...net.net_defs import AF_ANY, INTERFACE_UNKNOWN, IP4, IP6, TCP, VALID_AFS
from ...net.net_utils import af_bitlen, mac_norm
from ...net.ip_range import IPRange
from ...net.bind.bind_rules import binder_async


async def get_mac_mixed(if_name):
    """Try multiple OS-specific commands (and pyroute2 as a fallback) to retrieve the MAC address of if_name."""
    mac_p = r"((?:[0-9a-fA-F]{2}[\s:-]*){6})"
    win_p = r"[0-9]+\s*[.]+([^.]+)\s*[.]+"
    grep_p = "egrep 'lladdr|ether|link'"
    def win_f(x):
        """Extract the interface description matching if_name from Windows route print output."""
        return re.findall(win_p + re.escape(if_name), x)[0]
    vectors = {
        "Linux": [
            [fstr("cat /sys/class/net/{0}/address", (if_name,)), lambda x: x],
            [
                fstr(
                    "ip addr show {0} | {1}",
                    (
                        if_name,
                        grep_p,
                    ),
                ),
                lambda x: re.findall(mac_p, x)[0],
            ],
        ],
        "OpenBSD": [
            [
                fstr(
                    "ifconfig {0} | {1}",
                    (
                        if_name,
                        grep_p,
                    ),
                ),
                lambda x: re.findall(r"\s+[a-zA-Z]+\s+([^\s]+)", x)[0],
            ]
        ],
        "Windows": [["route print", win_f]],
    }
    vectors["Darwin"] = vectors["OpenBSD"]
    os_name = platform.system()
    if os_name not in vectors:
        return None

    try_vectors = vectors[os_name]
    for vector in try_vectors:
        lookup_cmd, proc_f = vector
        out = await cmd(lookup_cmd, er=None)
        try:
            out = proc_f(out).strip()
            out = out.replace(" ", "-")
            out = out.replace(":", "-")
            if not len(re.findall(mac_p, out)):
                continue

            return out
        except (ValueError, AttributeError, IndexError):
            log_exception()

    if os_name not in ["Darwin", "Windows"]:
        try:
            import pyroute2

            with pyroute2.NDB() as ndb:
                with ndb.interfaces[if_name] as interface:
                    return interface["address"]
        except (ImportError, SyntaxError, OSError):
            log_exception()
            return None


# Netifaces apparently doesn't use their own values...
def af_to_netiface(af):
    """Convert an internal AF constant to the integer value expected by the netifaces library."""
    if af == IP4:
        return int(IP4)

    if af == IP6:
        return int(IP6)

    return af


def netiface_to_af(af, netifaces):
    """Convert a netifaces AF constant to an internal AF constant, or return af unchanged if unknown."""
    if af == netifaces.AF_INET:
        return IP4

    if af == netifaces.AF_INET6:
        return IP6

    return af


def is_af_routable(af, netifaces):
    """Return True if the given address family has at least one gateway entry in the netifaces gateway table."""
    af = af_to_netiface(af)
    return af in netifaces.gateways()


async def get_mac_address(name, netifaces):
    """Return the normalised MAC address for network interface name, using netifaces or OS commands.

    Two layers of fallback. netifaces is tried first because it's the
    cheapest path on a normal Linux/BSD/Win/macOS box. Termux-on-Android
    is the canary case: its restricted netifaces returns an addresses
    dict that simply does not contain AF_LINK (the kernel won't expose
    the link-layer fd to a non-root app), so the lookup raises KeyError.
    Previously this function:
      * checked `hasattr(addrs, "AF_LINK")` which is always False on a
        dict and so always took the fallback branch,
      * but THEN overwrote the fallback's return with the same crashing
        netifaces lookup -- so the get_mac_mixed result was discarded
        and we still raised KeyError.
    Now the dict-membership check is correct (`AF_LINK in addrs`) and
    the fallback path actually returns its result.
    """
    addrs = netifaces.ifaddresses(name)
    if netifaces.AF_LINK in addrs and addrs[netifaces.AF_LINK]:
        try:
            mac = addrs[netifaces.AF_LINK][0]["addr"]
            return mac_norm(mac)
        except (KeyError, IndexError):
            pass

    # netifaces couldn't tell us -- fall back to ip/ifconfig parsing.
    try:
        mac = await get_mac_mixed(name)
    except asyncio.CancelledError:  # pylint: disable=try-except-raise
        raise
    except (OSError, asyncio.TimeoutError):
        log_exception()
        return None

    if mac is None:
        return None
    return mac_norm(mac)


# Note: Discards subnet for single addresses.
async def netiface_addr_to_ipr(
    af, nic_id, info
):
    """Convert a netifaces address info dict to an IPRange, validating the subnet by attempting a bind."""
    # Some interfaces might not have valid information set.
    if "addr" not in info:
        return None
    if "netmask" not in info:
        return None

    # info["addr"] = ip_strip_if
    log(
        fstr(
            "Netiface loaded nic ipr {0} {1} {2} {3}",
            (
                af,
                nic_id,
                info["addr"],
                info["netmask"],
            ),
        )
    )
    # Build the IPRange from the OS netmask.
    # Two distinct cases:
    #
    #   i_host == 0 → block assignment: the network address itself was assigned
    #                 to the interface (e.g. 203.0.113.0/27).  The IPRange
    #                 correctly represents the whole block; ipr[0] returns the
    #                 first usable host for STUN/bind.
    #
    #   i_host != 0 → host assignment: one specific IP within a subnet
    #                 (e.g. 192.168.1.100/24).  We collapse to a single-host
    #                 /max range so that ipr[0] reliably returns the actual
    #                 assigned address; subnet preserves the subnet prefix
    #                 for grouping and serialisation.
    #
    # Fall back to a single-host /max range on any parse error (tun/ppp
    # interfaces sometimes return unusual netmask strings).
    try:
        probe = IPRange(info["addr"], netmask=info["netmask"])
    except ValueError:
        probe = None

    if probe is not None and probe.i_host == 0:
        nic_ipr = probe
        nic_ipr.subnet = af_bitlen(af) - probe.bitlen
    else:
        nic_ipr = IPRange(info["addr"], bitlen=0)
        nic_ipr.subnet = (
            af_bitlen(af) - probe.bitlen if probe is not None else af_bitlen(af)
        )

    """
    If a range is detected test that the range is valid by
    trying to bind on the first and last address.
    """
    if not nic_ipr.i_host:
        invalid_subnet = False
        try:
            for host_index in [0, -1]:
                ip_obj = nic_ipr[host_index]
                bind_ip = str(ip_obj)
                bind_tup = await binder_async(af, bind_ip, nic_id=nic_id)

                s = socket.socket(af, TCP)
                try:
                    s.bind(bind_tup)
                except OSError:
                    log_exception()
                    log(
                        fstr(
                            "af = {0}, bind_ip = {1}",
                            (
                                af,
                                bind_ip,
                            ),
                        )
                    )
                    log(fstr("{0}", (bind_tup,)))
                    log(fstr("{0}", (s,)))
                    log(">get routes invalid subnet for {}".format(str(nic_ipr)))
                finally:
                    s.close()
                    break
        except OSError:
            pass

        # Don't add this address to any route.
        if invalid_subnet:
            return None

    return nic_ipr


async def get_nic_private_ips(
    interface, af, netifaces, loop=None
):
    """Return the list of private (non-public) IPRange objects assigned to interface for the given address family."""
    loop = loop or asyncio.get_event_loop()
    nic_iprs = []
    if_name = interface.name
    if_addresses = netifaces.ifaddresses(if_name)
    if af not in if_addresses:
        return nic_iprs

    for info in if_addresses[af]:
        nic_ipr = await netiface_addr_to_ipr(
            af, info, interface, loop, skip_bind_test=0
        )
        if nic_ipr is None:
            continue

        if nic_ipr.is_public and af == IP6:
            continue

        nic_iprs.append(nic_ipr)

    return nic_iprs


"""
Netifaces doesn't return the right default interface
on android. Need a patch for this.
"""


def netiface_gateways(
    netifaces, get_interface_type, preference=AF_ANY
):
    """Return the netifaces gateway dict, patching in a default entry when netifaces fails to identify one."""
    gws = netifaces.gateways()

    # Netifaces may not always find the default gateway.
    # Use first interface that get_interface_type finds.
    if gws["default"] == {}:
        # Create a list of address families to check.
        if preference == AF_ANY:
            afs = VALID_AFS
        else:
            afs = [preference]

        # Check the address families of related GWs.
        for af in afs:
            # No entry for AF found in GW info.
            log(fstr("Trying {0} in netiface_gateways", (af,)))
            if af not in gws:
                continue

            # Check that there is info to check.
            if not len(gws[af]):
                continue

            # Check the interface name.
            for net_info in gws[af]:
                # Af already set.
                if af in gws["default"]:
                    continue

                # Try to determine interface type from name.
                if_name = net_info[1]
                if_type = get_interface_type(if_name)

                # Unknown / bad interface type.
                if if_type == INTERFACE_UNKNOWN:
                    log(fstr("iface type unknown {0}", (if_name,)))
                    continue

                # Use this interface GW info as the default.
                if net_info[2]:
                    gws["default"][af] = net_info
                break

    return gws
