import pytest
import re
from selenium.webdriver.common.keys import Keys
import json
from multiprocessing import Lock

from dash.testing import wait
import dash
from dash import Dash, Input, Output, State, ALL, ALLSMALLER, MATCH, html, dcc

from tests.assets.todo_app import todo_app
from tests.assets.grouping_app import grouping_app


def stringify_id(id_):
    if isinstance(id_, dict):
        return json.dumps(id_, sort_keys=True, separators=(",", ":"))
    return id_


def css_escape(s):
    sel = re.sub("[\\{\\}\\\"\\'.:,]", lambda m: "\\" + m.group(0), s)
    print(sel)
    return sel


@pytest.mark.parametrize("content_callback", (False, True))
def test_cbwc001_todo_app(content_callback, dash_duo):
    app = todo_app(content_callback)
    dash_duo.start_server(app)

    dash_duo.wait_for_text_to_equal("#totals", "0 of 0 items completed")
    assert app.list_calls.value == 1
    assert app.style_calls.value == 0
    assert app.preceding_calls.value == 0
    assert app.total_calls.value == 1

    new_item = dash_duo.find_element("#new-item")
    add_item = dash_duo.find_element("#add")
    clear_done = dash_duo.find_element("#clear-done")

    def assert_count(items):
        assert len(dash_duo.find_elements("#list-container>div")) == items

    def get_done_item(item):
        selector = css_escape('#{"action":"done","item":%d} input' % item)
        return dash_duo.find_element(selector)

    def assert_item(item, text, done, prefix="", suffix=""):
        dash_duo.wait_for_text_to_equal(css_escape('#{"item":%d}' % item), text)

        expected_note = "" if done else (prefix + " preceding items are done" + suffix)
        dash_duo.wait_for_text_to_equal(
            css_escape('#{"item":%d,"preceding":true}' % item), expected_note
        )

        assert bool(get_done_item(item).get_attribute("checked")) == done

    new_item.send_keys("apples")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "0 of 1 items completed - 0%")
    assert_count(1)

    new_item.send_keys("bananas")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "0 of 2 items completed - 0%")
    assert_count(2)

    new_item.send_keys("carrots")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "0 of 3 items completed - 0%")
    assert_count(3)

    new_item.send_keys("dates")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "0 of 4 items completed - 0%")
    assert_count(4)
    assert_item(0, "apples", False, "0 of 0", " DO THIS NEXT!")
    assert_item(1, "bananas", False, "0 of 1")
    assert_item(2, "carrots", False, "0 of 2")
    assert_item(3, "dates", False, "0 of 3")

    get_done_item(2).click()
    dash_duo.wait_for_text_to_equal("#totals", "1 of 4 items completed - 25%")
    assert_item(0, "apples", False, "0 of 0", " DO THIS NEXT!")
    assert_item(1, "bananas", False, "0 of 1")
    assert_item(2, "carrots", True)
    assert_item(3, "dates", False, "1 of 3")

    get_done_item(0).click()
    dash_duo.wait_for_text_to_equal("#totals", "2 of 4 items completed - 50%")
    assert_item(0, "apples", True)
    assert_item(1, "bananas", False, "1 of 1", " DO THIS NEXT!")
    assert_item(2, "carrots", True)
    assert_item(3, "dates", False, "2 of 3")

    clear_done.click()
    dash_duo.wait_for_text_to_equal("#totals", "0 of 2 items completed - 0%")
    assert_count(2)
    assert_item(0, "bananas", False, "0 of 0", " DO THIS NEXT!")
    assert_item(1, "dates", False, "0 of 1")

    get_done_item(0).click()
    dash_duo.wait_for_text_to_equal("#totals", "1 of 2 items completed - 50%")
    assert_item(0, "bananas", True)
    assert_item(1, "dates", False, "1 of 1", " DO THIS NEXT!")

    get_done_item(1).click()
    dash_duo.wait_for_text_to_equal("#totals", "2 of 2 items completed - 100%")
    assert_item(0, "bananas", True)
    assert_item(1, "dates", True)

    clear_done.click()
    # This was a tricky one - trigger based on deleted components
    dash_duo.wait_for_text_to_equal("#totals", "0 of 0 items completed")
    assert_count(0)


