import platform
import pytest
from functools import wraps
import inspect

from dash.testing.browser import Browser
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait


# @preconditions decorator, copied from the preconditions PyPI package
# https://pypi.org/project/preconditions/
# https://github.com/nejucomo/preconditions
# and modified to support Python 3.12


class PreconditionError(TypeError):
    pass


def preconditions(*precs):
    stripped_source = lambda obj: inspect.getsource(obj).strip()

    if not precs:
        # This edge case makes ``@preconditions()`` efficiently delegate
        # to the wrapped function, which I anticipate will be useful
        # for stubbing and code consistency in applications:
        def null_decorator(f):
            f.nopre = f  # Meet the .nopre interface requirement.
            return f

        return null_decorator

    precinfo = []
    for p in precs:
        spec = inspect.getfullargspec(p)
        if spec.varargs or spec.varkw:
            raise PreconditionError(
                (
                    "Invalid precondition must not accept * nor ** args:\n" + "  {!s}\n"
                ).format(stripped_source(p))
            )

        i = -len(spec.defaults or ())
        if i == 0:
            appargs, closureargs = spec.args, []
        else:
            appargs, closureargs = spec.args[:i], spec.args[i:]
        precinfo.append((appargs, closureargs, p))

    def decorate(f):
        fspec = inspect.getfullargspec(f)

        for (appargs, closureargs, p) in precinfo:
            for apparg in appargs:
                if apparg not in fspec.args:
                    raise PreconditionError(
                        (
                            "Invalid precondition refers to unknown parameter {!r}:\n"
                            + "  {!s}\n"
                            + "Known parameters: {!r}\n"
                        ).format(apparg, stripped_source(p), fspec.args)
                    )
            for carg in closureargs:
                if carg in fspec.args:
                    raise PreconditionError(
                        (
                            "Invalid precondition masks parameter {!r}:\n"
                            + "  {!s}\n"
                            + "Known parameters: {!r}\n"
                        ).format(carg, stripped_source(p), fspec.args)
                    )

        @wraps(f)
        def g(*a, **kw):
            args = inspect.getcallargs(f, *a, **kw)
            for (appargs, _, p) in precinfo:
                if not p(*[args[aa] for aa in appargs]):
                    raise PreconditionError(
                        "Precondition failed in call {!r}{}:\n  {!s}\n".format(
                            g,
                            inspect.formatargvalues(
                                fspec.args, fspec.varargs, fspec.varkw, args
                            ),
                            stripped_source(p),
                        )
                    )

            return f(*a, **kw)

        g.nopre = f
        return g

    return decorate


_validate_col = lambda col: (isinstance(col, str) and len(col) > 0) or (
    isinstance(col, int) and col >= 0
)
_validate_id = lambda id: isinstance(id, str) and len(id) > 0
_validate_key = lambda key: isinstance(key, str) and len(key) == 1
_validate_keys = lambda keys: isinstance(keys, str) and len(keys) > 0
_validate_mixin = lambda mixin: isinstance(mixin, DataTableMixin)
_validate_row = lambda row: isinstance(row, int) and row >= 0
_validate_selector = lambda selector: isinstance(selector, str) and len(selector) > 0
_validate_state = lambda state: state in [_READY, _LOADING, _ANY]
_validate_target = lambda target: isinstance(target, DataTableFacade)

_READY = ".dash-spreadsheet:not(.dash-loading)"
_LOADING = ".dash-spreadsheet.dash-loading"
_ANY = ".dash-spreadsheet"
_TIMEOUT = 10

CMD = Keys.COMMAND if platform.system() == "Darwin" else Keys.CONTROL


class HoldKeyContext:
    @preconditions(_validate_mixin, _validate_key)
    def __init__(self, mixin, key):
        self.mixin = mixin
        self.key = key

    def __enter__(self):
        ActionChains(self.mixin.driver).key_down(self.key).perform()

    def __exit__(self, type, value, traceback):
        ActionChains(self.mixin.driver).key_up(self.key).perform()


