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

'''
UI components to display real-time and historical
`.accounting.Position` cross-asset PnL(s) as well as manage market
agnostic asset ownership state.

'''
from __future__ import annotations
from copy import copy
from dataclasses import dataclass
from functools import partial
from math import floor, copysign
from typing import (
    Callable,
    TYPE_CHECKING,
)

# from PyQt5.QtWidgets import QStyle
# from PyQt5.QtGui import (
#     QIcon, QPixmap, QColor
# )
from pyqtgraph import functions as fn

from piker.calc import (
    humanize,
    pnl,
    puterize,
)
from piker.accounting import (
    Allocator,
    Position,
    MktPair,
)
from piker.accounting._mktinfo import _derivs
from piker.types import Struct
from piker.data import (
    iterticks,
    Feed,
    Flume,
)
from ._annotate import LevelMarker
from ._anchors import (
    pp_tight_and_right,  # wanna keep it straight in the long run
    gpath_pin,
)
from ._label import Label
from ._lines import LevelLine, order_line
from ._style import _font
from ._forms import FieldsForm, FillStatusBar, QLabel
from ..log import get_logger

if TYPE_CHECKING:
    from ._chart import (
        ChartPlotWidget,
    )

log = get_logger(__name__)
_pnl_tasks: dict[str, bool] = {}


async def update_pnl_from_feed(

    flume: Flume,
    order_mode: OrderMode,  # noqa
    tracker: PositionTracker,

) -> None:
    '''
    Real-time display the current pp's PnL in the appropriate
    label.

    ``ValueError`` if this task is spawned where there is
    a net-zero pp.

    '''
    global _pnl_tasks

    pt: PositionTracker = order_mode.current_pp
    live: Position = pt.live_pp
    key: str = live.mkt.fqme

    log.info(f'Starting pnl display for {pt.alloc.account}')

    if live.cumsize < 0:
        types = ('ask', 'last', 'last', 'dark_trade')

    elif live.cumsize > 0:
        types = ('bid', 'last', 'last', 'dark_trade')

    else:
        log.info(f'No position (yet) for {tracker.alloc.account}@{key}')
        return

    # real-time update pnl on the status pane
    try:
        async with flume.stream.subscribe() as bstream:
            # last_tick = time.time()
            async for quotes in bstream:
                # now = time.time()
                # period = now - last_tick

                for sym, quote in quotes.items():
                    # print(f'{key} PnL: sym:{sym}')

                    # TODO: uggggh we probably want a better state
                    # management then this sincce we want to enable
                    # updating whatever the current symbol is in
                    # real-time right?
                    if sym != key:
                        continue

                    # watch out for wrong quote msg-data if you muck
                    # with backend feed subs code..
                    # assert sym == quote['fqme']

                    for tick in iterticks(quote, types):
                        # print(f'{1/period} Hz')

                        size = order_mode.current_pp.live_pp.cumsize
                        if size == 0:
                            # terminate this update task since we're
                            # no longer in a pt
                            order_mode.pane.pnl_label.format(pnl=0)
                            return

                        else:
                            # compute and display pnl status
                            pnl_val = (
                                 copysign(1, size)
                                 *
                                 pnl(
                                     # live.ppu,
                                     order_mode.current_pp.live_pp.ppu,
                                     tick['price'],
                                 )
                             )
                            # print(f'formatting PNL {sym} => {pnl_val}')
                            order_mode.pane.pnl_label.format(pnl=pnl_val)

                        # last_tick = time.time()
    finally:
        assert _pnl_tasks[key]
        assert _pnl_tasks.pop(key)


