import sys
from collections.abc import MutableSequence  # pylint: disable=import-error
import re
from textwrap import dedent
from keyword import iskeyword
import flask

from ._grouping import grouping_len, map_grouping
from ._no_update import NoUpdate
from .development.base_component import Component
from . import exceptions
from ._utils import (
    patch_collections_abc,
    stringify_id,
    to_json,
    coerce_to_list,
    clean_property_name,
)


def validate_callback(outputs, inputs, state, extra_args, types):
    Input, Output, State = types
    if extra_args:
        if not isinstance(extra_args[0], (Output, Input, State)):
            raise exceptions.IncorrectTypeException(
                dedent(
                    f"""
                    Callback arguments must be `Output`, `Input`, or `State` objects,
                    optionally wrapped in a list or tuple. We found (possibly after
                    unwrapping a list or tuple):
                    {repr(extra_args[0])}
                    """
                )
            )

        raise exceptions.IncorrectTypeException(
            dedent(
                f"""
                In a callback definition, you must provide all Outputs first,
                then all Inputs, then all States. After this item:
                {(outputs + inputs + state)[-1]!r}
                we found this item next:
                {extra_args[0]!r}
                """
            )
        )

    for args in [outputs, inputs, state]:
        for arg in args:
            validate_callback_arg(arg)


def validate_callback_arg(arg):
    if not isinstance(getattr(arg, "component_property", None), str):
        raise exceptions.IncorrectTypeException(
            dedent(
                f"""
                component_property must be a string, found {arg.component_property!r}
                """
            )
        )

    if hasattr(arg, "component_event"):
        raise exceptions.NonExistentEventException(
            """
            Events have been removed.
            Use the associated property instead.
            """
        )

    if isinstance(arg.component_id, dict):
        validate_id_dict(arg)

    elif isinstance(arg.component_id, str):
        validate_id_string(arg)

    else:
        raise exceptions.IncorrectTypeException(
            dedent(
                f"""
                component_id must be a string or dict, found {arg.component_id!r}
                """
            )
        )


def validate_id_dict(arg):
    arg_id = arg.component_id

    for k in arg_id:
        # Need to keep key type validation on the Python side, since
        # non-string keys will be converted to strings in json.dumps and may
        # cause unwanted collisions
        if not isinstance(k, str):
            raise exceptions.IncorrectTypeException(
                dedent(
                    f"""
                    Wildcard ID keys must be non-empty strings,
                    found {k!r} in id {arg_id!r}
                    """
                )
            )


def validate_id_string(arg):
    arg_id = arg.component_id

    invalid_chars = ".{"
    invalid_found = [x for x in invalid_chars if x in arg_id]
    if invalid_found:
        raise exceptions.InvalidComponentIdError(
            f"""
            The element `{arg_id}` contains `{"`, `".join(invalid_found)}` in its ID.
            Characters `{"`, `".join(invalid_chars)}` are not allowed in IDs.
            """
        )


def validate_output_spec(output, output_spec, Output):
    """
    This validation is for security and internal debugging, not for users,
    so the messages are not intended to be clear.
    `output` comes from the callback definition, `output_spec` from the request.
    """
    if not isinstance(output, (list, tuple)):
        output, output_spec = [output], [output_spec]
    elif len(output) != len(output_spec):
        raise exceptions.CallbackException("Wrong length output_spec")

    for outi, speci in zip(output, output_spec):
        speci_list = speci if isinstance(speci, (list, tuple)) else [speci]
        for specij in speci_list:
            if (
                not Output(specij["id"], clean_property_name(specij["property"]))
                == outi
            ):
                raise exceptions.CallbackException(
                    "Output does not match callback definition"
                )


def validate_and_group_input_args(flat_args, arg_index_grouping):
    if grouping_len(arg_index_grouping) != len(flat_args):
        raise exceptions.CallbackException("Inputs do not match callback definition")

    args_grouping = map_grouping(lambda ind: flat_args[ind], arg_index_grouping)
    if isinstance(arg_index_grouping, dict):
        func_args = []
        func_kwargs = args_grouping
        for key in func_kwargs:
            if not key.isidentifier():
                raise exceptions.CallbackException(
                    f"{key} is not a valid Python variable name"
                )
    elif isinstance(arg_index_grouping, (tuple, list)):
        func_args = list(args_grouping)
        func_kwargs = {}
    else:
        # Scalar input
        func_args = [args_grouping]
        func_kwargs = {}

    return func_args, func_kwargs