class DataTableCellFacade(object):
    @preconditions(
        _validate_id, _validate_mixin, _validate_row, _validate_col, _validate_state
    )
    def __init__(self, id, mixin, row, col, state=_ANY):
        self.id = id
        self.mixin = mixin
        self.row = row
        self.col = col
        self.state = state

    def _get_cell_value(self):
        return self.get().find_element(By.CSS_SELECTOR, ".dash-cell-value")

    def click(self):
        return self.get().click()

    def double_click(self):
        ac = ActionChains(self.mixin.driver)
        ac.move_to_element(self._get_cell_value())
        ac.pause(1)  # sometimes experiencing incorrect behavior on scroll otherwise
        ac.double_click()
        return ac.perform()

    def exists(self):
        self.mixin._wait_for_table(self.id, self.state)

        return (
            len(
                self.mixin.find_elements(
                    '#{} {} tbody td.dash-cell.column-{}[data-dash-row="{}"]:not(.phantom-cell)'.format(
                        self.id, self.state, self.col, self.row
                    )
                )
            )
            == 1
            if isinstance(self.col, int)
            else len(
                self.mixin.find_elements(
                    '#{} {} tbody td.dash-cell[data-dash-column="{}"][data-dash-row="{}"]:not(.phantom-cell)'.format(
                        self.id, self.state, self.col, self.row
                    )
                )
            )
            == 1
        )

    def get(self):
        self.mixin._wait_for_table(self.id, self.state)

        return (
            self.mixin.find_element(
                '#{} {} tbody td.dash-cell.column-{}[data-dash-row="{}"]:not(.phantom-cell)'.format(
                    self.id, self.state, self.col, self.row
                )
            )
            if isinstance(self.col, int)
            else self.mixin.find_element(
                '#{} {} tbody td.dash-cell[data-dash-column="{}"][data-dash-row="{}"]:not(.phantom-cell)'.format(
                    self.id, self.state, self.col, self.row
                )
            )
        )

    def find_inside(self, selector):
        return self.get().find_element(By.CSS_SELECTOR, selector)

    def find_all_inside(self, selector):
        return self.get().find_elements(By.CSS_SELECTOR, selector)

    def is_dropdown(self):
        el = self.get().find_elements(By.CSS_SELECTOR, ".Select-arrow")

        return len(el) == 1

    def is_input(self):
        el = self.get().find_elements(By.CSS_SELECTOR, ".dash-cell-value")

        return len(el) == 1 and el[0].get_attribute("type") is not None

    def get_text(self):
        el = self._get_cell_value()

        value = el.get_attribute("value")
        return (
            value
            if value is not None and value != ""
            else el.get_attribute("innerHTML")
        )

    def move_to(self):
        ac = ActionChains(self.mixin.driver)
        ac.move_to_element(self._get_cell_value())
        return ac.perform()

    def is_active(self):
        input = self.get().find_element(By.CSS_SELECTOR, "input")

        return "focused" in input.get_attribute("class").split(" ")

    def is_selected(self):
        cell = self.get()

        return "cell--selected" in cell.get_attribute("class").split(" ")

    def is_focused(self):
        cell = self.get()

        return "focused" in cell.get_attribute("class").split(" ")

    def is_value_focused(self):
        el = self._get_cell_value()

        return "focused" in el.get_attribute("class").split(" ")

    def open_dropdown(self):
        cell = self.get()

        cell.find_element(By.CSS_SELECTOR, ".Select-arrow").click()


