import inspect
import re
from functools import partial
from typing import _AnnotatedAlias, _LiteralGenericAlias

import holoviews as hv
import panel as pn
import param

from holoviz_utils.dimensionrangepicker import DimensionRangePickerGraph
from holoviz_utils.glossinator import Glossinator
from holoviz_utils.searcher import SearcherMixin


class BokehHooks:
    def hook_no_logo(self, plot, element):
        plot.state.toolbar.logo = None

    def hook_no_yaxis(self, plot, element):
        plot.state.yaxis.visible = False  # turn off y-axis

    def hook_no_xaxis(self, plot, element):
        plot.state.xaxis.visible = False  # turn off x-axis

    def hook_no_toolbar(self, plot, element):
        plot.state.toolbar_location = None  # turn off the toolbar

    def hook_transparent_border(self, plot, element):
        plot.state.border_fill_alpha = 0.0

    def no_background(self, plot, element):
        plot.state.background_fill_alpha = 0.0
        plot.state.outline_line_alpha = 0.0

    def beige_background(self, plot, element):
        plot.state.background_fill_color = "LightGray"
        plot.state.background_fill_alpha = 1.0
        plot.state.outline_line_alpha = 0.0

    def no_xlabel(self, plot, element):
        plot.state.xaxis.axis_label = ""

    def horizontal_grid(self, plot, element):
        plot.state.ygrid.grid_line_alpha = 0.95
        plot.state.ygrid.grid_line_color = "White"
        plot.state.ygrid.grid_line_dash = [6, 4]

    def fine_horizontal_grid(self, plot, element):
        plot.state.ygrid.grid_line_alpha = 0.5
        plot.state.ygrid.grid_line_width = 0.5
        plot.state.ygrid.grid_line_color = "Black"
        plot.state.ygrid.grid_line_dash = [2, 2]

    def vertical_grid(self, plot, element):
        plot.state.xgrid.grid_line_alpha = 0.95
        plot.state.xgrid.grid_line_color = "White"
        plot.state.xgrid.grid_line_dash = [6, 4]

    def clean_yaxis(self, plot, element):
        plot.state.yaxis.major_tick_line_color = None
        plot.state.yaxis.minor_tick_line_color = None
        plot.state.yaxis.major_label_text_alpha = 0
        plot.state.yaxis.axis_line_color = None

    def chart_border(self, plot, element, color="black", width=1, alpha=1.0):
        plot.state.outline_line_color = color
        plot.state.outline_line_width = width
        plot.state.outline_line_alpha = alpha

    def ylabel_space(self, plot, element, spacing=75):
        plot.state.min_border_left = spacing


