import copy
import os
from threading import Event

from opentrons import containers, drivers
from opentrons.util import trace
from opentrons.util.vector import Vector
from opentrons.util.log import get_logger
from opentrons.helpers import helpers
from opentrons.util.trace import traceable


log = get_logger(__name__)


class InstrumentMosfet(object):
    """
    Provides access to MagBead's MOSFET.
    """

    def __init__(self, this_robot, mosfet_index):
        self.robot = this_robot
        self.mosfet_index = mosfet_index

    def engage(self):
        """
        Engages the MOSFET.
        """
        self.robot._driver.set_mosfet(self.mosfet_index, True)

    def disengage(self):
        """
        Disengages the MOSFET.
        """
        self.robot._driver.set_mosfet(self.mosfet_index, False)

    def wait(self, seconds):
        """
        Pauses protocol execution.

        Parameters
        ----------
        seconds : int
            Number of seconds to pause for.
        """
        self.robot._driver.wait(seconds)


class InstrumentMotor(object):
    """
    Provides access to Robot's head motor.
    """
    def __init__(self, this_robot, axis):
        self.robot = this_robot
        self.axis = axis

    def move(self, value, mode='absolute'):
        """
        Move plunger motor.

        Parameters
        ----------
        value : int
            A one-dimensional coordinate to move to.
        mode : {'absolute', 'relative'}
        """
        kwargs = {self.axis: value}
        self.robot._driver.move_plunger(
            mode=mode, **kwargs
        )

    def home(self):
        """
        Home plunger motor.
        """
        self.robot._driver.home(self.axis)

    def wait(self, seconds):
        """
        Wait.

        Parameters
        ----------
        seconds : int
            Number of seconds to pause for.
        """
        self.robot._driver.wait(seconds)

    def speed(self, rate):
        """
        Set motor speed.

        Parameters
        ----------
        rate : int
        """
        self.robot._driver.set_plunger_speed(rate, self.axis)
        return self


