# -*- coding: utf-8 -*-
# http://multivax.com/last_question.html

"""Get the MAC address of remote hosts or network interfaces.

It provides a platform-independent interface to get the MAC addresses of:

* System network interfaces (by interface name)
* Remote hosts on the local network (by IPv4/IPv6 address or hostname)

It provides one function: `get_mac_address()`

Examples:

    from getmac import get_mac_address
    eth_mac = get_mac_address(interface="eth0")
    win_mac = get_mac_address(interface="Ethernet 3")
    ip_mac = get_mac_address(ip="192.168.0.1")
    ip6_mac = get_mac_address(ip6="::1")
    host_mac = get_mac_address(hostname="localhost")
    updated_mac = get_mac_address(ip="10.0.0.1", network_request=True)

"""

import ctypes
import logging
import os
import platform
import re
import shlex
import socket
import struct
import sys
import traceback
from subprocess import check_output

try:  # Python 3
    from subprocess import DEVNULL  # type: ignore
except ImportError:  # Python 2
    DEVNULL = open(os.devnull, 'wb')  # type: ignore

# Configure logging
log = logging.getLogger('getmac')
log.addHandler(logging.NullHandler())

__version__ = '0.8.1'
PY2 = sys.version_info[0] == 2

# Configurable settings
DEBUG = 0
PORT = 55555

# Platform identifiers
_SYST = platform.system()
if _SYST == 'Java':
    try:
        import java.lang
        _SYST = str(java.lang.System.getProperty("os.name"))
    except ImportError:
        log.critical("Can't determine OS: couldn't import java.lang on Jython")
WINDOWS = _SYST == 'Windows'
DARWIN = _SYST == 'Darwin'
OPENBSD = _SYST == 'OpenBSD'
FREEBSD = _SYST == 'FreeBSD'
BSD = OPENBSD or FREEBSD  # Not including Darwin for now
WSL = False  # Windows Subsystem for Linux (WSL)
LINUX = False
if _SYST == 'Linux':
    if 'Microsoft' in platform.version():
        WSL = True
    else:
        LINUX = True

PATH = os.environ.get('PATH', os.defpath).split(os.pathsep)
if not WINDOWS:
    PATH.extend(('/sbin', '/usr/sbin'))

# Use a copy of the environment so we don't
# modify the process's current environment.
ENV = dict(os.environ)
ENV['LC_ALL'] = 'C'  # Ensure ASCII output so we parse correctly

# Constants
IP4 = 0
IP6 = 1
INTERFACE = 2
HOSTNAME = 3

MAC_RE_COLON = r'([0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5})'
MAC_RE_DASH = r'([0-9a-fA-F]{2}(?:-[0-9a-fA-F]{2}){5})'
MAC_RE_DARWIN = r'([0-9a-fA-F]{1,2}(?::[0-9a-fA-F]{1,2}){5})'

# Used for mypy (a data type analysis tool)
# If you're copying the code, this section can be safely removed
try:
    from typing import TYPE_CHECKING
    if TYPE_CHECKING:
        from typing import Optional
except ImportError:
    pass


