import uuid
from importlib import import_module
import os
import numbers

try:
    from urllib import parse
except ImportError:
    from urlparse import urlparse as parse

import ipywidgets as widgets
from traitlets import List, Unicode, Dict, observe, Integer
from .basedatatypes import BaseFigure, BasePlotlyType
from .callbacks import BoxSelector, LassoSelector, InputDeviceState, Points
from .serializers import custom_serializers
from .version import __frontend_version__


@widgets.register
class BaseFigureWidget(BaseFigure, widgets.DOMWidget):
    """
    Base class for FigureWidget. The FigureWidget class is code-generated as a
    subclass
    """

    # Widget Traits
    # -------------
    # Widget traitlets are automatically synchronized with the FigureModel
    # JavaScript object
    _view_name = Unicode("FigureView").tag(sync=True)
    _view_module = Unicode("jupyterlab-plotly").tag(sync=True)
    _view_module_version = Unicode(__frontend_version__).tag(sync=True)

    _model_name = Unicode("FigureModel").tag(sync=True)
    _model_module = Unicode("jupyterlab-plotly").tag(sync=True)
    _model_module_version = Unicode(__frontend_version__).tag(sync=True)

    # ### _data and _layout ###
    # These properties store the current state of the traces and
    # layout as JSON-style dicts. These dicts do not store any subclasses of
    # `BasePlotlyType`
    #
    # Note: These are only automatically synced with the frontend on full
    # assignment, not on mutation. We use this fact to only directly sync
    # them to the front-end on FigureWidget construction. All other updates
    # are made using mutation, and they are manually synced to the frontend
    # using the relayout/restyle/update/etc. messages.
    _layout = Dict().tag(sync=True, **custom_serializers)
    _data = List().tag(sync=True, **custom_serializers)
    _config = Dict().tag(sync=True, **custom_serializers)

    # ### Python -> JS message properties ###
    # These properties are used to send messages from Python to the
    # frontend. Messages are sent by assigning the message contents to the
    # appropriate _py2js_* property and then immediatly assigning None to the
    # property.
    #
    # See JSDoc comments in the FigureModel class in js/src/Figure.js for
    # detailed descriptions of the messages.
    _py2js_addTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _py2js_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _py2js_relayout = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _py2js_update = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _py2js_animate = Dict(allow_none=True).tag(sync=True, **custom_serializers)

    _py2js_deleteTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _py2js_moveTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers)

    _py2js_removeLayoutProps = Dict(allow_none=True).tag(
        sync=True, **custom_serializers
    )
    _py2js_removeTraceProps = Dict(allow_none=True).tag(sync=True, **custom_serializers)

    # ### JS -> Python message properties ###
    # These properties are used to receive messages from the frontend.
    # Messages are received by defining methods that observe changes to these
    # properties. Receive methods are named `_handler_js2py_*` where '*' is
    # the name of the corresponding message property.  Receive methods are
    # responsible for setting the message property to None after retreiving
    # the message data.
    #
    # See JSDoc comments in the FigureModel class in js/src/Figure.js for
    # detailed descriptions of the messages.
    _js2py_traceDeltas = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _js2py_layoutDelta = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _js2py_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _js2py_relayout = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _js2py_update = Dict(allow_none=True).tag(sync=True, **custom_serializers)
    _js2py_pointsCallback = Dict(allow_none=True).tag(sync=True, **custom_serializers)

    # ### Message tracking properties ###
    # The _last_layout_edit_id and _last_trace_edit_id properties are used
    # to keep track of the edit id of the message that most recently
    # requested an update to the Figures layout or traces respectively.
    #
    # We track this information because we don't want to update the Figure's
    # default layout/trace properties (_layout_defaults, _data_defaults)
    # while edits are in process. This can lead to inconsistent property
    # states.
    _last_layout_edit_id = Integer(0).tag(sync=True)
    _last_trace_edit_id = Integer(0).tag(sync=True)

    _set_trace_uid = True
    _allow_disable_validation = False

    # Constructor
    # -----------
    def __init__(
        self, data=None, layout=None, frames=None, skip_invalid=False, **kwargs
    ):

        # Call superclass constructors
        # ----------------------------
        # Note: We rename layout to layout_plotly because to deconflict it
        # with the `layout` constructor parameter of the `widgets.DOMWidget`
        # ipywidgets class
        super(BaseFigureWidget, self).__init__(
            data=data,
            layout_plotly=layout,
            frames=frames,
            skip_invalid=skip_invalid,
            **kwargs,
        )

        # Validate Frames
        # ---------------
        # Frames are not supported by figure widget
        if self._frame_objs:
            BaseFigureWidget._display_frames_error()

        # Message States
        # --------------
        # ### Layout ###

        # _last_layout_edit_id is described above
        self._last_layout_edit_id = 0

        # _layout_edit_in_process is set to True if there are layout edit
        # operations that have been sent to the frontend that haven't
        # completed yet.
        self._layout_edit_in_process = False

        # _waiting_edit_callbacks is a list of callback functions that
        # should be executed as soon as all pending edit operations are
        # completed
        self._waiting_edit_callbacks = []

        # ### Trace ###
        # _last_trace_edit_id: described above
        self._last_trace_edit_id = 0

        # _trace_edit_in_process is set to True if there are trace edit
        # operations that have been sent to the frontend that haven't
        # completed yet.
        self._trace_edit_in_process = False

        # View count
        # ----------
        # ipywidget property that stores the number of active frontend
        # views of this widget
        self._view_count = 0

    # Python -> JavaScript Messages
    # -----------------------------
    def _send_relayout_msg(self, layout_data, source_view_id=None):
        """
        Send Plotly.relayout message to the frontend

        Parameters
        ----------
        layout_data : dict
            Plotly.relayout layout data
        source_view_id : str
            UID of view that triggered this relayout operation
            (e.g. By the user clicking 'zoom' in the toolbar). None if the
            operation was not triggered by a frontend view
        """
        # Increment layout edit messages IDs
        # ----------------------------------
        layout_edit_id = self._last_layout_edit_id + 1
        self._last_layout_edit_id = layout_edit_id
        self._layout_edit_in_process = True

        # Build message
        # -------------
        msg_data = {
            "relayout_data": layout_data,
            "layout_edit_id": layout_edit_id,
            "source_view_id": source_view_id,
        }

        # Send message
        # ------------
        self._py2js_relayout = msg_data
        self._py2js_relayout = None

    def _send_restyle_msg(self, restyle_data, trace_indexes=None, source_view_id=None):
        """
        Send Plotly.restyle message to the frontend

        Parameters
        ----------
        restyle_data : dict
            Plotly.restyle restyle data
        trace_indexes : list[int]
            List of trace indexes that the restyle operation
            applies to
        source_view_id : str
            UID of view that triggered this restyle operation
            (e.g. By the user clicking the legend to hide a trace).
            None if the operation was not triggered by a frontend view
        """

        # Validate / normalize inputs
        # ---------------------------
        trace_indexes = self._normalize_trace_indexes(trace_indexes)

        # Increment layout/trace edit message IDs
        # ---------------------------------------
        layout_edit_id = self._last_layout_edit_id + 1
        self._last_layout_edit_id = layout_edit_id
        self._layout_edit_in_process = True

        trace_edit_id = self._last_trace_edit_id + 1
        self._last_trace_edit_id = trace_edit_id
        self._trace_edit_in_process = True

        # Build message
        # -------------
        restyle_msg = {
            "restyle_data": restyle_data,
            "restyle_traces": trace_indexes,
            "trace_edit_id": trace_edit_id,
            "layout_edit_id": layout_edit_id,
            "source_view_id": source_view_id,
        }

        # Send message
        # ------------
        self._py2js_restyle = restyle_msg
        self._py2js_restyle = None

    def _send_addTraces_msg(self, new_traces_data):
        """
        Send Plotly.addTraces message to the frontend

        Parameters
        ----------
        new_traces_data : list[dict]
            List of trace data for new traces as accepted by Plotly.addTraces
        """

        # Increment layout/trace edit message IDs
        # ---------------------------------------
        layout_edit_id = self._last_layout_edit_id + 1
        self._last_layout_edit_id = layout_edit_id
        self._layout_edit_in_process = True

        trace_edit_id = self._last_trace_edit_id + 1
        self._last_trace_edit_id = trace_edit_id
        self._trace_edit_in_process = True

        # Build message
        # -------------
        add_traces_msg = {
            "trace_data": new_traces_data,
            "trace_edit_id": trace_edit_id,
            "layout_edit_id": layout_edit_id,
        }

        # Send message
        # ------------
        self._py2js_addTraces = add_traces_msg
        self._py2js_addTraces = None

    def _send_moveTraces_msg(self, current_inds, new_inds):
        """
        Send Plotly.moveTraces message to the frontend

        Parameters
        ----------
        current_inds : list[int]
            List of current trace indexes
        new_inds : list[int]
            List of new trace indexes
        """

        # Build message
        # -------------
        move_msg = {"current_trace_inds": current_inds, "new_trace_inds": new_inds}

        # Send message
        # ------------
        self._py2js_moveTraces = move_msg
        self._py2js_moveTraces = None

    def _send_update_msg(
        self, restyle_data, relayout_data, trace_indexes=None, source_view_id=None
    ):
        """
        Send Plotly.update message to the frontend

        Parameters
        ----------
        restyle_data : dict
            Plotly.update restyle data
        relayout_data : dict
            Plotly.update relayout data
        trace_indexes : list[int]
            List of trace indexes that the update operation applies to
        source_view_id : str
            UID of view that triggered this update operation
            (e.g. By the user clicking a button).
            None if the operation was not triggered by a frontend view
        """

        # Validate / normalize inputs
        # ---------------------------
        trace_indexes = self._normalize_trace_indexes(trace_indexes)

        # Increment layout/trace edit message IDs
        # ---------------------------------------
        trace_edit_id = self._last_trace_edit_id + 1
        self._last_trace_edit_id = trace_edit_id
        self._trace_edit_in_process = True

        layout_edit_id = self._last_layout_edit_id + 1
        self._last_layout_edit_id = layout_edit_id
        self._layout_edit_in_process = True

        # Build message
        # -------------
        update_msg = {
            "style_data": restyle_data,
            "layout_data": relayout_data,
            "style_traces": trace_indexes,
            "trace_edit_id": trace_edit_id,
            "layout_edit_id": layout_edit_id,
            "source_view_id": source_view_id,
        }

        # Send message
        # ------------
        self._py2js_update = update_msg
        self._py2js_update = None

    def _send_animate_msg(
        self, styles_data, relayout_data, trace_indexes, animation_opts
    ):
        """
        Send Plotly.update message to the frontend

        Note: there is no source_view_id parameter because animations
        triggered by the fontend are not currently supported

        Parameters
        ----------
        styles_data : list[dict]
            Plotly.animate styles data
        relayout_data : dict
            Plotly.animate relayout data
        trace_indexes : list[int]
            List of trace indexes that the animate operation applies to
        """

        # Validate / normalize inputs
        # ---------------------------
        trace_indexes = self._normalize_trace_indexes(trace_indexes)

        # Increment layout/trace edit message IDs
        # ---------------------------------------
        trace_edit_id = self._last_trace_edit_id + 1
        self._last_trace_edit_id = trace_edit_id
        self._trace_edit_in_process = True

        layout_edit_id = self._last_layout_edit_id + 1
        self._last_layout_edit_id = layout_edit_id
        self._layout_edit_in_process = True

        # Build message
        # -------------
        animate_msg = {
            "style_data": styles_data,
            "layout_data": relayout_data,
            "style_traces": trace_indexes,
            "animation_opts": animation_opts,
            "trace_edit_id": trace_edit_id,
            "layout_edit_id": layout_edit_id,
            "source_view_id": None,
        }

        # Send message
        # ------------
        self._py2js_animate = animate_msg
        self._py2js_animate = None

    def _send_deleteTraces_msg(self, delete_inds):
        """
        Send Plotly.deleteTraces message to the frontend

        Parameters
        ----------
        delete_inds : list[int]
            List of trace indexes of traces to delete
        """

        # Increment layout/trace edit message IDs
        # ---------------------------------------
        trace_edit_id = self._last_trace_edit_id + 1
        self._last_trace_edit_id = trace_edit_id
        self._trace_edit_in_process = True

        layout_edit_id = self._last_layout_edit_id + 1
        self._last_layout_edit_id = layout_edit_id
        self._layout_edit_in_process = True

        # Build message
        # -------------
        delete_msg = {
            "delete_inds": delete_inds,
            "layout_edit_id": layout_edit_id,
            "trace_edit_id": trace_edit_id,
        }

        # Send message
        # ------------
        self._py2js_deleteTraces = delete_msg
        self._py2js_deleteTraces = None

    # JavaScript -> Python Messages
    # -----------------------------
    @observe("_js2py_traceDeltas")
    def _handler_js2py_traceDeltas(self, change):
        """
        Process trace deltas message from the frontend
        """

        # Receive message
        # ---------------
        msg_data = change["new"]
        if not msg_data:
            self._js2py_traceDeltas = None
            return

        trace_deltas = msg_data["trace_deltas"]
        trace_edit_id = msg_data["trace_edit_id"]

        # Apply deltas
        # ------------
        # We only apply the deltas if this message corresponds to the most
        # recent trace edit operation
        if trace_edit_id == self._last_trace_edit_id:

            # ### Loop over deltas ###
            for delta in trace_deltas:

                # #### Find existing trace for uid ###
                trace_uid = delta["uid"]
                trace_uids = [trace.uid for trace in self.data]
                trace_index = trace_uids.index(trace_uid)
                uid_trace = self.data[trace_index]

                # #### Transform defaults to delta ####
                delta_transform = BaseFigureWidget._transform_data(
                    uid_trace._prop_defaults, delta
                )

                # #### Remove overlapping properties ####
                # If a property is present in both _props and _prop_defaults
                # then we remove the copy from _props
                remove_props = self._remove_overlapping_props(
                    uid_trace._props, uid_trace._prop_defaults
                )

                # #### Notify frontend model of property removal ####
                if remove_props:
                    remove_trace_props_msg = {
                        "remove_trace": trace_index,
                        "remove_props": remove_props,
                    }
                    self._py2js_removeTraceProps = remove_trace_props_msg
                    self._py2js_removeTraceProps = None

                # #### Dispatch change callbacks ####
                self._dispatch_trace_change_callbacks(delta_transform, [trace_index])

            # ### Trace edits no longer in process ###
            self._trace_edit_in_process = False

            # ### Call any waiting trace edit callbacks ###
            if not self._layout_edit_in_process:
                while self._waiting_edit_callbacks:
                    self._waiting_edit_callbacks.pop()()

        self._js2py_traceDeltas = None

    @observe("_js2py_layoutDelta")
    def _handler_js2py_layoutDelta(self, change):
        """
        Process layout delta message from the frontend
        """

        # Receive message
        # ---------------
        msg_data = change["new"]
        if not msg_data:
            self._js2py_layoutDelta = None
            return

        layout_delta = msg_data["layout_delta"]
        layout_edit_id = msg_data["layout_edit_id"]

        # Apply delta
        # -----------
        # We only apply the delta if this message corresponds to the most
        # recent layout edit operation
        if layout_edit_id == self._last_layout_edit_id:

            # ### Transform defaults to delta ###
            delta_transform = BaseFigureWidget._transform_data(
                self._layout_defaults, layout_delta
            )

            # ### Remove overlapping properties ###
            # If a property is present in both _layout and _layout_defaults
            # then we remove the copy from _layout
            removed_props = self._remove_overlapping_props(
                self._layout, self._layout_defaults
            )

            # ### Notify frontend model of property removal ###
            if removed_props:
                remove_props_msg = {"remove_props": removed_props}

                self._py2js_removeLayoutProps = remove_props_msg
                self._py2js_removeLayoutProps = None

            # ### Create axis objects ###
            # For example, when a SPLOM trace is created the layout defaults
            # may include axes that weren't explicitly defined by the user.
            for proppath in delta_transform:
                prop = proppath[0]
                match = self.layout._subplot_re_match(prop)
                if match and prop not in self.layout:
                    # We need to create a subplotid object
                    self.layout[prop] = {}

            # ### Dispatch change callbacks ###
            self._dispatch_layout_change_callbacks(delta_transform)

            # ### Layout edits no longer in process ###
            self._layout_edit_in_process = False

            # ### Call any waiting layout edit callbacks ###
            if not self._trace_edit_in_process:
                while self._waiting_edit_callbacks:
                    self._waiting_edit_callbacks.pop()()

        self._js2py_layoutDelta = None

    @observe("_js2py_restyle")
    def _handler_js2py_restyle(self, change):
        """
        Process Plotly.restyle message from the frontend
        """

        # Receive message
        # ---------------
        restyle_msg = change["new"]

        if not restyle_msg:
            self._js2py_restyle = None
            return

        style_data = restyle_msg["style_data"]
        style_traces = restyle_msg["style_traces"]
        source_view_id = restyle_msg["source_view_id"]

        # Perform restyle
        # ---------------
        self.plotly_restyle(
            restyle_data=style_data,
            trace_indexes=style_traces,
            source_view_id=source_view_id,
        )

        self._js2py_restyle = None

    @observe("_js2py_update")
    def _handler_js2py_update(self, change):
        """
        Process Plotly.update message from the frontend
        """

        # Receive message
        # ---------------
        update_msg = change["new"]

        if not update_msg:
            self._js2py_update = None
            return

        style = update_msg["style_data"]
        trace_indexes = update_msg["style_traces"]
        layout = update_msg["layout_data"]
        source_view_id = update_msg["source_view_id"]

        # Perform update
        # --------------
        self.plotly_update(
            restyle_data=style,
            relayout_data=layout,
            trace_indexes=trace_indexes,
            source_view_id=source_view_id,
        )

        self._js2py_update = None

    @observe("_js2py_relayout")
    def _handler_js2py_relayout(self, change):
        """
        Process Plotly.relayout message from the frontend
        """

        # Receive message
        # ---------------
        relayout_msg = change["new"]

        if not relayout_msg:
            self._js2py_relayout = None
            return

        relayout_data = relayout_msg["relayout_data"]
        source_view_id = relayout_msg["source_view_id"]

        if "lastInputTime" in relayout_data:
            # Remove 'lastInputTime'. Seems to be an internal plotly
            # property that is introduced for some plot types, but it is not
            # actually a property in the schema
            relayout_data.pop("lastInputTime")

        # Perform relayout
        # ----------------
        self.plotly_relayout(relayout_data=relayout_data, source_view_id=source_view_id)

        self._js2py_relayout = None

    @observe("_js2py_pointsCallback")
    def _handler_js2py_pointsCallback(self, change):
        """
        Process points callback message from the frontend
        """

        # Receive message
        # ---------------
        callback_data = change["new"]

        if not callback_data:
            self._js2py_pointsCallback = None
            return

        # Get event type
        # --------------
        event_type = callback_data["event_type"]

        # Build Selector Object
        # ---------------------
        if callback_data.get("selector", None):
            selector_data = callback_data["selector"]
            selector_type = selector_data["type"]
            selector_state = selector_data["selector_state"]
            if selector_type == "box":
                selector = BoxSelector(**selector_state)
            elif selector_type == "lasso":
                selector = LassoSelector(**selector_state)
            else:
                raise ValueError("Unsupported selector type: %s" % selector_type)
        else:
            selector = None

        # Build Input Device State Object
        # -------------------------------
        if callback_data.get("device_state", None):
            device_state_data = callback_data["device_state"]
            state = InputDeviceState(**device_state_data)
        else:
            state = None

        # Build Trace Points Dictionary
        # -----------------------------
        points_data = callback_data["points"]
        trace_points = {
            trace_ind: {
                "point_inds": [],
                "xs": [],
                "ys": [],
                "trace_name": self._data_objs[trace_ind].name,
                "trace_index": trace_ind,
            }
            for trace_ind in range(len(self._data_objs))
        }

        for x, y, point_ind, trace_ind in zip(
            points_data["xs"],
            points_data["ys"],
            points_data["point_indexes"],
            points_data["trace_indexes"],
        ):

            trace_dict = trace_points[trace_ind]
            trace_dict["xs"].append(x)
            trace_dict["ys"].append(y)
            trace_dict["point_inds"].append(point_ind)

        # Dispatch callbacks
        # ------------------
        for trace_ind, trace_points_data in trace_points.items():
            points = Points(**trace_points_data)
            trace = self.data[trace_ind]

            if event_type == "plotly_click":
                trace._dispatch_on_click(points, state)
            elif event_type == "plotly_hover":
                trace._dispatch_on_hover(points, state)
            elif event_type == "plotly_unhover":
                trace._dispatch_on_unhover(points, state)
            elif event_type == "plotly_selected":
                trace._dispatch_on_selection(points, selector)
            elif event_type == "plotly_deselect":
                trace._dispatch_on_deselect(points)

        self._js2py_pointsCallback = None

    # Display
    # -------
    def _repr_html_(self):
        """
        Customize html representation
        """
        raise NotImplementedError  # Prefer _repr_mimebundle_

    def _repr_mimebundle_(self, include=None, exclude=None, validate=True, **kwargs):
        """
        Return mimebundle corresponding to default renderer.
        """
        return {
            "application/vnd.jupyter.widget-view+json": {
                "version_major": 2,
                "version_minor": 0,
                "model_id": self._model_id,
            },
        }

    def _ipython_display_(self):
        """
        Handle rich display of figures in ipython contexts
        """
        raise NotImplementedError  # Prefer _repr_mimebundle_

    # Callbacks
    # ---------
    def on_edits_completed(self, fn):
        """
        Register a function to be called after all pending trace and layout
        edit operations have completed

        If there are no pending edit operations then function is called
        immediately

        Parameters
        ----------
        fn : callable
            Function of zero arguments to be called when all pending edit
            operations have completed
        """
        if self._layout_edit_in_process or self._trace_edit_in_process:
            self._waiting_edit_callbacks.append(fn)
        else:
            fn()

    # Validate No Frames
    # ------------------
    @property
    def frames(self):
        # Note: This property getter is identical to that of the superclass,
        # but it must be included here because we're overriding the setter
        # below.
        return self._frame_objs

    @frames.setter
    def frames(self, new_frames):
        if new_frames:
            BaseFigureWidget._display_frames_error()

    @staticmethod
    def _display_frames_error():
        """
        Display an informative error when user attempts to set frames on a
        FigureWidget

        Raises
        ------
        ValueError
            always
        """
        msg = """
Frames are not supported by the plotly.graph_objs.FigureWidget class.
Note: Frames are supported by the plotly.graph_objs.Figure class"""
        raise ValueError(msg)

    # Static Helpers
    # --------------
    @staticmethod
    def _remove_overlapping_props(input_data, delta_data, prop_path=()):
        """
        Remove properties in input_data that are also in delta_data, and do so
        recursively.

        Exception: Never remove 'uid' from input_data, this property is used
        to align traces

        Parameters
        ----------
        input_data : dict|list
        delta_data : dict|list

        Returns
        -------
        list[tuple[str|int]]
            List of removed property path tuples
        """

        # Initialize removed
        # ------------------
        # This is the list of path tuples to the properties that were
        # removed from input_data
        removed = []

        # Handle dict
        # -----------
        if isinstance(input_data, dict):
            assert isinstance(delta_data, dict)

            for p, delta_val in delta_data.items():
                if isinstance(delta_val, dict) or BaseFigure._is_dict_list(delta_val):
                    if p in input_data:
                        # ### Recurse ###
                        input_val = input_data[p]
                        recur_prop_path = prop_path + (p,)
                        recur_removed = BaseFigureWidget._remove_overlapping_props(
                            input_val, delta_val, recur_prop_path
                        )
                        removed.extend(recur_removed)

                        # Check whether the last property in input_val
                        # has been removed. If so, remove it entirely
                        if not input_val:
                            input_data.pop(p)
                            removed.append(recur_prop_path)

                elif p in input_data and p != "uid":
                    # ### Remove property ###
                    input_data.pop(p)
                    removed.append(prop_path + (p,))

        # Handle list
        # -----------
        elif isinstance(input_data, list):
            assert isinstance(delta_data, list)

            for i, delta_val in enumerate(delta_data):
                if i >= len(input_data):
                    break

                input_val = input_data[i]
                if (
                    input_val is not None
                    and isinstance(delta_val, dict)
                    or BaseFigure._is_dict_list(delta_val)
                ):

                    # ### Recurse ###
                    recur_prop_path = prop_path + (i,)
                    recur_removed = BaseFigureWidget._remove_overlapping_props(
                        input_val, delta_val, recur_prop_path
                    )

                    removed.extend(recur_removed)

        return removed

    @staticmethod
    def _transform_data(to_data, from_data, should_remove=True, relayout_path=()):
        """
        Transform to_data into from_data and return relayout-style
        description of the transformation

        Parameters
        ----------
        to_data : dict|list
        from_data : dict|list

        Returns
        -------
        dict
            relayout-style description of the transformation
        """

        # Initialize relayout data
        # ------------------------
        relayout_data = {}

        # Handle dict
        # -----------
        if isinstance(to_data, dict):

            # ### Validate from_data ###
            if not isinstance(from_data, dict):
                raise ValueError(
                    "Mismatched data types: {to_dict} {from_data}".format(
                        to_dict=to_data, from_data=from_data
                    )
                )

            # ### Add/modify properties ###
            # Loop over props/vals
            for from_prop, from_val in from_data.items():

                # #### Handle compound vals recursively ####
                if isinstance(from_val, dict) or BaseFigure._is_dict_list(from_val):

                    # ##### Init property value if needed #####
                    if from_prop not in to_data:
                        to_data[from_prop] = {} if isinstance(from_val, dict) else []

                    # ##### Transform property val recursively #####
                    input_val = to_data[from_prop]
                    relayout_data.update(
                        BaseFigureWidget._transform_data(
                            input_val,
                            from_val,
                            should_remove=should_remove,
                            relayout_path=relayout_path + (from_prop,),
                        )
                    )

                # #### Handle simple vals directly ####
                else:
                    if from_prop not in to_data or not BasePlotlyType._vals_equal(
                        to_data[from_prop], from_val
                    ):

                        to_data[from_prop] = from_val
                        relayout_path_prop = relayout_path + (from_prop,)
                        relayout_data[relayout_path_prop] = from_val

            # ### Remove properties ###
            if should_remove:
                for remove_prop in set(to_data.keys()).difference(
                    set(from_data.keys())
                ):
                    to_data.pop(remove_prop)

        # Handle list
        # -----------
        elif isinstance(to_data, list):

            # ### Validate from_data ###
            if not isinstance(from_data, list):
                raise ValueError(
                    "Mismatched data types: to_data: {to_data} {from_data}".format(
                        to_data=to_data, from_data=from_data
                    )
                )

            # ### Add/modify properties ###
            # Loop over indexes / elements
            for i, from_val in enumerate(from_data):

                # #### Initialize element if needed ####
                if i >= len(to_data):
                    to_data.append(None)
                input_val = to_data[i]

                # #### Handle compound element recursively ####
                if input_val is not None and (
                    isinstance(from_val, dict) or BaseFigure._is_dict_list(from_val)
                ):

                    relayout_data.update(
                        BaseFigureWidget._transform_data(
                            input_val,
                            from_val,
                            should_remove=should_remove,
                            relayout_path=relayout_path + (i,),
                        )
                    )

                # #### Handle simple elements directly ####
                else:
                    if not BasePlotlyType._vals_equal(to_data[i], from_val):
                        to_data[i] = from_val
                        relayout_data[relayout_path + (i,)] = from_val

        return relayout_data
