"""
Support for ASUSWRT routers.

For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/device_tracker.asuswrt/
"""
import logging
import re
import socket
import telnetlib
import threading
from collections import namedtuple
from datetime import timedelta

import voluptuous as vol

from homeassistant.components.device_tracker import (
    DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
from homeassistant.const import (
    CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT)
from homeassistant.util import Throttle
import homeassistant.helpers.config_validation as cv

REQUIREMENTS = ['pexpect==4.0.1']

_LOGGER = logging.getLogger(__name__)

CONF_MODE = 'mode'
CONF_PROTOCOL = 'protocol'
CONF_PUB_KEY = 'pub_key'
CONF_SSH_KEY = 'ssh_key'

DEFAULT_SSH_PORT = 22

MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)

SECRET_GROUP = 'Password or SSH Key'

PLATFORM_SCHEMA = vol.All(
    cv.has_at_least_one_key(CONF_PASSWORD, CONF_PUB_KEY, CONF_SSH_KEY),
    PLATFORM_SCHEMA.extend({
        vol.Required(CONF_HOST): cv.string,
        vol.Required(CONF_USERNAME): cv.string,
        vol.Optional(CONF_PROTOCOL, default='ssh'):
            vol.In(['ssh', 'telnet']),
        vol.Optional(CONF_MODE, default='router'):
            vol.In(['router', 'ap']),
        vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port,
        vol.Exclusive(CONF_PASSWORD, SECRET_GROUP): cv.string,
        vol.Exclusive(CONF_SSH_KEY, SECRET_GROUP): cv.isfile,
        vol.Exclusive(CONF_PUB_KEY, SECRET_GROUP): cv.isfile
    }))


_LEASES_CMD = 'cat /tmp/dnsmasq.leases'
_LEASES_REGEX = re.compile(
    r'\w+\s' +
    r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
    r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
    r'(?P<host>([^\s]+))')

# Command to get both 5GHz and 2.4GHz clients
_WL_CMD = 'for dev in `nvram get wl_ifnames`; do wl -i $dev assoclist; done'
_WL_REGEX = re.compile(
    r'\w+\s' +
    r'(?P<mac>(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))')

_IP_NEIGH_CMD = 'ip neigh'
_IP_NEIGH_REGEX = re.compile(
    r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3}|'
    r'([0-9a-fA-F]{1,4}:){1,7}[0-9a-fA-F]{0,4}(:[0-9a-fA-F]{1,4}){1,7})\s'
    r'\w+\s'
    r'\w+\s'
    r'(\w+\s(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s'
    r'\s?(router)?'
    r'(?P<status>(\w+))')


# pylint: disable=unused-argument
def get_scanner(hass, config):
    """Validate the configuration and return an ASUS-WRT scanner."""
    scanner = AsusWrtDeviceScanner(config[DOMAIN])

    return scanner if scanner.success_init else None


AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases')