def get_mac_address(
        interface=None, ip=None, ip6=None,
        hostname=None, network_request=True
):
    # type: (Optional[str], Optional[str], Optional[str], Optional[str], bool) -> Optional[str]
    """Get a Unicast IEEE 802 MAC-48 address from a local interface or remote host.

    You must only use one of the first four arguments. If none of the arguments
    are selected, the default network interface for the system will be used.

    Exceptions will be handled silently and returned as a None.
    For the time being, it assumes you are using Ethernet.

    NOTES:
    * You MUST provide str-typed arguments, REGARDLESS of Python version.
    * localhost/127.0.0.1 will always return '00:00:00:00:00:00'

    Args:
        interface (str): Name of a local network interface (e.g "Ethernet 3", "eth0", "ens32")
        ip (str): Canonical dotted decimal IPv4 address of a remote host (e.g 192.168.0.1)
        ip6 (str): Canonical shortened IPv6 address of a remote host (e.g ff02::1:ffe7:7f19)
        hostname (str): DNS hostname of a remote host (e.g "router1.mycorp.com", "localhost")
        network_request (bool): Send a UDP packet to a remote host to populate
        the ARP/NDP tables for IPv4/IPv6. The port this packet is sent to can
        be configured using the module variable `getmac.PORT`.
    Returns:
        Lowercase colon-separated MAC address, or None if one could not be
        found or there was an error.
    """
    if (hostname and hostname == 'localhost') or (ip and ip == '127.0.0.1'):
        return '00:00:00:00:00:00'

    # Resolve hostname to an IP address
    if hostname:
        ip = socket.gethostbyname(hostname)

    # Populate the ARP table by sending a empty UDP packet to a high port
    if network_request and (ip or ip6):
        if ip:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        else:
            s = socket.socket(socket.AF_INET6, socket.SOCK_DGRAM)
        try:
            if ip:
                s.sendto(b'', (ip, PORT))
            else:
                s.sendto(b'', (ip6, PORT))
        except Exception:
            log.error("Failed to send ARP table population packet")
            if DEBUG:
                log.debug(traceback.format_exc())
        finally:
            s.close()

    # Setup the address hunt based on the arguments specified
    if ip6:
        if not socket.has_ipv6:
            log.error("Cannot get the MAC address of a IPv6 host: "
                      "IPv6 is not supported on this system")
            return None
        elif ':' not in ip6:
            log.error("Invalid IPv6 address: %s", ip6)
            return None
        to_find = ip6
        typ = IP6
    elif ip:
        to_find = ip
        typ = IP4
    else:  # Default to searching for interface
        typ = INTERFACE
        if interface:
            to_find = interface
        else:
            # Default to finding MAC of the interface with the default route
            if WINDOWS and network_request:
                to_find = _fetch_ip_using_dns()
                typ = IP4
            elif WINDOWS:
                to_find = 'Ethernet'
            elif BSD:
                if OPENBSD:
                    to_find = _get_default_iface_openbsd()  # type: ignore
                else:
                    to_find = _get_default_iface_freebsd()  # type: ignore
                if not to_find:
                    to_find = 'em0'
            else:
                to_find = _hunt_linux_default_iface()  # type: ignore
                if not to_find:
                    to_find = 'en0'

    mac = _hunt_for_mac(to_find, typ, network_request)
    log.debug("Raw MAC found: %s", mac)

    # Check and format the result to be lowercase, colon-separated
    if mac is not None:
        mac = str(mac)
        if not PY2:  # Strip bytestring conversion artifacts
            mac = mac.replace("b'", '').replace("'", '')\
                     .replace('\\n', '').replace('\\r', '')
        mac = mac.strip().lower().replace(' ', '').replace('-', ':')

        # Fix cases where there are no colons
        if ':' not in mac and len(mac) == 12:
            log.debug("Adding colons to MAC %s", mac)
            mac = ':'.join(mac[i:i + 2] for i in range(0, len(mac), 2))

        # Pad single-character octets with a leading zero (e.g Darwin's ARP output)
        elif len(mac) < 17:
            log.debug("Length of MAC %s is %d, padding single-character "
                      "octets with zeros", mac, len(mac))
            parts = mac.split(':')
            new_mac = []
            for part in parts:
                if len(part) == 1:
                    new_mac.append('0' + part)
                else:
                    new_mac.append(part)
            mac = ':'.join(new_mac)

        # MAC address should ALWAYS be 17 characters before being returned
        if len(mac) != 17:
            log.warning("MAC address %s is not 17 characters long!", mac)
            mac = None
        elif mac.count(':') != 5:
            log.warning("MAC address %s is missing ':' characters", mac)
            mac = None
    return mac


def _search(regex, text, group_index=0):
    # type: (str, str, int) -> Optional[str]
    match = re.search(regex, text)
    if match:
        return match.groups()[group_index]
    return None


def _popen(command, args):
    # type: (str, str) -> str
    for directory in PATH:
        executable = os.path.join(directory, command)
        if (os.path.exists(executable)
            and os.access(executable, os.F_OK | os.X_OK)
                and not os.path.isdir(executable)):
            break
    else:
        executable = command
    if DEBUG >= 3:
        log.debug("Running: '%s %s'", executable, args)
    return _call_proc(executable, args)