@dataclass
class SettingsPane:
    '''
    Composite set of widgets plus an allocator model for configuring
    order entry sizes and position limits per tradable instrument.

    '''
    # input fields
    form: FieldsForm

    # output fill status and labels
    fill_bar: FillStatusBar

    step_label: QLabel
    pnl_label: QLabel
    limit_label: QLabel

    # encompasing high level namespace
    order_mode: OrderMode | None = None  # typing: ignore # noqa

    def update_fonts(self) -> None:
        '''Update font sizes after zoom change.'''
        from ._style import _font_small

        # update form fields
        if self.form and hasattr(self.form, 'update_fonts'):
            self.form.update_fonts()

        # update fill status bar
        if self.fill_bar and hasattr(self.fill_bar, 'update_fonts'):
            self.fill_bar.update_fonts(_font_small.px_size)

        # update labels with new fonts
        if self.step_label:
            self.step_label.setFont(_font_small.font)
        if self.pnl_label:
            self.pnl_label.setFont(_font_small.font)
        if self.limit_label:
            self.limit_label.setFont(_font_small.font)

    def set_accounts(
        self,
        names: list[str],
        sizes: list[float] | None = None,
    ) -> None:

        combo = self.form.fields['account']
        return combo.set_items(names)

    def on_selection_change(
        self,
        text: str,
        key: str,

    ) -> None:
        '''
        Called on any order pane drop down selection change.

        '''
        log.info(f'selection input {key}:{text}')
        self.on_ui_settings_change(key, text)

    def on_ui_settings_change(
        self,

        key: str,
        value: str,

    ) -> None:
        '''
        Try to apply some input setting (by the user), revert to
        previous setting if it fails display new value if applied.

        '''
        self.apply_setting(key, value)
        self.update_status_ui(self.order_mode.current_pp)

    def apply_setting(
        self,

        key: str,
        value: str,

    ) -> bool:
        '''
        Called on any order pane edit field value change.

        '''
        mode = self.order_mode
        tracker = mode.current_pp
        alloc = tracker.alloc

        # an account switch request
        if key == 'account':

            # hide details on the old selection
            old_tracker = mode.current_pp
            old_tracker.nav.hide_info()

            # re-assign the order mode tracker
            account_name = value
            tracker = mode.trackers.get(account_name)

            # if selection can't be found (likely never discovered with
            # a ``brokerd`) then error and switch back to the last
            # selection.
            if tracker is None:
                sym: str = old_tracker.charts[0].linked.mkt.fqme
                log.error(
                    f'Account `{account_name}` can not be set for {sym}'
                )
                self.form.fields['account'].setCurrentText(
                    old_tracker.alloc.account)
                return

            self.order_mode.current_pp = tracker
            assert tracker.alloc.account == account_name
            self.form.fields['account'].setCurrentText(account_name)
            tracker.nav.show()
            tracker.nav.hide_info()

            self.display_pnl(tracker)

            # load the new account's allocator
            alloc = tracker.alloc

        # WRITE any settings to current pos-tracker's allocator
        if key == 'size_unit':
            # implicit re-write of value if input
            # is the "text name" of the units.
            # yah yah, i know this is badd..
            alloc.size_unit = value

        elif key != 'account':  # numeric fields entry
            try:
                value: float = puterize(value)
            except ValueError as err:
                log.error(err.args[0])
                return False

            if key == 'limit':
                if value <= 0:
                    log.error('limit must be > 0')
                    return False

                lpos = mode.current_pp.live_pp

                if alloc.size_unit == 'currency':
                    dsize: float = lpos.dsize
                    if dsize > value:
                        log.error(
                            f'limit must > then current pp: {dsize}'
                        )
                        # reset position size value
                        alloc.currency_limit = round(
                            dsize,
                            ndigits=3,
                        )
                        return False

                    alloc.currency_limit = value

                else:
                    size = lpos.cumsize
                    if size > value:
                        log.error(
                            f'limit must > then current pp: {size}'
                        )
                        raise ValueError

                    alloc.units_limit = value

            elif key == 'slots':
                if value <= 0:
                    # raise ValueError('slots must be > 0')
                    log.error('limit must be > 0')
                    return False

                alloc.slots = int(value)

            else:
                log.error(f'Unknown setting {key}')
                raise ValueError

            # don't log account "change" case since it'll be submitted
            # on every mouse interaction.
            log.runtime(f'settings change: {key}: {value}')

        # TODO: maybe return a diff of settings so if we can an error we
        # can have general input handling code to report it through the
        # UI in some way?
        return True

    def update_status_ui(
        self,
        tracker: PositionTracker,

    ) -> None:

        alloc = tracker.alloc
        slots = alloc.slots
        used = alloc.slots_used(tracker.live_pp)
        size = tracker.live_pp.cumsize
        dsize = tracker.live_pp.dsize

        # READ out settings and update the status UI / settings widgets
        unit_char: str = {
            'currency': '$',
            'units': 'u',
        }[alloc.size_unit]
        size_unit, limit = alloc.limit_info()

        step_size, currency_per_slot = alloc.step_sizes()

        if alloc.size_unit == 'currency':
            step_size = currency_per_slot
            if dsize >= limit:
                self.apply_setting('limit', limit)

        elif size >= limit:
            self.apply_setting('limit', limit)

        self.step_label.format(
            unit_prefix=unit_char,
            step_size=str(humanize(step_size))
        )
        self.limit_label.format(
            limit=f'{unit_char}: {str(humanize(limit))}'
        )

        # update size unit in UI
        self.form.fields['size_unit'].setCurrentText(
            alloc._size_units[alloc.size_unit]
        )
        self.form.fields['slots'].setText(str(alloc.slots))
        self.form.fields['limit'].setText(str(limit))

        # update of level marker size label based on any new settings
        tracker.update_from_pp()

        # calculate proportion of position size limit
        # that exists and display in fill bar
        # TODO: what should we do for fractional slot pps?
        self.fill_bar.set_slots(
            slots,

            # TODO: how to show "partial" slots?
            # min(round(prop * slots), slots)
            min(used, slots)
        )
        self.update_account_icons({alloc.account: tracker.live_pp})

    def update_account_icons(
        self,
        pps: dict[str, Position],

    ) -> None:

        form = self.form
        accounts = form.fields['account']

        for account_name, pos in pps.items():
            icon_name = None

            if pos.cumsize > 0:
                icon_name = 'long_pp'
            elif pos.cumsize < 0:
                icon_name = 'short_pp'

            accounts.set_icon(account_name, icon_name)

    def display_pnl(
        self,
        tracker: PositionTracker,

    ) -> None:
        '''
        Display the PnL for the current symbol and personal positioning
        (pp).

        If a position is open start a background task which will
        real-time update the pnl label in the settings pane.

        '''
        mode = self.order_mode
        mkt: MktPair = mode.chart.linked.mkt
        size = tracker.live_pp.cumsize
        fqme: str = mkt.fqme
        flume: Feed = mode.feed.flumes[fqme]
        pnl_value = 0

        if size:
            # last historical close price
            last = flume.rt_shm.array[-1][['close']][0]
            pnl_value = copysign(1, size) * pnl(
                tracker.live_pp.ppu,
                last,
            )

            # maybe start update task
            global _pnl_tasks
            if fqme not in _pnl_tasks:
                _pnl_tasks[fqme] = True
                self.order_mode.nursery.start_soon(
                    update_pnl_from_feed,
                    flume,
                    mode,
                    tracker,
                )

        # immediately display in status label
        self.pnl_label.format(pnl=pnl_value)