fibonacci_count = 0
fibonacci_sum_count = 0


def fibonacci_app(clientside):
    global fibonacci_count
    global fibonacci_sum_count

    fibonacci_count = 0
    fibonacci_sum_count = 0

    # This app tests 2 things in particular:
    # - clientside callbacks work the same as server-side
    # - callbacks using ALLSMALLER as an input to MATCH of the exact same id/prop
    app = Dash(__name__)
    app.layout = html.Div(
        [
            dcc.Input(id="n", type="number", min=0, max=10, value=4),
            html.Div(id="series"),
            html.Div(id="sum"),
        ]
    )

    @app.callback(Output("series", "children"), Input("n", "value"))
    def items(n):
        return [html.Div(id={"i": i}) for i in range(n)]

    if clientside:
        app.clientside_callback(
            """
            function(vals) {
                var len = vals.length;
                return len < 2 ? len : +(vals[len - 1] || 0) + +(vals[len - 2] || 0);
            }
            """,
            Output({"i": MATCH}, "children"),
            Input({"i": ALLSMALLER}, "children"),
        )

        app.clientside_callback(
            """
            function(vals) {
                var sum = vals.reduce(function(a, b) { return +a + +b; }, 0);
                return vals.length + ' elements, sum: ' + sum;
            }
            """,
            Output("sum", "children"),
            Input({"i": ALL}, "children"),
        )

    else:

        @app.callback(
            Output({"i": MATCH}, "children"), Input({"i": ALLSMALLER}, "children")
        )
        def sequence(prev):
            global fibonacci_count
            fibonacci_count = fibonacci_count + 1
            print(fibonacci_count)

            if len(prev) < 2:
                return len(prev)
            return int(prev[-1] or 0) + int(prev[-2] or 0)

        @app.callback(Output("sum", "children"), Input({"i": ALL}, "children"))
        def show_sum(seq):
            global fibonacci_sum_count
            fibonacci_sum_count = fibonacci_sum_count + 1
            print("fibonacci_sum_count: ", fibonacci_sum_count)

            return "{} elements, sum: {}".format(
                len(seq), sum(int(v or 0) for v in seq)
            )

    return app


@pytest.mark.parametrize("clientside", (False, True))
def test_cbwc002_fibonacci_app(clientside, dash_duo):
    app = fibonacci_app(clientside)
    dash_duo.start_server(app)

    # app starts with 4 elements: 0, 1, 1, 2
    dash_duo.wait_for_text_to_equal("#sum", "4 elements, sum: 4")

    # add 5th item, "3"
    dash_duo.find_element("#n").send_keys(Keys.UP)
    dash_duo.wait_for_text_to_equal("#sum", "5 elements, sum: 7")

    # add 6th item, "5"
    dash_duo.find_element("#n").send_keys(Keys.UP)
    dash_duo.wait_for_text_to_equal("#sum", "6 elements, sum: 12")

    # add 7th item, "8"
    dash_duo.find_element("#n").send_keys(Keys.UP)
    dash_duo.wait_for_text_to_equal("#sum", "7 elements, sum: 20")

    # back down all the way to no elements
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "6 elements, sum: 12")
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "5 elements, sum: 7")
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "4 elements, sum: 4")
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "3 elements, sum: 2")
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "2 elements, sum: 1")
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "1 elements, sum: 0")
    dash_duo.find_element("#n").send_keys(Keys.DOWN)
    dash_duo.wait_for_text_to_equal("#sum", "0 elements, sum: 0")