def _call_proc(executable, args):
    # type: (str, str) -> str
    if WINDOWS:
        cmd = executable + ' ' + args  # type: ignore
    else:
        cmd = [executable] + shlex.split(args)  # type: ignore
    output = check_output(cmd, stderr=DEVNULL, env=ENV)
    if DEBUG >= 4:
        log.debug("Output from '%s' command: %s", executable, str(output))
    if not PY2 and isinstance(output, bytes):
        return str(output, 'utf-8')
    else:
        return str(output)


def _windows_ctypes_host(host):
    # type: (str) -> Optional[str]
    if not PY2:  # Convert to bytes on Python 3+ (Fixes GitHub issue #7)
        host = host.encode()  # type: ignore
    try:
        inetaddr = ctypes.windll.wsock32.inet_addr(host)  # type: ignore
        if inetaddr in (0, -1):
            raise Exception
    except Exception:
        hostip = socket.gethostbyname(host)
        inetaddr = ctypes.windll.wsock32.inet_addr(hostip)  # type: ignore

    buffer = ctypes.c_buffer(6)
    addlen = ctypes.c_ulong(ctypes.sizeof(buffer))

    send_arp = ctypes.windll.Iphlpapi.SendARP  # type: ignore
    if send_arp(inetaddr, 0, ctypes.byref(buffer), ctypes.byref(addlen)) != 0:
        return None

    # Convert binary data into a string.
    macaddr = ''
    for intval in struct.unpack('BBBBBB', buffer):  # type: ignore
        if intval > 15:
            replacestr = '0x'
        else:
            replacestr = 'x'
        macaddr = ''.join([macaddr, hex(intval).replace(replacestr, '')])
    return macaddr


def _fcntl_iface(iface):
    # type: (str) -> str
    import fcntl
    if not PY2:
        iface = iface.encode()  # type: ignore
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    # 0x8927 = SIOCGIFADDR
    info = fcntl.ioctl(s.fileno(), 0x8927, struct.pack('256s', iface[:15]))
    if PY2:
        return ':'.join(['%02x' % ord(char) for char in info[18:24]])
    else:
        return ':'.join(['%02x' % ord(chr(char)) for char in info[18:24]])


def _uuid_ip(ip):
    # type: (str) -> Optional[str]
    from uuid import _arp_getnode  # type: ignore
    backup = socket.gethostbyname
    try:
        socket.gethostbyname = lambda x: ip
        mac1 = _arp_getnode()
        if mac1 is not None:
            mac1 = _uuid_convert(mac1)
            mac2 = _arp_getnode()
            mac2 = _uuid_convert(mac2)
            if mac1 == mac2:
                return mac1
    except Exception:
        raise
    finally:
        socket.gethostbyname = backup
    return None


def _uuid_lanscan_iface(iface):
    # type: (str) -> Optional[str]
    from uuid import _find_mac  # type: ignore
    if not PY2:
        iface = bytes(iface, 'utf-8')  # type: ignore
    mac = _find_mac('lanscan', '-ai', [iface], lambda i: 0)
    if mac:
        return _uuid_convert(mac)
    return None


def _uuid_convert(mac):
    # type: (int) -> str
    return ':'.join(('%012X' % mac)[i:i+2] for i in range(0, 12, 2))


def _read_sys_iface_file(iface):
    # type: (str) -> Optional[str]
    data = _read_file('/sys/class/net/' + iface + '/address')
    # Sometimes this can be empty or a single newline character
    return None if data is not None and len(data) < 17 else data


def _read_arp_file(host):
    # type: (str) -> Optional[str]
    data = _read_file('/proc/net/arp')
    if data is not None and len(data) > 1:
        # Need a space, otherwise a search for 192.168.16.2
        # will match 192.168.16.254 if it comes first!
        return _search(re.escape(host) + r' .+' + MAC_RE_COLON, data)
    return None


def _read_file(filepath):
    # type: (str) -> Optional[str]
    try:
        with open(filepath) as f:
            return f.read()
    except (OSError, IOError):  # This is IOError on Python 2.7
        log.debug("Could not find file: '%s'", filepath)
        return None


