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

# 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/>.

"""
Annotations for ur faces.

"""
from typing import Callable

from pyqtgraph import (
    Point,
    functions as fn,
    Color,
    GraphicsObject,
)
from pyqtgraph.Qt import internals
import numpy as np
import pyqtgraph as pg

from piker.ui.qt import (
    QtCore,
    QtGui,
    QtWidgets,
    QPointF,
    QRectF,
    QGraphicsPathItem,
)
from piker.ui._style import hcolor
from piker.log import get_logger

log = get_logger(__name__)


def mk_marker_path(

    style: str,

) -> QGraphicsPathItem:
    '''
    Add a marker to be displayed on the line wrapped in
    a ``QGraphicsPathItem`` ready to be placed using scene coordinates
    (not view).

    **Arguments**
    style        String indicating the style of marker to add:
                  ``'<|'``, ``'|>'``, ``'>|'``, ``'|<'``, ``'<|>'``,
                  ``'>|<'``, ``'^'``, ``'v'``, ``'o'``

    This code is taken nearly verbatim from the
    `InfiniteLine.addMarker()` method but does not attempt do be aware
    of low(er) level graphics controls and expects for the output
    polygon to be applied to a ``QGraphicsPathItem``.

    '''
    path = QtGui.QPainterPath()

    if style == 'o':
        path.addEllipse(QtCore.QRectF(-0.5, -0.5, 1, 1))

    # arrow pointing away-from the top of line
    if '<|' in style:
        p = QtGui.QPolygonF([Point(0.5, 0), Point(0, -0.5), Point(-0.5, 0)])
        path.addPolygon(p)
        path.closeSubpath()

    # arrow pointing away-from the bottom of line
    if '|>' in style:
        p = QtGui.QPolygonF([Point(0.5, 0), Point(0, 0.5), Point(-0.5, 0)])
        path.addPolygon(p)
        path.closeSubpath()

    # arrow pointing in-to the top of line
    if '>|' in style:
        p = QtGui.QPolygonF([Point(0.5, -0.5), Point(0, 0), Point(-0.5, -0.5)])
        path.addPolygon(p)
        path.closeSubpath()

    # arrow pointing in-to the bottom of line
    if '|<' in style:
        p = QtGui.QPolygonF([Point(0.5, 0.5), Point(0, 0), Point(-0.5, 0.5)])
        path.addPolygon(p)
        path.closeSubpath()

    if '^' in style:
        p = QtGui.QPolygonF([Point(0, -0.5), Point(0.5, 0), Point(0, 0.5)])
        path.addPolygon(p)
        path.closeSubpath()

    if 'v' in style:
        p = QtGui.QPolygonF([Point(0, -0.5), Point(-0.5, 0), Point(0, 0.5)])
        path.addPolygon(p)
        path.closeSubpath()

    # self._maxMarkerSize = max([m[2] / 2. for m in self.markers])

    return path


