#!/usr/bin/env python3
from main_window import Ui_MainWindow
from PyQt5 import QtWidgets, QtGui, QtCore
import json
import sys
import threading
import time
import matplotlib
matplotlib.use("Qt5Agg")
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
from matplotlib.ticker import AutoMinorLocator
from components.sud import Sud
from ws.client.ws_client import WsClient
from ws.message import MessageDispatcherSync
from queue import Queue
import asyncio

# str(SudState.X) values (as sent in the 'State' message) that count as
# "running" for enabling/disabling the Start/Pause/Stop toolbar actions.
SUD_RUNNING_STATES = {"SudState.RAMPING", "SudState.HOLDING", "SudState.WAIT_USER"}
SUD_PAUSED_STATE = "SudState.PAUSED"


def _style_axis(ax):
	ax.set_facecolor('white')
	ax.spines['top'].set_visible(False)
	ax.spines['right'].set_visible(False)
	ax.xaxis.set_minor_locator(AutoMinorLocator())
	ax.yaxis.set_minor_locator(AutoMinorLocator())
	ax.tick_params(which='major', direction='out', labelsize='small')
	ax.tick_params(which='minor', direction='out', length=2)
	ax.grid(which='major', linestyle='-', linewidth=0.5, alpha=0.3)
	ax.grid(which='minor', linestyle=':', linewidth=0.5, alpha=0.15)


class RealtimePlot(FigureCanvasQTAgg):
	"""Live theta/heatrate/heater-power strip charts, mirroring figure 1 of
	scripts/demos/sud/demo_sud.py but fed from the websocket connection
	instead of an offline simulation run."""

	def __init__(self):
		figure = Figure(facecolor='white')
		FigureCanvasQTAgg.__init__(self, figure)

		self.ax_temp, self.ax_rate, self.ax_power = figure.subplots(3, 1, sharex=True)

		self.line_temp_ist, = self.ax_temp.plot([], [], '-b', linewidth=1, label="theta_ist")
		self.line_temp_soll, = self.ax_temp.plot([], [], '-r', linewidth=1, label="theta_soll")
		self.ax_temp.set_ylabel("Temp [°C]")
		self.ax_temp.legend(loc='upper left', fontsize='small')

		self.line_rate_ist, = self.ax_rate.plot([], [], '-b', linewidth=1, label="heatrate_ist")
		self.line_rate_soll, = self.ax_rate.plot([], [], '-r', linewidth=1, label="heatrate_soll")
		self.ax_rate.set_ylabel("Rate [K/min]")
		self.ax_rate.legend(loc='upper left', fontsize='small')

		self.line_power_set, = self.ax_power.plot([], [], '-r', linewidth=1, label="power_set")
		self.line_power_eff, = self.ax_power.plot([], [], '-b', linewidth=1, label="power_eff")
		self.ax_power.set_ylabel("Power [W]")
		self.ax_power.set_xlabel("t [s]")
		self.ax_power.legend(loc='upper left', fontsize='small')

		for ax in (self.ax_temp, self.ax_rate, self.ax_power):
			_style_axis(ax)

		# Only the bottom subplot needs x tick labels - sharex keeps the
		# others in sync without repeating them.
		self.ax_temp.label_outer()
		self.ax_rate.label_outer()

		figure.tight_layout()
		self.reset()

	def reset(self):
		self.t0 = time.monotonic()
		self.t = []
		self.temp_ist = []
		self.temp_soll = []
		self.rate_ist = []
		self.rate_soll = []
		self.power_set = []
		self.power_eff = []

	def sample(self, temp_ist, temp_soll, rate_ist, rate_soll, power_set, power_eff):
		self.t.append(time.monotonic() - self.t0)
		self.temp_ist.append(temp_ist)
		self.temp_soll.append(temp_soll)
		self.rate_ist.append(rate_ist)
		self.rate_soll.append(rate_soll)
		self.power_set.append(power_set)
		self.power_eff.append(power_eff)

		self.line_temp_ist.set_data(self.t, self.temp_ist)
		self.line_temp_soll.set_data(self.t, self.temp_soll)
		self.line_rate_ist.set_data(self.t, self.rate_ist)
		self.line_rate_soll.set_data(self.t, self.rate_soll)
		self.line_power_set.set_data(self.t, self.power_set)
		self.line_power_eff.set_data(self.t, self.power_eff)

		for ax in (self.ax_temp, self.ax_rate, self.ax_power):
			ax.relim()
			ax.autoscale_view()

		self.draw_idle()