def test_cbwc003_same_keys(dash_duo):
    app = Dash(__name__, suppress_callback_exceptions=True)

    app.layout = html.Div(
        [
            html.Button("Add Filter", id="add-filter", n_clicks=0),
            html.Div(id="container", children=[]),
        ]
    )

    @app.callback(
        Output("container", "children"),
        [Input("add-filter", "n_clicks")],
        [State("container", "children")],
    )
    def display_dropdowns(n_clicks, children):
        new_element = html.Div(
            [
                dcc.Dropdown(
                    id={"type": "dropdown", "index": n_clicks},
                    options=[
                        {"label": i, "value": i} for i in ["NYC", "MTL", "LA", "TOKYO"]
                    ],
                ),
                html.Div(id={"type": "output", "index": n_clicks}),
            ]
        )
        return children + [new_element]

    @app.callback(
        Output({"type": "output", "index": MATCH}, "children"),
        [Input({"type": "dropdown", "index": MATCH}, "value")],
        [State({"type": "dropdown", "index": MATCH}, "id")],
    )
    def display_output(value, id):
        return html.Div("Dropdown {} = {}".format(id["index"], value))

    dash_duo.start_server(app)
    dash_duo.wait_for_text_to_equal("#add-filter", "Add Filter")
    dash_duo.select_dcc_dropdown(
        '#\\{\\"index\\"\\:0\\,\\"type\\"\\:\\"dropdown\\"\\}', "LA"
    )
    dash_duo.wait_for_text_to_equal(
        '#\\{\\"index\\"\\:0\\,\\"type\\"\\:\\"output\\"\\}', "Dropdown 0 = LA"
    )
    dash_duo.find_element("#add-filter").click()
    dash_duo.select_dcc_dropdown(
        '#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"dropdown\\"\\}', "MTL"
    )
    dash_duo.wait_for_text_to_equal(
        '#\\{\\"index\\"\\:1\\,\\"type\\"\\:\\"output\\"\\}', "Dropdown 1 = MTL"
    )
    dash_duo.wait_for_text_to_equal(
        '#\\{\\"index\\"\\:0\\,\\"type\\"\\:\\"output\\"\\}', "Dropdown 0 = LA"
    )
    dash_duo.wait_for_no_elements(dash_duo.devtools_error_count_locator)


def test_cbwc004_layout_chunk_changed_props(dash_duo):
    app = Dash(__name__)
    app.layout = html.Div(
        [
            dcc.Input(id={"type": "input", "index": 1}, value="input-1"),
            html.Div(id="container"),
            html.Div(id="output-outer"),
            html.Button("Show content", id="btn"),
        ]
    )

    @app.callback(Output("container", "children"), [Input("btn", "n_clicks")])
    def display_output(n):
        if n:
            return html.Div(
                [
                    dcc.Input(id={"type": "input", "index": 2}, value="input-2"),
                    html.Div(id="output-inner"),
                ]
            )
        else:
            return "No content initially"

    def trigger_info():
        triggered = dash.callback_context.triggered
        return "triggered is {} with prop_ids {}".format(
            "Truthy" if triggered else "Falsy",
            ", ".join(t["prop_id"] for t in triggered),
        )

    @app.callback(
        Output("output-inner", "children"),
        [Input({"type": "input", "index": ALL}, "value")],
    )
    def update_dynamic_output_pattern(wc_inputs):
        return trigger_info()
        # When this is triggered because output-2 was rendered,
        # nothing has changed

    @app.callback(
        Output("output-outer", "children"),
        [Input({"type": "input", "index": ALL}, "value")],
    )
    def update_output_on_page_pattern(value):
        return trigger_info()
        # When this triggered on page load,
        # nothing has changed
        # When dcc.Input(id={'type': 'input', 'index': 2})
        # is rendered (from display_output)
        # then `{'type': 'input', 'index': 2}` has changed

    dash_duo.start_server(app)

    dash_duo.wait_for_text_to_equal("#container", "No content initially")
    dash_duo.wait_for_text_to_equal(
        "#output-outer", "triggered is Falsy with prop_ids ."
    )

    dash_duo.find_element("#btn").click()
    dash_duo.wait_for_text_to_equal(
        "#output-outer",
        'triggered is Truthy with prop_ids {"index":2,"type":"input"}.value',
    )
    dash_duo.wait_for_text_to_equal(
        "#output-inner", "triggered is Falsy with prop_ids ."
    )

    dash_duo.find_elements("input")[0].send_keys("X")
    trigger_text = 'triggered is Truthy with prop_ids {"index":1,"type":"input"}.value'
    dash_duo.wait_for_text_to_equal("#output-outer", trigger_text)
    dash_duo.wait_for_text_to_equal("#output-inner", trigger_text)