class DataPresentation(SearcherMixin, param.Parameterized):

    dm_funcs: list = param.List()
    widget_mapping: list = param.List(default=list())
    typing_to_param: dict = param.Dict(
        {
            str: param.String,
            int: param.Integer,
            float: param.Number,
            bool: param.Boolean,
            "Literal": param.Selector,
        }
    )

    default_widgets = param.Dict(
        default={
            param.Integer: pn.widgets.IntSlider,
            param.Number: pn.widgets.FloatSlider,
            param.Selector: pn.widgets.RadioButtonGroup,
            param.Boolean: pn.widgets.Checkbox,
            param.String: pn.widgets.TextInput,
        }
    )

    view_style = param.ObjectSelector(
        default="single_column",
        objects=["single_column", "column_with_background_pane"],
    )

    picker_kwargs = param.Dict(default={}, constant=True, doc="Extra keyword arguments forwarded to every DimensionRangePickerGraph instance.")

    glossary = param.Dict(default={}, constant=True, doc="A dictionary mapping terms to their tooltip content (HTML allowed).")

    stylesheets: dict = param.Dict(
        {
            "no_blue_dots_selection": """
            .bk-Canvas {
                --highlight-color: rgba(50,50,50,0)
            }
            """,
            "thick_slider": """
            .bk-slider-title {
                display: none;
            }
            :host {
            --handle-width: 15px;
            --handle-height: 31px;
            --slider-size: 31px;
            }
            """,
        }
    )
    bokeh_hooks: BokehHooks = param.ClassSelector(
        default=BokehHooks(), class_=BokehHooks
    )

    mapping = param.Dict(default={})

    #####
    ## DOC TITLE
    #####
    title: str = param.String(default="# An Exploration of Sin, Tan and Whatnot")
    introduction: str = param.String(default="")
    conclusion: str = param.String(default="")
    visible: bool = param.Boolean(default=True, doc="Toggle visibility of all panes.")

    def __init__(self, **params):

        # Register sub-objects and plain param.Parameter instances.
        for k, v in params.copy().items():
            if isinstance(v, param.Parameter):
                self.param.add_parameter(k, v)
                params.pop(k)
            elif isinstance(v, param.Parameterized):
                if v.__doc__ != None:
                    doc = v.__doc__.split("\n\x1b[1;32mParameters")[0]
                else:
                    doc = None
                self.param.add_parameter(
                    k,
                    param.ClassSelector(
                        default=v,
                        class_=v.__class__,
                        instantiate=False,
                        metadata={"caption_template": doc if doc else None},
                    ),
                )
                params.pop(k)

        super().__init__(**params)

        self._pickers = {}
        self._doc_templates = {}  # per-instance template storage for title/introduction/conclusion

        # Sub-pass A: register function params leniently (defer unknown unannotated params).
        intermediate_mapping = self.build_flat_mapping()
        func_info = []
        all_deferred = []
        for i, func in enumerate(self.dm_funcs):

            # Handle Parameterized objects.
            if not callable(func):
                self.widget_mapping.append((func, []))
                doc = self.param[func].metadata["caption_template"]
                func_info.append((i, func, None, None, doc, None))
            # Handle DynamicMap callback functions.
            else:
                self._add_class_method(func)
                kdims, widget_vars, deferred = self._make_params(
                    func, intermediate_mapping, lenient=True
                )
                self.widget_mapping.append((func.__name__, widget_vars))
                func_info.append((i, func, kdims, widget_vars, func.__doc__, None))
                for d in deferred:
                    all_deferred.append((func.__name__, d))

        # Sub-pass B: detect box-select tools and create selection params.
        self.param.update(mapping=self.build_flat_mapping())
        for idx, (i, func, kdims, widget_vars, doc, _) in enumerate(func_info):
            if callable(func):
                axis = self._detect_select_axis(func, kdims)
                if axis is not None:
                    length = 4 if axis == "both" else 2
                    sel_name = f"{func.__name__}_{axis}_selection"
                    self.param.add_parameter(
                        sel_name,
                        param.Tuple(default=None, length=length, allow_None=True),
                    )
                    self.mapping[sel_name] = ""
                func_info[idx] = (i, func, kdims, widget_vars, doc, axis)

        # Sub-pass C: verify all deferred params now exist.
        self.param.update(mapping=self.build_flat_mapping())
        if all_deferred:
            current_known = set(self.mapping)
            for func_name, param_name in all_deferred:
                if param_name not in current_known:
                    raise NotImplementedError(
                        f"Parameter '{param_name}' in function '{func_name}' "
                        f"has no type annotation and could not be resolved."
                    )

        # Second pass: create DynamicMap params and caption params.
        for i, func, kdims, widget_vars, doc, axis in func_info:
            if callable(func):
                chart_name = f"chart_{i:>03}"
                dmap = hv.DynamicMap(
                    getattr(self, func.__name__),
                    streams={name: self.get_param(name) for name in kdims},
                )

                # Wrap in DimensionRangePickerGraph if box-select was detected.
                if axis is not None:
                    sel_name = f"{func.__name__}_{axis}_selection"
                    picker = DimensionRangePickerGraph(graph=dmap, axis=axis, **self.picker_kwargs)
                    self._pickers[chart_name] = picker
                    dmap = picker.graph
                    picker.param.watch(
                        lambda event, cn=sel_name: self.param.update(
                            **{cn: event.new}
                        ),
                        ["selection"],
                    )

                self.param.add_parameter(
                    chart_name,
                    param.ClassSelector(
                        class_=hv.DynamicMap,
                        instantiate=False,
                        default=dmap,
                        metadata={"caption_template": func.__doc__},
                    ),
                )
                self.mapping[chart_name] = ""
                if doc is not None:
                    self.param.add_parameter(
                        f"{chart_name}_caption", param.String(default="")
                    )
                    self.mapping[f"{chart_name}_caption"] = ""
            else:
                if doc is not None:
                    self.param.add_parameter(
                        f"{func}_caption", param.String(default="")
                    )
                    self.mapping[f"{func}_caption"] = ""

        self.param.trigger("mapping")

        # Set up doc watchers for chart captions.
        for i, func, kdims, widget_vars, doc, axis in func_info:
            chart_name = f"chart_{i:>03}" if callable(func) else func
            if doc is not None:
                param_names = set(re.findall(r"\{(\w+)", doc))
                for param_name in param_names:
                    self.get_param_space(param_name).watch(
                        partial(self.update_doc, source_name=chart_name), [param_name]
                    )
                self.update_doc(None, chart_name)

        # Set up doc watchers for title, introduction, conclusion.
        for field_name in ["title", "introduction", "conclusion"]:
            template = getattr(self, field_name)
            rendered_name = f"_{field_name}_rendered"
            self._doc_templates[field_name] = template
            self.param.add_parameter(rendered_name, param.String(default=""))
            if template:
                param_names = set(re.findall(r"\{(\w+)", template))
                for param_name in param_names:
                    self.get_param_space(param_name).watch(
                        partial(self.update_doc, source_name=field_name, target_name=rendered_name), [param_name]
                    )
                self.update_doc(None, field_name, rendered_name)

        self.view: pn.Column = self.make_view()

        

    _SELECT_TOOL_TO_AXIS = {
        "box_select": "both",
        "xbox_select": "x",
        "ybox_select": "y",
    }

    def _detect_select_axis(self, func, kdims):
        """Call func with default param values, inspect opts for box-select tools."""
        try:
            defaults = {name: self.get_param_value(name) for name in kdims}
            sample = getattr(self, func.__name__)(**defaults)
            elements = [sample]
            if isinstance(sample, hv.Overlay):
                elements.extend(sample.values())
            for el in elements:
                plot_opts = hv.Store.lookup_options(
                    hv.Store.current_backend, el, "plot"
                ).kwargs
                tools = plot_opts.get("tools", [])
                for tool, axis in self._SELECT_TOOL_TO_AXIS.items():
                    if tool in tools:
                        return axis
        except Exception as e:
            print("ERROR", e)
            pass
        return None

    @classmethod
    def _add_class_method(cls, func):
        setattr(cls, func.__name__, func)

    def _make_params(self, func, intermediate_mapping, lenient=False):

        sig = inspect.signature(func)

        known = set(intermediate_mapping)

        kdims = list()
        widget_vars = list()
        deferred = list()

        for prmt in sig.parameters.values():

            x = prmt.annotation
            name = prmt.name

            if name == "self":
                continue

            kdims.append(name)
            if prmt.default != inspect._empty:
                widget_vars.append(name)

            if name in known:
                continue

            param_kwargs = dict()
            if isinstance(x, _AnnotatedAlias):
                dtype = x.__args__[0]
                if dtype == int or dtype == float:
                    range_str = x.__metadata__[0]
                    param_kwargs["softbounds"] = tuple(
                        [dtype(x_) for x_ in range_str[1:-1].split(",")]
                    )
                else:
                    raise NotImplementedError
            elif isinstance(x, _LiteralGenericAlias):
                dtype = "Literal"
                param_kwargs["objects"] = x.__args__
            elif x in [str, float, int, bool]:
                dtype = x
            else:
                if lenient:
                    deferred.append(name)
                    continue
                raise NotImplementedError(x, prmt, name)

            if prmt.default == inspect._empty:
                param_kwargs["default"] = None
            else:
                param_kwargs["default"] = prmt.default

            param_ = self.typing_to_param[dtype](**param_kwargs)

            # Add parameter to class if it doesn't exist yet.
            if name not in list(self.param):
                self.param.add_parameter(name, param_)

        return kdims, widget_vars, deferred

    # Matches {param}, {func(param['k']):fmt}, {len(param['a']['b'])}, etc.
    _TEMPLATE_REF_RE = re.compile(
        r"\{(?:(\w+)\()?(\w+)((?:\[['\"][^'\"]+['\"]\])*)\)?(:[^}]*)?\}"
    )
    _DICT_KEY_RE = re.compile(r"\[['\"]([^'\"]+)['\"]\]")
    _TEMPLATE_FUNCS = {"len": len, "sum": sum, "min": min, "max": max,
                       "abs": abs, "round": round, "sorted": sorted,
                       "int": int, "float": float, "str": str, "bool": bool}

    def update_doc(self, event, source_name, target_name=None):

        if target_name is None:
            target_name = f"{source_name}_caption"
        if source_name in self._doc_templates:
            template = self._doc_templates[source_name]
        else:
            template = self.get_param(source_name).metadata["caption_template"]

        def _resolve(m):
            func_name, param_name = m.group(1), m.group(2)
            keys_str, fmt = m.group(3), m.group(4)
            value = self.get_param_value(param_name)
            if keys_str and value is not None:
                for key_m in self._DICT_KEY_RE.finditer(keys_str):
                    try:
                        value = value[key_m.group(1)]
                    except (KeyError, TypeError, IndexError):
                        value = None
                        break
            if func_name is not None:
                value = self._TEMPLATE_FUNCS[func_name](value)
            if value is None:
                return "unknown"
            return format(value, fmt[1:]) if fmt else str(value)

        out = self._TEMPLATE_REF_RE.sub(_resolve, template)
        self.param.update(**{target_name: out})

    def add_widgets(self, *param_names):
        widgets = list()
        for param_name in param_names:
            prm = self.get_param(param_name)
            if prm.__class__ in self.default_widgets.keys():
                wdgt = self.default_widgets[prm.__class__]
                widgets.append(wdgt.from_param(prm, width=400, align="center"))
            else:
                widgets.append(prm)
        return widgets

    def _markdown_pane(self, param_or_str):
        gls = Glossinator(glossary=self.glossary, markdown=param_or_str)
        gls.html_pane.sizing_mode = "stretch_width"
        return gls.html_pane

    def make_view(self, repeat_widgets=True):

        out = [
            self._markdown_pane(self.param["_title_rendered"]),
            self._markdown_pane(self.param["_introduction_rendered"]),
            pn.layout.Divider(styles={"margin-top": "15px"}),
        ]

        parsed_widgets = list()

        for i, (func_name, widget_names) in enumerate(self.widget_mapping):

            if f"{func_name}_caption" in self.mapping.keys():
                out.append(self._markdown_pane(self.get_param(f"{func_name}_caption")))

            if func_name in self.mapping.keys():
                value = self.get_param_value(func_name)
                if hasattr(value, "make_view"):
                    out.append(value.make_view())

            chart_name = f"chart_{i:>03}"
            if f"{chart_name}_caption" in self.mapping.keys():
                out.append(self._markdown_pane(self.get_param(f"{chart_name}_caption")))
            if chart_name in self.mapping.keys():
                out.append(
                    pn.pane.HoloViews(
                        getattr(self, chart_name),
                        name=chart_name,
                        linked_axes=False,
                        align="center",
                        stylesheets=[self.stylesheets["no_blue_dots_selection"]],
                        sizing_mode="stretch_width",
                    ),
                )
            if widget_names:
                if not repeat_widgets:
                    widget_names = [x for x in widget_names if x not in parsed_widgets]
                parsed_widgets += widget_names
                out += self.add_widgets(*widget_names)

            if not isinstance(out[-1], pn.layout.Divider):
                out.append(pn.layout.Divider(styles={"margin-top": "15px"}))

        if self.conclusion:
            out.append(self._markdown_pane(self.param["_conclusion_rendered"]))

        if self.view_style == "single_column":

            toggleable = pn.Column(*out[3:], visible=self.visible)
            self.param.watch(lambda event: setattr(toggleable, "visible", event.new), "visible")

            view: pn.layout = pn.FlexBox(
                pn.Column(
                    *out[:3], toggleable,
                    styles={
                        "width": "90vw",
                        "max-width": "800px",
                        "background-color": "#ffffff",
                        "margin-top": "2rem",
                        "margin-bottom": "2rem",
                        "padding-top": "2.5rem",
                        "padding-bottom": "2.5rem",
                        "padding-left": "3rem",
                        "padding-right": "3rem",
                        "border-radius": "16px",
                        "box-shadow": "0 4px 24px rgba(0,0,0,0.08)",
                        "border": "1px solid rgba(0,0,0,0.06)",
                    },
                ),
                flex_direction="row",
                justify_content="center",
                styles={
                    "background-color": "FloralWhite",
                    "padding": "0px",
                    "margin": "0px",
                    "min-height": "100vh",
                },
            )

        elif self.view_style == "column_with_background_pane":

            if isinstance(out[3], pn.pane.markup.Markdown) or isinstance(out[3], pn.pane.markup.HTML):
                background_pane = out.pop(4)
            else:
                background_pane = out.pop(3)
            background_pane.sizing_mode = "stretch_both"

            out = [
                item for i, item in enumerate(out)
                if not (isinstance(item, pn.layout.Divider)
                        and i > 0
                        and isinstance(out[i - 1], pn.layout.Divider))
            ]

            toggleable = pn.Column(*out[2:], visible=self.visible)
            self.param.watch(lambda event: setattr(toggleable, "visible", event.new), "visible")

            fp = FloatingPane(
                background_pane=background_pane,
                floating_panel_content=[*out[:2], toggleable],
                padding=1,
                padding_unit="vw",
                auto_height=True,
            )

            view = fp.view

        return view