class SudForecastPlot(FigureCanvasQTAgg):
	"""A static preview of a Sud schedule's temperature course, computed
	from its nominal ramp rates/hold durations alone (no plant/controller
	model - just how long the declared schedule says each step should
	take), so it can be shown as soon as a schedule is loaded/edited,
	before a brew is actually started."""

	def __init__(self):
		figure = Figure(facecolor='white')
		FigureCanvasQTAgg.__init__(self, figure)

		self.ax = figure.subplots(1, 1)
		self.line, = self.ax.plot([], [], '-b', linewidth=1.5)
		self.progress_line = self.ax.axvline(0, color='r', linewidth=1.5, visible=False)
		self.ax.set_xlabel("t [min]")
		self.ax.set_ylabel("Temp [°C]")
		_style_axis(self.ax)

		figure.tight_layout()

	@staticmethod
	def _estimate_course(schedule, start_theta):
		"""Walks the schedule, accumulating (t, theta) waypoints: ramps take
		abs(delta)/rate minutes at the declared rate, holds last for their
		duration at the temperature the preceding ramp reached."""
		t = [0.0]
		theta = [start_theta]
		for step in schedule:
			ramp = step.get('ramp')
			hold = step.get('hold')
			if ramp is not None:
				rate = ramp.get('rate', 0)
				target = ramp['temp']
				if rate > 0:
					t.append(t[-1] + abs(target - theta[-1]) / rate * 60.0)
					theta.append(target)
			elif hold is not None:
				t.append(t[-1] + hold.get('duration', 0))
				theta.append(theta[-1])
		return t, theta

	def show_schedule(self, schedule, start_theta):
		t, theta = self._estimate_course(schedule, start_theta)
		t_min = [seconds / 60.0 for seconds in t]

		self.line.set_data(t_min, theta)
		self.ax.relim()
		self.ax.autoscale_view()
		self.ax.set_title("Estimated course (nominal rates, no plant model) "
			"- {:.0f} min total".format(t_min[-1] if t_min else 0.0), fontsize='small')
		self.figure.tight_layout()
		self.draw_idle()

	def set_progress(self, t_min):
		self.progress_line.set_xdata([t_min, t_min])
		self.progress_line.set_visible(True)
		self.draw_idle()

	def clear_progress(self):
		self.progress_line.set_visible(False)
		self.draw_idle()


