# -----------------------------------------------------------------------------
# Copyright (c) 2015 Ralph Hempel <rhempel@hempeldesigngroup.com>
# Copyright (c) 2015 Anton Vanhoucke <antonvh@gmail.com>
# Copyright (c) 2015 Denis Demidov <dennis.demidov@gmail.com>
# Copyright (c) 2015 Eric Pascual <eric@pobot.org>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
# -----------------------------------------------------------------------------

import sys
import os
import io
import fnmatch
import re
import stat
import errno
from os.path import abspath

try:
    # if we are in a released build, there will be an auto-generated "version"
    # module
    from .version import __version__
except ImportError:
    __version__ = "<unknown>"

if sys.version_info < (3, 4):
    raise SystemError('Must be using Python 3.4 or higher')


def is_micropython():
    return sys.implementation.name == "micropython"


def chain_exception(exception, cause):
    if is_micropython():
        raise exception
    else:
        raise exception from cause


def get_current_platform():
    """
    Look in /sys/class/board-info/ to determine the platform type.

    This can return 'ev3', 'evb', 'pistorms', 'brickpi', 'brickpi3' or 'fake'.
    """
    board_info_dir = '/sys/class/board-info/'

    if not os.path.exists(board_info_dir) or os.environ.get("FAKE_SYS"):
        return 'fake'

    for board in os.listdir(board_info_dir):
        uevent_filename = os.path.join(board_info_dir, board, 'uevent')

        if os.path.exists(uevent_filename):
            with open(uevent_filename, 'r') as fh:
                for line in fh.readlines():
                    (key, value) = line.strip().split('=')

                    if key == 'BOARD_INFO_MODEL':

                        if value == 'LEGO MINDSTORMS EV3':
                            return 'ev3'

                        elif value in ('FatcatLab EVB', 'QuestCape'):
                            return 'evb'

                        elif value == 'PiStorms':
                            return 'pistorms'

                        # This is the same for both BrickPi and BrickPi+.
                        # There is not a way to tell the difference.
                        elif value == 'Dexter Industries BrickPi':
                            return 'brickpi'

                        elif value == 'Dexter Industries BrickPi3':
                            return 'brickpi3'

                        elif value == 'FAKE-SYS':
                            return 'fake'

    return None


# -----------------------------------------------------------------------------
def list_device_names(class_path, name_pattern, **kwargs):
    """
    This is a generator function that lists names of all devices matching the
    provided parameters.

    Parameters:
        class_path: class path of the device, a subdirectory of /sys/class.
            For example, '/sys/class/tacho-motor'.
        name_pattern: pattern that device name should match.
            For example, 'sensor*' or 'motor*'. Default value: '*'.
        keyword arguments: used for matching the corresponding device
            attributes. For example, address='outA', or
            driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value
            is a list, then a match against any entry of the list is
            enough.
    """

    if not os.path.isdir(class_path):
        return

    def matches(attribute, pattern):
        try:
            with io.FileIO(attribute) as f:
                value = f.read().strip().decode()
        except Exception:
            return False

        if isinstance(pattern, list):
            return any([value.find(p) >= 0 for p in pattern])
        else:
            return value.find(pattern) >= 0

    for f in os.listdir(class_path):
        if fnmatch.fnmatch(f, name_pattern):
            path = class_path + '/' + f
            if all([matches(path + '/' + k, kwargs[k]) for k in kwargs]):
                yield f


def library_load_warning_message(library_name, dependent_class):
    return 'Import warning: Failed to import "{}". {} will be unusable!'.format(library_name, dependent_class)


class DeviceNotFound(Exception):
    pass


class DeviceNotDefined(Exception):
    pass


class ThreadNotRunning(Exception):
    pass


# -----------------------------------------------------------------------------
# Define the base class from which all other ev3dev classes are defined.


