import uuid
from unittest.mock import Mock

import plotly.graph_objs as go

import holoviews as hv
from holoviews.plotting.plotly.callbacks import (
    BoundsXCallback,
    BoundsXYCallback,
    BoundsYCallback,
    RangeXCallback,
    RangeXYCallback,
    RangeYCallback,
    Selection1DCallback,
)
from holoviews.plotting.plotly.util import PLOTLY_MAP, PLOTLY_SCATTERMAP
from holoviews.streams import (
    BoundsX,
    BoundsXY,
    BoundsY,
    RangeX,
    RangeXY,
    RangeY,
    Selection1D,
)


def mock_plot(trace_uid=None):
    # Build a mock to stand in for a PlotlyPlot subclass
    if trace_uid is None:
        trace_uid = str(uuid.uuid4())

    plot = Mock()
    plot.trace_uid = trace_uid
    return plot


def build_callback_set(callback_cls, trace_uids, stream_type, num_streams=2):
    """
    Build a collection of plots, callbacks, and streams for a given callback class and
    a list of trace_uids
    """
    plots = []
    streamss = []
    callbacks = []
    eventss = []
    for trace_uid in trace_uids:
        plot = mock_plot(trace_uid)
        streams, event_list = [], []
        for _ in range(num_streams):
            events = []
            stream = stream_type()

            def cb(events=events, **kwargs):
                events.append(kwargs)

            stream.add_subscriber(cb)
            streams.append(stream)
            event_list.append(events)
        callback = callback_cls(plot, streams, None)

        plots.append(plot)
        streamss.append(streams)
        callbacks.append(callback)
        eventss.append(event_list)

    return plots, streamss, callbacks, eventss