class Window(QtWidgets.QMainWindow, Ui_MainWindow):
	def __init__(self):
		QtWidgets.QMainWindow.__init__(self)
		self.setWindowIcon(QtGui.QIcon('res/beer.png'))

		loop = asyncio.new_event_loop()
		asyncio.set_event_loop(loop)
		self.msg_dispatch = MessageDispatcherSync(auto_subscribe=True)
		self.msg_dispatch.on_connect_changed = self.on_ws_connect_changed
		self.ws_client = WsClient(listener=self.msg_dispatch, loop=loop)
		self.connected = False
		self.sud_loaded = False
		self.sud_state = None
		# time.monotonic() of the last IDLE/DONE -> running transition, used
		# to position the forecast plot's progress line; None while not
		# running. sud_paused_total accumulates time spent PAUSED so the
		# progress line freezes during a pause instead of jumping ahead;
		# sud_pause_started_at is when the current pause began, if any.
		self.sud_start_time = None
		self.sud_paused_total = 0.0
		self.sud_pause_started_at = None
		self.msg_pot = self.msg_dispatch.msgio_get('Pot')
		self.msg_sensor = self.msg_dispatch.msgio_get('Sensor')
		self.msg_heater = self.msg_dispatch.msgio_get('Heater')
		self.msg_stirrer = self.msg_dispatch.msgio_get('Stirrer')
		self.msg_tempctrl = self.msg_dispatch.msgio_get('TempCtrl')
		self.msg_sud = self.msg_dispatch.msgio_get('Sud')

		self.msg_pot.set_recv_handler(self.on_pot_changed)
		self.msg_sensor.set_recv_handler(self.on_sensor_changed)
		self.msg_heater.set_recv_handler(self.on_heater_changed)
		self.msg_stirrer.set_recv_handler(self.on_stirrer_changed)
		self.msg_tempctrl.set_recv_handler(self.on_tempctrl_changed)
		self.msg_sud.set_recv_handler(self.on_sud_changed)

		self.setupUi(self)
		self.update_sud_actions()
		self.btn_connect.clicked.connect(self.on_btn_connect_clicked)
		self.actionStart.triggered.connect(self.on_action_sud_start)
		self.actionPause.triggered.connect(self.on_action_sud_pause)
		self.actionStop.triggered.connect(self.on_action_sud_stop)
		self.actionSudSave.triggered.connect(self.on_action_sud_save)
		self.actionSudLoad.triggered.connect(self.on_action_sud_load)
		self.Slider_pwr_soll.valueChanged.connect(self.on_slider_pwr_soll_changed)
		self.Slider_temp_soll.valueChanged.connect(self.on_slider_temp_soll_changed)
		self.Slider_speed_soll.valueChanged.connect(self.on_slider_stirrer_speed_soll_changed)

		self.doubleSpinBox_heatrate_soll.valueChanged.connect(self.on_heatrate_soll_changed)

		self.checkbox_heater_activate.stateChanged.connect(self.on_checkbox_changed)
		self.checkbox_stirrer_activate.stateChanged.connect(self.on_checkbox_stirrer_activate_changed)

		self.send_data = Queue()
		self.slider_pwr_initial_update = True
		self.slider_temp_soll_initial_update = True
		self.slider_speed_initial_update = True
		self.checkbox_heater_activate_initial_update = True
		self.checkbox_stirrer_activate_initial_update = True
		self.heatrate_soll_initial_update = True
		self.sud_save_path = None

		# Latest values sampled by the plot timer - written from the
		# websocket recv thread, read from the GUI thread; plain floats so
		# no locking, consistent with how the rest of the GUI is wired.
		self.plot_temp_ist = 0.0
		self.plot_temp_soll = 0.0
		self.plot_rate_ist = 0.0
		self.plot_rate_soll = 0.0
		self.plot_power_set = 0.0
		self.plot_power_eff = 0.0

		self.plot = RealtimePlot()
		plot_layout = QtWidgets.QVBoxLayout(self.widget_plot)
		plot_layout.setContentsMargins(0, 0, 0, 0)
		plot_layout.addWidget(self.plot)

		self.plot_timer = QtCore.QTimer(self)
		self.plot_timer.timeout.connect(self.on_plot_timer)
		self.plot_timer.start(1000)

		self.forecast_plot = SudForecastPlot()
		forecast_layout = QtWidgets.QVBoxLayout(self.widget_plot_forecast)
		forecast_layout.setContentsMargins(0, 0, 0, 0)
		forecast_layout.addWidget(self.forecast_plot)

	def connect(self):
		self.slider_pwr_initial_update = True
		self.slider_temp_soll_initial_update = True
		self.slider_speed_initial_update = True
		self.checkbox_heater_activate_initial_update = True
		self.checkbox_stirrer_activate_initial_update = True
		self.heatrate_soll_initial_update = True
		self.plot.reset()
		self.ws_client.connect(uri=self.plainTextUri.toPlainText())
		# Fetch the schedule for the Automatic tab's forecast preview - not
		# a user-initiated save, so sud_save_path stays None and
		# on_sud_changed() routes the reply to update_sud_forecast() instead
		# of a save dialog.
		self.msg_sud.send({'Save': True})

	def on_plot_timer(self):
		self.plot.sample(
			self.plot_temp_ist, self.plot_temp_soll,
			self.plot_rate_ist, self.plot_rate_soll,
			self.plot_power_set, self.plot_power_eff)

		if self.sud_start_time is not None:
			# While paused, use the moment the pause began as "now" so the
			# line freezes instead of drifting ahead of actual progress.
			now = self.sud_pause_started_at if self.sud_pause_started_at is not None else time.monotonic()
			elapsed_min = (now - self.sud_start_time - self.sud_paused_total) / 60.0
			self.forecast_plot.set_progress(elapsed_min)
		else:
			self.forecast_plot.clear_progress()

	def disconnect(self):
		# ws_client.disconnect() blocks until the websocket's close handshake
		# completes (or times out, ~10s if the server doesn't ack promptly),
		# joining the connection's background thread - run it off a throwaway
		# thread so that wait doesn't freeze the GUI. on_ws_connect_changed()
		# still flips the button once the disconnect actually finishes.
		threading.Thread(target=self.ws_client.disconnect, daemon=True).start()

	def on_btn_connect_clicked(self):
		if self.connected:
			self.disconnect()
		else:
			self.connect()

	def on_ws_connect_changed(self, connected):
		self.connected = connected
		self.btn_connect.setText("Disconnect" if connected else "Connect")
		if not connected:
			# Nothing reported over a dead connection can be trusted anymore.
			self.sud_loaded = False
			self.sud_state = None
			self.sud_start_time = None
			self.sud_paused_total = 0.0
			self.sud_pause_started_at = None
			self.update_sud_actions()

	def update_sud_actions(self):
		running = self.sud_loaded and self.sud_state in SUD_RUNNING_STATES
		paused = self.sud_loaded and self.sud_state == SUD_PAUSED_STATE
		# Start also doubles as Resume while paused; Stop can still abort a
		# paused run, but there's nothing left to Pause once already paused.
		self.actionStart.setEnabled(self.sud_loaded and not running)
		self.actionPause.setEnabled(running)
		self.actionStop.setEnabled(running or paused)

	def on_slider_temp_soll_changed(self, value):
		print("on_slider_temp_soll_changed {}".format(value))
		self.msg_tempctrl.send({'Soll': {'Temp': value}})

	def on_heatrate_soll_changed(self, value):
		value = round(value, 1)
		print("on_textbox_heatrate_soll_changed {}".format(value))
		self.msg_tempctrl.send({'Soll': {'Rate': value}})

	def on_slider_pwr_soll_changed(self, value):
		print("on_slider_pwr_soll_changed {}".format(value))
		self.msg_heater.send({'Power': value})

	def on_checkbox_changed(self, value):
		print("on_checkbox_changed {}".format(value))
		self.msg_heater.send({'Activate': int(value == 2)})

	def on_slider_stirrer_speed_soll_changed(self, value):
		print("on_slider_stirrer_speed_soll_changed {}".format(value))
		self.msg_stirrer.send({'Speed': value})

	def on_checkbox_stirrer_activate_changed(self, value):
		print("on_checkbox_stirrer_activate_changed {}".format(value))
		self.msg_stirrer.send({'Activate': int(value == 2)})

	def on_action_sud_start(self):
		self.msg_sud.send({'Start': True})

	def on_action_sud_pause(self):
		self.msg_sud.send({'Pause': True})

	def on_action_sud_stop(self):
		self.msg_sud.send({'Stop': True})

	def on_action_sud_save(self):
		# Pick the destination up front, on the GUI thread - the actual
		# write happens in on_sud_changed(), which runs off a worker thread
		# and must not touch Qt (e.g. open a file dialog) itself.
		path, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Sud", filter="JSON files (*.json)")
		if not path:
			return
		self.sud_save_path = path
		self.msg_sud.send({'Save': True})

	def on_action_sud_load(self):
		path, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Sud", filter="JSON files (*.json)")
		if not path:
			return
		with open(path) as f:
			data = json.load(f)
		self.msg_sud.send({'Load': data})

	def on_pot_changed(self, msg):
		print("on_pot_changed {}".format(msg))
		for key in msg:
			if "Power" in key:
				self.lcdNumber_power_pot.display(msg['Power'])

	def on_sensor_changed(self, msg):
		print("on_sensor_changed {}".format(msg))

	def on_tempctrl_changed(self, msg):
		print("on_tempctrl_changed {}".format(msg))
		for key in msg:
			if 'State' in key:
				self.label_state.setText(msg['State'])
			elif "Soll" in key:
				submsg = msg['Soll']
				if 'Temp' in submsg:
					self.plot_temp_soll = submsg['Temp']
					self.lcdNumber_temp_soll.display(str(submsg['Temp']))
					if self.slider_temp_soll_initial_update:
						self.Slider_temp_soll.setValue(int(round(submsg['Temp'])))
						self.slider_temp_soll_initial_update = False
				if 'Rate' in submsg:
					subsubmsg = submsg['Rate']
					for subkey in subsubmsg:
						if "Current" in subkey:
							self.plot_rate_soll = subsubmsg['Current']
							self.lcdNumber_heatrate_soll.display(str(subsubmsg['Current']))
						if "Set" in subkey:
							if self.heatrate_soll_initial_update:
								self.heatrate_soll_initial_update = False
								self.doubleSpinBox_heatrate_soll.setValue(subsubmsg['Set'])
			if "Ist" in key:
				submsg = msg['Ist']
				if 'Temp' in submsg:
					self.plot_temp_ist = submsg['Temp']
					self.lcdNumber_temp_ist.display(str(submsg['Temp']))
				if 'Rate' in submsg:
					self.plot_rate_ist = submsg['Rate']
					self.lcdNumber_heatrate_ist.display(str(submsg['Rate']))

	def on_heater_changed(self, msg):
		print("on_heater_changed {}".format(msg))
		for key in msg:
			if "Activate" in key:
				if self.checkbox_heater_activate_initial_update:
					self.checkbox_heater_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
					self.checkbox_heater_activate_initial_update = False
			elif "PowerSet" in key:
				self.plot_power_set = msg['PowerSet']
			elif "Power" in key:
				self.plot_power_eff = msg['Power']
				self.lcdNumber_power_heater.display(msg['Power'])
				if self.slider_pwr_initial_update:
					self.Slider_pwr_soll.setValue(int(round(msg['Power'])))
					self.slider_pwr_initial_update = False
			elif "Capabilities" in key:
				submsg = msg['Capabilities']
				if "Power" in submsg:
					submsg = submsg['Power']
					self.Slider_pwr_soll.setMinimum(int(submsg['Min']))
					self.Slider_pwr_soll.setMaximum(int(submsg['Max']))

	def on_stirrer_changed(self, msg):
		print("on_stirrer_changed {}".format(msg))
		for key in msg:
			if "Activate" in key:
				if self.checkbox_stirrer_activate_initial_update:
					self.checkbox_stirrer_activate.setCheckState(2 if msg['Activate'] == 1 else 0)
					self.checkbox_stirrer_activate_initial_update = False
			elif "Speed" in key:
				if self.slider_speed_initial_update:
					self.Slider_speed_soll.setValue(int(round(msg['Speed'])))
					self.slider_speed_initial_update = False
			elif "Capabilities" in key:
				submsg = msg['Capabilities']
				if "Power" in submsg:
					submsg = submsg['Power']
					self.Slider_speed_soll.setMinimum(int(submsg['Min']))
					self.Slider_speed_soll.setMaximum(int(submsg['Max']))

	def on_sud_changed(self, msg):
		print("on_sud_changed {}".format(msg))
		# Receiving anything at all on this channel means a Sud is loaded
		# server-side - the schedule it's running may still be coming.
		self.sud_loaded = True
		for key in msg:
			if "Json" in key:
				if self.sud_save_path:
					with open(self.sud_save_path, 'w') as f:
						json.dump(msg['Json'], f, indent='\t')
					self.sud_save_path = None
				else:
					self.update_sud_forecast(msg['Json'])
			elif "Name" in key:
				self.statusBar().showMessage("Sud schedule updated: {}".format(msg['Name']), 5000)
			elif "State" in key:
				prev_state = self.sud_state
				self.sud_state = msg['State']

				if self.sud_state == SUD_PAUSED_STATE and prev_state != SUD_PAUSED_STATE:
					self.sud_pause_started_at = time.monotonic()
				elif self.sud_state != SUD_PAUSED_STATE and prev_state == SUD_PAUSED_STATE:
					self.sud_paused_total += time.monotonic() - self.sud_pause_started_at
					self.sud_pause_started_at = None

				if self.sud_state in SUD_RUNNING_STATES and self.sud_start_time is None:
					self.sud_start_time = time.monotonic()
				elif self.sud_state not in SUD_RUNNING_STATES and self.sud_state != SUD_PAUSED_STATE:
					self.sud_start_time = None
					self.sud_paused_total = 0.0
					self.sud_pause_started_at = None
			elif "Step" in key:
				step = msg['Step']
				descr = step.get('Descr')
				if descr:
					self.statusBar().showMessage("Step {}: {}".format(step.get('Index'), descr))
				else:
					self.statusBar().clearMessage()
		self.update_sud_actions()

	def update_sud_forecast(self, doc):
		try:
			_, _, schedule, _, _ = Sud._parse_data(doc)
		except (KeyError, TypeError):
			return
		# No measured starting temperature is available before a brew has
		# started; fall back to a plausible ambient guess.
		start_theta = self.plot_temp_ist if self.plot_temp_ist else 20.0
		self.forecast_plot.show_schedule(schedule, start_theta)

	def closeEvent(self, event):
		print("Exitting gracefully!")
		self.ws_client.disconnect()


if __name__ == '__main__':
	app = QtWidgets.QApplication(sys.argv)
	win = Window()
	win.show()
	sys.exit(app.exec_())

	print ("End of program.")
