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