class AsusWrtDeviceScanner(DeviceScanner):
    """This class queries a router running ASUSWRT firmware."""

    # Eighth attribute needed for mode (AP mode vs router mode)
    def __init__(self, config):
        """Initialize the scanner."""
        self.host = config[CONF_HOST]
        self.username = config[CONF_USERNAME]
        self.password = config.get(CONF_PASSWORD, '')
        self.ssh_key = config.get('ssh_key', config.get('pub_key', ''))
        self.protocol = config[CONF_PROTOCOL]
        self.mode = config[CONF_MODE]
        self.port = config[CONF_PORT]

        if self.protocol == 'ssh':
            if not (self.ssh_key or self.password):
                _LOGGER.error("No password or private key specified")
                self.success_init = False
                return

            self.connection = SshConnection(self.host, self.port,
                                            self.username,
                                            self.password,
                                            self.ssh_key,
                                            self.mode == "ap")
        else:
            if not self.password:
                _LOGGER.error("No password specified")
                self.success_init = False
                return

            self.connection = TelnetConnection(self.host, self.port,
                                               self.username,
                                               self.password,
                                               self.mode == "ap")

        self.lock = threading.Lock()

        self.last_results = {}

        # Test the router is accessible.
        data = self.get_asuswrt_data()
        self.success_init = data is not None

    def scan_devices(self):
        """Scan for new devices and return a list with found device IDs."""
        self._update_info()
        return [client['mac'] for client in self.last_results]

    def get_device_name(self, device):
        """Return the name of the given device or None if we don't know."""
        if not self.last_results:
            return None
        for client in self.last_results:
            if client['mac'] == device:
                return client['host']
        return None

    @Throttle(MIN_TIME_BETWEEN_SCANS)
    def _update_info(self):
        """Ensure the information from the ASUSWRT router is up to date.

        Return boolean if scanning successful.
        """
        if not self.success_init:
            return False

        with self.lock:
            _LOGGER.info('Checking Devices')
            data = self.get_asuswrt_data()
            if not data:
                return False

            active_clients = [client for client in data.values() if
                              client['status'] == 'REACHABLE' or
                              client['status'] == 'DELAY' or
                              client['status'] == 'STALE' or
                              client['status'] == 'IN_ASSOCLIST']
            self.last_results = active_clients
            return True

    def get_asuswrt_data(self):
        """Retrieve data from ASUSWRT and return parsed result."""
        result = self.connection.get_result()

        if not result:
            return {}

        devices = {}
        if self.mode == 'ap':
            for lease in result.leases:
                match = _WL_REGEX.search(lease.decode('utf-8'))

                if not match:
                    _LOGGER.warning("Could not parse wl row: %s", lease)
                    continue

                host = ''

                devices[match.group('mac').upper()] = {
                    'host': host,
                    'status': 'IN_ASSOCLIST',
                    'ip': '',
                    'mac': match.group('mac').upper(),
                    }

        else:
            for lease in result.leases:
                if lease.startswith(b'duid '):
                    continue
                match = _LEASES_REGEX.search(lease.decode('utf-8'))

                if not match:
                    _LOGGER.warning("Could not parse lease row: %s", lease)
                    continue

                # For leases where the client doesn't set a hostname, ensure it
                # is blank and not '*', which breaks entity_id down the line.
                host = match.group('host')
                if host == '*':
                    host = ''

                devices[match.group('mac')] = {
                    'host': host,
                    'status': '',
                    'ip': match.group('ip'),
                    'mac': match.group('mac').upper(),
                    }

            for neighbor in result.neighbors:
                match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8'))
                if not match:
                    _LOGGER.warning("Could not parse neighbor row: %s",
                                    neighbor)
                    continue
                if match.group('mac') in devices:
                    devices[match.group('mac')]['status'] = (
                        match.group('status'))

        return devices


class _Connection:
    def __init__(self):
        self._connected = False

    @property
    def connected(self):
        """Return connection state."""
        return self._connected

    def connect(self):
        """Mark currenct connection state as connected."""
        self._connected = True

    def disconnect(self):
        """Mark current connection state as disconnected."""
        self._connected = False