def validate_multi_return(output_lists, output_values, callback_id):
    if not isinstance(output_values, (list, tuple)):
        raise exceptions.InvalidCallbackReturnValue(
            dedent(
                f"""
                The callback {callback_id} is a multi-output.
                Expected the output type to be a list or tuple but got:
                {output_values!r}.
                """
            )
        )

    if len(output_values) != len(output_lists):
        raise exceptions.InvalidCallbackReturnValue(
            f"""
            Invalid number of output values for {callback_id}.
            Expected {len(output_lists)}, got {len(output_values)}
            """
        )

    for i, output_spec in enumerate(output_lists):
        if isinstance(output_spec, list):
            output_value = output_values[i]
            if not isinstance(output_value, (list, tuple)):
                raise exceptions.InvalidCallbackReturnValue(
                    dedent(
                        f"""
                        The callback {callback_id} output {i} is a wildcard multi-output.
                        Expected the output type to be a list or tuple but got:
                        {output_value!r}.
                        output spec: {output_spec!r}
                        """
                    )
                )

            if len(output_value) != len(output_spec):
                raise exceptions.InvalidCallbackReturnValue(
                    dedent(
                        f"""
                        Invalid number of output values for {callback_id} item {i}.
                        Expected {len(output_spec)}, got {len(output_value)}
                        output spec: {output_spec!r}
                        output value: {output_value!r}
                        """
                    )
                )


def fail_callback_output(output_value, output):
    valid_children = (str, int, float, type(None), Component, NoUpdate)
    valid_props = (str, int, float, type(None), tuple, MutableSequence, NoUpdate)

    def _raise_invalid(bad_val, outer_val, path, index=None, toplevel=False):
        bad_type = type(bad_val).__name__
        outer_id = f"(id={outer_val.id:s})" if getattr(outer_val, "id", False) else ""
        outer_type = type(outer_val).__name__
        if toplevel:
            location = dedent(
                """
                The value in question is either the only value returned,
                or is in the top level of the returned list,
                """
            )
        else:
            index_string = "[*]" if index is None else f"[{index:d}]"
            location = dedent(
                f"""
                The value in question is located at
                {index_string} {outer_type} {outer_id}
                {path},
                """
            )

        obj = "tree with one value" if not toplevel else "value"
        raise exceptions.InvalidCallbackReturnValue(
            dedent(
                f"""
                The callback for `{output!r}`
                returned a {obj:s} having type `{bad_type}`
                which is not JSON serializable.

                {location}
                and has string representation
                `{bad_val}`

                In general, Dash properties can only be
                dash components, strings, dictionaries, numbers, None,
                or lists of those.
                """
            )
        )

    def _valid_child(val):
        return isinstance(val, valid_children)

    def _valid_prop(val):
        return isinstance(val, valid_props)

    def _can_serialize(val):
        if not (_valid_child(val) or _valid_prop(val)):
            return False
        try:
            to_json(val)
        except TypeError:
            return False
        return True

    def _validate_value(val, index=None):
        # val is a Component
        if isinstance(val, Component):
            unserializable_items = []
            # pylint: disable=protected-access
            for p, j in val._traverse_with_paths():
                # check each component value in the tree
                if not _valid_child(j):
                    _raise_invalid(bad_val=j, outer_val=val, path=p, index=index)

                if not _can_serialize(j):
                    # collect unserializable items separately, so we can report
                    # only the deepest level, not all the parent components that
                    # are just unserializable because of their children.
                    unserializable_items = [
                        i for i in unserializable_items if not p.startswith(i[0])
                    ]
                    if unserializable_items:
                        # we already have something unserializable in a different
                        # branch - time to stop and fail
                        break
                    if all(not i[0].startswith(p) for i in unserializable_items):
                        unserializable_items.append((p, j))

                # Children that are not of type Component or
                # list/tuple not returned by traverse
                child = getattr(j, "children", None)
                if not isinstance(child, (tuple, MutableSequence)):
                    if child and not _can_serialize(child):
                        _raise_invalid(
                            bad_val=child,
                            outer_val=val,
                            path=p + "\n" + "[*] " + type(child).__name__,
                            index=index,
                        )
            if unserializable_items:
                p, j = unserializable_items[0]
                # just report the first one, even if there are multiple,
                # as that's how all the other errors work
                _raise_invalid(bad_val=j, outer_val=val, path=p, index=index)

            # Also check the child of val, as it will not be returned
            child = getattr(val, "children", None)
            if not isinstance(child, (tuple, MutableSequence)):
                if child and not _can_serialize(val):
                    _raise_invalid(
                        bad_val=child,
                        outer_val=val,
                        path=type(child).__name__,
                        index=index,
                    )

        if not _can_serialize(val):
            _raise_invalid(
                bad_val=val,
                outer_val=type(val).__name__,
                path="",
                index=index,
                toplevel=True,
            )

    if isinstance(output_value, list):
        for i, val in enumerate(output_value):
            _validate_value(val, index=i)
    else:
        _validate_value(output_value)

    # if we got this far, raise a generic JSON error
    raise exceptions.InvalidCallbackReturnValue(
        f"""
        The callback for output `{output!r}`
        returned a value which is not JSON serializable.

        In general, Dash properties can only be dash components, strings,
        dictionaries, numbers, None, or lists of those.
        """
    )


