# piker: trading gear for hackers
# Copyright (C) Tyler Goodlet (in stewardship for pikers)

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.

# You should have received a copy of the GNU Affero General Public License
# along with this program.  If not, see <https://www.gnu.org/licenses/>.

'''
Chart view box primitives

'''
from __future__ import annotations
from contextlib import (
    asynccontextmanager,
    ExitStack,
)
from functools import partial
import time
from typing import (
    Callable,
    TYPE_CHECKING,
)

import pyqtgraph as pg
# NOTE XXX: pg is super annoying and re-implements it's own mouse
# event subsystem.. we should really look into re-working/writing
# this down the road.. Bo
from pyqtgraph.GraphicsScene import mouseEvents as mevs
# from pyqtgraph.GraphicsScene.mouseEvents import MouseDragEvent
from pyqtgraph import (
    ViewBox,
    Point,
    QtCore,
    functions as fn,
)
import numpy as np
import tractor
import trio

from piker.ui.qt import (
    QWheelEvent,
    QGraphicsSceneMouseEvent as gs_mouse,
    Qt,
    QEvent,
)
from ..log import get_logger
from ..toolz import (
    Profiler,
    pg_profile_enabled,
    ms_slower_then,
)
from .view_mode import overlay_viewlists
# from ._style import _min_points_to_show
from ._editors import SelectRect
from . import _event

if TYPE_CHECKING:
    # from ._search import (
    #     SearchWidget,
    # )
    from ._chart import (
        ChartnPane,
        ChartPlotWidget,
        GodWidget,
    )
    from ._dataviz import Viz
    from .order_mode import (
        OrderMode,
        Dialog,
    )
    from ._display import DisplayState


log = get_logger(__name__)

NUMBER_LINE = {
    Qt.Key.Key_1,
    Qt.Key.Key_2,
    Qt.Key.Key_3,
    Qt.Key.Key_4,
    Qt.Key.Key_5,
    Qt.Key.Key_6,
    Qt.Key.Key_7,
    Qt.Key.Key_8,
    Qt.Key.Key_9,
    Qt.Key.Key_0,
}

ORDER_MODE = {
    Qt.Key.Key_A,
    Qt.Key.Key_F,
    Qt.Key.Key_D,
}


