# piker: trading gear for hackers
# Copyright (C) 2018-present  Tyler Goodlet (in stewardship of 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/>.

"""
Time-series (remote) annotation APIs.

"""
from __future__ import annotations
from math import copysign
from typing import (
    Any,
    TYPE_CHECKING,
)

import polars as pl
import tractor

from piker.data._formatters import BGM
from piker.storage import log
from piker.ui._style import get_fonts

if TYPE_CHECKING:
    from piker.ui._remote_ctl import AnnotCtl


def humanize_duration(
    seconds: float,
) -> str:
    '''
    Convert duration in seconds to short human-readable form.

    Uses smallest appropriate time unit:
    - d: days
    - h: hours
    - m: minutes
    - s: seconds

    Examples:
    - 86400 -> "1d"
    - 28800 -> "8h"
    - 180 -> "3m"
    - 45 -> "45s"

    '''
    abs_secs: float = abs(seconds)

    if abs_secs >= 86400:
        days: float = abs_secs / 86400
        if days >= 10 or days == int(days):
            return f'{int(days)}d'
        return f'{days:.1f}d'

    elif abs_secs >= 3600:
        hours: float = abs_secs / 3600
        if hours >= 10 or hours == int(hours):
            return f'{int(hours)}h'
        return f'{hours:.1f}h'

    elif abs_secs >= 60:
        mins: float = abs_secs / 60
        if mins >= 10 or mins == int(mins):
            return f'{int(mins)}m'
        return f'{mins:.1f}m'

    else:
        if abs_secs >= 10 or abs_secs == int(abs_secs):
            return f'{int(abs_secs)}s'
        return f'{abs_secs:.1f}s'