class DataTableColumnFacade(object):
    @preconditions(_validate_id, _validate_mixin, _validate_col, _validate_state)
    def __init__(self, id, mixin, col, state=_ANY):
        self.id = id
        self.mixin = mixin
        self.col = col
        self.state = state

    @preconditions(_validate_row)
    def get(self, row=0):
        self.mixin._wait_for_table(self.id, self.state)

        return (
            self.mixin.find_element(
                "#{} {} tbody tr:nth-of-type({}) th.dash-header.column-{}:not(.phantom-cell)".format(
                    self.id, self.state, row + 1, self.col
                )
            )
            if isinstance(self.col, int)
            else self.mixin.find_element(
                '#{} {} tbody tr:nth-of-type({}) th.dash-header[data-dash-column="{}"]:not(.phantom-cell)'.format(
                    self.id, self.state, row + 1, self.col
                )
            )
        )

    def find_inside(self, row, selector):
        return self.get(row).find_element(By.CSS_SELECTOR, selector)

    def find_all_inside(self, row, selector):
        return self.get(row).find_elements(By.CSS_SELECTOR, selector)

    def exists(self, row=0):
        self.mixin._wait_for_table(self.id, self.state)

        els = (
            self.mixin.find_elements(
                "#{} {} tbody tr:nth-of-type({}) th.dash-header.column-{}:not(.phantom-cell)".format(
                    self.id, self.state, row + 1, self.col
                )
            )
            if isinstance(self.col, int)
            else self.mixin.find_elements(
                '#{} {} tbody tr:nth-of-type({}) th.dash-header[data-dash-column="{}"]:not(.phantom-cell)'.format(
                    self.id, self.state, row + 1, self.col
                )
            )
        )

        return len(els) != 0

    @preconditions(_validate_row)
    def clear(self, row=0):
        self.find_inside(row, ".column-header--clear").click()

    @preconditions(_validate_row)
    def delete(self, row=0):
        self.find_inside(row, ".column-header--delete").click()

    @preconditions(_validate_row)
    def edit(self, row=0):
        self.find_inside(row, ".column-header--edit").click()

    @preconditions(_validate_row)
    def get_text(self, row=0):
        el = self.find_inside(row, "span.column-header-name")

        return el.get_attribute("innerHTML") if el is not None else None

    @preconditions(_validate_row)
    def hide(self, row=0):
        self.find_inside(row, ".column-header--hide").click()

    @preconditions(_validate_row)
    def is_selected(self, row=0):
        return self.find_inside(row, ".column-header--select input").is_selected()

    @preconditions(_validate_row)
    def move_to(self, row=0):
        ac = ActionChains(self.mixin.driver)
        ac.move_to_element(self.get(row))
        return ac.perform()

    @preconditions(_validate_row)
    def select(self, row=0):
        self.find_inside(row, ".column-header--select input").click()

    @preconditions(_validate_row)
    def sort(self, row=0):
        self.find_inside(row, ".column-header--sort").click()

    def filter(self):
        return (
            self.mixin.find_element(
                "#{} {} tbody tr th.dash-filter.column-{}:not(.phantom-cell)".format(
                    self.id, self.state, self.col
                )
            )
            if isinstance(self.col, int)
            else self.mixin.find_element(
                '#{} {} tbody tr th.dash-filter[data-dash-column="{}"]:not(.phantom-cell)'.format(
                    self.id, self.state, self.col
                )
            )
        )

    def filter_clear(self):
        self.filter().find_element(By.CSS_SELECTOR, "input").click()
        ac = ActionChains(self.mixin.driver)
        ac.key_down(CMD)
        ac.send_keys("a")
        ac.key_up(CMD)
        ac.send_keys(Keys.DELETE)
        ac.perform()

    def filter_click(self):
        self.filter().click()

    def filter_invalid(self):
        return "invalid" in self.filter().get_attribute("class").split(" ")

    def filter_value(self, value=None):
        if value is None:
            return (
                self.filter()
                .find_element(By.CSS_SELECTOR, "input")
                .get_attribute("value")
            )
        elif value == "":
            self.filter_clear()
        else:
            self.filter_clear()
            self.mixin.driver.switch_to.active_element.send_keys(value + Keys.ENTER)

    def filter_placeholder(self):
        return (
            self.filter()
            .find_element(By.CSS_SELECTOR, "input")
            .get_attribute("placeholder")
        )