async def handle_viewmode_kb_inputs(

    view: ChartView,
    recv_chan: trio.abc.ReceiveChannel,
    dss: dict[str, DisplayState],

) -> None:

    order_mode: OrderMode = view.order_mode
    godw: GodWidget = order_mode.godw  # noqa

    # track edge triggered keys
    # (https://en.wikipedia.org/wiki/Interrupt#Triggering_methods)
    pressed: set[str] = set()

    last = time.time()
    action: str

    on_next_release: Callable | None = None

    # for quick key sequence-combo pattern matching
    # we have a min_tap period and these should not
    # ever be auto-repeats since we filter those at the
    # event filter level prior to the above mem chan.
    min_tap = 1/6
    fast_key_seq: list[str] = []
    fast_taps: dict[str, Callable] = {
        'cc': order_mode.cancel_all_orders,
    }

    async for kbmsg in recv_chan:
        event, etype, key, mods, text = kbmsg.to_tuple()
        log.debug(
            f'View-mode kb-msg received,\n'
            f'mods: {mods!r}\n'
            f'key: {key!r}\n'
            f'text: {text!r}\n'
        )
        now = time.time()
        period = now - last

        # reset mods
        ctrl: bool = False
        shift: bool = False

        # press branch
        if etype in {QEvent.KeyPress}:

            pressed.add(key)

            if (
                # clear any old values not part of a "fast" tap sequence:
                # presumes the period since last tap is longer then our
                # min_tap period
                fast_key_seq and period >= min_tap or

                # don't support more then 2 key sequences for now
                len(fast_key_seq) > 2
            ):
                fast_key_seq.clear()

            # capture key to fast tap sequence if we either
            # have no previous keys or we do and the min_tap period is
            # met
            if (
                not fast_key_seq
                or (
                    period <= min_tap
                    and
                    fast_key_seq
                )
            ):
                fast_key_seq.append(text)
                log.debug(f'fast keys seqs {fast_key_seq}')

            # mods run through
            if mods == Qt.ShiftModifier:
                shift = True

            if mods == Qt.ControlModifier:
                ctrl = True

            # UI REPL-shell, with ctrl-p (for "pause")
            if (
                ctrl
                and
                key in {
                    Qt.Key_P,
                }
            ):
                feed = order_mode.feed  # noqa
                chart = order_mode.chart  # noqa
                viz = chart.main_viz  # noqa
                vlm_chart = chart.linked.subplots['volume']  # noqa
                vlm_viz = vlm_chart.main_viz  # noqa
                dvlm_pi = vlm_chart._vizs['dolla_vlm'].plot  # noqa
                await tractor.pause()
                view.interact_graphics_cycle()

            # FORCE graphics reset-and-render of all currently
            # shown data `Viz`s for the current chart app.
            if (
                ctrl
                and
                key in {
                    Qt.Key_R,
                }
            ):
                fqme: str
                ds: DisplayState
                for fqme, ds in dss.items():

                    viz: Viz
                    for tf, viz in {
                        60: ds.hist_viz,
                        1: ds.viz,
                    }.items():
                        # TODO: only allow this when the data is IN VIEW!
                        # also, we probably can do this more efficiently
                        # / smarter by only redrawing the portion of the
                        # path necessary?
                        viz.reset_graphics()

            # ------ - ------
            # SEARCH MODE
            # ------ - ------
            # ctlr-<space>/<l> for "lookup", "search" -> open search tree
            if (
                ctrl
                and key in {
                    Qt.Key_L,
                    # Qt.Key_Space,
                }
            ):
                godw = view._chart.linked.godwidget
                godw.hist_linked.resize_sidepanes(from_linked=godw.rt_linked)
                godw.search.focus()

            # esc and ctrl-c
            if (
                key == Qt.Key_Escape
                or (
                    ctrl
                    and
                    key == Qt.Key_C
                )
            ):
                # ctrl-c as cancel
                # https://forum.qt.io/topic/532/how-to-catch-ctrl-c-on-a-widget/9
                view.select_box.clear()
                view.linked.focus()

            # cancel order or clear graphics
            if (
                key == Qt.Key_C
                or
                key == Qt.Key_Delete
            ):
                # log.info('Handling <c> hotkey!')
                try:
                    dialogs: list[Dialog] = order_mode.cancel_orders_under_cursor()
                except BaseException:
                    log.exception('Failed to cancel orders !?\n')
                    await tractor.pause()

                if not dialogs:
                    log.warning(
                        'No orders were cancelled?\n'
                        'Is there an order-line under the cursor?\n'
                        'If you think there IS your DE might be "hiding the mouse" before '
                        'we rx the keyboard input via Qt..\n'
                        '=> Check your DE and/or TWM settings to be sure! <=\n'
                    )
                    # ^TODO?, some way to detect if there's lines and
                    # the DE is cuckin with things?
                    # await tractor.pause()

            # View modes
            if (
                ctrl
                and (
                    key == Qt.Key_Equal
                    or
                    key == Qt.Key_I
                )
            ):
                view.wheelEvent(
                    ev=None,
                    axis=None,
                    delta=view.def_delta,
                )
            elif (
                ctrl
                and (
                    key == Qt.Key_Minus
                    or
                    key == Qt.Key_O
                )
            ):
                view.wheelEvent(
                    ev=None,
                    axis=None,
                    delta=-view.def_delta,
                )

            elif (
                not ctrl
                and
                key == Qt.Key_R
            ):
                # NOTE: seems that if we don't yield a Qt render
                # cycle then the m4 downsampled curves will show here
                # without another reset..
                view._viz.default_view()
                view.interact_graphics_cycle()
                await trio.sleep(0)
                view.interact_graphics_cycle()

            if len(fast_key_seq) > 1:
                # begin matches against sequences
                func: Callable = fast_taps.get(''.join(fast_key_seq))
                if func:
                    func()
                    fast_key_seq.clear()

        # release branch
        elif etype in {QEvent.KeyRelease}:

            if on_next_release:
                on_next_release()
                on_next_release = None

            if key in pressed:
                pressed.remove(key)

        # QUERY/QUOTE MODE
        # ----------------
        if {Qt.Key_Q}.intersection(pressed):

            view.linked.cursor.in_query_mode = True

        else:
            view.linked.cursor.in_query_mode = False

        # SELECTION MODE
        # --------------
        if shift:
            if view.state['mouseMode'] == ViewBox.PanMode:
                view.setMouseMode(ViewBox.RectMode)
        else:
            view.setMouseMode(ViewBox.PanMode)

        # Toggle position config pane
        if (
            ctrl
            and key in {
                Qt.Key_Space,
            }
        ):
            # searchw: SearchWidget = godw.search
            # pp_pane = order_mode.current_pp.pane
            qframes: list[ChartnPane] = []

            for linked in (
                godw.rt_linked,
                godw.hist_linked,
            ):
                for chartw in (
                    [linked.chart]
                    +
                    list(linked.subplots.values())
                ):
                    qframes.append(
                        chartw.qframe
                    )

            # NOTE: place priority on FIRST hiding all
            # panes before showing them.
            # TODO: make this more "fancy"?
            # - maybe look at majority of hidden states and then
            #   flip based on that?
            # - move these loops into the chart APIs?
            # - store the UX-state for a given feed/symbol and
            #   apply when opening a new one (eg. if panes were
            #   hidden then also hide them on newly loaded mkt
            #   feeds).
            if not any(
                qf.sidepane.isHidden() for qf in qframes
            ):
                for qf in qframes:
                    qf.sidepane.hide()

            else:
                for qf in qframes:
                    qf.sidepane.show()

        # ORDER MODE
        # ----------
        # live vs. dark trigger + an action {buy, sell, alert}
        order_keys_pressed = ORDER_MODE.intersection(pressed)

        if order_keys_pressed:

            # TODO: it seems like maybe the composition should be
            # reversed here? Like, maybe we should have the nav have
            # access to the pos state and then make encapsulated logic
            # that shows the right stuff on screen instead or order mode
            # and position-related abstractions doing this?

            # show the pp size label only if there is
            # a non-zero pos existing
            tracker = order_mode.current_pp
            if tracker.live_pp.cumsize:
                tracker.nav.show()

            # TODO: show pp config mini-params in status bar widget
            # mode.pp_config.show()

            trigger_type: str = 'dark'
            if (
                # 's' for "submit" to activate "live" order
                Qt.Key_S in pressed or
                ctrl
            ):
                trigger_type: str = 'live'

            # order mode trigger "actions"
            if Qt.Key_D in pressed:  # for "damp eet"
                action = 'sell'

            elif Qt.Key_F in pressed:  # for "fillz eet"
                action = 'buy'

            elif Qt.Key_A in pressed:
                action = 'alert'
                trigger_type = 'live'

            order_mode.active = True

            # XXX: order matters here for line style!
            order_mode._trigger_type = trigger_type
            order_mode.stage_order(
                action,
                trigger_type=trigger_type,
            )

            prefix = trigger_type + '-' if action != 'alert' else ''
            view._chart.window().set_mode_name(f'{prefix}{action}')

        elif (
            (
                Qt.Key_S in pressed or
                order_keys_pressed or
                Qt.Key_O in pressed
            )
            and key in NUMBER_LINE
        ):
            # hot key to set order slots size.
            # change edit field to current number line value,
            # update the pp allocator bar, unhighlight the
            # field when ctrl is released.
            num = int(text)
            pp_pane = order_mode.pane
            pp_pane.on_ui_settings_change('slots', num)
            edit = pp_pane.form.fields['slots']
            edit.selectAll()
            # un-highlight on ctrl release
            on_next_release = edit.deselect
            pp_pane.update_status_ui(pp_pane.order_mode.current_pp)

        else:  # none active

            # hide pp label
            order_mode.current_pp.nav.hide_info()

            # if none are pressed, remove "staged" level
            # line under cursor position
            order_mode.lines.unstage_line()

            if view.hasFocus():
                # update mode label
                view._chart.window().set_mode_name('view')

            order_mode.active = False

        last = time.time()


