#!/usr/bin/env python3
## stupid venv

import asyncclick as click
import trio
from deframed import App, Worker
from deframed.default import CFG
from deframed.util import attrdict, yload, combine_dict
from pathlib import Path
from moat.ems.victron.dbus import Dbus
from functools import partial
from pprint import pprint

import logging
from logging.config import dictConfig as logging_config

logger = logging.getLogger("hello")

NDATA = 1000


def colors(n=0):
    while True:
        if n == 1:
            yield "#b00"
            yield "#b0f"
            yield "#b30"
            yield "#f0b"
            yield "#b66"
        elif n == 2:
            yield "#0b0"
            yield "#060"
            yield "#6b0"
            yield "#0f0"
        elif n == 3:
            yield "#00f"
            yield "#06f"
            yield "#40f"
            yield "#6bf"
        else:
            yield "#000"
            yield "#333"
            yield "#555"
            yield "#500"
            yield "#050"
            yield "#008"


class DataSet:
    """
    One sequence of data.

    We store a rolling window of NDATA measurements, but incrementally updating the clients
    requires keeping some past data, and shifting a 1000+-item array by one every time
    we update is inefficient.
    """

    # TODO: optionally use numpy (unfortunately not available on Venus)

    def __init__(self, name, axis="y", axis_l="yaxis", color="#fff", title=None):
        self.name = name
        self.title = name.capitalize() if title is None else title
        self.x = []
        self.y = []
        self.off = 0
        self._min_y = None
        self.max_y = None
        self.axis = axis
        self.axis_l = axis_l
        self.color = color

    @property
    def min_y(self):
        return self._min_y

    @min_y.setter
    def min_y(self, val):
        if not isinstance(val, (float, int)):
            breakpoint()
        self._min_y = val

    def add(self, x, y):
        if self.x and self.x[-1] == x:
            return
        self.x.append(x)
        self.y.append(y)
        if self.min_y is None or self.min_y > y:
            self.min_y = y
        if self.max_y is None or self.max_y < y:
            self.max_y = y

    @property
    def xo(self):
        """
        Current window of up to NDATA items, X coordinate
        """
        return self.x[self.off :]

    @property
    def yo(self):
        """
        Current window of up to NDATA items, Y coordinate
        """
        return self.y[self.off :]

    def min_x(self, x):
        recalc_min = False
        recalc_max = False
        while len(self.x) > self.off and self.x[self.off] < x:
            y = self.y[self.off]
            if y == self.min_y:
                recalc_min = True
            if y == self.max_y:
                recalc_max = True
            self.off += 1
        if self.off > NDATA // 2:
            self.x = self.x[self.off - NDATA // 4 :]
            self.y = self.y[self.off - NDATA // 4 :]
            self.off = NDATA // 4

        if recalc_min:
            y = self.y[self.off]
            for off in range(self.off + 1, len(self.y)):
                y = min(y, self.y[off])
            self.min_y = y
        if recalc_max:
            y = self.y[self.off]
            for off in range(self.off + 1, len(self.y)):
                y = max(y, self.y[off])
            self.max_y = y


class DataSetProxy:
    def __init__(self, worker, dataset):
        self.worker = worker
        self.data = dataset
        self.min_x = -1
        self.max_x = -1

    @property
    def name(self):
        return self.data.name

    @property
    def axis(self):
        return self.data.axis

    @property
    def axis_l(self):
        return self.data.axis_l

    @property
    def color(self):
        return self.data.color

    async def send_data(self, n):
        async def send_all():
            sx = self.data.x[self.data.off :]
            sy = self.data.y[self.data.off :]

            async def f1():
                self.px = await self.worker.assign(f"p_x_{self.data.name}", (), sx)

            async def f2():
                self.py = await self.worker.assign(f"p_y_{self.data.name}", (), sy)

            n.start_soon(f1)
            n.start_soon(f2)

            if sx:
                self.min_x = sx[0]
                self.max_x = sx[-1]
            else:
                self.max_x = -2

        if self.min_x == -1:
            if self.max_x == -1:
                await send_all()
            return True

        else:  # update
            if not self.data.x:
                return False

            work = False
            off = self.data.off
            while self.data.x[off] > self.min_x:
                # we could use a binary search here
                # but this should get called often enough to not bother
                off -= 1
                if off < 0:  # we lost. re-send
                    await send_all()
                    return True
            if off != self.data.off:
                self.min_x = self.data.x[self.data.off]

                async def f3():
                    await self.worker.eval(
                        var="", obj=self.px, attr=("splice",), args=(0, self.data.off - off)
                    )

                async def f4():
                    await self.worker.eval(
                        var="", obj=self.py, attr=("splice",), args=(0, self.data.off - off)
                    )

                n.start_soon(f3)
                n.start_soon(f4)
                work = True
            off = len(self.data.x) - 1
            while off > 0 and self.data.x[off] > self.max_x:
                off -= 1
            if off < len(self.data.x) - 1:
                self.max_x = self.data.x[-1]

                async def f5():
                    await self.worker.eval(
                        var="",
                        obj=self.px,
                        attr=("splice",),
                        args=(NDATA, 0, *self.data.x[off + 1 :]),
                    )

                async def f6():
                    await self.worker.eval(
                        var="",
                        obj=self.py,
                        attr=("splice",),
                        args=(NDATA, 0, *self.data.y[off + 1 :]),
                    )

                n.start_soon(f5)
                n.start_soon(f6)
                work = True
            return work


class Work(Worker):
    title = "Hello!"
    # version="1.2.3" -- uses DeFramed's version if not set

    async def show_main(self, token):
        await self.debug(True)
        await self.set_content(
            "df_main",
            """
<div>Here's a nice graph.</div>
<div id="plot" style="height: 500px;"></div>
		""",
        )
        # style="width:1200px;height:500px;"

        await self.alert("info", None)
        await self.busy(False)
        await super().show_main()
        # await self.spawn(self._main2)

    async def talk(self):
        v = self.app.victron
        proxies = [DataSetProxy(self, ds) for ds in v.data]
        async with trio.open_nursery() as n:
            for p in proxies:
                await p.send_data(n)

        await self.eval(
            var="plot",
            obj=("Plotly", "newPlot"),
            args=[
                "plot",
                [
                    dict(
                        x=p.px,
                        y=p.py,
                        ids=p.px,
                        line={"simplify": False, "shape": "line", "color": p.color},
                        type="scatter",
                        mode="lines",
                        name=p.name,
                        yaxis=p.axis,
                    )
                    for p in proxies
                ],
                dict(
                    title="System state",
                    margin={"t": 0},
                    legend={"y": 0.5},
                    # grid= { "columns": 1, "rows": len(v.axes) },
                    **v.axes,
                ),
                {"displaylogo": False, "responsive": True},
            ],
        )

        while True:
            await v.updated.wait()
            work = False
            async with trio.open_nursery() as n:
                for p in proxies:
                    work = (await p.send_data(n)) or work
            if not work:
                continue

            axlimits = {}
            for p in proxies:
                ax = p.data.axis_l
                if ax in axlimits:
                    lmin, lmax = axlimits[ax]["range"]
                    lmin = min(lmin, p.data.min_y)
                    lmax = max(lmax, p.data.max_y)
                    axlimits[ax]["range"] = (lmin, lmax)
                elif isinstance(p.data.min_y, float):
                    axlimits[ax] = {"range": (p.data.min_y, p.data.max_y)}

            await self.eval(
                var="",
                obj=("Plotly", "animate"),
                args=[
                    "plot",
                    {
                        "data": [
                            {
                                "x": p.px,
                                "y": p.py,
                                "ids": p.px,
                            }
                            for p in proxies
                        ],
                        # "traces": [0],
                        "layout": {
                            "xaxis": {"range": [max(0, v.last_x - NDATA), v.last_x]},
                            **axlimits,
                        },
                    },
                    {
                        "transition": {
                            "duration": 1000,
                            "easing": "cubic-in-out",
                        },
                        # "frame": {
                        # "duration": 500,
                        # },
                    },
                ],
            )


async def fetch_data(app, *, task_status=trio.TASK_STATUS_IGNORED):
    app.victron = v = attrdict()
    v.data = []
    v.last_x = 0
    v.ndata = app.cfg.graph.get("span", NDATA)
    v.min_y = 0
    v.max_y = 1
    v.new_data = False
    v.updated = trio.Event()
    v.axes = {}

    async with trio.open_nursery() as tg:
        async with Dbus() as dbus:

            def setter(ds, s, p, val):
                val = val["Value"]

                if isinstance(val, (int, float)):
                    ds.add(v.last_x, val)
                    v.new_data = True
                else:
                    logger.info("No value at %s %s", s, p)

            async def poller(ds, val, poll):
                while True:
                    await trio.sleep(poll)
                    await val.refresh()
                    setter(ds, val.serviceName, val.path, {"Value": val.value})

            async def imp(ds, service, path, poll=None, **kv):
                if poll is None:
                    val = await dbus.importer(
                        service,
                        path,
                        partial(setter, ds),
                    )
                else:
                    val = await dbus.importer(service, path, createsignal=False)
                if val.exists:
                    setter(ds, val.serviceName, val.path, {"Value": val.value})
                    if poll:
                        tg.start_soon(poller, ds, val, poll)

            n = 0

            def dpos(i, nr):
                # We want N equal-sized ranges between 0 and 1. The gap of 0.2
                # shall be distributed equally between these.
                # i is in [0,nr-1].

                sp = 0.2  # total inter-group spacing
                if nr == 1:
                    return 0, 1  # easy case; also, don't divide by zero
                i = nr - i - 1  # from the top please
                x = sp / (nr - 1)  # space between two adjacent ranges
                # We space the ranges equally between [0,1+x] and then
                # chop off x at the ends, thus the top ends up at 1
                return i * (1 + x) / nr, (i + 1) * (1 + x) / nr - x

            for gname, dsg in app.cfg.graph.groups.items():
                cols = colors(n)
                col = next(cols)
                yaxis = "yaxis" if n == 0 else f"yaxis{n + 1}"
                yax = "y" if n == 0 else f"y{n + 1}"
                v.axes[yaxis] = dict(
                    title=dsg.get("title", gname.capitalize()),
                    titlefont={"color": col},
                    tickfont={"color": col},
                    side="right",
                    domain=dpos(n, len(app.cfg.graph.groups)),
                    # **({"overlaying":"y"} if n else {}),
                )
                for sname, src in dsg.sources.items():
                    ds = DataSet(
                        f"{gname}_{sname}", yax, yaxis, col, src.get("title", gname.capitalize())
                    )
                    v.data.append(ds)
                    try:
                        await imp(ds, **src)
                    except Exception as exc:
                        raise RuntimeError(f"Could not register {gname}.{sname}: {src}") from exc

                    col = next(cols)
                n += 1

            task_status.started()

            t = trio.current_time()
            while True:
                t += 1
                now = trio.current_time()
                if now < t:
                    await trio.sleep(t - now)

                v.last_x += 1
                if not v.new_data:
                    continue
                v.new_data = False

                min_x = v.last_x - v.ndata
                for vv in v.data:
                    vv.min_x(min_x)
                v.updated.set()
                v.updated = trio.Event()


@click.command
@click.option("--config", "--cfg", "-c", "cfg", type=click.File(), help="config file")
async def main(cfg):
    if cfg:
        with cfg:
            cfg = yload(cfg, attr=True)
    else:
        cfg = {}

    del CFG.logging.handlers.logfile
    CFG.data.loc.plotly = "static/ext/plotly.min.js"
    CFG.data.loc.mustache = "static/ext/mustache.js"
    CFG.mainpage = "static/main.mustache"
    CFG.data.static = Path("static").absolute()
    CFG.logging.handlers.stderr["level"] = "DEBUG"
    CFG.logging.root["level"] = "DEBUG"
    CFG.server.host = "0.0.0.0"
    CFG.server.port = 50080

    cfg = combine_dict(cfg, CFG, cls=attrdict)
    logging_config(cfg.logging)
    app = App(cfg, Work, debug=True)
    async with trio.open_nursery() as n:
        await n.start(fetch_data, app)
        await app.run()


main(_anyio_backend="trio")

# See "deframed.default.CFG" for defaults and whatnot