class Robot(object):
    """
    This class is the main interface to the robot.

    Through this class you can can:
        * define your :class:`opentrons.Deck`
        * :meth:`simulate` the protocol run
        * :meth:`connect` to Opentrons physical robot
        * :meth:`run` the protocol on a robot
        * :meth:`home` axis, move head (:meth:`move_to`)
        * :meth:`pause` and :func:`resume` the protocol run
        * set the :meth:`head_speed` of the robot

    Each Opentrons protocol is a Python script. When evaluated the script
    creates an execution plan which is stored as a list of commands in
    Robot's command queue.

    Here are the typical steps of writing the protocol:
        * Using a Python script and the Opentrons API load your
          containers and define instruments
          (see :class:`~opentrons.instruments.pipette.Pipette`).
        * Call :meth:`reset` to reset the robot's state and command queue.
        * Write your instructions which will get converted
          into an execution plan.
        * Review the list of commands in the robot's queue by running
          :meth:`commands`.
        * Call :func:`simulate` to run the protocol it against a virtual robot.
        * :meth:`connect` to the robot and call :func:`run` it on a real robot.

    See :class:`Pipette` for the list of supported instructions.

    Examples
    --------
    >>> from opentrons import robot, instruments, containers
    >>> robot.reset() # doctest: +ELLIPSIS
    <opentrons.robot.robot.Robot object at ...>
    >>> plate = containers.load('96-flat', 'A1', 'plate')
    >>> p200 = instruments.Pipette(axis='b', max_volume=200)
    >>> p200.aspirate(200, plate[0]) # doctest: +ELLIPSIS
    <opentrons.instruments.pipette.Pipette object at ...>
    >>> robot.commands()
    ['Aspirating 200 at <Deck><Slot A1><Container plate><Well A1>']
    """

    def __init__(self):
        """
        Initializes a robot instance.

        Notes
        -----
        This class is a singleton. That means every time you call
        :func:`__init__` the same instance will be returned. There's
        only once instance of a robot.
        """

        self._commands = None  # []
        self.INSTRUMENT_DRIVERS_CACHE = {}

        self.can_pop_command = Event()
        self.can_pop_command.set()

        self.mode = None
        self.smoothie_drivers = {
            'live': None,
            'simulate': drivers.get_virtual_driver(
                options={'limit_switches': False}
            ),
            'simulate_switches': drivers.get_virtual_driver(
                options={'limit_switches': True}
            )
        }

        null_driver = drivers.get_virtual_driver()

        def _null(*args, **kwargs):
            return

        null_driver.move = _null
        null_driver.home = _null
        self.smoothie_drivers['null'] = null_driver

        self._driver = drivers.get_virtual_driver()
        self.disconnect()
        self.arc_height = 5
        self.cmds_total = None
        self.set_connection('simulate')
        self.reset()

    @helpers.not_app_run_safe
    def reset(self):
        """
        Resets the state of the robot and clears:
            * Deck
            * Instruments
            * Command queue
            * Runtime warnings

        """
        self._commands = []
        self._runtime_warnings = []

        self._previous_container = None

        self._deck = containers.Deck()
        self.setup_deck()

        self._ingredients = {}  # TODO needs to be discusses/researched
        self._instruments = {}

        self.axis_homed = {
            'x': False, 'y': False, 'z': False, 'a': False, 'b': False}

        return self

    def add_instrument(self, axis, instrument):
        """
        Adds instrument to a robot.

        Parameters
        ----------
        axis : str
            Specifies which axis the instruments is attached to.
        instrument : Instrument
            An instance of a :class:`Pipette` to attached to the axis.

        Notes
        -----
        A canonical way to add to add a Pipette to a robot is:

        ::

            from opentrons.instruments.pipette import Pipette
            p200 = Pipette(axis='a')

        This will create a pipette and call :func:`add_instrument`
        to attach the instrument.
        """
        axis = axis.upper()
        self._instruments[axis] = instrument

    def add_warning(self, warning_msg):
        """
        Internal. Add a runtime warning to the queue.
        """
        self._runtime_warnings.append(warning_msg)

    def get_warnings(self):
        """
        Get current runtime warnings.

        Returns
        -------

        Runtime warnings accumulated since the last :func:`run`
        or :func:`simulate`.
        """
        return list(self._runtime_warnings)

    def get_mosfet(self, mosfet_index):
        """
        Get MOSFET for a MagBead (URL).

        Parameters
        ----------
        mosfet_index : int
            Number of a MOSFET on MagBead.

        Returns
        -------
        Instance of :class:`InstrumentMosfet`.
        """
        instr_type = 'mosfet'
        key = (instr_type, mosfet_index)

        motor_obj = self.INSTRUMENT_DRIVERS_CACHE.get(key)
        if not motor_obj:
            motor_obj = InstrumentMosfet(self, mosfet_index)
            self.INSTRUMENT_DRIVERS_CACHE[key] = motor_obj
        return motor_obj

    def get_motor(self, axis):
        """
        Get robot's head motor.

        Parameters
        ----------
        axis : {'a', 'b'}
            Axis name. Please check stickers on robot's gantry for the name.
        """
        instr_type = 'instrument'
        key = (instr_type, axis)

        motor_obj = self.INSTRUMENT_DRIVERS_CACHE.get(key)
        if not motor_obj:
            motor_obj = InstrumentMotor(self, axis)
            self.INSTRUMENT_DRIVERS_CACHE[key] = motor_obj
        return motor_obj

    def flip_coordinates(self, coordinates):
        """
        Flips between Deck and Robot coordinate systems.

        TODO: Add image explaining coordinate systems.
        """
        dimensions = self._driver.get_dimensions()
        return helpers.flip_coordinates(coordinates, dimensions)

    @helpers.not_app_run_safe
    def connect(self, port=None, options=None):
        """
        Connects the robot to a serial port.

        Parameters
        ----------
        port : str
            OS-specific port name or ``'Virtual Smoothie'``
        options : dict
            if :attr:`port` is set to ``'Virtual Smoothie'``, provide
            the list of options to be passed to :func:`get_virtual_device`

        Returns
        -------
        ``True`` for success, ``False`` for failure.
        """
        device = None
        if not port or port == drivers.VIRTUAL_SMOOTHIE_PORT:
            device = drivers.get_virtual_driver(options)
        else:
            device = drivers.get_serial_driver(port)

        self._driver = device
        self.smoothie_drivers['live'] = device

        # set virtual smoothie do have same dimensions as real smoothie
        ot_v = device.ot_version
        self.smoothie_drivers['simulate'].ot_version = ot_v
        self.smoothie_drivers['simulate_switches'].ot_version = ot_v
        self.smoothie_drivers['null'].ot_version = ot_v

    def _update_axis_homed(self, *args):
        for a in args:
            for letter in a:
                if letter.lower() in self.axis_homed:
                    self.axis_homed[letter.lower()] = True

    def home(self, *args, **kwargs):
        """
        Home robot's head and plunger motors.

        Parameters
        ----------
        *args :
            A string with axes to home. For example ``'xyz'`` or ``'ab'``.

            If no arguments provided home Z-axis then X, Y, B, A

        Notes
        -----
        Sometimes while executing a long protocol,
        a robot might accumulate precision
        error and it is recommended to home it. In this scenario, add
        ``robot.home('xyzab')`` into your script.

        Examples
        --------
        >>> from opentrons import Robot
        >>> robot.connect('Virtual Smoothie')
        >>> robot.home()
        """
        self._driver.calm_down()
        if args:
            self._update_axis_homed(*args)
            self._driver.home(*args)
        else:
            self._update_axis_homed('xyzab')
            self._driver.home('z')
            self._driver.home('x', 'y', 'b', 'a')

    def add_command(self, command):
        if self.mode == 'live':
            cmd_run_event = {'caller': 'ui'}
            cmd_run_event['mode'] = 'live'
            cmd_run_event['name'] = 'command-run'
            cmd_run_event.update({
                'command_description': command,
                'command_index': len(self._commands),
                'commands_total': self.cmds_total
            })
            trace.EventBroker.get_instance().notify(cmd_run_event)
        self._commands.append(command)

    @helpers.not_app_run_safe
    def move_head(self, *args, **kwargs):
        self._driver.move_head(*args, **kwargs)

    @helpers.not_app_run_safe
    def move_plunger(self, *args, **kwargs):
        self._driver.move_plunger(*args, **kwargs)

    def head_speed(self, *args, **kwargs):
        """
        Set the XY axis speeds of the robot, set in millimeters per minute

        Parameters
        ----------
        rate : int
            An integer setting the mm/minute rate of the X and Y axis.
            Speeds too fast (around 6000 and higher) will cause the robot
            to skip step, be careful when using this method

        Examples
        --------
        >>> from opentrons import robot
        >>> robot.connect('Virtual Smoothie')
        >>> robot.home()
        >>> robot.head_speed(4500)
        >>> robot.move_head(x=200, y=200)
        """
        self._driver.set_speed(*args, **kwargs)

    @traceable('move-to')
    def move_to(self, location, instrument=None, strategy='arc', **kwargs):
        """
        Move an instrument to a coordinate, container or a coordinate within
        a container.

        Parameters
        ----------
        location : one of the following:
            1. :class:`Placeable` (i.e. Container, Deck, Slot, Well) — will
            move to the origin of a container.
            2. :class:`Vector` move to the given coordinate in Deck coordinate
            system.
            3. (:class:`Placeable`, :class:`Vector`) move to a given coordinate
            within object's coordinate system.

        instrument :
            Instrument to move relative to. If ``None``, move relative to the
            center of a gantry.

        strategy : {'arc', 'direct'}
            ``arc`` : move to the point using arc trajectory
            avoiding obstacles.

            ``direct`` : move to the point in a straight line.

        Examples
        --------
        >>> from opentrons import Robot
        >>> robot.reset() # doctest: +ELLIPSIS
        <opentrons.robot.robot.Robot object at ...>
        >>> robot.connect('Virtual Smoothie')
        >>> robot.home()
        >>> plate = robot.add_container('96-flat', 'A1', 'plate')
        >>> robot.move_to(plate[0])
        >>> robot.move_to(plate[0].top())
        """

        placeable, coordinates = containers.unpack_location(location)

        if instrument:
            coordinates = instrument.calibrator.convert(
                placeable,
                coordinates)
        else:
            coordinates += placeable.coordinates(placeable.get_deck())

        if strategy == 'arc':
            arc_coords = self._create_arc(coordinates, placeable, instrument)
            for coord in arc_coords:
                self._driver.move_head(**coord)
        elif strategy == 'direct':
            self._driver.move_head(
                x=coordinates[0],
                y=coordinates[1],
                z=coordinates[2]
            )
        else:
            raise RuntimeError(
                'Unknown move strategy: {}'.format(strategy))

    def _calibrated_max_dimension(self, container=None, instrument=None):
        """
        Returns a Vector, each axis being the calibrated maximum
        for all instruments
        """
        if not self._instruments or not self.containers():
            if container:
                return container.max_dimensions(self._deck)
            return self._deck.max_dimensions(self._deck)

        def _max_per_instrument(placeable, inst):
            """
            Returns list of Vectors, one for each Instrument's farthest
            calibrated coordinate for the supplied placeable
            """
            if inst:
                return [
                    instrument.calibrator.convert(
                        placeable,
                        placeable.max_dimensions(placeable)
                    )
                ]
            return [
                instrument.calibrator.convert(
                    placeable,
                    placeable.max_dimensions(placeable)
                )
                for instrument in self._instruments.values()
            ]

        container_max_coords = []
        if container:
            container_max_coords = _max_per_instrument(container, instrument)
        else:
            for c in self.containers().values():
                container_max_coords += _max_per_instrument(c, instrument)

        max_coords = [
            max(
                container_max_coords,
                key=lambda coordinates: coordinates[axis]
            )[axis]
            for axis in range(3)
        ]

        return Vector(max_coords)

    def _create_arc(self, destination, placeable=None, instrument=None):
        """
        Returns a list of coordinates to arrive to the destination coordinate
        """
        this_container = None
        if isinstance(placeable, containers.Well):
            this_container = placeable.get_parent()
        elif isinstance(placeable, containers.WellSeries):
            this_container = placeable.get_parent()
        elif isinstance(placeable, containers.Container):
            this_container = placeable

        ref_container = None
        if this_container and (self._previous_container == this_container):
            ref_container = this_container

        _, _, tallest_z = self._calibrated_max_dimension(
            ref_container, instrument)
        tallest_z += self.arc_height

        _, _, robot_max_z = self._driver.get_dimensions()
        arc_top = min(tallest_z, robot_max_z)
        arrival_z = min(destination[2], robot_max_z)

        self._previous_container = this_container

        return [
            {'z': arc_top},
            {'x': destination[0], 'y': destination[1]},
            {'z': arrival_z}
        ]

    @property
    def actions(self):
        """
        Return a copy of a raw list of commands in the Robot's queue.
        """
        return copy.deepcopy(self._commands)

    def prepare_for_run(self):
        """
        Internal. Prepare for a Robot's run.
        """
        if not self._driver.is_connected():
            raise RuntimeWarning('Please connect to the robot')

        self._runtime_warnings = []

        if not self._instruments:
            self.add_warning('No instruments added to robot')
        if not self._commands:
            self.add_warning('No commands added to robot')

        for instrument in self._instruments.values():
            instrument.reset()

    def set_connection(self, mode):
        self.mode = mode
        if mode not in self.smoothie_drivers:
            raise ValueError(
                'mode expected to be "live", "simulate_switches", '
                '"null" or "simulate", {} provided'.format(mode)
            )

        d = self.smoothie_drivers[mode]

        # set VirtualSmoothie's coordinates to be the same as physical robot
        if d and d.is_simulating():
            if self._driver and self._driver.is_connected():
                d.connection.serial_port.set_position_from_arguments({
                    ax.upper(): val
                    for ax, val in self._driver.get_current_position().items()
                })

        self._driver = d
        if self._driver and not self._driver.is_connected():
            self._driver.toggle_port()

    @helpers.not_app_run_safe
    def disconnect(self):
        """
        Disconnects from the robot.
        """
        if self._driver:
            self._driver.disconnect()

        self.axis_homed = {
            'x': False, 'y': False, 'z': False, 'a': False, 'b': False}

    def containers(self):
        """
        Returns the dict with all of the containers on the deck.
        """
        return self._deck.containers()

    def get_deck_slot_types(self):
        return 'slots'

    def get_slot_offsets(self):
        """
        col_offset
        - from bottom left corner of A to bottom corner of B

        row_offset
        - from bottom left corner of 1 to bottom corner of 2

        TODO: figure out actual X and Y offsets (from origin)
        """
        SLOT_OFFSETS = {
            'slots': {
                'x_offset': 10,
                'y_offset': 10,
                'col_offset': 91,
                'row_offset': 134.5
            }
        }
        slot_settings = SLOT_OFFSETS.get(self.get_deck_slot_types())
        row_offset = slot_settings.get('row_offset')
        col_offset = slot_settings.get('col_offset')
        x_offset = slot_settings.get('x_offset')
        y_offset = slot_settings.get('y_offset')
        return (row_offset, col_offset, x_offset, y_offset)

    def get_max_robot_rows(self):
        # TODO: dynamically figure out robot rows
        return 3

    def setup_deck(self):
        robot_rows = self.get_max_robot_rows()
        row_offset, col_offset, x_offset, y_offset = self.get_slot_offsets()

        for col_index, col in enumerate('ABCDE'):
            for row_index, row in enumerate(range(1, robot_rows + 1)):
                properties = {
                    'width': col_offset,
                    'length': row_offset,
                    'height': 0
                }
                slot = containers.Slot(properties=properties)
                slot_coordinates = (
                    (col_offset * col_index) + x_offset,
                    (row_offset * row_index) + y_offset,
                    0  # TODO: should z always be zero?
                )
                slot_name = "{}{}".format(col, row)
                self._deck.add(slot, slot_name, slot_coordinates)

    @property
    def deck(self):
        return self._deck

    def get_instruments_by_name(self, name):
        res = []
        for k, v in self.get_instruments():
            if v.name == name:
                res.append((k, v))

        return res

    def get_instruments(self, name=None):
        """
        :returns: sorted list of (axis, instrument)
        """
        if name:
            return self.get_instruments_by_name(name)

        return sorted(
            self._instruments.items(), key=lambda s: s[0].lower())

    def get_containers(self):
        """
        Returns the list of the containers on the deck.
        """
        return sorted(
            self._deck.containers().items(), key=lambda s: s[0].lower())

    def add_container(self, container_name, slot, label=None):
        if not label:
            label = container_name
        container = containers.get_persisted_container(container_name)
        container.properties['type'] = container_name
        self._deck[slot].add(container, label)

        # if a container is added to Deck AFTER a Pipette, the Pipette's
        # Calibrator must update to include all children of Deck
        for _, instr in self.get_instruments():
            if hasattr(instr, 'update_calibrator'):
                instr.update_calibrator()
        return container

    def clear_commands(self):
        """
        Clear Robot's command queue.
        """
        self._previous_container = None
        self._commands = []

    def pause(self):
        """
        Pauses execution of the protocol. Use :meth:`resume` to resume
        """
        self.can_pop_command.clear()
        self._driver.pause()

    def stop(self):
        """
        Stops execution of the protocol.
        """
        self._driver.stop()
        self.can_pop_command.set()

    def resume(self):
        """
        Resume execution of the protocol after :meth:`pause`
        """
        self.can_pop_command.set()
        self._driver.resume()

    def halt(self):
        """
        Stops execution of both the protocol and the Smoothie board immediately
        """
        self._driver.halt()
        self.can_pop_command.set()

    def get_serial_ports_list(self):
        ports = []
        # TODO: Store these settings in config
        if os.environ.get('ENABLE_VIRTUAL_SMOOTHIE', '').lower() == 'true':
            ports = [drivers.VIRTUAL_SMOOTHIE_PORT]
        ports.extend(drivers.get_serial_ports_list())
        return ports

    def is_connected(self):
        if not self._driver:
            return False
        return self._driver.is_connected()

    def is_simulating(self):
        if not self._driver:
            return False
        return self._driver.is_simulating()

    def get_connected_port(self):
        return self._driver.get_connected_port()

    def versions(self):
        # TODO: Store these versions in config
        compatible = self._driver.versions_compatible()
        return {
            'firmware': {
                'version': self._driver.get_firmware_version(),
                'compatible': compatible['firmware']
            },
            'config': {
                'version': self._driver.get_config_version(),
                'compatible': compatible['config']
            },
            'ot_version': {
                'version': self._driver.get_ot_version(),
                'compatible': compatible['ot_version']
            }
        }

    def diagnostics(self):
        """
        Access diagnostics information for the robot.

        Returns
        -------
        Dictionary with the following keys:
            * ``axis_homed`` — axis that are currently in home position.
            * ``switches`` — end stop switches currently hit.
            * ``steps_per_mm`` — steps per millimeter calibration
            values for ``x`` and ``y`` axis.
        """
        # TODO: Store these versions in config
        return {
            'axis_homed': self.axis_homed,
            'switches': self._driver.get_endstop_switches(),
            'steps_per_mm': {
                'x': self._driver.get_steps_per_mm('x'),
                'y': self._driver.get_steps_per_mm('y')
            }
        }

    def commands(self):
        """
        Access the human-readable list of commands in the robot's queue.

        Returns
        -------
        A list of string values for each command in the queue, for example:

        ``'Aspirating 200uL at <Deck>/<Slot A1>/<Container plate>/<Well A1>'``
        """
        return self._commands

    def comment(self, msg):
        self.add_command(msg)