class DataTableRowFacade(object):
    @preconditions(_validate_id, _validate_mixin, _validate_row, _validate_state)
    def __init__(self, id, mixin, row, state=_ANY):
        self.id = id
        self.mixin = mixin
        self.row = row
        self.state = state

    def delete(self):
        return self.mixin.find_elements(
            "#{} {} tbody tr td.dash-delete-cell:not(.phantom-cell)".format(
                self.id, self.state
            )
        )[self.row].click()

    def select(self):
        return self.mixin.find_elements(
            "#{} {} tbody tr td.dash-select-cell:not(.phantom-cell)".format(
                self.id, self.state
            )
        )[self.row].click()

    def is_selected(self):
        return (
            self.mixin.find_elements(
                "#{} {} tbody tr td.dash-select-cell:not(.phantom-cell)".format(
                    self.id, self.state
                )
            )[self.row]
            .find_element(By.CSS_SELECTOR, "input")
            .is_selected()
        )


class DataTablePagingActionFacade(object):
    @preconditions(_validate_id, _validate_mixin, _validate_selector)
    def __init__(self, id, mixin, selector):
        self.id = id
        self.mixin = mixin
        self.selector = selector

    def click(self):
        self.mixin._wait_for_table(self.id)

        return self.mixin.find_element("#{} {}".format(self.id, self.selector)).click()

    def exists(self):
        self.mixin._wait_for_table(self.id)

        el = self.mixin.find_element("#{} {}".format(self.id, self.selector))

        return el is not None and el.is_enabled()


class DataTablePagingCurrentFacade(object):
    @preconditions(_validate_id, _validate_mixin)
    def __init__(self, id, mixin):
        self.id = id
        self.mixin = mixin

    def click(self):
        self.mixin._wait_for_table(self.id)

        return self.mixin.find_element("#{} input.current-page".format(self.id)).click()

    def get_value(self):
        self.mixin._wait_for_table(self.id)

        return self.mixin.find_element(
            "#{} input.current-page".format(self.id)
        ).get_attribute("placeholder")


class DataTablePagingFacade(object):
    @preconditions(_validate_id, _validate_mixin)
    def __init__(self, id, mixin):
        self.id = id
        self.mixin = mixin

        self.current = DataTablePagingCurrentFacade(self.id, self.mixin)
        self.first = DataTablePagingActionFacade(
            self.id, self.mixin, "button.first-page"
        )
        self.last = DataTablePagingActionFacade(self.id, self.mixin, "button.last-page")
        self.next = DataTablePagingActionFacade(self.id, self.mixin, "button.next-page")
        self.previous = DataTablePagingActionFacade(
            self.id, self.mixin, "button.previous-page"
        )

    def exists(self):
        self.mixin._wait_for_table(self.id)

        return len(self.mixin.find_elements(".previous-next-container")) != 0


class DataTableTooltipFacade(object):
    @preconditions(_validate_id, _validate_mixin)
    def __init__(self, id, mixin):
        self.id = id
        self.mixin = mixin

    def _get_tooltip(self):
        return self.mixin.find_element(".dash-tooltip")

    def get(self):
        return self._get_tooltip()

    def find_inside(self, selector):
        return self.get().find_element(By.CSS_SELECTOR, selector)

    def find_all_inside(self, selector):
        return self.get().find_elements(By.CSS_SELECTOR, selector)

    def exists(self):
        self.mixin._wait_for_table(self.id)

        tooltip = self._get_tooltip()

        return tooltip is not None and tooltip.is_displayed()

    def missing(self):
        self.mixin._wait_for_table(self.id)

        return len(self.mixin.find_elements(".dash-tooltip")) == 0

    def get_text(self):
        return self.find_inside(".dash-table-tooltip").get_attribute("innerHTML")