class FloatingPane(param.Parameterized):

    vertical_anchor = param.ObjectSelector(
        default="top", constant=True, objects=["top", "bottom"]
    )
    vertical_offset = param.Number(default=1, bounds=(0, 100), step=1, per_instance=True)
    vertical_unit = param.ObjectSelector(default="vw", objects=["vw", "vh", "px", "%"])

    horizontal_anchor = param.ObjectSelector(
        default="left", constant=True, objects=["left", "right"]
    )
    horizontal_offset = param.Number(default=1, bounds=(0, 100), step=1, per_instance=True)
    horizontal_unit = param.ObjectSelector(
        default="vw", objects=["vw", "vh", "px", "%"]
    )

    width = param.Number(default=40, bounds=(0, 100), step=1, per_instance=True)
    width_unit = param.ObjectSelector(default="vw", objects=["vw", "vh", "px", "%"])

    min_width = param.Number(default=300, bounds=(0, None), step=1, per_instance=True)
    min_width_unit = param.ObjectSelector(default="px", objects=["vw", "vh", "px", "%"])

    height = param.Number(default=98, bounds=(0, 100), step=1, per_instance=True)
    height_unit = param.ObjectSelector(default="vh", objects=["vw", "vh", "px", "%"])
    auto_height = param.Boolean(
        default=False,
        doc="If True, height fills the viewport minus the vertical offset on both sides, ignoring height/height_unit.",
    )

    background_color = param.Color(
        default="#FAF9F6", doc="Background color of the floating pane."
    )
    background_opacity = param.Integer(
        default=80,
        bounds=(0, 100),
        step=1,
        doc="Opacity of the background color (0-100).",
    )

    border_radius = param.Number(
        default=15,
        bounds=(0, None),
        step=1,
        doc="Border radius of the floating pane in pixels.",
    )

    padding = param.Number(
        default=0,
        bounds=(0, None),
        step=1,
        doc="Padding inside the floating pane.",
    )
    padding_unit = param.ObjectSelector(default="px", objects=["vw", "vh", "px", "%"])

    background_pane = param.ClassSelector(
        class_=pn.viewable.Viewable, instantiate=False
    )
    floating_panel_content = param.List(default=[], item_type=pn.viewable.Viewable)

    append_params = param.Boolean(default=False)

    view = param.ClassSelector(class_=pn.Column, instantiate=False)

    _unit_to_offset = {
        "vertical_unit": "vertical_offset",
        "horizontal_unit": "horizontal_offset",
        "width_unit": "width",
        "min_width_unit": "min_width",
        "height_unit": "height",
        "padding_unit": "padding",
    }

    def __init__(self, **params):
        for unit_param, offset_param in self._unit_to_offset.items():
            if unit_param in params:
                new_bounds = (0, None) if params[unit_param] == "px" else (0, 100)
                self.param[offset_param].bounds = new_bounds
        super().__init__(**params)
        self._floating_panel = None
        self.make_view()
        self.param.watch(
            self._update_styles,
            [
                "vertical_anchor",
                "vertical_offset",
                "vertical_unit",
                "horizontal_anchor",
                "horizontal_offset",
                "horizontal_unit",
                "width",
                "width_unit",
                "min_width",
                "min_width_unit",
                "height",
                "height_unit",
                "auto_height",
                "background_color",
                "background_opacity",
                "border_radius",
                "padding",
                "padding_unit",
            ],
        )
        for unit_param in self._unit_to_offset:
            self.param.watch(self._update_bounds, unit_param)

    def _compute_styles(self):
        r = int(self.background_color[1:3], 16)
        g = int(self.background_color[3:5], 16)
        b = int(self.background_color[5:7], 16)
        opacity = self.background_opacity / 100
        return {
            "position": "fixed",
            self.vertical_anchor: f"{self.vertical_offset}{self.vertical_unit}",
            self.horizontal_anchor: f"{self.horizontal_offset}{self.horizontal_unit}",
            "width": f"{self.width}{self.width_unit}",
            "min-width": f"{self.min_width}{self.min_width_unit}",
            "height": (
                f"calc(100vh - {2 * self.vertical_offset}{self.vertical_unit})"
                if self.auto_height
                else f"{self.height}{self.height_unit}"
            ),
            "background-color": f"rgba({r}, {g}, {b}, {opacity})",
            "backdrop-filter": "blur(4px)",
            "-webkit-backdrop-filter": "blur(4px)",
            "box-shadow": "0 8px 40px rgba(0,0,0,0.14), 0 1px 4px rgba(0,0,0,0.08)",
            "border": f"5px solid rgba({r}, {g}, {b}, 0.5)",
            "z-index": "100",
            "overflow": "hidden",
            "border-radius": f"{self.border_radius}px",
            "padding": f"{self.padding}{self.padding_unit}",
        }

    def _update_bounds(self, event):
        offset_param = self._unit_to_offset[event.name]
        new_bounds = (0, None) if event.new == "px" else (0, 100)
        self.param[offset_param].bounds = new_bounds

    def _update_styles(self, *events):
        if self._floating_panel is not None:
            self._floating_panel.styles = self._compute_styles()

    def make_view(self, *events):

        if self.append_params:
            floating_panel_content = self.floating_panel_content + [self.param]
        else:
            floating_panel_content = self.floating_panel_content

        inner_panel = pn.Column(
            *floating_panel_content,
            margin=0,
            sizing_mode="stretch_width",
            styles={
                "overflow": "auto",
                "scrollbar-width": "none",
                "height": "100%",
            },
        )

        self._floating_panel = pn.Column(
            inner_panel,
            margin=0,
            styles=self._compute_styles(),
        )

        view = pn.Column(
            self.background_pane,
            self._floating_panel,
            sizing_mode="stretch_both",
            styles={"overflow": "hidden"},
        )

        self.param.update(view=view)