def check_obsolete(kwargs):
    for key in kwargs:
        if key in ["components_cache_max_age", "static_folder"]:
            raise exceptions.ObsoleteKwargException(
                f"""
                {key} is no longer a valid keyword argument in Dash since v1.0.
                See https://dash.plotly.com for details.
                """
            )
        if key in ["dynamic_loading", "preloaded_libraries"]:
            # Only warns as this was only available for a short time.
            print(
                f"{key} has been removed and no longer a valid keyword argument in Dash.",
                file=sys.stderr,
            )
            continue
        if key in ["long_callback_manager"]:
            raise exceptions.ObsoleteKwargException(
                "long_callback_manager is obsolete, use background_callback_manager instead"
            )
        # any other kwarg mimic the built-in exception
        raise TypeError(f"Dash() got an unexpected keyword argument '{key}'")


def validate_js_path(registered_paths, package_name, path_in_package_dist):
    if package_name not in registered_paths:
        raise exceptions.DependencyException(
            f"""
            Error loading dependency. "{package_name}" is not a registered library.
            Registered libraries are:
            {list(registered_paths.keys())}
            """
        )

    if path_in_package_dist not in registered_paths[package_name]:
        raise exceptions.DependencyException(
            f"""
            "{package_name}" is registered but the path requested is not valid.
            The path requested: "{path_in_package_dist}"
            List of registered paths: {registered_paths}
            """
        )


def validate_index(name, checks, index):
    missing = [i for check, i in checks if not re.compile(check).search(index)]
    if missing:
        plural = "s" if len(missing) > 1 else ""
        raise exceptions.InvalidIndexException(
            f"Missing item{plural} {', '.join(missing)} in {name}."
        )


def validate_layout_type(value):
    if not isinstance(
        value, (Component, patch_collections_abc("Callable"), list, tuple)
    ):
        raise exceptions.NoLayoutException(
            """
            Layout must be a single dash component, a list of dash components,
            or a function that returns a dash component.
            """
        )


def validate_layout(layout, layout_value):
    if layout is None:
        raise exceptions.NoLayoutException(
            """
            The layout was `None` at the time that `run` was called.
            Make sure to set the `layout` attribute of your application
            before running the server.
            """
        )

    component_ids = set()

    def _validate(value):
        def _validate_id(comp):
            component_id = stringify_id(getattr(comp, "id", None))
            if component_id and component_id in component_ids:
                raise exceptions.DuplicateIdError(
                    f"""
                    Duplicate component id found in the initial layout: `{component_id}`
                    """
                )
            component_ids.add(component_id)

        _validate_id(value)

        for component in value._traverse():  # pylint: disable=protected-access
            _validate_id(component)

    if isinstance(layout_value, (list, tuple)):
        for component in layout_value:
            if isinstance(component, (str,)):
                continue
            if isinstance(component, (Component,)):
                _validate(component)
            else:
                raise exceptions.NoLayoutException(
                    "Only strings and components are allowed in a list layout."
                )
    else:
        _validate(layout_value)