class SshConnection(_Connection):
    """Maintains an SSH connection to an ASUS-WRT router."""

    def __init__(self, host, port, username, password, ssh_key, ap):
        """Initialize the SSH connection properties."""
        super(SshConnection, self).__init__()

        self._ssh = None
        self._host = host
        self._port = port
        self._username = username
        self._password = password
        self._ssh_key = ssh_key
        self._ap = ap

    def get_result(self):
        """Retrieve a single AsusWrtResult through an SSH connection.

        Connect to the SSH server if not currently connected, otherwise
        use the existing connection.
        """
        from pexpect import pxssh, exceptions

        try:
            if not self.connected:
                self.connect()
            if self._ap:
                neighbors = ['']
                self._ssh.sendline(_WL_CMD)
                self._ssh.prompt()
                leases_result = self._ssh.before.split(b'\n')[1:-1]
            else:
                self._ssh.sendline(_IP_NEIGH_CMD)
                self._ssh.prompt()
                neighbors = self._ssh.before.split(b'\n')[1:-1]
                self._ssh.sendline(_LEASES_CMD)
                self._ssh.prompt()
                leases_result = self._ssh.before.split(b'\n')[1:-1]
            return AsusWrtResult(neighbors, leases_result)
        except exceptions.EOF as err:
            _LOGGER.error("Connection refused. SSH enabled?")
            self.disconnect()
            return None
        except pxssh.ExceptionPxssh as err:
            _LOGGER.error("Unexpected SSH error: %s", str(err))
            self.disconnect()
            return None
        except AssertionError as err:
            _LOGGER.error("Connection to router unavailable: %s", str(err))
            self.disconnect()
            return None

    def connect(self):
        """Connect to the ASUS-WRT SSH server."""
        from pexpect import pxssh

        self._ssh = pxssh.pxssh()
        if self._ssh_key:
            self._ssh.login(self._host, self._username,
                            ssh_key=self._ssh_key, port=self._port)
        else:
            self._ssh.login(self._host, self._username,
                            password=self._password, port=self._port)

        super(SshConnection, self).connect()

    def disconnect(self):   \
            # pylint: disable=broad-except
        """Disconnect the current SSH connection."""
        try:
            self._ssh.logout()
        except Exception:
            pass
        finally:
            self._ssh = None

        super(SshConnection, self).disconnect()


class TelnetConnection(_Connection):
    """Maintains a Telnet connection to an ASUS-WRT router."""

    def __init__(self, host, port, username, password, ap):
        """Initialize the Telnet connection properties."""
        super(TelnetConnection, self).__init__()

        self._telnet = None
        self._host = host
        self._port = port
        self._username = username
        self._password = password
        self._ap = ap
        self._prompt_string = None

    def get_result(self):
        """Retrieve a single AsusWrtResult through a Telnet connection.

        Connect to the Telnet server if not currently connected, otherwise
        use the existing connection.
        """
        try:
            if not self.connected:
                self.connect()

            self._telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii'))
            neighbors = (self._telnet.read_until(self._prompt_string).
                         split(b'\n')[1:-1])
            if self._ap:
                self._telnet.write('{}\n'.format(_WL_CMD).encode('ascii'))
                leases_result = (self._telnet.read_until(self._prompt_string).
                                 split(b'\n')[1:-1])
            else:
                self._telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii'))
                leases_result = (self._telnet.read_until(self._prompt_string).
                                 split(b'\n')[1:-1])
            return AsusWrtResult(neighbors, leases_result)
        except EOFError:
            _LOGGER.error("Unexpected response from router")
            self.disconnect()
            return None
        except ConnectionRefusedError:
            _LOGGER.error("Connection refused by router. Telnet enabled?")
            self.disconnect()
            return None
        except socket.gaierror as exc:
            _LOGGER.error("Socket exception: %s", exc)
            self.disconnect()
            return None
        except OSError as exc:
            _LOGGER.error("OSError: %s", exc)
            self.disconnect()
            return None

    def connect(self):
        """Connect to the ASUS-WRT Telnet server."""
        self._telnet = telnetlib.Telnet(self._host)
        self._telnet.read_until(b'login: ')
        self._telnet.write((self._username + '\n').encode('ascii'))
        self._telnet.read_until(b'Password: ')
        self._telnet.write((self._password + '\n').encode('ascii'))
        self._prompt_string = self._telnet.read_until(b'#').split(b'\n')[-1]

        super(TelnetConnection, self).connect()

    def disconnect(self):   \
            # pylint: disable=broad-except
        """Disconnect the current Telnet connection."""
        try:
            self._telnet.write('exit\n'.encode('ascii'))
        except Exception:
            pass

        super(TelnetConnection, self).disconnect()