class LevelMarker(QGraphicsPathItem):
    '''
    An arrow marker path graphic which redraws itself
    to the specified view coordinate level on each paint cycle.

    '''
    def __init__(
        self,
        chart: 'ChartPlotWidget',  # noqa
        style: str,
        get_level: Callable[..., float],
        size: float = 20,
        keep_in_view: bool = True,
        on_paint: Callable | None = None,

    ) -> None:

        # get polygon and scale
        super().__init__()
        # self.setScale(size, size)
        self.setScale(size)

        # interally generates path
        self._style = None
        self.style = style

        self.chart = chart

        self.get_level = get_level
        self._on_paint = on_paint

        self.scene_x = lambda: chart.marker_right_points()[1]
        self.level: float = 0
        self.keep_in_view = keep_in_view

    @property
    def style(self) -> str:
        return self._style

    @style.setter
    def style(self, value: str) -> None:
        if self._style != value:
            polygon = mk_marker_path(value)
            self.setPath(polygon)
            self._style = value

    def path_br(self) -> QRectF:
        '''Return the bounding rect for the opaque path part
        of this item.

        '''
        return self.mapToScene(
            self.path()
        ).boundingRect()

    def delete(self) -> None:
        self.scene().removeItem(self)

    @property
    def h(self) -> float:
        return self.path_br().height()

    @property
    def w(self) -> float:
        return self.path_br().width()

    def position_in_view(self) -> None:
        '''
        Show a pp off-screen indicator for a level label.

        This is like in fps games where you have a gps "nav" indicator
        but your teammate is outside the range of view, except in 2D, on
        the y-dimension.

        '''
        level = self.get_level()
        view = self.chart.getViewBox()
        vr = view.state['viewRange']
        ymn, ymx = vr[1]

        # _, marker_right, _ = line._chart.marker_right_points()
        x = self.scene_x()

        if self.style == '>|':  # short style, points "down-to" line
            top_offset = self.h
            bottom_offset = 0
        else:
            top_offset = 0
            bottom_offset = self.h

        if level > ymx:  # pin to top of view
            self.setPos(
                QPointF(
                    x,
                    top_offset + self.h/3,
                )
            )

        elif level < ymn:  # pin to bottom of view
            self.setPos(
                QPointF(
                    x,
                    view.height() - (bottom_offset + self.h/3),
                )
            )

        else:
            # pp line is viewable so show marker normally
            self.setPos(
                x,
                self.chart.view.mapFromView(
                    QPointF(0, self.get_level())
                ).y()
            )

    def paint(
        self,

        p: QtGui.QPainter,
        opt: QtWidgets.QStyleOptionGraphicsItem,
        w: QtWidgets.QWidget

    ) -> None:
        '''
        Core paint which we override to always update
        our marker position in scene coordinates from a
        view cooridnate "level".

        '''
        if self.keep_in_view:
            self.position_in_view()

        super().paint(p, opt, w)

        if self._on_paint:
            self._on_paint(self)


def qgo_draw_markers(

    markers: list,
    color: Color,
    p: QtGui.QPainter,
    left: float,
    right: float,
    right_offset: float,

) -> float:
    '''
    Paint markers in ``pg.GraphicsItem`` style by first removing the
    view transform for the painter, drawing the markers in scene
    coords, then restoring the view coords.

    '''
    # paint markers in native coordinate system
    orig_tr = p.transform()

    start = orig_tr.map(Point(left, 0))
    end = orig_tr.map(Point(right, 0))
    up = orig_tr.map(Point(left, 1))

    dif = end - start
    # length = Point(dif).length()
    angle = np.arctan2(dif.y(), dif.x()) * 180 / np.pi

    p.resetTransform()

    p.translate(start)
    p.rotate(angle)

    up = up - start
    det = up.x() * dif.y() - dif.x() * up.y()
    p.scale(1, 1 if det > 0 else -1)

    p.setBrush(fn.mkBrush(color))
    # p.setBrush(fn.mkBrush(self.currentPen.color()))
    tr = p.transform()

    sizes = []
    for path, pos, size in markers:
        p.setTransform(tr)

        # XXX: we drop the "scale / %" placement
        # x = length * pos
        x = right_offset

        p.translate(x, 0)
        p.scale(size, size)
        p.drawPath(path)
        sizes.append(size)

    p.setTransform(orig_tr)
    return max(sizes)