def validate_template(template):
    variable_names = re.findall("<(.*?)>", template)

    for name in variable_names:
        if not name.isidentifier() or iskeyword(name):
            raise Exception(
                f'`{name}` is not a valid Python variable name in `path_template`: "{template}".'
            )


def check_for_duplicate_pathnames(registry):
    path_to_module = {}
    for page in registry.values():
        if page["path"] not in path_to_module:
            path_to_module[page["path"]] = [page["module"]]
        else:
            path_to_module[page["path"]].append(page["module"])

    for modules in path_to_module.values():
        if len(modules) > 1:
            raise Exception(f"modules {modules} have duplicate paths")


def validate_registry(registry):
    for page in registry.values():
        if "layout" not in page:
            raise exceptions.NoLayoutException(
                f"No layout in module `{page['module']}` in dash.page_registry"
            )
        if page["module"] == "__main__":
            raise Exception(
                """
                When registering pages from app.py, `__name__` is not a valid module name.  Use a string instead.
                For example, `dash.register_page("my_module_name")`, rather than `dash.register_page(__name__)`
                """
            )


def validate_pages_layout(module, page):
    if not hasattr(page, "layout"):
        raise exceptions.NoLayoutException(
            f"""
            No layout found in module {module}
            A variable or a function named "layout" is required.
            """
        )


def validate_use_pages(config):
    if not config.get("assets_folder", None):
        raise exceptions.PageError(
            "`dash.register_page()` must be called after app instantiation"
        )

    if flask.has_request_context():
        raise exceptions.PageError(
            """
            dash.register_page() can’t be called within a callback as it updates dash.page_registry, which is a global variable.
             For more details, see https://dash.plotly.com/sharing-data-between-callbacks#why-global-variables-will-break-your-app
            """
        )


def validate_module_name(module):
    if not isinstance(module, str):
        raise exceptions.PageError(
            "The first attribute of dash.register_page() must be a string or '__name__'"
        )
    return module


def validate_background_callbacks(callback_map):
    # Validate that background callback side output & inputs are not circular
    # If circular, triggering a background callback would result in a fatal server/computer crash.
    all_outputs = set()
    input_indexed = {}
    for callback in callback_map.values():
        out = coerce_to_list(callback["output"])
        all_outputs.update(out)
        for o in out:
            input_indexed.setdefault(o, set())
            input_indexed[o].update(coerce_to_list(callback["raw_inputs"]))

    for callback in (x for x in callback_map.values() if x.get("background")):
        bg_info = callback["background"]
        progress = bg_info.get("progress", [])
        running = bg_info.get("running", [])

        bg_inputs = coerce_to_list(callback["raw_inputs"])
        outputs = set([x[0] for x in running] + progress)
        circular = [
            x
            for x in set(k for k, v in input_indexed.items() if v.intersection(outputs))
            if x in bg_inputs
        ]

        if circular:
            raise exceptions.BackgroundCallbackError(
                f"Background callback circular error!\n{circular} is used as input for a background callback"
                f" but also used as output from an input that is updated with progress or running argument."
            )


def validate_duplicate_output(
    output, prevent_initial_call, config_prevent_initial_call
):
    if "initial_duplicate" in (prevent_initial_call, config_prevent_initial_call):
        return

    def _valid(out):
        if (
            out.allow_duplicate
            and not prevent_initial_call
            and not config_prevent_initial_call
        ):
            raise exceptions.DuplicateCallback(
                "allow_duplicate requires prevent_initial_call to be True. The order of the call is not"
                " guaranteed to be the same on every page load. "
                "To enable duplicate callback with initial call, set prevent_initial_call='initial_duplicate' "
                " or globally in the config prevent_initial_callbacks='initial_duplicate'"
            )

    if isinstance(output, (list, tuple)):
        for o in output:
            _valid(o)

        return

    _valid(output)