def _hunt_for_mac(to_find, type_of_thing, net_ok=True):
    # type: (Optional[str], int, bool) -> Optional[str]
    """Tries a variety of methods to get a MAC address.

    Format of method lists:
    Tuple:  (regex, regex index, command, command args)
            Command args is a list of strings to attempt to use as arguments
    lambda: Function to call
    """
    if to_find is None:
        log.warning("_hunt_for_mac() failed: to_find is None")
        return None
    if not PY2 and isinstance(to_find, bytes):
        to_find = str(to_find, 'utf-8')

    if WINDOWS and type_of_thing == INTERFACE:
        methods = [
            # getmac - Connection Name
            (r'\r\n' + to_find + r'.*' + MAC_RE_DASH + r'.*\r\n',
             0, 'getmac.exe', ['/NH /V']),

            # ipconfig
            (to_find + r'(?:\n?[^\n]*){1,8}Physical Address[ .:]+' + MAC_RE_DASH + r'\r\n',
             0, 'ipconfig.exe', ['/all']),

            # getmac - Network Adapter (the human-readable name)
            (r'\r\n.*' + to_find + r'.*' + MAC_RE_DASH + r'.*\r\n',
             0, 'getmac.exe', ['/NH /V']),

            # wmic - WMI command line utility
            lambda x: _popen('wmic.exe', 'nic where "NetConnectionID = \'%s\'" get '
                                         'MACAddress /value' % x).strip().partition('=')[2],
        ]
    elif (WINDOWS or WSL) and type_of_thing in [IP4, IP6, HOSTNAME]:
        methods = [
            # arp -a - Parsing result with a regex
            (MAC_RE_DASH, 0, 'arp.exe', ['-a %s' % to_find]),
        ]

        # Add methods that make network requests
        # Insert it *after* arp.exe since that's probably faster.
        if net_ok and type_of_thing != IP6 and not WSL:
            methods.insert(1, _windows_ctypes_host)
    elif (DARWIN or FREEBSD) and type_of_thing == INTERFACE:
        methods = [
            (r'ether ' + MAC_RE_COLON,
             0, 'ifconfig', [to_find]),

            # Alternative match for ifconfig if it fails
            (to_find + r'.*ether ' + MAC_RE_COLON,
             0, 'ifconfig', ['']),

            (MAC_RE_COLON,
             0, 'networksetup', ['-getmacaddress %s' % to_find]),
        ]
    elif FREEBSD and type_of_thing in [IP4, IP6, HOSTNAME]:
        methods = [
            (r'\(' + re.escape(to_find) + r'\)\s+at\s+' + MAC_RE_COLON,
             0, 'arp', [to_find])
        ]
    elif OPENBSD and type_of_thing == INTERFACE:
        methods = [
            (r'lladdr ' + MAC_RE_COLON,
             0, 'ifconfig', [to_find]),
        ]
    elif OPENBSD and type_of_thing in [IP4, IP6, HOSTNAME]:
        methods = [
            (re.escape(to_find) + r'[ ]+' + MAC_RE_COLON,
             0, 'arp', ['-an']),
        ]
    elif type_of_thing == INTERFACE:
        methods = [
            _read_sys_iface_file,
            _fcntl_iface,

            # Fast modern Ubuntu ifconfig
            (r'ether ' + MAC_RE_COLON,
             0, 'ifconfig', [to_find]),

            # Fast ifconfig
            (r'HWaddr ' + MAC_RE_COLON,
             0, 'ifconfig', [to_find]),

            # ip link (Don't use 'list' due to SELinux [Android 24+])
            (to_find + r'.*\n.*link/ether ' + MAC_RE_COLON,
             0, 'ip', ['link %s' % to_find, 'link']),

            # netstat
            (to_find + r'.*HWaddr ' + MAC_RE_COLON,
             0, 'netstat', ['-iae']),

            # More variations of ifconfig
            (to_find + r'.*ether ' + MAC_RE_COLON,
             0, 'ifconfig', ['']),
            (to_find + r'.*HWaddr ' + MAC_RE_COLON,
             0, 'ifconfig', ['', '-a', '-v']),

            # Tru64 ('-av')
            (to_find + r'.*Ether ' + MAC_RE_COLON,
             0, 'ifconfig', ['-av']),
            _uuid_lanscan_iface,
        ]
    elif type_of_thing in [IP4, IP6, HOSTNAME]:
        esc = re.escape(to_find)
        methods = [
            _read_arp_file,
            lambda x: _popen('ip', 'neighbor show %s' % x)
            .partition(x)[2].partition('lladdr')[2].strip().split()[0],

            (r'\(' + esc + r'\)\s+at\s+' + MAC_RE_COLON,
             0, 'arp', [to_find, '-an', '-an %s' % to_find]),

            # Darwin oddness
            (r'\(' + esc + r'\)\s+at\s+' + MAC_RE_DARWIN,
             0, 'arp', [to_find, '-a', '-a %s' % to_find]),
            _uuid_ip,
        ]
    else:
        log.critical("Reached end of _hunt_for_mac() if-else chain!")
        return None
    return _try_methods(methods, to_find)