def pp_line(

    chart: ChartPlotWidget,  # noqa
    size: float,
    level: float,
    color: str,
    marker: LevelMarker,

    orient_v: str = 'bottom',

) -> LevelLine:
    '''
    Convenience routine to create a line graphic representing a "pp"
    aka the acro for a,
    "{piker, private, personal, puny, <place your p-word here>} position".

    If ``marker`` is provided it will be configured appropriately for
    the "direction" of the position.

    '''
    line = order_line(
        chart,
        level,

        # TODO: could we maybe add a ``action=None`` which
        # would be a mechanism to check a marker was passed in?

        color=color,
        highlight_on_hover=False,
        movable=False,
        hide_xhair_on_hover=False,
        only_show_markers_on_hover=False,
        always_show_labels=False,

        # explicitly disable ``order_line()`` factory's creation
        # of a level marker since we do it in this tracer thing.
        show_markers=False,
    )

    # TODO: use `LevelLine.add_marker()`` for this instead?
    # set marker color to same as line
    marker.setPen(line.currentPen)
    marker.setBrush(fn.mkBrush(line.currentPen.color()))
    marker.level = level
    marker.update()
    marker.show()

    line._marker = marker
    line.track_marker_pos = True

    # show position marker on view "edge" when out of view
    vb = line.getViewBox()
    vb.sigRangeChanged.connect(marker.position_in_view)

    return line


# TODO: move into annoate module?
def mk_level_marker(
    chart: ChartPlotWidget,
    size: float,
    level: float,
    on_paint: Callable,

) -> LevelMarker:
    '''
    Allocate and return nan arrow graphics element.

    '''
    # scale marker size with dpi-aware font size
    font_size = _font.font.pixelSize()
    arrow_size = floor(1.375 * font_size)
    arrow = LevelMarker(
        chart=chart,
        style='|<',  # actual style is set by caller based on size
        get_level=level,
        size=arrow_size,
        on_paint=on_paint,
    )
    arrow.show()
    return arrow