async def handle_viewmode_mouse(

    view: ChartView,
    recv_chan: trio.abc.ReceiveChannel,
    dss: dict[str, DisplayState],

) -> None:

    async for msg in recv_chan:
        button = msg.button

        # XXX: ugggh ``pyqtgraph`` has its own mouse events..
        # so we can't overried this easily.
        # it's going to take probably some decent
        # reworking of the mouseClickEvent() handler.

        # if button == QtCore.Qt.RightButton and view.menuEnabled():
        #     event = mouseEvents.MouseClickEvent(msg.event)
        #     # event.accept()
        #     view.raiseContextMenu(event)

        if (
            view.order_mode.active
            and
            button == QtCore.Qt.LeftButton
        ):
            # when in order mode, submit execution
            # msg.event.accept()
            view.order_mode.submit_order()


class ChartView(ViewBox):
    '''
    Price chart view box with interaction behaviors you'd expect from
    any interactive platform:

        - zoom on mouse scroll that auto fits y-axis
        - vertical scrolling on y-axis
        - zoom on x to most recent in view datum
        - zoom on right-click-n-drag to cursor position

    '''
    mode_name: str = 'view'
    def_delta: float = 616 * 6
    def_scale_factor: float = 1.016 ** (def_delta * -1 / 20)
    # annots: dict[int, GraphicsObject] = {}

    def __init__(
        self,

        name: str,

        parent: pg.PlotItem = None,
        static_yrange: tuple[float, float] | None = None,
        **kwargs,

    ):
        super().__init__(
            parent=parent,
            name=name,
            # TODO: look into the default view padding
            # support that might replace somem of our
            # ``ChartPlotWidget._set_yrange()`
            # defaultPadding=0.,
            **kwargs
        )

        # for "known y-range style"
        self._static_yrange = static_yrange

        # disable vertical scrolling
        self.setMouseEnabled(
            x=True,
            y=True,
        )

        self.linked = None
        self._chart: ChartPlotWidget | None = None  # noqa

        # add our selection box annotator
        self.select_box = SelectRect(self)
        # self.select_box.add_to_view(self)
        # self.addItem(
        #     self.select_box,
        #     ignoreBounds=True,
        # )

        self.mode = None
        self.order_mode: bool = False

        self.setFocusPolicy(QtCore.Qt.StrongFocus)
        self._in_interact: trio.Event | None = None
        self._interact_stack: ExitStack = ExitStack()

        # TODO: probably just assign this whenever a new `PlotItem` is
        # allocated since they're 1to1 with views..
        self._viz: Viz | None = None
        self._yrange: tuple[float, float] | None = None

    def start_ic(
        self,
    ) -> None:
        '''
        Signal the beginning of a click-drag interaction
        to any interested task waiters.

        '''
        if self._in_interact is None:
            chart = self.chart
            try:
                self._in_interact = trio.Event()

                chart.pause_all_feeds()
                self._interact_stack.enter_context(
                    chart.reset_graphics_caches()
                )
            except RuntimeError:
                pass

    def signal_ic(
        self,
        *args,

    ) -> None:
        '''
        Signal the end of a click-drag interaction
        to any waiters.

        '''
        if self._in_interact:
            try:
                self._interact_stack.close()
                self.chart.resume_all_feeds()

                self._in_interact.set()
                self._in_interact = None
            except RuntimeError:
                pass

    @asynccontextmanager
    async def open_async_input_handler(
        self,
        **handler_kwargs,

    ) -> ChartView:

        async with (
            _event.open_handlers(
                [self],
                event_types={
                    QEvent.KeyPress,
                    QEvent.KeyRelease,
                },
                async_handler=partial(
                    handle_viewmode_kb_inputs,
                    **handler_kwargs,
                ),
            ),
            _event.open_handlers(
                [self],
                event_types={
                    gs_mouse.GraphicsSceneMousePress,
                },
                async_handler=partial(
                    handle_viewmode_mouse,
                    **handler_kwargs,
                ),
            ),
        ):
            yield self

    @property
    def chart(self) -> ChartPlotWidget:  # type: ignore # noqa
        return self._chart

    @chart.setter
    def chart(self, chart: ChartPlotWidget) -> None:  # type: ignore # noqa
        self._chart = chart
        self.select_box.chart = chart

    def wheelEvent(
        self,
        ev: QWheelEvent | None = None,
        axis: int | None = None,
        delta: float | None = None,
    ):
        '''
        Override "center-point" location for scrolling.

        This is an override of the ``ViewBox`` method simply changing
        the center of the zoom to be the y-axis.

        TODO: PR a method into ``pyqtgraph`` to make this configurable

        '''
        # NOTE: certain operations are only avail when this handler is
        # actually called on events.
        if ev is None:
            assert delta
            assert axis is None

        linked = self.linked
        if (
            not linked
        ):
            return

        if axis in (0, 1):
            mask = [False, False]
            mask[axis] = self.state['mouseEnabled'][axis]
        else:
            mask: list[bool] = self.state['mouseEnabled'][:]

        chart = self.linked.chart

        # don't zoom more then the min points setting
        viz = chart.get_viz(chart.name)
        _, vl, lbar, rbar, vr, r = viz.datums_range()

        # TODO: max/min zoom limits incorporating time step size.
        # rl = vr - vl
        # if ev.delta() > 0 and rl <= _min_points_to_show:
        #     log.warning("Max zoom bruh...")
        #     return
        # if (
        #     ev.delta() < 0
        #     and rl >= len(chart._vizs[chart.name].shm.array) + 666
        # ):
        #     log.warning("Min zoom bruh...")
        #     return

        # actual scaling factor
        delta: float = ev.delta() if ev else delta
        scale_factor: float = 1.016 ** (delta * -1 / 20)

        # NOTE: if elem is False -> None meaning "do not scale that
        # axis".
        scales: list[float | bool] = [
            (None if m is False else scale_factor)
            for m in mask
        ]

        if (
            # zoom happened on axis
            axis == 1

            # if already in axis zoom mode then keep it
            or self.chart._static_yrange == 'axis'
        ):
            self.chart._static_yrange = 'axis'
            self.setLimits(yMin=None, yMax=None)

            # print(scale_y)
            # pos = ev.pos()
            # lastPos = ev.lastPos()
            # dif = pos - lastPos
            # dif = dif * -1
            center = Point(
                fn.invertQTransform(
                    self.childGroup.transform()
                ).map(ev.pos())
            )
            # scale_y = 1.3 ** (center.y() * -1 / 20)
            self.scaleBy(scales, center)

        # zoom in view-box area
        else:
            # use right-most point of current curve graphic
            xl = viz.graphics.x_last()
            focal = min(
                xl,
                r,
            )

            self._resetTarget()

            # NOTE: scroll "around" the right most datum-element in view
            # gives the feeling of staying "pinned" in place.
            self.scaleBy(scales, focal)

            # XXX: the order of the next 2 lines i'm pretty sure
            # matters, we want the resize to trigger before the graphics
            # update, but i gotta feelin that because this one is signal
            # based (and thus not necessarily sync invoked right away)
            # that calling the resize method manually might work better.
            # self.sigRangeChangedManually.emit(mask)

            # XXX: without this is seems as though sometimes
            # when zooming in from far out (and maybe vice versa?)
            # the signal isn't being fired enough since if you pan
            # just after you'll see further downsampling code run
            # (pretty noticeable on the OHLC ds curve) but with this
            # that never seems to happen? Only question is how much this
            # "double work" is causing latency when these missing event
            # fires don't happen?
            self.interact_graphics_cycle()
            self.interact_graphics_cycle()

            if ev:
                ev.accept()

    def mouseDragEvent(
        self,
        ev: mevs.MouseDragEvent,
        axis: int | None = None,

    ) -> None:
        pos: Point = ev.pos()
        lastPos: Point = ev.lastPos()
        dif: Point = (pos - lastPos) * -1
        # dif: Point = pos - lastPos
        # dif: Point = dif * -1

        # NOTE: if axis is specified, event will only affect that axis.
        btn = ev.button()

        # Ignore axes if mouse is disabled
        mouseEnabled = np.array(
            self.state['mouseEnabled'],
            dtype=float,
        )
        mask = mouseEnabled.copy()
        if axis is not None:
            mask[1-axis] = 0.0

        # Scale or translate based on mouse button
        if btn & (
            QtCore.Qt.LeftButton
            | QtCore.Qt.MidButton
        ):
            # zoom y-axis ONLY when click-n-drag on it
            # if axis == 1:
            #     # set a static y range special value on chart widget to
            #     # prevent sizing to data in view.
            #     self.chart._static_yrange = 'axis'

            #     scale_y = 1.3 ** (dif.y() * -1 / 20)
            #     self.setLimits(yMin=None, yMax=None)

            #     # print(scale_y)
            #     self.scaleBy((0, scale_y))

            # SELECTION MODE
            if (
                self.state['mouseMode'] == ViewBox.RectMode
                and axis is None
            ):
                # XXX: WHY
                ev.accept()

                down_pos: Point = ev.buttonDownPos(
                    btn=btn,
                )
                scen_pos: Point = ev.scenePos()
                scen_down_pos: Point = ev.buttonDownScenePos(
                    btn=btn,
                )

                # This is the final position in the drag
                if ev.isFinish():

                    # import pdbp; pdbp.set_trace()

                    # NOTE: think of this as a `.mouse_drag_release()`
                    # (bc HINT that's what i called the shit ass
                    # method that wrapped this call [yes, as a single
                    # fucking call] originally.. you bish, guille)
                    # Bo.. oraleeee
                    self.select_box.set_scen_pos(
                        # down_pos,
                        # pos,
                        scen_down_pos,
                        scen_pos,
                    )

                    # this is the zoom transform cmd
                    ax = QtCore.QRectF(down_pos, pos)
                    ax = self.childGroup.mapRectFromParent(ax)
                    # self.showAxRect(ax)
                    # axis history tracking
                    self.axHistoryPointer += 1
                    self.axHistory = self.axHistory[
                        :self.axHistoryPointer] + [ax]

                else:
                    self.select_box.set_scen_pos(
                        # down_pos,
                        # pos,
                        scen_down_pos,
                        scen_pos,
                    )

                    # update shape of scale box
                    # self.updateScaleBox(ev.buttonDownPos(), ev.pos())
                    # breakpoint()
                    # self.updateScaleBox(
                    #     down_pos,
                    #     ev.pos(),
                    # )

            # PANNING MODE
            else:
                try:
                    self.start_ic()
                except RuntimeError:
                    pass

                if axis == 1:
                    self.chart._static_yrange = 'axis'

                tr = self.childGroup.transform()
                tr = fn.invertQTransform(tr)
                tr = tr.map(dif*mask) - tr.map(Point(0, 0))

                x = tr.x() if mask[0] == 1 else None
                y = tr.y() if mask[1] == 1 else None

                self._resetTarget()

                if x is not None or y is not None:
                    self.translateBy(x=x, y=y)

                # self.sigRangeChangedManually.emit(mask)
                    # self.state['mouseEnabled']
                # )
                self.interact_graphics_cycle()

                if ev.isFinish():
                    self.signal_ic()
                    # self._in_interact.set()
                    # self._in_interact = None
                    # self.chart.resume_all_feeds()

                # # XXX: WHY
                # ev.accept()

        # WEIRD "RIGHT-CLICK CENTER ZOOM" MODE
        elif btn & QtCore.Qt.RightButton:

            if self.state['aspectLocked'] is not False:
                mask[0] = 0

            dif = ev.screenPos() - ev.lastScreenPos()
            dif = np.array([dif.x(), dif.y()])
            dif[0] *= -1
            s = ((mask * 0.02) + 1) ** dif

            tr = self.childGroup.transform()
            tr = fn.invertQTransform(tr)

            x = s[0] if mouseEnabled[0] == 1 else None
            y = s[1] if mouseEnabled[1] == 1 else None

            center = Point(tr.map(ev.buttonDownPos(QtCore.Qt.RightButton)))
            self._resetTarget()
            self.scaleBy(x=x, y=y, center=center)

            # self.sigRangeChangedManually.emit(self.state['mouseEnabled'])
            self.interact_graphics_cycle()

        # XXX: WHY
        ev.accept()

    # def mouseClickEvent(self, event: QtCore.QEvent) -> None:
    #      '''This routine is rerouted to an async handler.
    #      '''
    #     pass

    def keyReleaseEvent(self, event: QtCore.QEvent) -> None:
        '''This routine is rerouted to an async handler.
        '''
        pass

    def keyPressEvent(self, event: QtCore.QEvent) -> None:
        '''This routine is rerouted to an async handler.
        '''
        pass

    def _set_yrange(
        self,
        *,

        yrange: tuple[float, float] | None = None,
        viz: Viz | None = None,

        # NOTE: this value pairs (more or less) with L1 label text
        # height offset from from the bid/ask lines.
        range_margin: float | None = 0.06,

        bars_range: tuple[int, int, int, int] | None = None,

        # flag to prevent triggering sibling charts from the same linked
        # set from recursion errors.
        autoscale_linked_plots: bool = False,
        name: str | None = None,

    ) -> None:
        '''
        Set the viewable y-range based on embedded data.

        This adds auto-scaling like zoom on the scroll wheel such
        that data always fits nicely inside the current view of the
        data set.

        '''
        name = self.name
        # print(f'YRANGE ON {name} -> yrange{yrange}')
        profiler = Profiler(
            msg=f'`ChartView._set_yrange()`: `{name}`',
            disabled=not pg_profile_enabled(),
            ms_threshold=ms_slower_then,
            delayed=True,
        )
        chart = self._chart

        # view has been set in 'axis' mode
        # meaning it can be panned and zoomed
        # arbitrarily on the y-axis:
        # - disable autoranging
        # - remove any y range limits
        if chart._static_yrange == 'axis':
            self.setLimits(yMin=None, yMax=None)
            return

        # static y-range has been set likely by
        # a specialized FSP configuration.
        elif chart._static_yrange is not None:
            ylow, yhigh = chart._static_yrange

        # range passed in by caller, usually a
        # maxmin detection algos inside the
        # display loop for re-draw efficiency.
        elif yrange is not None:
            ylow, yhigh = yrange

        # XXX: only compute the mxmn range
        # if none is provided as input!
        if not yrange:

            if not viz:
                breakpoint()

            out = viz.maxmin()
            if out is None:
                log.warning(f'No yrange provided for {name}!?')
                return
            (
                ixrng,
                _,
                yrange
            ) = out

            profiler(f'`{self.name}:Viz.maxmin()` -> {ixrng}=>{yrange}')

            if yrange is None:
                log.warning(f'No yrange provided for {name}!?')
                return

            ylow, yhigh = yrange

        # always stash last range for diffing by
        # incremental update calculations BEFORE adding
        # margin.
        self._yrange = ylow, yhigh

        # view margins: stay within a % of the "true range"
        if range_margin is not None:
            diff = yhigh - ylow
            ylow = max(
                ylow - (diff * range_margin),
                0,
            )
            yhigh = min(
                yhigh + (diff * range_margin),
                yhigh * (1 + range_margin),
            )

        # print(
        #     f'set limits {self.name}:\n'
        #     f'ylow: {ylow}\n'
        #     f'yhigh: {yhigh}\n'
        # )
        self.setYRange(
            ylow,
            yhigh,
            padding=0,
        )
        self.setLimits(
            yMin=ylow,
            yMax=yhigh,
        )
        self.update()

        # LOL: yet anothercucking pg buggg..
        # can't use `msg=f'setYRange({ylow}, {yhigh}')`
        profiler.finish()

    def enable_auto_yrange(
        self,
        viz: Viz,
        src_vb: ChartView | None = None,

    ) -> None:
        '''
        Assign callbacks for rescaling and resampling y-axis data
        automatically based on data contents and ``ViewBox`` state.

        '''
        if src_vb is None:
            src_vb = self

        # re-sampling trigger:
        # TODO: a smarter way to avoid calling this needlessly?
        # 2 things i can think of:
        # - register downsample-able graphics specially and only
        #   iterate those.
        # - only register this when certain downsample-able graphics are
        #   "added to scene".
        # src_vb.sigRangeChangedManually.connect(
        #     self.interact_graphics_cycle
        # )

        # widget-UIs/splitter(s) resizing
        src_vb.sigResized.connect(
            self.interact_graphics_cycle
        )

    def disable_auto_yrange(self) -> None:

        # XXX: not entirely sure why we can't de-reg this..
        self.sigResized.disconnect(
            self.interact_graphics_cycle
        )

    def x_uppx(self) -> float:
        '''
        Return the "number of x units" within a single
        pixel currently being displayed for relevant
        graphics items which are our children.

        '''
        graphics = [f.graphics for f in self._chart._vizs.values()]
        if not graphics:
            return 0

        for graphic in graphics:
            xvec = graphic.pixelVectors()[0]
            if xvec:
                return xvec.x()
        else:
            return 0

    def interact_graphics_cycle(
        self,
        *args,  # capture Qt signal (slot) inputs

        # debug_print: bool = False,
        do_linked_charts: bool = True,
        do_overlay_scaling: bool = True,

        yrange_kwargs: dict[
            str,
            tuple[float, float],
        ] | None = None,
 
    ):
        profiler = Profiler(
            msg=f'ChartView.interact_graphics_cycle() for {self.name}',
            disabled=not pg_profile_enabled(),
            ms_threshold=ms_slower_then,

            # XXX: important to avoid not seeing underlying
            # ``Viz.update_graphics()`` nested profiling likely
            # due to the way delaying works and garbage collection of
            # the profiler in the delegated method calls.
            delayed=True,

            # for hardcore latency checking, comment these flags above.
            # disabled=False,
            # ms_threshold=4,
        )

        linked = self.linked
        if (
            do_linked_charts
            and linked
        ):
            plots = {linked.chart.name: linked.chart}
            plots |= linked.subplots

        else:
            chart = self._chart
            plots = {chart.name: chart}

        # TODO: a faster single-loop-iterator way of doing this?
        return overlay_viewlists(
            self._viz,
            plots,
            profiler,
            do_overlay_scaling=do_overlay_scaling,
            do_linked_charts=do_linked_charts,
            yrange_kwargs=yrange_kwargs,
        )
