#!/usr/bin/env python3
""" Editor objects for the manual adjustments tool """

import gettext
import logging
import tkinter as tk

from collections import OrderedDict

import numpy as np

from lib.gui.control_helper import ControlPanelOption

logger = logging.getLogger(__name__)  # pylint: disable=invalid-name

# LOCALES
_LANG = gettext.translation("tools.manual", localedir="locales", fallback=True)
_ = _LANG.gettext


class Editor():
    """ Parent Class for Object Editors.

    Editors allow the user to use a variety of tools to manipulate alignments from the main
    display frame.

    Parameters
    ----------
    canvas: :class:`tkinter.Canvas`
        The canvas that holds the image and annotations
    detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces`
        The _detected_faces data for this manual session
    control_text: str
        The text that is to be displayed at the top of the Editor's control panel.
    """
    def __init__(self, canvas, detected_faces, control_text="", key_bindings=None):
        logger.debug("Initializing %s: (canvas: '%s', detected_faces: %s, control_text: %s)",
                     self.__class__.__name__, canvas, detected_faces, control_text)
        self.zoomed_centering = "face"  # Override for different zoomed centering per editor
        self._canvas = canvas
        self._globals = canvas._globals
        self._det_faces = detected_faces

        self._current_color = dict()
        self._actions = OrderedDict()
        self._controls = dict(header=control_text, controls=[])
        self._add_key_bindings(key_bindings)

        self._add_actions()
        self._add_controls()
        self._add_annotation_format_controls()

        self._mouse_location = None
        self._drag_data = dict()
        self._drag_callback = None
        self.bind_mouse_motion()
        logger.debug("Initialized %s", self.__class__.__name__)

    @property
    def _default_colors(self):
        """ dict: The default colors for each annotation """
        return {"BoundingBox": "#0000ff",
                "ExtractBox": "#00ff00",
                "Landmarks": "#ff00ff",
                "Mask": "#ff0000",
                "Mesh": "#00ffff"}

    @property
    def _is_active(self):
        """ bool: ``True`` if this editor is currently active otherwise ``False``.

        Notes
        -----
        When initializing, the active_editor parameter will not be set in the parent,
        so return ``False`` in this instance
        """
        return hasattr(self._canvas, "active_editor") and self._canvas.active_editor == self

    @property
    def view_mode(self):
        """ ["frame", "face"]: The view mode for the currently selected editor. If the editor does
        not have a view mode that can be updated, then `"frame"` will be returned. """
        tk_var = self._actions.get("magnify", dict()).get("tk_var", None)
        retval = "frame" if tk_var is None or not tk_var.get() else "face"
        return retval

    @property
    def _zoomed_roi(self):
        """ :class:`numpy.ndarray`: The (`left`, `top`, `right`, `bottom`) roi of the zoomed face
        in the display frame. """
        half_size = min(self._globals.frame_display_dims) / 2
        left = self._globals.frame_display_dims[0] / 2 - half_size
        top = 0
        right = self._globals.frame_display_dims[0] / 2 + half_size
        bottom = self._globals.frame_display_dims[1]
        retval = np.rint(np.array((left, top, right, bottom))).astype("int32")
        logger.trace("Zoomed ROI: %s", retval)
        return retval

    @property
    def _zoomed_dims(self):
        """ tuple: The (`width`, `height`) of the zoomed ROI. """
        roi = self._zoomed_roi
        return (roi[2] - roi[0], roi[3] - roi[1])

    @property
    def _control_vars(self):
        """ dict: The tk control panel variables for the currently selected editor. """
        return self._canvas.control_tk_vars.get(self.__class__.__name__, dict())

    @property
    def controls(self):
        """ dict: The control panel options and header text for the current editor """
        return self._controls

    @property
    def _control_color(self):
        """ str: The hex color code set in the control panel for the current editor. """
        annotation = self.__class__.__name__
        return self._annotation_formats[annotation]["color"].get()

    @property
    def _annotation_formats(self):
        """ dict: The format (color, opacity etc.) of each editor's annotation display. """
        return self._canvas.annotation_formats

    @property
    def actions(self):
        """ list: The optional action buttons for the actions frame in the GUI for the
        current editor """
        return self._actions

    @property
    def _face_iterator(self):
        """ list: The detected face objects to be iterated. This will either be all faces in the
        frame (normal view) or the single zoomed in face (zoom mode). """
        if self._globals.frame_index == -1:
            faces = []
        else:
            faces = self._det_faces.current_faces[self._globals.frame_index]
            faces = ([faces[self._globals.face_index]]
                     if self._globals.is_zoomed and faces else faces)
        return faces

    def _add_key_bindings(self, key_bindings):
        """ Add the editor specific key bindings for the currently viewed editor.

        Parameters
        ----------
        key_bindings: dict
            The key binding to method dictionary for this editor.
        """
        if key_bindings is None:
            return
        for key, method in key_bindings.items():
            logger.debug("Binding key '%s' to method %s for editor '%s'",
                         key, method, self.__class__.__name__)
            self._canvas.key_bindings.setdefault(key, dict())["bound_to"] = None
            self._canvas.key_bindings[key][self.__class__.__name__] = method

    @staticmethod
    def _get_anchor_points(bounding_box):
        """ Retrieve the (x, y) co-ordinates for each of the 4 corners of a bounding box's anchors
        for both the displayed anchors and the anchor grab locations.

        Parameters
        ----------
        bounding_box: tuple
            The (`top-left`, `top-right`, `bottom-right`, `bottom-left`) (x, y) coordinates of the
            bounding box

        Returns
            display_anchors: tuple
                The (`top`, `left`, `bottom`, `right`) co-ordinates for each circle at each point
                of the bounding box corners, sized for display
            grab_anchors: tuple
                The (`top`, `left`, `bottom`, `right`) co-ordinates for each circle at each point
                of the bounding box corners, at a larger size for grabbing with a mouse
        """
        radius = 3
        grab_radius = radius * 3
        display_anchors = tuple((cnr[0] - radius, cnr[1] - radius,
                                 cnr[0] + radius, cnr[1] + radius)
                                for cnr in bounding_box)
        grab_anchors = tuple((cnr[0] - grab_radius, cnr[1] - grab_radius,
                              cnr[0] + grab_radius, cnr[1] + grab_radius)
                             for cnr in bounding_box)
        return display_anchors, grab_anchors

    def update_annotation(self):  # pylint:disable=no-self-use
        """ Update the display annotations for the current objects.

        Override for specific editors.
        """
        logger.trace("Default annotations. Not storing Objects")

    def hide_annotation(self, tag=None):
        """ Hide annotations for this editor.

        Parameters
        ----------
        tag: str, optional
            The specific tag to hide annotations for. If ``None`` then all annotations for this
            editor are hidden, otherwise only the annotations specified by the given tag are
            hidden. Default: ``None``
        """
        tag = self.__class__.__name__ if tag is None else tag
        logger.trace("Hiding annotations for tag: %s", tag)
        self._canvas.itemconfig(tag, state="hidden")

    def _object_tracker(self, key, object_type, face_index,
                        coordinates, object_kwargs):
        """ Create an annotation object and add it to :attr:`_objects` or update an existing
        annotation if it has already been created.

        Parameters
        ----------
        key: str
            The key for this annotation in :attr:`_objects`
        object_type: str
            This can be any string that is a natural extension to :class:`tkinter.Canvas.create_`
        face_index: int
            The index of the face within the current frame
        coordinates: tuple or list
            The bounding box coordinates for this object
        object_kwargs: dict
            The keyword arguments for this object

        Returns
        -------
        int:
            The tkinter canvas item identifier for the created object
        """
        object_color_keys = self._get_object_color_keys(key, object_type)
        tracking_id = "_".join((key, str(face_index)))
        face_tag = "face_{}".format(face_index)
        face_objects = set(self._canvas.find_withtag(face_tag))
        annotation_objects = set(self._canvas.find_withtag(key))
        existing_object = tuple(face_objects.intersection(annotation_objects))
        if not existing_object:
            item_id = self._add_new_object(key,
                                           object_type,
                                           face_index,
                                           coordinates,
                                           object_kwargs)
            update_color = bool(object_color_keys)
        else:
            item_id = existing_object[0]
            update_color = self._update_existing_object(
                existing_object[0],
                coordinates,
                object_kwargs,
                tracking_id,
                object_color_keys)
        if update_color:
            self._current_color[tracking_id] = object_kwargs[object_color_keys[0]]
        return item_id

    @staticmethod
    def _get_object_color_keys(key, object_type):
        """ The canvas object's parameter that needs to be adjusted for color varies based on
        the type of object that is being used. Returns the correct parameter based on object.

        Parameters
        ----------
        key: str
            The key for this annotation's tag creation
        object_type: str
            This can be any string that is a natural extension to :class:`tkinter.Canvas.create_`

        Returns
        -------
        list:
            The list of keyword arguments for this objects color parameter(s) or an empty list
            if it is not relevant for this object
        """
        if object_type in ("line", "text"):
            retval = ["fill"]
        elif object_type == "image":
            retval = []
        elif object_type == "oval" and key.startswith("lm_dsp_"):
            retval = ["fill", "outline"]
        else:
            retval = ["outline"]
        logger.trace("returning %s for key: %s, object_type: %s", retval, key, object_type)
        return retval

    def _add_new_object(self, key, object_type, face_index, coordinates, object_kwargs):
        """ Add a new object to the canvas.

        Parameters
        ----------
        key: str
            The key for this annotation's tag creation
        object_type: str
            This can be any string that is a natural extension to :class:`tkinter.Canvas.create_`
        face_index: int
            The index of the face within the current frame
        coordinates: tuple or list
            The bounding box coordinates for this object
        object_kwargs: dict
            The keyword arguments for this object

        Returns
        -------
        int:
            The tkinter canvas item identifier for the created object
        """
        logger.debug("Adding object: (key: '%s', object_type: '%s', face_index: %s, "
                     "coordinates: %s, object_kwargs: %s)", key, object_type, face_index,
                     coordinates, object_kwargs)
        object_kwargs["tags"] = self._set_object_tags(face_index, key)
        item_id = getattr(self._canvas,
                          "create_{}".format(object_type))(*coordinates, **object_kwargs)
        return item_id

    def _set_object_tags(self, face_index, key):
        """ Create the tkinter object tags for the incoming object.

        Parameters
        ----------
        face_index: int
            The face index within the current frame for the face that tags are being created for
        key: str
            The base tag for this object, for which additional tags will be generated

        Returns
        -------
        list
            The generated tags for the current object
        """
        tags = ["face_{}".format(face_index),
                self.__class__.__name__,
                "{}_face_{}".format(self.__class__.__name__, face_index),
                key,
                "{}_face_{}".format(key, face_index)]
        if "_" in key:
            split_key = key.split("_")
            if split_key[-1].isdigit():
                base_tag = "_".join(split_key[:-1])
                tags.append(base_tag)
                tags.append("{}_face_{}".format(base_tag, face_index))
        return tags

    def _update_existing_object(self, item_id, coordinates, object_kwargs,
                                tracking_id, object_color_keys):
        """ Update an existing tracked object.

        Parameters
        ----------
        item_id: int
            The canvas object item_id to be updated
        coordinates: tuple or list
            The bounding box coordinates for this object
        object_kwargs: dict
            The keyword arguments for this object
        tracking_id: str
            The tracking identifier for this object's color
        object_color_keys: list
            The list of keyword arguments for this object to update for color

        Returns
        -------
        bool
            ``True`` if :attr:`_current_color` should be updated otherwise ``False``
        """
        update_color = (object_color_keys and
                        object_kwargs[object_color_keys[0]] != self._current_color[tracking_id])
        update_kwargs = dict(state=object_kwargs.get("state", "normal"))
        if update_color:
            for key in object_color_keys:
                update_kwargs[key] = object_kwargs[object_color_keys[0]]
        if self._canvas.type(item_id) == "image" and "image" in object_kwargs:
            update_kwargs["image"] = object_kwargs["image"]
        logger.trace("Updating coordinates: (item_id: '%s', object_kwargs: %s, "
                     "coordinates: %s, update_kwargs: %s", item_id, object_kwargs,
                     coordinates, update_kwargs)
        self._canvas.itemconfig(item_id, **update_kwargs)
        self._canvas.coords(item_id, *coordinates)
        return update_color

    # << MOUSE CALLBACKS >>
    # Mouse cursor display
    def bind_mouse_motion(self):
        """ Binds the mouse motion for the current editor's mouse <Motion> event to the editor's
        :func:`_update_cursor` function.

        Called on initialization and active editor update.
        """
        self._canvas.bind("<Motion>", self._update_cursor)

    def _update_cursor(self, event):  # pylint: disable=unused-argument
        """ The mouse cursor display as bound to the mouse's <Motion> event..

        The default is to always return a standard cursor, so this method should be overridden for
        editor specific cursor update.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event. Unused for default tracking, but available for specific editor
            tracking.
        """
        self._canvas.config(cursor="")

    # Mouse click and drag actions
    def set_mouse_click_actions(self):
        """ Add the bindings for left mouse button click and drag actions.

        This binds the mouse to the :func:`_drag_start`, :func:`_drag` and :func:`_drag_stop`
        methods.

        By default these methods do nothing (except for :func:`_drag_stop` which resets
        :attr:`_drag_data`.

        This bindings should be added for all editors. To add additional bindings,
        `super().set_mouse_click_actions` should be called prior to adding them..
        """
        logger.debug("Setting mouse bindings")
        self._canvas.bind("<ButtonPress-1>", self._drag_start)
        self._canvas.bind("<ButtonRelease-1>", self._drag_stop)
        self._canvas.bind("<B1-Motion>", self._drag)

    def _drag_start(self, event):  # pylint:disable=unused-argument
        """ The action to perform when the user starts clicking and dragging the mouse.

        The default does nothing except reset the attr:`drag_data` and attr:`drag_callback`.
        Override for Editor specific click and drag start actions.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event. Unused but for default action, but available for editor
            specific actions
        """
        self._drag_data = dict()
        self._drag_callback = None

    def _drag(self, event):
        """ The default callback for the drag part of a mouse click and drag action.

        :attr:`_drag_callback` should be set in :func:`self._drag_start`. This callback will then
        be executed on a mouse drag event.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event.
        """
        if self._drag_callback is None:
            return
        self._drag_callback(event)

    def _drag_stop(self, event):  # pylint:disable=unused-argument
        """ The action to perform when the user stops clicking and dragging the mouse.

        Default is to set :attr:`_drag_data` to `dict`. Override for Editor specific stop actions.

        Parameters
        ----------
        event: :class:`tkinter.Event`
            The tkinter mouse event. Unused but required
        """
        self._drag_data = dict()

    def _scale_to_display(self, points):
        """ Scale and offset the given points to the current display scale and offset values.

        Parameters
        ----------
        points: :class:`numpy.ndarray`
            Array of x, y co-ordinates to adjust

        Returns
        -------
        :class:`numpy.ndarray`
            The adjusted x, y co-ordinates for display purposes rounded to the nearest integer
        """
        retval = np.rint((points * self._globals.current_frame["scale"])
                         + self._canvas.offset).astype("int32")
        logger.trace("Original points: %s, scaled points: %s", points, retval)
        return retval

    def scale_from_display(self, points, do_offset=True):
        """ Scale and offset the given points from the current display to the correct original
        values.

        Parameters
        ----------
        points: :class:`numpy.ndarray`
            Array of x, y co-ordinates to adjust
        offset: bool, optional
            ``True`` if the offset should be calculated otherwise ``False``. Default: ``True``

        Returns
        -------
        :class:`numpy.ndarray`
            The adjusted x, y co-ordinates to the original frame location rounded to the nearest
            integer
        """
        offset = self._canvas.offset if do_offset else (0, 0)
        retval = np.rint((points - offset) / self._globals.current_frame["scale"]).astype("int32")
        logger.trace("Original points: %s, scaled points: %s", points, retval)
        return retval

    # << ACTION CONTROL PANEL OPTIONS >>
    def _add_actions(self):
        """ Add the Action buttons for this editor's optional left hand side action sections.

        The default does nothing. Override for editor specific actions.
        """
        self._actions = self._actions

    def _add_action(self, title, icon, helptext, group=None, hotkey=None):
        """ Add an action dictionary to :attr:`_actions`. This will create a button in the optional
        actions frame to the left hand side of the frames viewer.

        Parameters
        ----------
        title: str
            The title of the action to be generated
        icon: str
            The name of the icon that is used to display this action's button
        helptext: str
            The tooltip text to display for this action
        group: str, optional
            If a group is passed in, then any buttons belonging to that group will be linked (i.e.
            only one button can be active at a time.). If ``None`` is passed in then the button
            will act independently. Default: ``None``
        hotkey: str, optional
            The hotkey binding for this action. Set to ``None`` if there is no hotkey binding.
            Default: ``None``
        """
        var = tk.BooleanVar()
        action = dict(icon=icon, helptext=helptext, group=group, tk_var=var, hotkey=hotkey)
        logger.debug("Adding action: %s", action)
        self._actions[title] = action

    def _add_controls(self):
        """ Add the controls for this editor's control panel.

        The default does nothing. Override for editor specific controls.
        """
        self._controls = self._controls

    def _add_control(self, option, global_control=False):
        """ Add a control panel control to :attr:`_controls` and add a trace to the variable
        to update display.

        Parameters
        ----------
        option: :class:`lib.gui.control_helper.ControlPanelOption'
            The control panel option to add to this editor's control
        global_control: bool, optional
            Whether the given control is a global control (i.e. annotation formatting).
            Default: ``False``
        """
        self._controls["controls"].append(option)
        if global_control:
            logger.debug("Added global control: '%s' for editor: '%s'",
                         option.title, self.__class__.__name__)
            return
        logger.debug("Added local control: '%s' for editor: '%s'",
                     option.title, self.__class__.__name__)
        editor_key = self.__class__.__name__
        group_key = option.group.replace(" ", "").lower()
        group_key = "none" if group_key == "_master" else group_key
        annotation_key = option.title.replace(" ", "")
        self._canvas.control_tk_vars.setdefault(
            editor_key, dict()).setdefault(group_key, dict())[annotation_key] = option.tk_var

    def _add_annotation_format_controls(self):
        """ Add the annotation display (color/size) controls to :attr:`_annotation_formats`.

        These should be universal and available for all editors.
        """
        editors = ("Bounding Box", "Extract Box", "Landmarks", "Mask", "Mesh")
        if not self._annotation_formats:
            opacity = ControlPanelOption("Mask Opacity",
                                         int,
                                         group="Color",
                                         min_max=(0, 100),
                                         default=40,
                                         rounding=1,
                                         helptext="Set the mask opacity")
            for editor in editors:
                annotation_key = editor.replace(" ", "")
                logger.debug("Adding to global format controls: '%s'", editor)
                colors = ControlPanelOption(editor,
                                            str,
                                            group="Color",
                                            subgroup="colors",
                                            choices="colorchooser",
                                            default=self._default_colors[annotation_key],
                                            helptext="Set the annotation color")
                colors.set(self._default_colors[annotation_key])
                self._annotation_formats.setdefault(annotation_key, dict())["color"] = colors
                self._annotation_formats[annotation_key]["mask_opacity"] = opacity

        for editor in editors:
            annotation_key = editor.replace(" ", "")
            for group, ctl in self._annotation_formats[annotation_key].items():
                logger.debug("Adding global format control to editor: (editor:'%s', group: '%s')",
                             editor, group)
                self._add_control(ctl, global_control=True)


class View(Editor):
    """ The view Editor.

    Does not allow any editing, just used for previewing annotations.

    This is the default start-up editor.

    Parameters
    ----------
    canvas: :class:`tkinter.Canvas`
        The canvas that holds the image and annotations
    detected_faces: :class:`~tools.manual.detected_faces.DetectedFaces`
        The _detected_faces data for this manual session
    """
    def __init__(self, canvas, detected_faces):
        control_text = "Viewer\nPreview the frame's annotations."
        super().__init__(canvas, detected_faces, control_text)

    def _add_actions(self):
        """ Add the optional action buttons to the viewer. Current actions are Zoom. """
        self._add_action("magnify", "zoom", _("Magnify/Demagnify the View"),
                         group=None, hotkey="M")
        self._actions["magnify"]["tk_var"].trace("w", lambda *e: self._globals.tk_update.set(True))