if __name__ == "__main__":

    import numpy as np
    from typing import Annotated, Literal
    import holoviews as hv
    from holoviz_utils.sharer import share

    def wave_function(
        self,
        wave_type: Literal["sin", "cos", "tan"] = "sin",
        frequency: Annotated[float, "[1,6]"] = 1.0,
        amplitude: Annotated[float, "[3,9]"] = 3.0,
        phase: Annotated[float, "[-3.14159,3.14159]"] = 0.0,
    ):
        """
        Generate a sine, cosine, or tangent wave plot.

        Wave type: {wave_type}
        Frequency: {frequency} Hz
        Amplitude: {amplitude}
        Phase: {phase} radians
        """
        x = np.linspace(0, 4 * np.pi, 1000)
        wave_func = getattr(np, wave_type)
        y = amplitude * wave_func(frequency * x + phase)
        return hv.Curve((x, y), kdims="x", vdims="y").opts(
            responsive=True,
            height=400,
            line_width=2,
            framewise=True,
            default_tools=[],
            tools=["ywheel_zoom", "save", "xbox_select"],
            active_tools=["ywheel_zoom", "xbox_select"],
            hooks=[self.bokeh_hooks.hook_no_logo, self.bokeh_hooks.horizontal_grid],
        )

    dp = DataPresentation(dm_funcs=[wave_function])

    share(dp.view, __name__, __file__, 0)