class TestCallbacks:
    def setup_method(self):
        self.fig_dict = go.Figure(
            {
                "data": [
                    {"type": "scatter", "y": [1, 2, 3], "uid": "first"},
                    {"type": "bar", "y": [1, 2, 3], "uid": "second", "xaxis": "x", "yaxis": "y"},
                    {
                        "type": "scatter",
                        "y": [1, 2, 3],
                        "uid": "third",
                        "xaxis": "x2",
                        "yaxis": "y2",
                    },
                    {"type": "bar", "y": [1, 2, 3], "uid": "forth", "xaxis": "x3", "yaxis": "y3"},
                ],
                "layout": {"title": {"text": "Figure Title"}},
            }
        ).to_dict()

        self.mapbox_fig_dict = go.Figure(
            {
                "data": [
                    {"type": PLOTLY_SCATTERMAP, "uid": "first", "subplot": PLOTLY_MAP},
                    {"type": PLOTLY_SCATTERMAP, "uid": "second", "subplot": PLOTLY_MAP + "2"},
                    {"type": PLOTLY_SCATTERMAP, "uid": "third", "subplot": PLOTLY_MAP + "3"},
                ],
                "layout": {
                    "title": {"text": "Figure Title"},
                },
            }
        ).to_dict()

        # Precompute a pair of lat/lon, easting/northing, mapbox coord values
        self.lon_range1, self.lat_range1 = (10, 30), (20, 40)
        self.easting_range1, self.northing_range1 = hv.Tiles.lon_lat_to_easting_northing(
            self.lon_range1, self.lat_range1
        )
        self.easting_range1 = tuple(self.easting_range1)
        self.northing_range1 = tuple(self.northing_range1)

        self.mapbox_coords1 = [
            [self.lon_range1[0], self.lat_range1[1]],
            [self.lon_range1[1], self.lat_range1[1]],
            [self.lon_range1[1], self.lat_range1[0]],
            [self.lon_range1[0], self.lat_range1[0]],
        ]

        self.lon_range2, self.lat_range2 = (-50, -30), (-70, -40)
        self.easting_range2, self.northing_range2 = hv.Tiles.lon_lat_to_easting_northing(
            self.lon_range2, self.lat_range2
        )
        self.easting_range2 = tuple(self.easting_range2)
        self.northing_range2 = tuple(self.northing_range2)

        self.mapbox_coords2 = [
            [self.lon_range2[0], self.lat_range2[1]],
            [self.lon_range2[1], self.lat_range2[1]],
            [self.lon_range2[1], self.lat_range2[0]],
            [self.lon_range2[0], self.lat_range2[0]],
        ]

    def testCallbackClassInstanceTracking(self):
        # Each callback class should track all active instances of its own class in a
        # weak value dictionary. Here we make sure that instances stay separated per
        # class
        plot1 = mock_plot()
        plot2 = mock_plot()
        plot3 = mock_plot()

        # Check RangeXYCallback
        rangexy_cb = RangeXYCallback(plot1, [], None)
        assert plot1.trace_uid in RangeXYCallback.instances
        assert rangexy_cb is RangeXYCallback.instances[plot1.trace_uid]

        # Check BoundsXYCallback
        boundsxy_cb = BoundsXYCallback(plot2, [], None)
        assert plot2.trace_uid in BoundsXYCallback.instances
        assert boundsxy_cb is BoundsXYCallback.instances[plot2.trace_uid]

        # Check Selection1DCallback
        selection1d_cb = Selection1DCallback(plot3, [], None)
        assert plot3.trace_uid in Selection1DCallback.instances
        assert selection1d_cb is Selection1DCallback.instances[plot3.trace_uid]

        # Check that objects don't show up as instances in the wrong class
        assert plot1.trace_uid not in BoundsXYCallback.instances
        assert plot1.trace_uid not in Selection1DCallback.instances
        assert plot2.trace_uid not in RangeXYCallback.instances
        assert plot2.trace_uid not in Selection1DCallback.instances
        assert plot3.trace_uid not in RangeXYCallback.instances
        assert plot3.trace_uid not in BoundsXYCallback.instances

    def testRangeXYCallbackEventData(self):
        for viewport in [
            {"xaxis.range": [1, 4], "yaxis.range": [-1, 5]},
            {"xaxis.range[0]": 1, "xaxis.range[1]": 4, "yaxis.range[0]": -1, "yaxis.range[1]": 5},
        ]:
            event_data = RangeXYCallback.get_event_data_from_property_update(
                "viewport", viewport, self.fig_dict
            )

            assert event_data == {
                "first": {"x_range": (1, 4), "y_range": (-1, 5)},
                "second": {"x_range": (1, 4), "y_range": (-1, 5)},
            }

    def testRangeXCallbackEventData(self):
        for viewport in [
            {"xaxis.range": [1, 4], "yaxis.range": [-1, 5]},
            {"xaxis.range[0]": 1, "xaxis.range[1]": 4, "yaxis.range[0]": -1, "yaxis.range[1]": 5},
        ]:
            event_data = RangeXCallback.get_event_data_from_property_update(
                "viewport", viewport, self.fig_dict
            )

            assert event_data == {
                "first": {"x_range": (1, 4)},
                "second": {"x_range": (1, 4)},
            }

    def testRangeYCallbackEventData(self):
        for viewport in [
            {"xaxis.range": [1, 4], "yaxis.range": [-1, 5]},
            {"xaxis.range[0]": 1, "xaxis.range[1]": 4, "yaxis.range[0]": -1, "yaxis.range[1]": 5},
        ]:
            event_data = RangeYCallback.get_event_data_from_property_update(
                "viewport", viewport, self.fig_dict
            )

            assert event_data == {
                "first": {"y_range": (-1, 5)},
                "second": {"y_range": (-1, 5)},
            }

    def testMapboxRangeXYCallbackEventData(self):
        relayout_data = {
            f"{PLOTLY_MAP}._derived": {"coordinates": self.mapbox_coords1},
            f"{PLOTLY_MAP}3._derived": {"coordinates": self.mapbox_coords2},
        }

        event_data = RangeXYCallback.get_event_data_from_property_update(
            "relayout_data", relayout_data, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {"x_range": self.easting_range1, "y_range": self.northing_range1},
            "third": {"x_range": self.easting_range2, "y_range": self.northing_range2},
        }

    def testMapboxRangeXCallbackEventData(self):
        relayout_data = {
            f"{PLOTLY_MAP}._derived": {"coordinates": self.mapbox_coords1},
            f"{PLOTLY_MAP}3._derived": {"coordinates": self.mapbox_coords2},
        }

        event_data = RangeXCallback.get_event_data_from_property_update(
            "relayout_data", relayout_data, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {"x_range": self.easting_range1},
            "third": {"x_range": self.easting_range2},
        }

    def testMapboxRangeYCallbackEventData(self):
        relayout_data = {
            f"{PLOTLY_MAP}._derived": {"coordinates": self.mapbox_coords1},
            f"{PLOTLY_MAP}3._derived": {"coordinates": self.mapbox_coords2},
        }

        event_data = RangeYCallback.get_event_data_from_property_update(
            "relayout_data", relayout_data, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {"y_range": self.northing_range1},
            "third": {"y_range": self.northing_range2},
        }

    def testRangeCallbacks(self):

        # Build callbacks
        range_classes = [RangeXYCallback, RangeXCallback, RangeYCallback]

        _xyplots, xystreamss, _xycallbacks, xyevents = build_callback_set(
            RangeXYCallback, ["first", "second", "third", "forth", "other"], RangeXY, 2
        )

        _xplots, xstreamss, _xcallbacks, xevents = build_callback_set(
            RangeXCallback, ["first", "second", "third", "forth", "other"], RangeX, 2
        )

        _yplots, ystreamss, _y_callbacks, yevents = build_callback_set(
            RangeYCallback, ["first", "second", "third", "forth", "other"], RangeY, 2
        )

        # Sanity check the length of the streams lists
        for xystreams in xystreamss:
            assert len(xystreams) == 2

        # Change viewport on first set of axes
        viewport1 = {"xaxis.range": [1, 4], "yaxis.range": [-1, 5]}
        for cb_cls in range_classes:
            cb_cls.update_streams_from_property_update("viewport", viewport1, self.fig_dict)

        # Check that all streams attached to 'first' and 'second' plots were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[0] + xystreamss[1],
            xstreamss[0] + xstreamss[1],
            ystreamss[0] + ystreamss[1],
            strict=None,
        ):
            assert xystream.x_range == (1, 4)
            assert xystream.y_range == (-1, 5)
            assert xstream.x_range == (1, 4)
            assert ystream.y_range == (-1, 5)

        # And that no other streams were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[2] + xystreamss[3],
            xstreamss[2] + xstreamss[3],
            ystreamss[2] + ystreamss[3],
            strict=None,
        ):
            assert xystream.x_range is None
            assert xystream.y_range is None
            assert xstream.x_range is None
            assert ystream.y_range is None

        # Change viewport on second set of axes
        viewport2 = {"xaxis2.range": [2, 5], "yaxis2.range": [0, 6]}
        for cb_cls in range_classes:
            cb_cls.update_streams_from_property_update("viewport", viewport2, self.fig_dict)

        # Check that all streams attached to 'third' were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[2], xstreamss[2], ystreamss[2], strict=None
        ):
            assert xystream.x_range == (2, 5)
            assert xystream.y_range == (0, 6)
            assert xstream.x_range == (2, 5)
            assert ystream.y_range == (0, 6)

        # Change viewport on third set of axes
        viewport3 = {"xaxis3.range": [3, 6], "yaxis3.range": [1, 7]}
        for cb_cls in range_classes:
            cb_cls.update_streams_from_property_update("viewport", viewport3, self.fig_dict)

        # Check that all streams attached to 'forth' were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[3], xstreamss[3], ystreamss[3], strict=None
        ):
            assert xystream.x_range == (3, 6)
            assert xystream.y_range == (1, 7)
            assert xstream.x_range == (3, 6)
            assert ystream.y_range == (1, 7)

        # Check that streams attached to a trace not in this plot are not triggered
        for xyevent, xevent, yevent in zip(xyevents[4], xevents[4], yevents[4], strict=None):
            assert len(xyevent) == 0
            assert len(xevent) == 0
            assert len(yevent) == 0

    def testBoundsXYCallbackEventData(self):
        selected_data1 = {"range": {"x": [1, 4], "y": [-1, 5]}}
        event_data = BoundsXYCallback.get_event_data_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        assert event_data == {
            "first": {"bounds": (1, -1, 4, 5)},
            "second": {"bounds": (1, -1, 4, 5)},
            "third": {"bounds": None},
            "forth": {"bounds": None},
        }

    def testBoundsXCallbackEventData(self):
        selected_data1 = {"range": {"x": [1, 4], "y": [-1, 5]}}
        event_data = BoundsXCallback.get_event_data_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        assert event_data == {
            "first": {"boundsx": (1, 4)},
            "second": {"boundsx": (1, 4)},
            "third": {"boundsx": None},
            "forth": {"boundsx": None},
        }

    def testBoundsYCallbackEventData(self):
        selected_data1 = {"range": {"x": [1, 4], "y": [-1, 5]}}
        event_data = BoundsYCallback.get_event_data_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        assert event_data == {
            "first": {"boundsy": (-1, 5)},
            "second": {"boundsy": (-1, 5)},
            "third": {"boundsy": None},
            "forth": {"boundsy": None},
        }

    def testMapboxBoundsXYCallbackEventData(self):
        selected_data = {
            "range": {
                f"{PLOTLY_MAP}2": [
                    [self.lon_range1[0], self.lat_range1[0]],
                    [self.lon_range1[1], self.lat_range1[1]],
                ]
            }
        }

        event_data = BoundsXYCallback.get_event_data_from_property_update(
            "selected_data", selected_data, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {"bounds": None},
            "second": {
                "bounds": (
                    self.easting_range1[0],
                    self.northing_range1[0],
                    self.easting_range1[1],
                    self.northing_range1[1],
                )
            },
            "third": {"bounds": None},
        }

    def testMapboxBoundsXCallbackEventData(self):
        selected_data = {
            "range": {
                f"{PLOTLY_MAP}": [
                    [self.lon_range1[0], self.lat_range1[0]],
                    [self.lon_range1[1], self.lat_range1[1]],
                ]
            }
        }

        event_data = BoundsXCallback.get_event_data_from_property_update(
            "selected_data", selected_data, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {
                "boundsx": (
                    self.easting_range1[0],
                    self.easting_range1[1],
                )
            },
            "second": {"boundsx": None},
            "third": {"boundsx": None},
        }

    def testMapboxBoundsYCallbackEventData(self):
        selected_data = {
            "range": {
                f"{PLOTLY_MAP}3": [
                    [self.lon_range1[0], self.lat_range1[0]],
                    [self.lon_range1[1], self.lat_range1[1]],
                ]
            }
        }

        event_data = BoundsYCallback.get_event_data_from_property_update(
            "selected_data", selected_data, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {"boundsy": None},
            "second": {"boundsy": None},
            "third": {"boundsy": (self.northing_range1[0], self.northing_range1[1])},
        }

    def testBoundsCallbacks(self):

        # Build callbacks
        bounds_classes = [BoundsXYCallback, BoundsXCallback, BoundsYCallback]

        _xyplots, xystreamss, _xycallbacks, xyevents = build_callback_set(
            BoundsXYCallback, ["first", "second", "third", "forth", "other"], BoundsXY, 2
        )

        _xplots, xstreamss, _xcallbacks, xevents = build_callback_set(
            BoundsXCallback, ["first", "second", "third", "forth", "other"], BoundsX, 2
        )

        _yplots, ystreamss, _ycallbacks, yevents = build_callback_set(
            BoundsYCallback, ["first", "second", "third", "forth", "other"], BoundsY, 2
        )

        # box selection on first set of axes
        selected_data1 = {"range": {"x": [1, 4], "y": [-1, 5]}}
        for cb_cls in bounds_classes:
            cb_cls.update_streams_from_property_update(
                "selected_data", selected_data1, self.fig_dict
            )

        # Check that all streams attached to 'first' and 'second' plots were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[0] + xystreamss[1],
            xstreamss[0] + xstreamss[1],
            ystreamss[0] + ystreamss[1],
            strict=None,
        ):
            assert xystream.bounds == (1, -1, 4, 5)
            assert xstream.boundsx == (1, 4)
            assert ystream.boundsy == (-1, 5)

        # Check that streams attached to plots in other subplots are called with None
        # to clear their bounds
        for xystream, xstream, ystream in zip(
            xystreamss[2] + xystreamss[3],
            xstreamss[2] + xstreamss[3],
            ystreamss[2] + ystreamss[3],
            strict=None,
        ):
            assert xystream.bounds is None
            assert xstream.boundsx is None
            assert ystream.boundsy is None

        # box select on second set of axes
        selected_data2 = {"range": {"x2": [2, 5], "y2": [0, 6]}}
        for cb_cls in bounds_classes:
            cb_cls.update_streams_from_property_update(
                "selected_data", selected_data2, self.fig_dict
            )

        # Check that all streams attached to 'second' were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[2],
            xstreamss[2],
            ystreamss[2],
            strict=None,
        ):
            assert xystream.bounds == (2, 0, 5, 6)
            assert xstream.boundsx == (2, 5)
            assert ystream.boundsy == (0, 6)

        # box select on third set of axes
        selected_data3 = {"range": {"x3": [3, 6], "y3": [1, 7]}}
        for cb_cls in bounds_classes:
            cb_cls.update_streams_from_property_update(
                "selected_data", selected_data3, self.fig_dict
            )

        # Check that all streams attached to 'third' were triggered
        for xystream, xstream, ystream in zip(
            xystreamss[3],
            xstreamss[3],
            ystreamss[3],
            strict=None,
        ):
            assert xystream.bounds == (3, 1, 6, 7)
            assert xstream.boundsx == (3, 6)
            assert ystream.boundsy == (1, 7)

        # lasso select on first set of axes should clear all bounds
        selected_data_lasso = {"lassoPoints": {"x": [1, 4, 2], "y": [-1, 5, 2]}}
        for cb_cls in bounds_classes:
            cb_cls.update_streams_from_property_update(
                "selected_data", selected_data_lasso, self.fig_dict
            )

        # Check that all streams attached to this figure are called with None
        # to clear their bounds
        for xystream, xstream, ystream in zip(
            xystreamss[0] + xystreamss[1] + xystreamss[2] + xystreamss[3],
            xstreamss[0] + xstreamss[1] + xstreamss[2] + xstreamss[3],
            ystreamss[0] + ystreamss[1] + ystreamss[2] + ystreamss[3],
            strict=None,
        ):
            assert xystream.bounds is None
            assert xstream.boundsx is None
            assert ystream.boundsy is None

        # Check that streams attached to plots not in this figure are not called
        for xyevent, xevent, yevent in zip(xyevents[4], xevents[4], yevents[4], strict=None):
            assert xyevent == []
            assert xevent == []
            assert yevent == []

    def testSelection1DCallbackEventData(self):
        selected_data1 = {
            "points": [
                {"pointNumber": 0, "curveNumber": 0},
                {"pointNumber": 2, "curveNumber": 0},
            ]
        }

        event_data = Selection1DCallback.get_event_data_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        assert event_data == {
            "first": {"index": [0, 2]},
            "second": {"index": []},
            "third": {"index": []},
            "forth": {"index": []},
        }

    def testMapboxSelection1DCallbackEventData(self):
        selected_data1 = {
            "points": [
                {"pointNumber": 0, "curveNumber": 1},
                {"pointNumber": 2, "curveNumber": 1},
            ]
        }

        event_data = Selection1DCallback.get_event_data_from_property_update(
            "selected_data", selected_data1, self.mapbox_fig_dict
        )

        assert event_data == {
            "first": {"index": []},
            "second": {"index": [0, 2]},
            "third": {"index": []},
        }

    def testSelection1DCallback(self):
        _plots, streamss, _callbacks, sel_events = build_callback_set(
            Selection1DCallback, ["first", "second", "third", "forth", "other"], Selection1D, 2
        )

        # Select points from the 'first' plot (first set of axes)
        selected_data1 = {
            "points": [
                {"pointNumber": 0, "curveNumber": 0},
                {"pointNumber": 2, "curveNumber": 0},
            ]
        }
        Selection1DCallback.update_streams_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        # Check that all streams attached to the 'first' plots were triggered
        for stream, events in zip(streamss[0], sel_events[0], strict=True):
            assert stream.index == [0, 2]
            assert len(events) == 1

        # Check that all streams attached to other plots in this figure were triggered
        # with empty selection
        for stream in streamss[1] + streamss[2] + streamss[3]:
            assert stream.index == []

        # Select points from the 'first' and 'second' plot (first set of axes)
        selected_data1 = {
            "points": [
                {"pointNumber": 0, "curveNumber": 0},
                {"pointNumber": 1, "curveNumber": 0},
                {"pointNumber": 1, "curveNumber": 1},
                {"pointNumber": 2, "curveNumber": 1},
            ]
        }
        Selection1DCallback.update_streams_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        # Check that all streams attached to the 'first' plot were triggered
        for stream in streamss[0]:
            assert stream.index == [0, 1]

        # Check that all streams attached to the 'second' plot were triggered
        for stream in streamss[1]:
            assert stream.index == [1, 2]

        # Check that all streams attached to other plots in this figure were triggered
        # with empty selection
        for stream in streamss[2] + streamss[3]:
            assert stream.index == []

        # Select points from the 'forth' plot (third set of axes)
        selected_data1 = {
            "points": [
                {"pointNumber": 0, "curveNumber": 3},
                {"pointNumber": 2, "curveNumber": 3},
            ]
        }
        Selection1DCallback.update_streams_from_property_update(
            "selected_data", selected_data1, self.fig_dict
        )

        # Check that all streams attached to the 'forth' plot were triggered
        for stream, _events in zip(streamss[3], sel_events[3], strict=True):
            assert stream.index == [0, 2]

        # Check that streams attached to plots not in this figure are not called
        for _stream, events in zip(streamss[4], sel_events[4], strict=True):
            assert len(events) == 0