class DataTableToggleColumnsFacade(object):
    @preconditions(_validate_id, _validate_mixin)
    def __init__(self, id, mixin):
        self.id = id
        self.mixin = mixin

    def open(self):
        if not self.is_opened():
            self.mixin.find_element("#{} .show-hide".format(self.id)).click()

    def close(self):
        if self.is_opened():
            self.mixin.find_element("#{} .show-hide".format(self.id)).click()

    def get_hidden(self):
        els = self.mixin.find_elements("#table .show-hide-menu input")

        return list(filter(lambda el: not el.is_selected(), els))

    def get_hidden_values(self):
        return [el.get_attribute("value") for el in self.get_hidden()]

    def get_visible(self):
        els = self.mixin.find_elements("#table .show-hide-menu input")

        return list(filter(lambda el: el.is_selected(), els))

    def get_visible_values(self):
        return [el.get_attribute("value") for el in self.get_visible()]

    def is_opened(self):
        return len(self.mixin.find_elements("#{} .show-hide-menu".format(self.id))) != 0


class DataTableFacade(object):
    @preconditions(_validate_id, _validate_mixin)
    def __init__(self, id, mixin):
        self.id = id
        self.mixin = mixin

        self.paging = DataTablePagingFacade(id, mixin)
        self.tooltip = DataTableTooltipFacade(id, mixin)

    @preconditions(_validate_row, _validate_col, _validate_state)
    def cell(self, row, col, state=_ANY):
        return DataTableCellFacade(self.id, self.mixin, row, col, state)

    @preconditions(_validate_col, _validate_state)
    def column(self, col, state=_ANY):
        return DataTableColumnFacade(self.id, self.mixin, col, state)

    @preconditions(_validate_row, _validate_state)
    def row(self, row, state=_ANY):
        return DataTableRowFacade(self.id, self.mixin, row, state)

    @preconditions(_validate_state)
    def toggle_columns(self, state=_ANY):
        return DataTableToggleColumnsFacade(self.id, self.mixin)

    def is_ready(self):
        return self.mixin._wait_for_table(self.id, _READY)

    def is_loading(self):
        return self.mixin._wait_for_table(self.id, _LOADING)


class DataTableMixin(object):
    @preconditions(_validate_id, _validate_state)
    def _wait_for_table(self, id, state=_ANY):
        return WebDriverWait(self.driver, _TIMEOUT).until(
            EC.presence_of_element_located(
                (By.CSS_SELECTOR, "#{} {}".format(id, state))
            )
        )

    @preconditions(_validate_id)
    def table(self, id):
        return DataTableFacade(id, self)

    def get_table_ids(self):
        return self.driver.execute_script(
            """
            return Array.from(
                document.querySelectorAll('.dash-spreadsheet-container')
            ).map(
                e => e.parentElement.getAttribute('id')
            )
        """
        )

    def copy(self):
        with self.hold(CMD):
            self.send_keys("c")

    def paste(self):
        with self.hold(CMD):
            self.send_keys("v")

    @preconditions(_validate_key)
    def hold(self, key):
        return HoldKeyContext(self, key)

    def get_selected_text(self):
        return self.driver.execute_script("return window.getSelection().toString()")

    @preconditions(_validate_keys)
    def send_keys(self, keys):
        self.driver.switch_to.active_element.send_keys(keys)


class DataTableComposite(Browser, DataTableMixin):
    def __init__(self, server, **kwargs):
        super(DataTableComposite, self).__init__(**kwargs)
        self.server = server

        self.READY = _READY
        self.LOADING = _LOADING
        self.ANY = _ANY

    def get_log_errors(self):
        return list(filter(lambda i: i.get("level") != "WARNING", self.get_logs()))

    def start_server(self, app, **kwargs):
        """start the local server with app"""

        # start server with app and pass Dash arguments
        self.server(app, **kwargs)

        # set the default server_url, it implicitly call wait_for_page
        self.server_url = self.server.url


@pytest.fixture
def test(request, dash_thread_server, tmpdir):
    with DataTableComposite(
        dash_thread_server,
        browser=request.config.getoption("webdriver"),
        remote=request.config.getoption("remote"),
        remote_url=request.config.getoption("remote_url"),
        headless=request.config.getoption("headless"),
        options=request.config.hook.pytest_setup_options(),
        download_path=tmpdir.mkdir("dt-download").strpath,
        percy_finalize=request.config.getoption("nopercyfinalize"),
        pause=request.config.getoption("pause"),
    ) as dc:
        yield dc