def test_cbwc005_callbacks_count(dash_duo):
    global fibonacci_count
    global fibonacci_sum_count

    app = fibonacci_app(False)
    dash_duo.start_server(app)

    wait.until(lambda: fibonacci_count == 4, 3)  # initial
    wait.until(lambda: fibonacci_sum_count == 2, 3)  # initial + triggered

    dash_duo.find_element("#n").send_keys(Keys.UP)  # 5
    wait.until(lambda: fibonacci_count == 9, 3)
    wait.until(lambda: fibonacci_sum_count == 3, 3)

    dash_duo.find_element("#n").send_keys(Keys.UP)  # 6
    wait.until(lambda: fibonacci_count == 15, 3)
    wait.until(lambda: fibonacci_sum_count == 4, 3)

    dash_duo.find_element("#n").send_keys(Keys.DOWN)  # 5
    wait.until(lambda: fibonacci_count == 20, 3)
    wait.until(lambda: fibonacci_sum_count == 5, 3)

    dash_duo.find_element("#n").send_keys(Keys.DOWN)  # 4
    wait.until(lambda: fibonacci_count == 24, 3)
    wait.until(lambda: fibonacci_sum_count == 6, 3)

    dash_duo.find_element("#n").send_keys(Keys.DOWN)  # 3
    wait.until(lambda: fibonacci_count == 27, 3)
    wait.until(lambda: fibonacci_sum_count == 7, 3)

    dash_duo.find_element("#n").send_keys(Keys.DOWN)  # 2
    wait.until(lambda: fibonacci_count == 29, 3)
    wait.until(lambda: fibonacci_sum_count == 8, 3)

    dash_duo.find_element("#n").send_keys(Keys.DOWN)  # 1
    wait.until(lambda: fibonacci_count == 30, 3)
    wait.until(lambda: fibonacci_sum_count == 9, 3)

    dash_duo.find_element("#n").send_keys(Keys.DOWN)  # 0
    wait.until(lambda: fibonacci_count == 30, 3)
    wait.until(lambda: fibonacci_sum_count == 10, 3)


def test_cbwc006_grouping_callbacks(dash_duo):
    app = grouping_app()
    dash_duo.start_server(app)

    dash_duo.wait_for_text_to_equal("#title", "Dash To-Do list")

    new_item = dash_duo.find_element("#new-item")
    add_item = dash_duo.find_element("#add")

    def assert_count(items):
        assert len(dash_duo.find_elements("#list-container>div")) == items

    def assert_callback_context(items_text):
        # Check args_grouping
        args_grouping = dict(
            items=dict(
                all=[
                    {
                        "id": {"id": i},
                        "property": "children",
                        "value": text,
                        "str_id": stringify_id({"id": i}),
                        "triggered": False,
                    }
                    for i, text in enumerate(items_text[:-1])
                ],
                new=dict(
                    id="new-item",
                    property="value",
                    value=items_text[-1],
                    str_id="new-item",
                    triggered=False,
                ),
            ),
            triggers=[
                {
                    "id": "add",
                    "property": "n_clicks",
                    "value": len(items_text),
                    "str_id": "add",
                    "triggered": True,
                },
                {
                    "id": "new-item",
                    "property": "n_submit",
                    "value": None,
                    "str_id": "new-item",
                    "triggered": False,
                },
            ],
        )
        dash_duo.wait_for_text_to_equal("#cc-args-grouping", repr(args_grouping))

        # Check outputs_grouping
        outputs_grouping = dict(
            list_container={"id": "list-container", "property": "children"},
            new_item={"id": "new-item", "property": "value"},
            totals={"id": "totals", "property": "children"},
            cc_args_grouping={"id": "cc-args-grouping", "property": "children"},
            cc_outputs_grouping={"id": "cc-outputs-grouping", "property": "children"},
        )
        dash_duo.wait_for_text_to_equal("#cc-outputs-grouping", repr(outputs_grouping))

    new_item.send_keys("apples")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "1 total item(s)")
    assert_count(1)
    assert_callback_context(["apples"])

    new_item.send_keys("bananas")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "2 total item(s)")
    assert_count(2)
    assert_callback_context(["apples", "bananas"])

    new_item.send_keys("carrots")
    add_item.click()
    dash_duo.wait_for_text_to_equal("#totals", "3 total item(s)")
    assert_count(3)
    assert_callback_context(["apples", "bananas", "carrots"])