class GapAnnotations(GraphicsObject):
    '''
    Batch-rendered gap annotations using Qt's efficient drawing
    APIs.

    Instead of creating individual `QGraphicsItem` instances per
    gap (which is very slow for 1000+ gaps), this class stores all
    gap rectangles and arrows in numpy-backed arrays and renders
    them in single batch paint calls.

    Performance: ~1000x faster than individual items for large gap
    counts.

    Based on patterns from:
    - `pyqtgraph.BarGraphItem` (batch rect rendering)
    - `pyqtgraph.ScatterPlotItem` (fragment rendering)
    - `piker.ui._curve.FlowGraphic` (single path pattern)

    '''
    def __init__(
        self,
        gap_specs: list[dict],
        array: np.ndarray|None = None,
        color: str = 'dad_blue',
        alpha: int = 169,
        arrow_size: float = 10.0,
        fqme: str|None = None,
        timeframe: float|None = None,
    ) -> None:
        '''
        gap_specs: list of dicts with keys:
            - start_pos: (x, y) tuple for left corner of rect
            - end_pos: (x, y) tuple for right corner of rect
            - arrow_x: x position for arrow
            - arrow_y: y position for arrow
            - pointing: 'up' or 'down' for arrow direction
            - start_time: (optional) timestamp for repositioning
            - end_time: (optional) timestamp for repositioning

        array: optional OHLC numpy array for repositioning on
               backfill updates (when abs-index changes)

        fqme: symbol name for these gaps (for logging/debugging)

        timeframe: period in seconds that these gaps were
                   detected on (used to skip reposition when
                   called with wrong timeframe's array)

        '''
        super().__init__()
        self._gap_specs = gap_specs
        self._array = array
        self._fqme = fqme
        self._timeframe = timeframe
        n_gaps = len(gap_specs)

        # shared pen/brush matching original SelectRect/ArrowItem style
        base_color = pg.mkColor(hcolor(color))

        # rect pen: base color, fully opaque for outline
        self._rect_pen = pg.mkPen(base_color, width=1)

        # rect brush: base color with alpha=66 (SelectRect default)
        rect_fill = pg.mkColor(hcolor(color))
        rect_fill.setAlpha(66)
        self._rect_brush = pg.functions.mkBrush(rect_fill)

        # arrow pen: same as rects
        self._arrow_pen = pg.mkPen(base_color, width=1)

        # arrow brush: base color with user-specified alpha (default 169)
        arrow_fill = pg.mkColor(hcolor(color))
        arrow_fill.setAlpha(alpha)
        self._arrow_brush = pg.functions.mkBrush(arrow_fill)

        # allocate rect array using Qt's efficient storage
        self._rectarray = internals.PrimitiveArray(
            QtCore.QRectF,
            4,
        )
        self._rectarray.resize(n_gaps)
        rect_memory = self._rectarray.ndarray()

        # fill rect array from gap specs
        for (
            i,
            spec,
        ) in enumerate(gap_specs):
            (
                start_x,
                start_y,
            ) = spec['start_pos']
            (
                end_x,
                end_y,
            ) = spec['end_pos']

            # QRectF expects (x, y, width, height)
            rect_memory[i, 0] = start_x
            rect_memory[i, 1] = min(start_y, end_y)
            rect_memory[i, 2] = end_x - start_x
            rect_memory[i, 3] = abs(end_y - start_y)

        # build single QPainterPath for all arrows
        self._arrow_path = QtGui.QPainterPath()
        self._arrow_size = arrow_size

        for spec in gap_specs:
            arrow_x = spec['arrow_x']
            arrow_y = spec['arrow_y']
            pointing = spec['pointing']

            # create arrow polygon
            if pointing == 'down':
                # arrow points downward
                arrow_poly = QtGui.QPolygonF([
                    QPointF(arrow_x, arrow_y),  # tip
                    QPointF(
                        arrow_x - arrow_size/2,
                        arrow_y - arrow_size,
                    ),  # left
                    QPointF(
                        arrow_x + arrow_size/2,
                        arrow_y - arrow_size,
                    ),  # right
                ])
            else:  # up
                # arrow points upward
                arrow_poly = QtGui.QPolygonF([
                    QPointF(arrow_x, arrow_y),  # tip
                    QPointF(
                        arrow_x - arrow_size/2,
                        arrow_y + arrow_size,
                    ),  # left
                    QPointF(
                        arrow_x + arrow_size/2,
                        arrow_y + arrow_size,
                    ),  # right
                ])

            self._arrow_path.addPolygon(arrow_poly)
            self._arrow_path.closeSubpath()

        # cache bounding rect
        self._br: QRectF|None = None

    def boundingRect(self) -> QRectF:
        '''
        Compute bounding rect from rect array and arrow path.

        '''
        if self._br is not None:
            return self._br

        # get rect bounds
        rect_memory = self._rectarray.ndarray()
        if len(rect_memory) == 0:
            self._br = QRectF()
            return self._br

        x_min = rect_memory[:, 0].min()
        y_min = rect_memory[:, 1].min()
        x_max = (rect_memory[:, 0] + rect_memory[:, 2]).max()
        y_max = (rect_memory[:, 1] + rect_memory[:, 3]).max()

        # expand for arrow path
        arrow_br = self._arrow_path.boundingRect()
        x_min = min(x_min, arrow_br.left())
        y_min = min(y_min, arrow_br.top())
        x_max = max(x_max, arrow_br.right())
        y_max = max(y_max, arrow_br.bottom())

        self._br = QRectF(
            x_min,
            y_min,
            x_max - x_min,
            y_max - y_min,
        )
        return self._br

    def paint(
        self,
        p: QtGui.QPainter,
        opt: QtWidgets.QStyleOptionGraphicsItem,
        w: QtWidgets.QWidget,
    ) -> None:
        '''
        Batch render all rects and arrows in minimal paint calls.

        '''
        # draw all rects in single batch call (data coordinates)
        p.setPen(self._rect_pen)
        p.setBrush(self._rect_brush)
        drawargs = self._rectarray.drawargs()
        p.drawRects(*drawargs)

        # draw arrows in scene/pixel coordinates so they maintain
        # size regardless of zoom level
        orig_tr = p.transform()
        p.resetTransform()

        # rebuild arrow path in scene coordinates
        arrow_path_scene = QtGui.QPainterPath()

        # arrow geometry matching pg.ArrowItem defaults
        # headLen=10, headWidth=2.222
        # headWidth is the half-width (center to edge distance)
        head_len = self._arrow_size
        head_width = head_len * 0.2222  # 2.222 at size=10

        for spec in self._gap_specs:
            if 'arrow_x' not in spec:
                continue

            arrow_x = spec['arrow_x']
            arrow_y = spec['arrow_y']
            pointing = spec['pointing']

            # transform data coords to scene coords
            scene_pt = orig_tr.map(QPointF(arrow_x, arrow_y))
            sx = scene_pt.x()
            sy = scene_pt.y()

            # create arrow polygon in scene/pixel coords
            # matching pg.ArrowItem geometry but rotated for up/down
            if pointing == 'down':
                # tip points downward (negative y direction)
                arrow_poly = QtGui.QPolygonF([
                    QPointF(sx, sy),  # tip
                    QPointF(
                        sx - head_width,
                        sy - head_len,
                    ),  # left base
                    QPointF(
                        sx + head_width,
                        sy - head_len,
                    ),  # right base
                ])
            else:  # up
                # tip points upward (positive y direction)
                arrow_poly = QtGui.QPolygonF([
                    QPointF(sx, sy),  # tip
                    QPointF(
                        sx - head_width,
                        sy + head_len,
                    ),  # left base
                    QPointF(
                        sx + head_width,
                        sy + head_len,
                    ),  # right base
                ])

            arrow_path_scene.addPolygon(arrow_poly)
            arrow_path_scene.closeSubpath()

        p.setPen(self._arrow_pen)
        p.setBrush(self._arrow_brush)
        p.drawPath(arrow_path_scene)

        # restore original transform
        p.setTransform(orig_tr)

    def reposition(
        self,
        array: np.ndarray|None = None,
        fqme: str|None = None,
        timeframe: float|None = None,
    ) -> None:
        '''
        Reposition all annotations based on timestamps.

        Used when viz is updated (eg during backfill) and abs-index
        range changes - we need to lookup new indices from timestamps.

        '''
        # skip reposition if timeframe doesn't match
        # (e.g., 1s gaps being repositioned with 60s array)
        if (
            fqme is not None
            and
            self._fqme is not None
            and
            fqme != self._fqme
        ):
            return

        if (
            timeframe is not None
            and
            self._timeframe is not None
            and
            timeframe != self._timeframe
        ):
            log.debug(
                f'Skipping reposition for {self._fqme} gaps:\n'
                f'  gap timeframe: {self._timeframe}s\n'
                f'  array timeframe: {timeframe}s\n'
            )
            return

        if array is None:
            array = self._array

        if array is None:
            log.warning(
                'GapAnnotations.reposition() called but no array '
                'provided'
            )
            return

        if not len(array):
            log.warning(
                'GapAnnotations.reposition() called with an empty '
                'array'
            )
            return

        # collect all unique timestamps we need to lookup
        timestamps: set[float] = set()
        for spec in self._gap_specs:
            if spec.get('start_time') is not None:
                timestamps.add(spec['start_time'])
            if spec.get('end_time') is not None:
                timestamps.add(spec['end_time'])
            if spec.get('time') is not None:
                timestamps.add(spec['time'])

        # vectorized timestamp -> row lookup using binary search
        time_to_row: dict[float, dict] = {}
        if timestamps:
            import numpy as np
            time_arr = array['time']
            ts_array = np.array(list(timestamps))

            search_indices = np.searchsorted(
                time_arr,
                ts_array,
            )

            # vectorized bounds check and exact match verification
            in_bounds = search_indices < len(array)
            valid_mask = np.zeros(
                len(search_indices),
                dtype=bool,
            )
            valid_mask[in_bounds] = (
                time_arr[search_indices[in_bounds]]
                == ts_array[in_bounds]
            )

            valid_indices = search_indices[valid_mask]
            valid_timestamps = ts_array[valid_mask]
            matched_rows = array[valid_indices]

            time_to_row = {
                float(ts): {
                    'index': float(row['index']),
                    'open': float(row['open']),
                    'close': float(row['close']),
                }
                for ts, row in zip(
                    valid_timestamps,
                    matched_rows,
                )
            }

        # Notify Qt before mutating anything used by
        # `boundingRect()`.
        self.prepareGeometryChange()
        self._br = None

        # rebuild rect array from gap specs with new indices
        rect_memory = self._rectarray.ndarray()

        for (
            i,
            spec,
        ) in enumerate(self._gap_specs):
            start_time = spec.get('start_time')
            end_time = spec.get('end_time')

            if (
                start_time is None
                or end_time is None
            ):
                continue

            start_row = time_to_row.get(start_time)
            end_row = time_to_row.get(end_time)

            if (
                start_row is None
                or end_row is None
            ):
                log.warning(
                    f'Timestamp lookup failed for gap[{i}] during '
                    f'reposition:\n'
                    f'  fqme: {fqme}\n'
                    f'  timeframe: {timeframe}s\n'
                    f'  start_time: {start_time}\n'
                    f'  end_time: {end_time}\n'
                    f'  array time range: '
                    f'{array["time"][0]} -> {array["time"][-1]}\n'
                )
                continue

            start_idx = start_row['index']
            end_idx = end_row['index']
            start_close = start_row['close']
            end_open = end_row['open']

            from_idx: float = 0.16 - 0.06
            start_x = start_idx + 1 - from_idx
            end_x = end_idx + from_idx

            # update rect in array
            rect_memory[i, 0] = start_x
            rect_memory[i, 1] = min(start_close, end_open)
            rect_memory[i, 2] = end_x - start_x
            rect_memory[i, 3] = abs(end_open - start_close)

        # rebuild arrow path with new indices
        self._arrow_path.clear()

        spec: dict
        for spec in self._gap_specs:
            time_val: float|None = spec.get('time')
            if time_val is None:
                continue

            arrow_row: dict[str, float]|None = time_to_row.get(
                time_val,
            )
            if arrow_row is None:
                continue

            arrow_x: float = arrow_row['index']
            start_row: dict[str, float]|None = time_to_row.get(
                spec.get('start_time'),
            )
            arrow_y: float = (
                start_row['close']
                if start_row is not None
                else spec['arrow_y']
            )
            pointing: str = spec['pointing']
            spec['arrow_x'] = arrow_x
            spec['arrow_y'] = arrow_y

            # create arrow polygon
            if pointing == 'down':
                arrow_poly = QtGui.QPolygonF([
                    QPointF(arrow_x, arrow_y),
                    QPointF(
                        arrow_x - self._arrow_size/2,
                        arrow_y - self._arrow_size,
                    ),
                    QPointF(
                        arrow_x + self._arrow_size/2,
                        arrow_y - self._arrow_size,
                    ),
                ])
            else:  # up
                arrow_poly = QtGui.QPolygonF([
                    QPointF(arrow_x, arrow_y),
                    QPointF(
                        arrow_x - self._arrow_size/2,
                        arrow_y + self._arrow_size,
                    ),
                    QPointF(
                        arrow_x + self._arrow_size/2,
                        arrow_y + self._arrow_size,
                    ),
                ])

            self._arrow_path.addPolygon(arrow_poly)
            self._arrow_path.closeSubpath()

        self.update()