class Nav(Struct):
    '''
    Composite for holding a set of charts and respective (by order)
    graphics-elements which display position information acting as sort
    of "navigation" system for a position.

    '''
    charts: dict[int, ChartPlotWidget]
    pp_labels: dict[str, Label] = {}
    size_labels: dict[str, Label] = {}
    lines: dict[str, LevelLine | None] = {}
    level_markers: dict[str, LevelMarker | None] = {}
    color: str = 'default_lightest'

    def update_ui(
        self,
        account: str,
        price: float,
        size: float,
        slots_used: float,
        size_digits: int | None = None,

    ) -> None:
        '''
        Update personal position level line.

        '''
        for key, chart in self.charts.items():
            size_digits = size_digits or chart.linked.mkt.size_tick_digits
            line = self.lines.get(key)
            level_marker = self.level_markers[key]
            pp_label = self.pp_labels[key]

            if size:
                # create and show a pp line if none yet exists
                if line is None:
                    arrow = self.level_markers[key]
                    line = pp_line(
                        chart=chart,
                        level=price,
                        size=size,
                        color=self.color,
                        marker=arrow,
                    )
                    self.lines[key] = line

                # modify existing indicator line
                line.set_level(price)

                # update LHS sizing label
                line.update_labels({
                    'size': size,
                    'size_digits': size_digits,
                    'fiat_size': round(price * size, ndigits=2),

                    # TODO: per account lines on a single (or very
                    # related) symbol
                    'account': account,
                })
                line.show()

                # always show arrow-marker when a non-zero
                # pos size.
                level_marker.show()

                # configure marker to position data
                if size > 0:  # long
                    # point "up to" the line
                    level_marker.style = '|<'

                elif size < 0:  # short
                    # point "down to" the line
                    level_marker.style = '>|'

            # remove line from view for a net-zero pos
            else:
                self.hide()

            # label updates
            size_label = self.size_labels[key]
            size_label.fields['slots_used'] = slots_used
            size_label.render()

            # set arrow marker to correct level
            level_marker.level = price

            # these updates are critical to avoid lag on view/scene changes
            # TODO: couldn't we integrate this into
            # a ``.inter_ui_elements_and_update()``?
            level_marker.update()  # trigger paint
            pp_label.update()
            size_label.update()

    def level(self) -> float:
        '''
        Return the "level" value from the underlying ``LevelLine`` which tracks
        the "average position" price defined the represented position instance.

        '''
        if self.lines:
            for key, line in self.lines.items():
                if line:
                    return line.value()
        return 0

    def iter_ui_elements(self) -> tuple[
        Label,
        Label,
        LevelLine,
        LevelMarker,
    ]:
        for key, chart in self.charts.items():
            yield (
                self.pp_labels[key],
                self.size_labels[key],
                self.lines.get(key),
                self.level_markers[key],
            )

    def show(self) -> None:
        '''
        Show all UI elements on all managed charts.

        '''
        for (
            pp_label,
            size_label,
            line,
            level_marker,
        ) in self.iter_ui_elements():

            # NOTE: be sure to re-trigger arrow/label placement in case
            # a new sidepane or other widget (like the search bar) was
            # dynamically swapped into the chart-row-widget-space in
            # which case we want to reposition in the view but including
            # the new x-distance added by that sidepane. See details in
            # ``LevelMarker.position_in_view()`` but more less ``.
            # ``ChartPlotWidget.self.marker_right_points()`` gets called
            # which itself eventually calls `.getAxis.pos().x()` and
            # it's THIS that needs to be called **AFTER** the sidepane
            # has been added..
            level_marker.show()
            level_marker.position_in_view()

            # labels
            pp_label.show()
            size_label.show()

            if line:
                line.show()
                line.show_labels()

    def hide(self) -> None:
        for (
            pp_label,
            size_label,
            line,
            level_marker,
        ) in self.iter_ui_elements():
            pp_label.hide()
            level_marker.hide()
            size_label.hide()
            if line:
                line.hide()

    def update_graphics(
        self,
        marker: LevelMarker,
    ) -> None:
        '''
        Update all labels callback.

        Meant to be called from the marker ``.paint()``
        for immediate, lag free label draws.

        '''
        for (
            pp_label,
            size_label,
            line,
            level_marker,
        ) in self.iter_ui_elements():

            pp_label.update()
            size_label.update()

            # XXX: can't call this because it causes a recursive paint/render
            # level_marker.update()

    def hide_info(self) -> None:
        '''
        Hide details (just size label?) of position nav elements.

        '''
        for (
            pp_label,
            size_label,
            line,
            level_marker,
        ) in self.iter_ui_elements():

            size_label.hide()
            if line:
                line.hide_labels()