class Device(object):
    """The ev3dev device base class"""

    __slots__ = [
        '_path',
        '_device_index',
        '_attr_cache',
        'kwargs',
    ]

    DEVICE_ROOT_PATH = '/sys/class'

    _DEVICE_INDEX = re.compile(r'^.*(\d+)$')

    def __init__(self, class_name, name_pattern='*', name_exact=False, **kwargs):
        """Spin through the Linux sysfs class for the device type and find
        a device that matches the provided name pattern and attributes (if any).

        Parameters:
            class_name: class name of the device, a subdirectory of /sys/class.
                For example, 'tacho-motor'.
            name_pattern: pattern that device name should match.
                For example, 'sensor*' or 'motor*'. Default value: '*'.
            name_exact: when True, assume that the name_pattern provided is the
                exact device name and use it directly.
            keyword arguments: used for matching the corresponding device
                attributes. For example, address='outA', or
                driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value
                is a list, then a match against any entry of the list is
                enough.

        Example::

            d = ev3dev.Device('tacho-motor', address='outA')
            s = ev3dev.Device('lego-sensor', driver_name=['lego-ev3-us', 'lego-nxt-us'])

        If there was no valid connected device, an error is thrown.
        """

        classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name)
        self.kwargs = kwargs
        self._attr_cache = {}

        def get_index(file):
            match = Device._DEVICE_INDEX.match(file)
            if match:
                return int(match.group(1))
            else:
                return None

        if name_exact:
            self._path = classpath + '/' + name_pattern
            self._device_index = get_index(name_pattern)
        else:
            try:
                name = next(list_device_names(classpath, name_pattern, **kwargs))
                self._path = classpath + '/' + name
                self._device_index = get_index(name)
            except StopIteration:
                self._path = None
                self._device_index = None

                chain_exception(DeviceNotFound("%s is not connected." % self), None)

    def __str__(self):
        if 'address' in self.kwargs:
            return "%s(%s)" % (self.__class__.__name__, self.kwargs.get('address'))
        else:
            return self.__class__.__name__

    def __repr__(self):
        return self.__str__()

    # This allows us to sort lists of Device objects
    def __lt__(self, other):
        return str(self) < str(other)

    def _attribute_file_open(self, name):
        path = os.path.join(self._path, name)
        mode = stat.S_IMODE(os.stat(path)[stat.ST_MODE])
        r_ok = mode & stat.S_IRGRP
        w_ok = mode & stat.S_IWGRP

        if r_ok and w_ok:
            mode_str = 'r+'
        elif w_ok:
            mode_str = 'w'
        else:
            mode_str = 'r'

        return io.FileIO(path, mode_str)

    def _get_attribute(self, attribute, name):
        """Device attribute getter"""
        try:
            if attribute is None:
                attribute = self._attribute_file_open(name)
            else:
                attribute.seek(0)
            return attribute, attribute.read().strip().decode()
        except Exception as ex:
            self._raise_friendly_access_error(ex, name, None)

    def _set_attribute(self, attribute, name, value):
        """Device attribute setter"""
        try:
            if attribute is None:
                attribute = self._attribute_file_open(name)
            else:
                attribute.seek(0)

            if isinstance(value, str):
                value = value.encode()
            attribute.write(value)
            attribute.flush()
        except Exception as ex:
            self._raise_friendly_access_error(ex, name, value)
        return attribute

    def _raise_friendly_access_error(self, driver_error, attribute, value):
        if not isinstance(driver_error, OSError):
            raise driver_error

        driver_errorno = driver_error.args[0] if is_micropython() else driver_error.errno

        if driver_errorno == errno.EINVAL:
            if attribute == "speed_sp":
                try:
                    max_speed = self.max_speed
                except (AttributeError, Exception):
                    chain_exception(ValueError("The given speed value {} was out of range".format(value)), driver_error)
                else:
                    chain_exception(
                        ValueError("The given speed value {} was out of range. Max speed: +/-{}".format(
                            value, max_speed)), driver_error)
            chain_exception(ValueError("One or more arguments were out of range or invalid, value {}".format(value)),
                            driver_error)
        elif driver_errorno == errno.ENODEV or driver_errorno == errno.ENOENT:
            # We will assume that a file-not-found error is the result of a disconnected device
            # rather than a library error. If that isn't the case, at a minimum the underlying
            # error info will be printed for debugging.
            chain_exception(DeviceNotFound("%s is no longer connected" % self), driver_error)
        raise driver_error

    def get_attr_int(self, attribute, name):
        attribute, value = self._get_attribute(attribute, name)
        return attribute, int(value)

    def get_cached_attr_int(self, filehandle, keyword):
        value = self._attr_cache.get(keyword)

        if value is None:
            (filehandle, value) = self.get_attr_int(filehandle, keyword)
            self._attr_cache[keyword] = value

        return (filehandle, value)

    def set_attr_int(self, attribute, name, value):
        return self._set_attribute(attribute, name, str(int(value)))

    def set_attr_raw(self, attribute, name, value):
        return self._set_attribute(attribute, name, value)

    def get_attr_string(self, attribute, name):
        return self._get_attribute(attribute, name)

    def get_cached_attr_string(self, filehandle, keyword):
        value = self._attr_cache.get(keyword)

        if value is None:
            (filehandle, value) = self.get_attr_string(filehandle, keyword)
            self._attr_cache[keyword] = value

        return (filehandle, value)

    def set_attr_string(self, attribute, name, value):
        return self._set_attribute(attribute, name, value)

    def get_attr_line(self, attribute, name):
        return self._get_attribute(attribute, name)

    def get_attr_set(self, attribute, name):
        attribute, value = self.get_attr_line(attribute, name)
        return attribute, [v.strip('[]') for v in value.split()]

    def get_cached_attr_set(self, filehandle, keyword):
        value = self._attr_cache.get(keyword)

        if value is None:
            (filehandle, value) = self.get_attr_set(filehandle, keyword)
            self._attr_cache[keyword] = value

        return (filehandle, value)

    def get_attr_from_set(self, attribute, name):
        attribute, value = self.get_attr_line(attribute, name)
        for a in value.split():
            v = a.strip('[]')
            if v != a:
                return v
        return ""

    @property
    def device_index(self):
        return self._device_index


def list_devices(class_name, name_pattern, **kwargs):
    """
    This is a generator function that takes same arguments as `Device` class
    and enumerates all devices present in the system that match the provided
    arguments.

    Parameters:
        class_name: class name of the device, a subdirectory of /sys/class.
            For example, 'tacho-motor'.
        name_pattern: pattern that device name should match.
            For example, 'sensor*' or 'motor*'. Default value: '*'.
        keyword arguments: used for matching the corresponding device
            attributes. For example, address='outA', or
            driver_name=['lego-ev3-us', 'lego-nxt-us']. When argument value
            is a list, then a match against any entry of the list is
            enough.
    """
    classpath = abspath(Device.DEVICE_ROOT_PATH + '/' + class_name)

    return (Device(class_name, name, name_exact=True) for name in list_device_names(classpath, name_pattern, **kwargs))