def test_cbwc007_pmc_update_subtree_ordering(dash_duo):
    # Test for regression bug #2368, updated pmc subtree should keep order.
    app = dash.Dash(__name__)

    app.layout = html.Div(
        [
            html.Button("refresh options", id="refresh-options"),
            html.Br(),
            html.Div(
                [
                    *[
                        dcc.Dropdown(
                            id={"type": "demo-options", "index": i},
                            placeholder=f"dropdown-{i}",
                            style={"width": "200px"},
                        )
                        for i in range(2)
                    ],
                    dcc.Dropdown(
                        id={"type": "demo-options", "index": 2},
                        options=[f"option2-{i}" for i in range(3)],
                        placeholder="dropdown-2",
                        style={"width": "200px"},
                    ),
                ],
                id="dropdown-container",
            ),
            html.Br(),
            html.Pre(id="selected-values"),
        ],
        style={"padding": "50px"},
    )

    @app.callback(
        [
            Output({"type": "demo-options", "index": 0}, "options"),
            Output({"type": "demo-options", "index": 1}, "options"),
        ],
        Input("refresh-options", "n_clicks"),
        prevent_initial_call=True,
    )
    def refresh_options(_):
        return [[f"option0-{i}" for i in range(3)], [f"option1-{i}" for i in range(3)]]

    @app.callback(
        Output("selected-values", "children"),
        Input({"type": "demo-options", "index": ALL}, "value"),
    )
    def update_selected_values(values):
        return str(values)

    dash_duo.start_server(app)
    dash_duo.select_dcc_dropdown(".dash-dropdown:nth-child(3)", index=2)

    dash_duo.wait_for_text_to_equal("#selected-values", "[None, None, 'option2-2']")

    dash_duo.wait_for_element("#refresh-options").click()

    dash_duo.select_dcc_dropdown(".dash-dropdown:nth-child(2)", index=2)
    dash_duo.wait_for_text_to_equal(
        "#selected-values", "[None, 'option1-2', 'option2-2']"
    )

    dash_duo.select_dcc_dropdown(".dash-dropdown:nth-child(1)", index=2)
    dash_duo.wait_for_text_to_equal(
        "#selected-values", "['option0-2', 'option1-2', 'option2-2']"
    )


def test_cbwc008_running_match(dash_duo):
    lock = Lock()
    app = dash.Dash()

    app.layout = [
        html.Div(
            [
                html.Button(
                    "Test1",
                    id={"component": "button", "index": "1"},
                ),
                html.Button(
                    "Test2",
                    id={"component": "button", "index": "2"},
                ),
            ],
            id="buttons",
        ),
        html.Div(html.Div(id={"component": "output", "index": "1"}), id="output1"),
        html.Div(html.Div(id={"component": "output", "index": "2"}), id="output2"),
    ]

    @app.callback(
        Output({"component": "output", "index": MATCH}, "children"),
        Input({"component": "button", "index": MATCH}, "n_clicks"),
        running=[
            (
                Output({"component": "button", "index": MATCH}, "children"),
                "running",
                "finished",
            ),
            (Output({"component": "button", "index": ALL}, "disabled"), True, False),
        ],
        prevent_initial_call=True,
    )
    def on_click(_) -> str:
        with lock:
            return "done"

    dash_duo.start_server(app)

    for i in range(1, 3):
        with lock:
            dash_duo.find_element(f"#buttons button:nth-child({i})").click()
            dash_duo.wait_for_text_to_equal(
                f"#buttons button:nth-child({i})", "running"
            )
            # verify all the buttons were disabled.
            assert dash_duo.find_element("#buttons button:nth-child(1)").get_attribute(
                "disabled"
            )
            assert dash_duo.find_element("#buttons button:nth-child(2)").get_attribute(
                "disabled"
            )

        dash_duo.wait_for_text_to_equal(f"#output{i}", "done")
        dash_duo.wait_for_text_to_equal(f"#buttons button:nth-child({i})", "finished")

        assert not dash_duo.find_element("#buttons button:nth-child(1)").get_attribute(
            "disabled"
        )
        assert not dash_duo.find_element("#buttons button:nth-child(2)").get_attribute(
            "disabled"
        )