class PositionTracker:
    '''
    Track and display real-time positions for a single asset-symbol
    held in a single account, normally shown on a single chart.

    Graphically composed of a level line and marker as well as labels
    for indcating current position information. Updates are made to the
    corresponding "settings pane" for the chart's "order mode" UX.

    '''
    alloc: Allocator
    startup_pp: Position
    live_pp: Position
    nav: Nav  # holds all UI elements across all charts

    def __init__(
        self,
        charts: list[ChartPlotWidget],
        alloc: Allocator,
        startup_pp: Position,

    ) -> None:

        nav = self.nav = Nav(charts={id(chart): chart for chart in charts})
        self.alloc = alloc
        self.startup_pp = startup_pp
        self.live_pp = copy(startup_pp)

        # TODO: maybe add this as a method ``Nav.add_chart()``
        # init all UI elements
        for key, chart in nav.charts.items():
            view = chart.getViewBox()

            arrow = mk_level_marker(
                chart=chart,
                size=1,
                level=nav.level,
                on_paint=nav.update_graphics,
            )

            # TODO: we really need some kinda "spacing" manager for all
            # this stuff...
            def offset_from_yaxis() -> float:
                '''
                If no L1 labels are present beside the x-axis place
                the line label offset from the y-axis just enough to avoid
                label overlap with any sticky labels.

                '''
                x = chart.marker_right_points()[1]
                if chart._max_l1_line_len == 0:
                    mkw = pp_label.txt.boundingRect().width()
                    x -= 1.5 * mkw

                return x

            arrow.scene_x = offset_from_yaxis
            view.scene().addItem(arrow)
            arrow.hide()  # never show on startup
            nav.level_markers[key] = arrow

            # literally the 'pp' (pee pee) "position price" label that's
            # always in view
            pp_label = Label(
                view=view,
                fmt_str='pp',
                color=nav.color,
                update_on_range_change=False,
            )
            pp_label.render()
            nav.pp_labels[key] = pp_label

            size_label = Label(
                view=view,
                color=self.nav.color,

                # this is "static" label
                # update_on_range_change=False,
                fmt_str='\n'.join((
                    ':{slots_used:.1f}x',
                )),

                fields={
                    'slots_used': 0,
                },
            )
            size_label.render()
            size_label.scene_anchor = partial(
                pp_tight_and_right,
                label=pp_label,
            )
            nav.size_labels[key] = size_label

            pp_label.scene_anchor = partial(
                gpath_pin,
                gpath=arrow,
                label=pp_label,
            )

        nav.show()

    @property
    def pane(self) -> FieldsForm:
        '''
        Return handle to pp side pane form.

        '''
        chart: ChartPlotWidget = next(
            iter(self.nav.charts.values())
        )
        return chart.linked.godwidget.pp_pane

    def update_from_pp(
        self,
        position: Position | None = None,
        set_as_startup: bool = False,

    ) -> None:
        '''
        Update graphics and data from average price and size passed in
        our EMS ``BrokerdPosition`` msg.

        '''
        # live pp updates
        lpos = position or self.live_pp
        if set_as_startup:
            startup_pp = lpos
        else:
            startup_pp = self.startup_pp
        alloc = self.alloc

        # update allocator settings
        asset_type = lpos.mkt.type_key

        # specific configs by asset class / type
        if asset_type in _derivs:
            # since it's harder to know how currency "applies" in this case
            # given leverage properties
            alloc.size_unit = '# units'

            # set units limit to slots size thus making make the next
            # entry step 1.0
            alloc.units_limit = alloc.slots

        else:
            alloc.size_unit = 'currency'

        # if the current position is already greater then the limit
        # settings, increase the limit to the current position
        if alloc.size_unit == 'currency':
            startup_size = self.startup_pp.cumsize * startup_pp.ppu

            if startup_size > alloc.currency_limit:
                alloc.currency_limit = round(startup_size, ndigits=2)

        else:
            startup_size = abs(startup_pp.cumsize)

            if startup_size > alloc.units_limit:
                alloc.units_limit = startup_size

                if asset_type in _derivs:
                    alloc.slots = alloc.units_limit

        self.nav.update_ui(
            self.alloc.account,
            lpos.ppu,
            lpos.cumsize,
            round(alloc.slots_used(lpos), ndigits=1),  # slots used
        )

        if self.live_pp.cumsize:
            # print("SHOWING NAV")
            self.nav.show()

        # if lpos.cumsize == 0:
        else:
            # print("HIDING NAV")
            self.nav.hide()

        # don't show side and status widgets unless
        # order mode is "engaged" (which done via input controls)
        self.nav.hide_info()
