from unittest.mock import MagicMock, patch

import pytest

import holoviews as hv

try:
    import dash  # noqa: F401
except ImportError:
    pytest.skip("Dash not installed", allow_module_level=True)

from dash._callback_context import CallbackContext

from holoviews.plotting.plotly.dash import (
    DashComponents,
    decode_store_data,
    encode_store_data,
    to_dash,
)
from holoviews.streams import BoundsXY, RangeXY, Selection1D
from holoviews.testing import assert_element_equal

from .test_plot import TestPlotlyPlot

try:
    from dash_core_components import Store
except ImportError:
    from dash.dcc import Store
import plotly.io as pio

pio.templates.default = None


class TestHoloViewsDash(TestPlotlyPlot):
    def setup_method(self):
        super().setup_method()

        # Build Dash app mock
        self.app = MagicMock()
        self.decorator = MagicMock()
        self.app.callback.return_value = self.decorator

    def test_simple_element(self):
        # Build Holoviews Elements
        scatter = hv.Scatter([0, 0])

        # Convert to Dash
        components = to_dash(self.app, [scatter])

        # Check returned components
        assert isinstance(components, DashComponents)
        assert len(components.graphs) == 1
        assert len(components.kdims) == 0
        assert isinstance(components.store, Store)
        assert len(components.resets) == 0

        # Check initial figure
        fig = components.graphs[0].figure
        assert len(fig["data"]) == 1
        assert fig["data"][0]["type"] == "scatter"

    def test_boundsxy_dynamic_map(self):
        # Build Holoviews Elements
        scatter = hv.Scatter([0, 0])
        boundsxy = BoundsXY(source=scatter)
        dmap = hv.DynamicMap(
            lambda bounds: hv.Bounds(bounds) if bounds is not None else hv.Bounds((0, 0, 0, 0)),
            streams=[boundsxy],
        )

        # Convert to Dash
        components = to_dash(self.app, [scatter, dmap], reset_button=True)

        # Check returned components
        assert isinstance(components, DashComponents)
        assert len(components.graphs) == 2
        assert len(components.kdims) == 0
        assert isinstance(components.store, Store)
        assert len(components.resets) == 1

        # Get arguments passed to @app.callback decorator
        decorator_args = next(iter(self.app.callback.call_args_list[0]))
        outputs, inputs, states = decorator_args

        # Check outputs
        expected_outputs = [(g.id, "figure") for g in components.graphs] + [
            (components.store.id, "data")
        ]
        assert [
            (output.component_id, output.component_property) for output in outputs
        ] == expected_outputs

        # Check inputs
        expected_inputs = [
            (g.id, prop) for g in components.graphs for prop in ["selectedData", "relayoutData"]
        ] + [(components.resets[0].id, "n_clicks")]

        assert [(ip.component_id, ip.component_property) for ip in inputs] == expected_inputs

        # Check State
        expected_state = [(components.store.id, "data")]
        assert [
            (state.component_id, state.component_property) for state in states
        ] == expected_state

        # Check initial figures
        fig1 = components.graphs[0].figure
        fig2 = components.graphs[1].figure
        assert fig1["data"][0]["type"] == "scatter"

        # Second figure holds the bounds element
        assert len(fig2["data"]) == 0
        assert len(fig2["layout"]["shapes"]) == 1
        assert fig2["layout"]["shapes"][0]["path"] == "M0 0L0 0L0 0L0 0L0 0Z"

        # Get callback function
        callback_fn = self.app.callback.return_value.call_args[0][0]

        # # mimic initial callback invocation
        store_value = encode_store_data({"streams": {id(boundsxy): boundsxy.contents}})

        # Update store, then mimic a box selection on scatter figure
        # store_value = new_store
        with patch.object(
            CallbackContext, "triggered", [{"prop_id": inputs[0].component_id + ".selectedData"}]
        ):
            [fig1, fig2, new_store] = callback_fn(
                {"range": {"x": [1, 2], "y": [3, 4]}}, {}, {}, {}, 0, store_value
            )

        # First figure is the scatter trace
        assert fig1["data"][0]["type"] == "scatter"

        # Second figure holds the bounds element
        assert len(fig2["data"]) == 0
        assert len(fig2["layout"]["shapes"]) == 1
        assert_element_equal(
            fig2["layout"]["shapes"][0]["path"],
            "M1 3L1 4L2 4L2 3L1 3Z",
        )

        # Check that store was updated
        assert decode_store_data(new_store) == {
            "streams": {id(boundsxy): {"bounds": (1, 3, 2, 4)}},
            "kdims": {},
        }

        # Click reset button
        with patch.object(
            CallbackContext, "triggered", [{"prop_id": components.resets[0].id + ".n_clicks"}]
        ):
            [fig1, fig2, new_store] = callback_fn(
                {"range": {"x": [1, 2], "y": [3, 4]}}, {}, {}, {}, 1, store_value
            )

        # First figure is the scatter trace
        assert fig1["data"][0]["type"] == "scatter"

        # Second figure holds reset bounds element
        assert len(fig2["data"]) == 0
        assert len(fig2["layout"]["shapes"]) == 1
        assert fig2["layout"]["shapes"][0]["path"] == "M0 0L0 0L0 0L0 0L0 0Z"

        # Reset button should clear bounds in store
        assert decode_store_data(new_store) == {
            "streams": {id(boundsxy): {"bounds": None}},
            "reset_nclicks": 1,
            "kdims": {},
        }

    def test_rangexy_dynamic_map(self):

        # Create dynamic map that inputs rangexy, returns scatter on bounds
        scatter = hv.Scatter([[0, 1], [0, 1]], kdims=["x"], vdims=["y"])
        rangexy = RangeXY(source=scatter)

        def dmap_fn(x_range, y_range):
            x_range = (0, 1) if x_range is None else x_range
            y_range = (0, 1) if y_range is None else y_range
            return hv.Scatter(
                [[x_range[0], y_range[0]], [x_range[1], y_range[1]]], kdims=["x1"], vdims=["y1"]
            )

        dmap = hv.DynamicMap(dmap_fn, streams=[rangexy])

        # Convert to Dash
        components = to_dash(self.app, [scatter, dmap], reset_button=True)

        # Check returned components
        assert isinstance(components, DashComponents)
        assert len(components.graphs) == 2
        assert len(components.kdims) == 0
        assert isinstance(components.store, Store)
        assert len(components.resets) == 1

        # Get arguments passed to @app.callback decorator
        decorator_args = next(iter(self.app.callback.call_args_list[0]))
        outputs, inputs, states = decorator_args

        # Check outputs
        expected_outputs = [(g.id, "figure") for g in components.graphs] + [
            (components.store.id, "data")
        ]
        assert [
            (output.component_id, output.component_property) for output in outputs
        ] == expected_outputs

        # Check inputs
        expected_inputs = [
            (g.id, prop) for g in components.graphs for prop in ["selectedData", "relayoutData"]
        ] + [(components.resets[0].id, "n_clicks")]

        assert [(ip.component_id, ip.component_property) for ip in inputs] == expected_inputs

        # Check State
        expected_state = [(components.store.id, "data")]
        assert [
            (state.component_id, state.component_property) for state in states
        ] == expected_state

        # Get callback function
        callback_fn = self.app.callback.return_value.call_args[0][0]

        # mimic initial callback invocation
        store_value = encode_store_data({"streams": {id(rangexy): rangexy.contents}})
        with patch.object(
            CallbackContext, "triggered", [{"prop_id": components.graphs[0].id + ".relayoutData"}]
        ):
            [fig1, fig2, new_store] = callback_fn(
                {},
                {
                    "xaxis.range[0]": 1,
                    "xaxis.range[1]": 3,
                    "yaxis.range[0]": 2,
                    "yaxis.range[1]": 4,
                },
                {},
                {},
                None,
                store_value,
            )

        # First figure is the scatter trace
        assert fig1["data"][0]["type"] == "scatter"

        # Second figure holds the bounds element
        assert len(fig2["data"]) == 1
        assert list(fig2["data"][0]["x"]) == [1, 3]
        assert list(fig2["data"][0]["y"]) == [2, 4]

        # Check updated store
        assert decode_store_data(new_store) == {
            "streams": {id(rangexy): {"x_range": (1, 3), "y_range": (2, 4)}},
            "kdims": {},
        }

    def test_selection1d_dynamic_map(self):
        # Create dynamic map that inputs selection1d, returns overlay of scatter on
        # selected points
        scatter = hv.Scatter([[0, 0], [1, 1], [2, 2]])
        selection1d = Selection1D(source=scatter)
        dmap = hv.DynamicMap(
            lambda index: scatter.iloc[index].opts(size=len(index) + 1), streams=[selection1d]
        )

        # Convert to Dash
        components = to_dash(self.app, [scatter, dmap], reset_button=True)

        # Check returned components
        assert isinstance(components, DashComponents)
        assert len(components.graphs) == 2
        assert len(components.kdims) == 0
        assert isinstance(components.store, Store)
        assert len(components.resets) == 1

        # Get arguments passed to @app.callback decorator
        decorator_args = next(iter(self.app.callback.call_args_list[0]))
        outputs, inputs, states = decorator_args

        # Check outputs
        expected_outputs = [(g.id, "figure") for g in components.graphs] + [
            (components.store.id, "data")
        ]
        assert [
            (output.component_id, output.component_property) for output in outputs
        ] == expected_outputs

        # Check inputs
        expected_inputs = [
            (g.id, prop) for g in components.graphs for prop in ["selectedData", "relayoutData"]
        ] + [(components.resets[0].id, "n_clicks")]

        assert [(ip.component_id, ip.component_property) for ip in inputs] == expected_inputs

        # Check State
        expected_state = [(components.store.id, "data")]
        assert [
            (state.component_id, state.component_property) for state in states
        ] == expected_state

        # Check initial figures
        _fig1 = components.graphs[0].figure
        fig2 = components.graphs[1].figure

        # Figure holds the scatter trace
        assert len(fig2["data"]) == 1

        # Check expected marker size
        assert fig2["data"][0]["marker"]["size"] == 1
        assert list(fig2["data"][0]["x"]) == []
        assert list(fig2["data"][0]["y"]) == []

        # Get callback function
        callback_fn = self.app.callback.return_value.call_args[0][0]

        # mimic initial callback invocation
        store_value = encode_store_data({"streams": {id(selection1d): selection1d.contents}})

        # Update store, then mimic a selection on scatter figure
        # store_value = new_store
        with patch.object(
            CallbackContext, "triggered", [{"prop_id": inputs[0].component_id + ".selectedData"}]
        ):
            [_fig1, fig2, new_store] = callback_fn(
                {
                    "points": [
                        {
                            "curveNumber": 0,
                            "pointNumber": 0,
                            "pointIndex": 0,
                        },
                        {
                            "curveNumber": 0,
                            "pointNumber": 2,
                            "pointIndex": 2,
                        },
                    ]
                },
                {},
                0,
                store_value,
            )

        # Figure holds the scatter trace
        assert len(fig2["data"]) == 1

        # Check expected marker size
        assert fig2["data"][0]["marker"]["size"] == 3
        assert list(fig2["data"][0]["x"]) == [0, 2]
        assert list(fig2["data"][0]["y"]) == [0, 2]

        # Check that store was updated
        assert decode_store_data(new_store) == {
            "streams": {id(selection1d): {"index": [0, 2]}},
            "kdims": {},
        }

        # Click reset button
        store = new_store
        with patch.object(
            CallbackContext, "triggered", [{"prop_id": components.resets[0].id + ".n_clicks"}]
        ):
            [_fig1, fig2, new_store] = callback_fn({}, {}, 1, store)

        # Figure holds the scatter trace
        assert len(fig2["data"]) == 1

        # Check expected marker size
        assert fig2["data"][0]["marker"]["size"] == 1
        assert list(fig2["data"][0]["x"]) == []
        assert list(fig2["data"][0]["y"]) == []

        # Check that store was updated
        assert_element_equal(
            decode_store_data(new_store),
            {"streams": {id(selection1d): {"index": []}}, "reset_nclicks": 1, "kdims": {}},
        )

    def test_kdims_dynamic_map(self):
        # Dynamic map with two key dimensions
        dmap = hv.DynamicMap(
            lambda kdim1: hv.Scatter([kdim1, kdim1]), kdims=["kdim1"]
        ).redim.values(kdim1=[1, 2, 3, 4])

        # Convert to Dash
        components = to_dash(self.app, [dmap])

        # Check returned components
        assert isinstance(components, DashComponents)
        assert len(components.graphs) == 1
        assert len(components.kdims) == 1
        assert isinstance(components.store, Store)
        assert len(components.resets) == 0

        # Get arguments passed to @app.callback decorator
        decorator_args = next(iter(self.app.callback.call_args_list[0]))
        outputs, inputs, states = decorator_args

        # Check outputs
        expected_outputs = [(g.id, "figure") for g in components.graphs] + [
            (components.store.id, "data")
        ]
        assert [
            (output.component_id, output.component_property) for output in outputs
        ] == expected_outputs

        # Check inputs
        expected_inputs = [
            (g.id, prop) for g in components.graphs for prop in ["selectedData", "relayoutData"]
        ] + [(next(iter(components.kdims.values())).children[1].id, "value")]

        assert [(ip.component_id, ip.component_property) for ip in inputs] == expected_inputs

        # Check State
        expected_state = [(components.store.id, "data")]
        assert [
            (state.component_id, state.component_property) for state in states
        ] == expected_state

        # Get callback function
        callback_fn = self.decorator.call_args_list[0][0][0]

        # mimic initial callback invocation
        store_value = encode_store_data({"streams": {}})
        with patch.object(CallbackContext, "triggered", []):
            [fig, _new_store] = callback_fn({}, {}, 3, None, store_value)

        # First figure is the scatter trace
        assert fig["data"][0]["type"] == "scatter"
        assert list(fig["data"][0]["x"]) == [0, 1]
        assert list(fig["data"][0]["y"]) == [3, 3]