async def markup_gaps(
    fqme: str,
    timeframe: float,
    actl: AnnotCtl,
    wdts: pl.DataFrame,
    gaps: pl.DataFrame,

    # XXX, switch on to see txt showing a "humanized" label of each
    # gap's duration.
    show_txt: bool = False,

) -> dict[int, dict]:
    '''
    Remote annotate time-gaps in a dt-fielded ts (normally OHLC)
    with rectangles.

    '''
    # XXX: force chart redraw FIRST to ensure PlotItem coordinate
    # system is properly initialized before we position annotations!
    # Without this, annotations may be misaligned on first creation
    # due to Qt/pyqtgraph initialization race conditions.
    await actl.redraw(
        fqme=fqme,
        timeframe=timeframe,
    )

    aids: dict[int] = {}
    for i in range(gaps.height):
        row: pl.DataFrame = gaps[i]

        # the gap's RIGHT-most bar's OPEN value
        # at that time (sample) step.
        iend: int = row['index'][0]

        # dt: datetime = row['dt'][0]
        # dt_prev: datetime = row['dt_prev'][0]
        # dt_end_t: float = dt.timestamp()


        # TODO: can we eventually remove this
        # once we figure out why the epoch cols
        # don't match?
        # TODO: FIX HOW/WHY these aren't matching
        # and are instead off by 4hours (EST
        # vs. UTC?!?!)
        # end_t: float = row['time']
        # assert (
        #     dt.timestamp()
        #     ==
        #     end_t
        # )

        # the gap's LEFT-most bar's CLOSE value
        # at that time (sample) step.
        prev_r: pl.DataFrame = wdts.filter(
            pl.col('index') == iend - 1
        )
        # XXX: probably a gap in the (newly sorted or de-duplicated)
        # dt-df, so we might need to re-index first..
        dt: pl.Series = row['dt']
        dt_prev: pl.Series = row['dt_prev']
        if prev_r.is_empty():

            # XXX, filter out any special ignore cases,
            # - UNIX-epoch stamped datums
            # - first row
            if (
                dt_prev.dt.epoch()[0] == 0
                or
                dt.dt.epoch()[0] == 0
            ):
                log.warning('Skipping row with UNIX epoch timestamp ??')
                continue

            if wdts[0]['index'][0] == iend:  # first row
                log.warning('Skipping first-row (has no previous obvi) !!')
                continue

            # XXX, if the previous-row by shm-index is missing,
            # meaning there is a missing sample (set), get the prior
            # row by df index and attempt to use it?
            i_wdts: pl.DataFrame = wdts.with_row_index(name='i')
            i_row: int = i_wdts.filter(pl.col('index') == iend)['i'][0]
            prev_row_by_i = wdts[i_row]
            prev_r: pl.DataFrame = prev_row_by_i

            # debug any missing pre-row
            if tractor._state.is_debug_mode():
                await tractor.pause()

        istart: int = prev_r['index'][0]
        # TODO: implement px-col width measure
        # and ensure at least as many px-cols
        # shown per rect as configured by user.
        # gap_w: float = abs((iend - istart))
        # if gap_w < 6:
        #     margin: float = 6
        #     iend += margin
        #     istart -= margin

        opn: float = row['open'][0]
        cls: float = prev_r['close'][0]

        # get gap duration for humanized label
        gap_dur_s: float = row['s_diff'][0]
        gap_label: str = humanize_duration(gap_dur_s)

        # XXX: get timestamps for server-side index lookup
        start_time: float = prev_r['time'][0]
        end_time: float = row['time'][0]

        # BGM=0.16 is the normal diff from overlap between bars, SO
        # just go slightly "in" from that "between them".
        from_idx: int = BGM - .06  # = .10
        lc: tuple[float, float] = (
            istart + 1 - from_idx,
            cls,
        )
        ro: tuple[float, float] = (
            iend + from_idx,
            opn,
        )

        diff: float = cls - opn
        sgn: float = copysign(1, diff)
        up_gap: bool = sgn == -1
        down_gap: bool = sgn == 1
        flat: bool = sgn == 0

        color: str = 'dad_blue'
        # TODO? mks more sense to have up/down coloring?
        # color: str = {
        #     -1: 'lilypad_green',  # up-gap
        #     1: 'wine',  # down-gap
        # }[sgn]

        rect_kwargs: dict[str, Any] = dict(
            fqme=fqme,
            timeframe=timeframe,
            start_pos=lc,
            end_pos=ro,
            color=color,
            start_time=start_time,
            end_time=end_time,
        )

        # add up/down rects
        aid: int|None = await actl.add_rect(**rect_kwargs)
        if aid is None:
            log.error(
                f'Failed to add rect for,\n'
                f'{rect_kwargs!r}\n'
                f'\n'
                f'Skipping to next gap!\n'
            )
            continue

        assert aid
        aids[aid] = rect_kwargs
        direction: str = (
            'down' if down_gap
            else 'up'
        )
        # TODO! mk this a `msgspec.Struct` which we deserialize
        # on the server side!
        # XXX: send timestamp for server-side index lookup
        # to ensure alignment with current shm state
        gap_time: float = row['time'][0]
        arrow_kwargs: dict[str, Any] = dict(
            fqme=fqme,
            timeframe=timeframe,
            x=iend,  # fallback if timestamp lookup fails
            y=cls,
            time=gap_time,  # for server-side index lookup
            color=color,
            alpha=169,
            pointing=direction,
            # TODO: expose these as params to markup_gaps()?
            headLen=10,
            headWidth=2.222,
            pxMode=True,
        )

        aid: int = await actl.add_arrow(
            **arrow_kwargs
        )

        # add duration label to RHS of arrow
        if up_gap:
            anchor = (0, 0)
            # ^XXX? i dun get dese dims.. XD
        elif down_gap:
            anchor = (0, 1)  # XXX y, x?
        else:  # no-gap?
            assert flat
            anchor = (0, 0)  # up from bottom

        # use a slightly smaller font for gap label txt.
        font, small_font = get_fonts()
        font_size: int = small_font.px_size - 1
        assert isinstance(font_size, int)

        if show_txt:
            text_aid: int = await actl.add_text(
                fqme=fqme,
                timeframe=timeframe,
                text=gap_label,
                x=iend + 1,  # fallback if timestamp lookup fails
                y=cls,
                time=gap_time,  # server-side index lookup
                color=color,
                anchor=anchor,
                font_size=font_size,
            )
            aids[text_aid] = {'text': gap_label}

    # tell chart to redraw all its
    # graphics view layers Bo
    await actl.redraw(
        fqme=fqme,
        timeframe=timeframe,
    )
    return aids
