# 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 axes graphics and behavior.

"""
from __future__ import annotations
from functools import lru_cache
from typing import Callable
from math import floor

import polars as pl
import pyqtgraph as pg

from piker.ui.qt import (
    QtCore,
    QtGui,
    QtWidgets,
    QPointF,
    txt_flag,
    align_flag,
    px_cache_mode,
)
from . import _pg_overrides as pgo
from ..accounting._mktinfo import float_digits
from ._label import Label
from ._style import DpiAwareFont, hcolor, _font
from ._interaction import ChartView
from ._dataviz import Viz

_axis_pen = pg.mkPen(hcolor('bracket'))


class Axis(pg.AxisItem):
    '''
    A better axis that sizes tick contents considering font size.

    Also includes tick values lru caching originally proposed in but never
    accepted upstream:
    https://github.com/pyqtgraph/pyqtgraph/pull/2160

    '''
    def __init__(
        self,
        plotitem: pgo.PlotItem,
        typical_max_str: str = '100 000.000  ',
        text_color: str = 'bracket',
        lru_cache_tick_strings: bool = True,
        **kwargs

    ) -> None:
        super().__init__(
            # textPen=textPen,
            **kwargs
        )

        # XXX: pretty sure this makes things slower!
        # no idea why given we only move labels for the most part?
        # self.setCacheMode(QtWidgets.QGraphicsItem.DeviceCoordinateCache)

        self.pi = plotitem
        self._dpi_font = _font

        self.setTickFont(_font.font)
        font_size = self._dpi_font.font.pixelSize()

        style_conf = {
            'textFillLimits': [(0, 0.5)],
            'tickFont': self._dpi_font.font,

        }
        text_offset = None
        if self.orientation in ('bottom',):
            text_offset = floor(0.25 * font_size)

        elif self.orientation in ('left', 'right'):
            text_offset = floor(font_size / 2)

        if text_offset:
            style_conf.update({
                # offset of text *away from* axis line in px
                # use approx. half the font pixel size (height)
                'tickTextOffset': text_offset,
            })

        self.setStyle(**style_conf)
        self.setTickFont(_font.font)

        # NOTE: this is for surrounding "border"
        self.setPen(_axis_pen)

        # this is the text color
        self.text_color = text_color

        # generate a bounding rect based on sizing to a "typical"
        # maximum length-ed string defined as init default.
        self.typical_br = _font._qfm.boundingRect(typical_max_str)

        # size the pertinent axis dimension to a "typical value"
        self.size_to_values()

        # NOTE: requires override ``.tickValues()`` method seen below.
        if lru_cache_tick_strings:
            self.tickStrings = lru_cache(
                maxsize=2**20
            )(self.tickStrings)

        # axis "sticky" labels
        self._stickies: dict[str, YAxisLabel] = {}

    # NOTE: only overriden to cast tick values entries into tuples
    # for use with the lru caching.
    def tickValues(
        self,
        minVal: float,
        maxVal: float,
        size: int,

    ) -> list[tuple[float, tuple[str]]]:
        '''
        Repack tick values into tuples for lru caching.

        '''
        ticks = []
        for scalar, values in super().tickValues(minVal, maxVal, size):
            ticks.append((
                scalar,
                tuple(values),  # this
            ))

        return ticks

    @property
    def text_color(self) -> str:
        return self._text_color

    @text_color.setter
    def text_color(self, text_color: str) -> None:
        self.setTextPen(pg.mkPen(hcolor(text_color)))
        self._text_color = text_color

    def size_to_values(self) -> None:
        pass

    def txt_offsets(self) -> tuple[int, int]:
        return tuple(self.style['tickTextOffset'])

    def add_sticky(
        self,
        pi: pgo.PlotItem,
        name: None | str = None,
        digits: None | int = 2,
        bg_color='default',
        fg_color='black',

    ) -> YAxisLabel:

        # if the sticky is for our symbol
        # use the tick size precision for display
        name = name or pi.name
        digits = digits or 2

        # TODO: ``._ysticks`` should really be an attr on each
        # ``PlotItem`` now instead of the containing widget (because of
        # overlays) ?

        # add y-axis "last" value label
        sticky = self._stickies[name] = YAxisLabel(
            pi=pi,
            parent=self,
            digits=digits,  # TODO: pass this from symbol data
            opacity=0.9,  # slight see-through
            bg_color=bg_color,
            fg_color=fg_color,
        )

        pi.sigRangeChanged.connect(sticky.update_on_resize)
        return sticky


class PriceAxis(Axis):

    def __init__(
        self,
        *args,
        min_tick: int = 2,
        title: str = '',
        formatter: Callable[[float], str] | None = None,
        **kwargs

    ) -> None:
        super().__init__(*args, **kwargs)
        self.formatter = formatter
        self._min_tick: int = min_tick
        self.title = None

    def set_title(
        self,
        title: str,
        view: ChartView | None = None,
        color: str | None = None,

    ) -> Label:
        '''
        Set a sane UX label using our built-in ``Label``.

        '''
        # XXX: built-in labels but they're huge, and placed weird..
        # self.setLabel(title)
        # self.showLabel()

        label: Label | None = self.title
        if label is None:
            label = self.title = Label(
                view=view or self.linkedView(),
                fmt_str=title,
                color=color or self.text_color,
                parent=self,
                # update_on_range_change=False,
            )
        else:
            label.fmt_str: str = title

        def below_axis() -> QPointF:
            return QPointF(
                0,
                self.size().height(),
            )

        # XXX: doesn't work? have to pass it above
        # label.txt.setParent(self)
        label.scene_anchor = below_axis
        label.render()
        label.show()
        label.update()
        return label

    def set_min_tick(
        self,
        size: int
    ) -> None:
        self._min_tick = size

    def size_to_values(self) -> None:
        self.setWidth(self.typical_br.width())

    # XXX: drop for now since it just eats up h space

    def tickStrings(
        self,
        vals: tuple[float],
        scale: float,
        spacing: float,

    ) -> list[str]:
        # TODO: figure out how to enforce min tick spacing by passing it
        # into the parent type
        digits = max(
            float_digits(spacing * scale),
            self._min_tick,
        )
        if self.title:
            self.title.update()

        # print(f'vals: {vals}\nscale: {scale}\nspacing: {spacing}')
        # print(f'digits: {digits}')

        if not self.formatter:
            return [
                ('{value:,.{digits}f}').format(
                    digits=digits,
                    value=v,
                ).replace(',', ' ') for v in vals
            ]
        else:
            return list(map(self.formatter, vals))


class DynamicDateAxis(Axis):

    # time formats mapped by seconds between bars
    tick_tpl = {
        60 * 60 * 24: '%Y-%b-%d',
        60: '%Y-%b-%d(%H:%M)',
        1: '%H:%M:%S',
    }

    def size_to_values(self) -> None:
        self.setHeight(self.typical_br.height() + 1)

    def _indexes_to_timestrs(
        self,
        indexes: list[int],

    ) -> list[str]:

        # XX: ARGGGGG AG:LKSKDJF:LKJSDFD
        chart = self.pi.chart_widget

        viz: Viz = chart._vizs[chart.name]
        shm = viz.shm
        array = shm.array
        ifield: str = viz.index_field
        index = array[ifield]
        i_0, i_l = index[0], index[-1]

        # edge cases
        if (
            not indexes
            or
            (indexes[0] < i_0
             and indexes[-1] < i_l)
            or
            (indexes[0] > i_0
             and indexes[-1] > i_l)
        ):
            # print(f"x-label indexes empty edge case: {indexes}")
            return []

        if ifield == 'index':
            arr_len = index.shape[0]
            first = shm._first.value
            times = array['time']
            epochs: list[int] = times[
                list(
                    map(
                        int,
                        filter(
                            lambda i: i > 0 and i < arr_len,
                            (i - first for i in indexes)
                        )
                    )
                )
            ]
        else:
            epochs: list[int] = list(map(int, indexes))

        # TODO: **don't** have this hard coded shift to EST
        delay: float = viz.time_step()
        if delay > 1:
            # NOTE: use less granular dt-str when using 1M+ OHLC
            fmtstr: str = self.tick_tpl[delay]
        else:
            fmtstr: str = '%Y-%m-%d(%H:%M:%S)'

        # https://pola-rs.github.io/polars/py-polars/html/reference/expressions/api/polars.from_epoch.html#polars-from-epoch
        pl_dts: pl.Series = pl.from_epoch(
            epochs,
            time_unit='s',
        # NOTE: kinda weird we can pass it to `.from_epoch()` no?
        ).dt.replace_time_zone(
            time_zone='UTC'
        ).dt.convert_time_zone(
            # TODO: pull this from either:
            # -[ ] the mkt venue tz by default
            # -[ ] the user's config under `sys.mkt_timezone: str`
            'EST'
        )
        return pl_dts.dt.to_string(fmtstr).to_list()

    def tickStrings(
        self,
        values: tuple[float],
        scale: float,
        spacing: float,

    ) -> list[str]:

        return self._indexes_to_timestrs(values)

        # NOTE: handy for debugging the lru cache
        # info = self.tickStrings.cache_info()
        # print(info)


class AxisLabel(pg.GraphicsObject):

    # relative offsets *OF* the bounding rect relative
    # to parent graphics object.
    # eg.  <parent>| => <_x_br_offset> => | <text> |
    _x_br_offset: float = 0
    _y_br_offset: float = 0

    # relative offsets of text *within* bounding rect
    # eg. | <_x_margin> => <text> |
    _x_margin: float = 0
    _y_margin: float = 0

    # multiplier of the text content's height in order
    # to force a larger (y-dimension) bounding rect.
    _y_txt_h_scaling: float = 1

    def __init__(
        self,
        parent: pg.GraphicsItem,
        digits: int = 2,

        bg_color: str = 'default',
        fg_color: str = 'black',
        opacity: int = .8,  # XXX: seriously don't set this to 0
        font_size: str = 'default',

        use_arrow: bool = True,

    ) -> None:

        super().__init__()
        self.setParentItem(parent)

        self.setFlag(
            self.GraphicsItemFlag.ItemIgnoresTransformations
        )
        self.setZValue(100)

        # XXX: pretty sure this is faster
        self.setCacheMode(
            px_cache_mode.DeviceCoordinateCache
        )

        self._parent = parent

        self.opacity = opacity
        self.label_str = ''
        self.digits = digits

        self._txt_br: QtCore.QRect = None

        self._dpifont = DpiAwareFont(_font_size_key=font_size)
        self._dpifont.configure_to_dpi()

        self.bg_color = pg.mkColor(hcolor(bg_color))
        self.fg_color = pg.mkColor(hcolor(fg_color))

        self._use_arrow = use_arrow

        # create triangle path
        self.path = None
        self.rect = None

        self._pw = self.pixelWidth()

    def paint(
        self,
        p: QtGui.QPainter,
        opt: QtWidgets.QStyleOptionGraphicsItem,
        w: QtWidgets.QWidget

    ) -> None:
        '''
        Draw a filled rectangle based on the size of ``.label_str`` text.

        Subtypes can customize further by overloading ``.draw()``.

        '''
        if self.label_str:

            # if not self.rect:
            self._size_br_from_str(self.label_str)

            # can be overrided in subtype
            self.draw(p, self.rect)

            p.setFont(self._dpifont.font)
            p.setPen(self.fg_color)
            p.drawText(
                self.rect,
                self.text_flags,
                self.label_str,
            )

    def draw(
        self,
        p: QtGui.QPainter,
        rect: QtCore.QRectF
    ) -> None:

        p.setOpacity(self.opacity)

        if self._use_arrow:
            if not self.path:
                self._draw_arrow_path()

            p.drawPath(self.path)
            p.fillPath(self.path, pg.mkBrush(self.bg_color))

        # this cause the L1 labels to glitch out if used in the subtype
        # and it will leave a small black strip with the arrow path if
        # done before the above
        p.fillRect(
            self.rect,
            self.bg_color,
        )

    def boundingRect(self):  # noqa
        '''
        Size the graphics space from the text contents.

        '''
        if self.label_str:
            self._size_br_from_str(self.label_str)

            # if self.path:
            #     self.tl = self.path.controlPointRect().topLeft()
            if not self.path:
                self.tl = self.rect.topLeft()

            return QtCore.QRectF(
                self.tl,
                self.rect.bottomRight(),
            )

        return QtCore.QRectF()

    # TODO: but the input probably needs to be the "len" of
    # the current text value:
    @lru_cache
    def _size_br_from_str(
        self,
        value: str

    ) -> tuple[float, float]:
        '''
        Do our best to render the bounding rect to a set margin
        around provided string contents.

        '''
        # size the filled rect to text and/or parent axis
        # if not self._txt_br:
        #     # XXX: this can't be called until stuff is rendered?
        #     self._txt_br = self._dpifont.boundingRect(value)

        txt_br = self._txt_br = self._dpifont.boundingRect(value)
        txt_h, txt_w = txt_br.height(), txt_br.width()
        # print(f'wsw: {self._dpifont.boundingRect(" ")}')

        # allow subtypes to override width and height
        h, w = self.size_hint()

        self.rect = QtCore.QRectF(

            # relative bounds offsets
            self._x_br_offset,
            self._y_br_offset,

            (w or txt_w) + self._x_margin / 2,

            (h or txt_h) * self._y_txt_h_scaling + (self._y_margin / 2),
        )
        # print(self.rect)
        # hb = self.path.controlPointRect()
        # hb_size = hb.size()

        return (self.rect.width(), self.rect.height())



class XAxisLabel(AxisLabel):
    _x_margin = 8

    text_flags = (
        align_flag.AlignCenter
        | txt_flag.TextDontClip
    )

    def size_hint(self) -> tuple[float, float]:
        # size to parent axis height
        return self._parent.height(), None

    def update_label(
        self,
        abs_pos: QPointF,  # scene coords
        value: float,  # data for text
        offset: int = 0  # if have margins, k?

    ) -> None:

        timestrs = self._parent._indexes_to_timestrs([int(value)])

        if not len(timestrs):
            return

        pad = 1*' '
        self.label_str = pad + str(timestrs[0]) + pad

        _, y_offset = self._parent.txt_offsets()

        w = self.boundingRect().width()

        self.setPos(
            QPointF(
                abs_pos.x() - w/2 - self._pw,
                y_offset/2,
            )
        )
        self.update()

    def _draw_arrow_path(self):
        y_offset = self._parent.style['tickTextOffset'][1]
        path = QtGui.QPainterPath()
        h, w = self.rect.height(), self.rect.width()
        middle = w/2 - self._pw * 0.5
        aw = h/2
        left = middle - aw
        right = middle + aw
        path.moveTo(left, 0)
        path.lineTo(middle, -y_offset)
        path.lineTo(right, 0)
        path.closeSubpath()
        self.path = path

        # top left point is local origin and tip of the arrow path
        self.tl = QtCore.QPointF(0, -y_offset)


class YAxisLabel(AxisLabel):
    _y_margin: int = 4

    text_flags = (
        align_flag.AlignLeft
        | align_flag.AlignVCenter
        # | align_flag.AlignHCenter
        | txt_flag.TextDontClip
    )

    def __init__(
        self,
        pi: pgo.PlotItem,
        *args,
        **kwargs
    ) -> None:

        super().__init__(*args, **kwargs)

        self._pi = pi
        pi.sigRangeChanged.connect(self.update_on_resize)

        self._last_datum = (None, None)

        self.x_offset = 0
        # pull text offset from axis from parent axis
        if getattr(self._parent, 'txt_offsets', False):
            self.x_offset, y_offset = self._parent.txt_offsets()

    def size_hint(self) -> tuple[float, float]:
        # size to parent axis width(-ish)
        wsh = self._dpifont.boundingRect(' ').height() / 2
        return (
            None,
            self._parent.size().width() - wsh,
        )

    def update_label(
        self,
        abs_pos: QPointF,  # scene coords
        value: float,  # data for text

        # on odd dimension and/or adds nice black line
        x_offset: int = 0,

    ) -> None:

        # this is read inside ``.paint()``
        self.label_str = '{value:,.{digits}f}'.format(
            digits=self.digits, value=value).replace(',', ' ')

        # pull text offset from axis from parent axis
        x_offset = x_offset or self.x_offset

        br = self.boundingRect()
        h = br.height()

        self.setPos(
            QPointF(
                x_offset,
                abs_pos.y() - h / 2 - self._pw,
            )
        )
        self.update()

    def update_on_resize(self, vr, r):
        '''
        This is a ``.sigRangeChanged()`` handler.

        '''
        index, last = self._last_datum
        if index is not None:
            self.update_from_data(index, last)

    def update_from_data(
        self,
        index: int,
        value: float,
        _save_last: bool = True,

    ) -> None:
        '''
        Update the label's text contents **and** position from
        a view box coordinate datum.

        '''
        if _save_last:
            self._last_datum = (index, value)

        self.update_label(
            self._pi.mapFromView(QPointF(index, value)),
            value
        )

    def _draw_arrow_path(self):
        x_offset = self._parent.style['tickTextOffset'][0]
        path = QtGui.QPainterPath()
        h = self.rect.height()
        path.moveTo(0, 0)
        path.lineTo(-x_offset - h/4, h/2. - self._pw/2)
        path.lineTo(0, h)
        path.closeSubpath()
        self.path = path
        self.tl = path.controlPointRect().topLeft()