def _try_methods(methods, to_find=None):
    # type: (list, Optional[str]) -> Optional[str]
    """Runs the methods specified by _hunt_for_mac().

    We try every method and see if it returned a MAC address. If it returns
    None or raises an exception, we continue and try the next method.
    """
    found = None
    for m in methods:
        try:
            if isinstance(m, tuple):
                for arg in m[3]:  # list(str)
                    if DEBUG:
                        log.debug("Trying: '%s %s'", m[2], arg)
                    # Arguments: (regex, _popen(command, arg), regex index)
                    found = _search(m[0], _popen(m[2], arg), m[1])
                    if DEBUG:
                        log.debug("Result: %s\n", found)
                    if found:  # Skip remaining args AND remaining methods
                        break
            elif callable(m):
                if DEBUG:
                    log.debug("Trying: '%s' (to_find: '%s')", m.__name__, str(to_find))
                if to_find is not None:
                    found = m(to_find)
                else:
                    found = m()
                if DEBUG:
                    log.debug("Result: %s\n", found)
            else:
                log.critical("Invalid type '%s' for method '%s'", type(m), str(m))
        except Exception as ex:
            if DEBUG:
                log.debug("Exception: %s", str(ex))
            if DEBUG >= 2:
                log.debug(traceback.format_exc())
            continue
        if found:  # Skip remaining methods
            break
    return found


def _get_default_iface_linux():
    # type: () -> Optional[str]
    """Get the default interface by reading /proc/net/route.

    This is the same source as the `route` command, however it's much
    faster to read this file than to call `route`. If it fails for whatever
    reason, we can fall back on the system commands (e.g for a platform
    that has a route command, but maybe doesn't use /proc?).
    """
    data = _read_file('/proc/net/route')
    if data is not None and len(data) > 1:
        for line in data.split('\n')[1:-1]:
            iface_name, dest = line.split('\t')[:2]
            if dest == '00000000':
                return iface_name
    return None


def _hunt_linux_default_iface():
    # type: () -> Optional[str]
    # NOTE: for now, we check the default interface for WSL using the
    # same methods as POSIX, since those parts of the net stack work fine.
    methods = [
        _get_default_iface_linux,
        lambda: _popen('route', '-n').partition('0.0.0.0')[2].partition('\n')[0].split()[-1],
        lambda: _popen('ip', 'route list 0/0').partition('dev')[2].partition('proto')[0].strip(),
    ]
    return _try_methods(methods)


def _get_default_iface_openbsd():
    # type: () -> Optional[str]
    methods = [
        lambda: _popen('route', '-nq show -inet -gateway -priority 1')
        .partition('127.0.0.1')[0].strip().rpartition(' ')[2],
    ]
    return _try_methods(methods)


def _get_default_iface_freebsd():
    # type: () -> Optional[str]
    methods = [
        (r'default[ ]+\S+[ ]+\S+[ ]+(\S+)\n',
         0, 'netstat', ['-r']),
    ]
    return _try_methods(methods)


def _fetch_ip_using_dns():
    # type: () -> str
    """Determines the IP address of the default network interface.

    Sends a UDP packet to Cloudflare's DNS (1.1.1.1), which should go through
    the default interface. This populates the source address of the socket,
    which we then inspect and return.
    """
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.connect(('1.1.1.1', 53))
    ip = s.getsockname()[0]
    s.close()  # NOTE: sockets don't have context manager in 2.7 :(
    return ip
