import functools
import warnings
import json
import contextvars
import typing

from dash.backends.ws import DashWebsocketCallback

from . import exceptions
from ._get_app import get_app
from ._utils import AttributeDict, stringify_id

context_value: contextvars.ContextVar[
    typing.Dict[str, typing.Any]
] = contextvars.ContextVar("callback_context")
context_value.set({})


def has_context(func):
    @functools.wraps(func)
    def assert_context(*args, **kwargs):
        if not context_value.get():
            raise exceptions.MissingCallbackContextException(
                f"dash.callback_context.{getattr(func, '__name__')} is only available from a callback!"
            )
        return func(*args, **kwargs)

    return assert_context


def _get_context_value():
    return context_value.get()


def _get_from_context(key, default):
    return getattr(_get_context_value(), key, default)


class FalsyList(list):
    def __bool__(self):
        # for Python 3
        return False

    def __nonzero__(self):
        # for Python 2
        return False


falsy_triggered = FalsyList([{"prop_id": ".", "value": None}])


# pylint: disable=no-init
class CallbackContext:
    @property
    @has_context
    def inputs(self):
        return getattr(_get_context_value(), "input_values", {})

    @property
    @has_context
    def states(self):
        return getattr(_get_context_value(), "state_values", {})

    @property
    @has_context
    def triggered(self):
        """
        Returns a list of all the Input props that changed and caused the callback to execute. It is empty when the
        callback is called on initial load, unless an Input prop got its value from another initial callback.
        Callbacks triggered by user actions typically have one item in triggered, unless the same action changes
        two props at once or the callback has several Input props that are all modified by another callback based on
        a single user action.

        Example:  To get the id of the component that triggered the callback:
        `component_id = ctx.triggered[0]['prop_id'].split('.')[0]`

        Example:  To detect initial call, empty triggered is not really empty, it's falsy so that you can use:
        `if ctx.triggered:`
        """
        # For backward compatibility: previously `triggered` always had a
        # value - to avoid breaking existing apps, add a dummy item but
        # make the list still look falsy. So `if ctx.triggered` will make it
        # look empty, but you can still do `triggered[0]["prop_id"].split(".")`
        return getattr(_get_context_value(), "triggered_inputs", []) or falsy_triggered

    @property
    @has_context
    def triggered_prop_ids(self):
        """
        Returns a dictionary of all the Input props that changed and caused the callback to execute. It is empty when
        the callback is called on initial load, unless an Input prop got its value from another initial callback.
        Callbacks triggered by user actions typically have one item in triggered, unless the same action changes
        two props at once or the callback has several Input props that are all modified by another callback based
        on a single user action.

        triggered_prop_ids (dict):
        - keys (str) : the triggered "prop_id" composed of "component_id.component_property"
        - values (str or dict): the id of the component that triggered the callback. Will be the dict id for pattern matching callbacks

        Example - regular callback
        {"btn-1.n_clicks": "btn-1"}

        Example - pattern matching callbacks:
        {'{"index":0,"type":"filter-dropdown"}.value': {"index":0,"type":"filter-dropdown"}}

        Example usage:
        `if "btn-1.n_clicks" in ctx.triggered_prop_ids:
            do_something()`
        """
        triggered = getattr(_get_context_value(), "triggered_inputs", [])
        ids = AttributeDict({})
        for item in triggered:
            component_id, _, _ = item["prop_id"].rpartition(".")
            ids[item["prop_id"]] = component_id
            if component_id.startswith("{"):
                ids[item["prop_id"]] = AttributeDict(json.loads(component_id))
        return ids

    @property
    @has_context
    def triggered_id(self):
        """
        Returns the component id (str or dict) of the Input component that triggered the callback.

        Note - use `triggered_prop_ids` if you need both the component id and the prop that triggered the callback or if
        multiple Inputs triggered the callback.

        Example usage:
        `if "btn-1" == ctx.triggered_id:
            do_something()`

        """
        component_id = None
        if self.triggered:
            prop_id = self.triggered_prop_ids.first()
            component_id = self.triggered_prop_ids[prop_id]
        return component_id

    @property
    @has_context
    def args_grouping(self):
        """
        args_grouping is a dict of the inputs used with flexible callback signatures. The keys are the variable names
        and the values are dictionaries containing:
        - “id”: (string or dict) the component id. If it’s a pattern matching id, it will be a dict.
        - “id_str”: (str) for pattern matching ids, it’s the stringified dict id with no white spaces.
        - “property”: (str) The component property used in the callback.
        - “value”: the value of the component property at the time the callback was fired.
        - “triggered”: (bool)Whether this input triggered the callback.

        Example usage:
        @app.callback(
            Output("container", "children"),
            inputs=dict(btn1=Input("btn-1", "n_clicks"), btn2=Input("btn-2", "n_clicks")),
        )
        def display(btn1, btn2):
            c = ctx.args_grouping
            if c.btn1.triggered:
                return f"Button 1 clicked {btn1} times"
            elif c.btn2.triggered:
                return f"Button 2 clicked {btn2} times"
            else:
               return "No clicks yet"

        """
        return getattr(_get_context_value(), "args_grouping", [])

    @property
    @has_context
    def outputs_grouping(self):
        return getattr(_get_context_value(), "outputs_grouping", [])

    @property
    @has_context
    def outputs_list(self):
        if self.using_outputs_grouping:
            warnings.warn(
                "outputs_list is deprecated, use outputs_grouping instead",
                DeprecationWarning,
                stacklevel=2,
            )

        return getattr(_get_context_value(), "outputs_list", [])

    @property
    @has_context
    def inputs_list(self):
        if self.using_args_grouping:
            warnings.warn(
                "inputs_list is deprecated, use args_grouping instead",
                DeprecationWarning,
                stacklevel=2,
            )

        return getattr(_get_context_value(), "inputs_list", [])

    @property
    @has_context
    def states_list(self):
        if self.using_args_grouping:
            warnings.warn(
                "states_list is deprecated, use args_grouping instead",
                DeprecationWarning,
                stacklevel=2,
            )
        return getattr(_get_context_value(), "states_list", [])

    @property
    @has_context
    def response(self):
        return getattr(_get_context_value(), "dash_response")

    @staticmethod
    @has_context
    def record_timing(name, duration, description=None):
        """Records timing information for a server resource.

        :param name: The name of the resource.
        :type name: string

        :param duration: The time in seconds to report. Internally, this
            is rounded to the nearest millisecond.
        :type duration: float

        :param description: A description of the resource.
        :type description: string or None
        """
        request = get_app().backend.request_adapter()
        timing_information = getattr(request.context, "timing_information", {})

        if name in timing_information:
            raise KeyError(f'Duplicate resource name "{name}" found.')

        timing_information[name] = {"dur": round(duration * 1000), "desc": description}

        setattr(request.context, "timing_information", timing_information)

    @property
    @has_context
    def using_args_grouping(self):
        """
        Return True if this callback is using dictionary or nested groupings for
        Input/State dependencies, or if Input and State dependencies are interleaved
        """
        return getattr(_get_context_value(), "using_args_grouping", [])

    @property
    @has_context
    def using_outputs_grouping(self):
        """
        Return True if this callback is using dictionary or nested groupings for
        Output dependencies.
        """
        return getattr(_get_context_value(), "using_outputs_grouping", [])

    @property
    @has_context
    def timing_information(self):
        request = get_app().backend.request_adapter()
        return getattr(request.context, "timing_information", {})

    @has_context
    def set_props(self, component_id: typing.Union[str, dict], props: dict):
        ctx_value = _get_context_value()
        _id = stringify_id(component_id)
        existing = ctx_value.updated_props.get(_id)
        if existing is not None:
            ctx_value.updated_props[_id] = {**existing, **props}
        else:
            ctx_value.updated_props[_id] = props

    @property
    @has_context
    def cookies(self):
        """
        Get the cookies for the current callback.
        Works with background callbacks.
        """
        return _get_from_context("cookies", {})

    @property
    @has_context
    def headers(self):
        """
        Get the original headers for the current callback.
        Works with background callbacks.
        """
        return _get_from_context("headers", {})

    @property
    @has_context
    def path(self):
        """
        Path of the callback request with the query parameters.
        """
        return _get_from_context("path", "")

    @property
    @has_context
    def args(self):
        """
        Query parameters of the callback request as a dictionary-like object.
        """
        return _get_from_context("args", "")

    @property
    @has_context
    def remote(self):
        """
        Remote addr of the callback request.
        """
        return _get_from_context("remote", "")

    @property
    @has_context
    def origin(self):
        """
        Origin of the callback request.
        """
        return _get_from_context("origin", "")

    @property
    @has_context
    def custom_data(self):
        """
        Custom data set by hooks.custom_data.
        """
        return _get_from_context("custom_data", {})

    @property
    @has_context
    def websocket(self) -> typing.Optional[DashWebsocketCallback]:
        """Get WebSocket interface if running in WebSocket context.

        Returns the DashWebsocketCallback instance if the callback is being
        executed via WebSocket, otherwise returns None.

        Raises:
            RuntimeError: If websocket_callbacks is requested but the backend
                doesn't support WebSocket.
        """
        ws = _get_from_context("dash_websocket", None)
        if ws is None:
            app = get_app()
            if (
                hasattr(app, "_websocket_callbacks")
                and app._websocket_callbacks  # pylint: disable=protected-access
                and not app.backend.websocket_capability
            ):
                raise RuntimeError(
                    f"WebSocket callbacks requested but backend "
                    f"'{app.backend.server_type}' doesn't support them."
                )
        return ws


callback_context = CallbackContext()


def set_props(component_id: typing.Union[str, dict], props: dict):
    """
    Set the props for a component not included in the callback outputs.

    If running in a WebSocket context, props are streamed immediately to the
    client. Otherwise, props are batched and sent with the callback response.
    """
    ws = _get_from_context("dash_websocket", None)
    if ws is not None:
        # Stream immediately via WebSocket. Queuing is synchronous and thread-safe
        # (janus sync side), so we queue directly instead of scheduling a task. This
        # avoids detached/orphaned tasks when the callback runs on the event loop and
        # preserves ordering relative to the callback response.
        _id = stringify_id(component_id)
        for prop_name, value in props.items():
            ws.set_prop_sync(_id, prop_name, value)
    else:
        # Batch for response (existing behavior)
        callback_context.set_props(component_id, props)
