#!/usr/bin/env python3
import config
import subprocess
import os
import os.path
import serial
import threading
import json
import bottle
from bottle import route, run, request, post
import time
import sys
import logging
import math
import numpy as np
import string
from collections import OrderedDict
import scipy.misc
logging.basicConfig(filename='pixelserver.log', level=config.LogLevel)
running = True

########################################################################
#                                Utils                                 #
########################################################################
class DataSource:
    def __init__(self, initial):
        self.data = initial
        self.listeners = []
    def getData(self):
        return self.data
    def addListener(self, listener):
        self.listeners.append(listener)
        return self
    def pushData(self, data):
        self.data = data
        for listener in self.listeners:
            with listener:
                listener.notify_all()

class WatchDog(threading.Thread):
    def __init__(self, check, action):
        super().__init__()
        self.check = check
        self.action = action
        self.running = True
    def run(self):
        while running and self.running:
            if self.check():
                logging.error("Watchdog: Executed")
                self.action()
            time.sleep(1)
    def stop(self):
        self.running = False

class LogReader(threading.Thread):
    def __init__(self, runner):
        super().__init__()
        self.runner = runner
        self.log = ""
        self.running = True
    def clear(self):
        self.log = ""
    def getLog(self):
        return self.log
    def run(self):
        logging.info("LogReader started")
        while running and self.running:
            try:
                self.log += self.runner.app.stderr.read(1).decode("utf-8")
            except Exception as e:
                print(e)
                logging.error(str(e))
                time.sleep(1) 
        logging.info("LogReader closed")
    def stop(self):
        self.running = False
    
class Frame:
    def __init__(self, buffer, channels=3):
        self.buffer = buffer
        self.created = time.time()
        self.channels = channels
    def clone(self):
        f = Frame(self.buffer+0, self.channels)
        f.created = self.created
        return f

########################################################################
#                                  GUI                                 #
########################################################################
if config.UseGui:
    import pygame

    class Gui(threading.Thread):
        def __init__(self, datasource):
            super().__init__()
            self.cv = threading.Condition()
            self.datasource = datasource.addListener(self.cv)
        def run(self):
            last_frame = time.time()
            logging.info("Starting GUI")
            sf = config.GuiScaleFactor
            screen = pygame.display.set_mode((sf*config.ScreenX, sf*config.ScreenY))
            pygame.display.set_caption("Pixelserver - GUI Vis")
            while running:
                for event in pygame.event.get():
                    pass
                with self.cv:
                    self.cv.wait()
                    frame = self.datasource.getData()
                screen.fill((0, 0, 0))
                if frame.channels == 3:
                    for x in range(config.ScreenX):
                        for y in range(config.ScreenY):
                            color = (frame.buffer[y, x, 0], frame.buffer[y, x, 1], frame.buffer[y, x, 2])
                            pygame.draw.rect(screen, color, pygame.Rect(sf*x, sf*y, sf, sf))
                elif frame.channels == 4:
                    for x in range(config.ScreenX):
                        for y in range(config.ScreenY):
                            w = frame.buffer[y, x, 3]//2
                            color = (frame.buffer[y, x, 0]//2+w, frame.buffer[y, x, 1]//2+w, frame.buffer[y, x, 2]//2+w)
                            pygame.draw.rect(screen, color, pygame.Rect(sf*x, sf*y, sf, sf))
                #logging.debug("Time to gui: "+str(time.time()-frame.created))
                pygame.display.flip()
                if time.time() < last_frame+1/config.GuiFPS:
                    time.sleep(time.time()-(last_frame+1/config.GuiFPS))
                    #time.sleep(0.01)
                last_frame = time.time()

            logging.info("Closing GUI")
        def join(self):
            with self.cv:
                self.cv.notify_all()
            super().join()

########################################################################
#                                 Serial                               #
########################################################################
class SerialWriter(threading.Thread):
    def __init__(self, datasource):
        super().__init__()
        self.cv = threading.Condition()
        self.datasource = datasource.addListener(self.cv)
        self.updateGamma = False
    def run(self):
        should_connect = True
        ser = None
        logging.info("Starting SerialWriter")
        while running:
            try:
                if should_connect:
                    ser = serial.Serial(config.Serial)
                    should_connect = False
                    logging.info("Serial Opened")
                with self.cv:
                    self.cv.wait(timeout = 1/30)
                    frame = self.datasource.getData()
                    data = frame.buffer.reshape((config.ScreenX*config.ScreenY*frame.channels,)).astype(np.uint8).tobytes()
                    if self.updateGamma:
                        buf = bytearray(b"\x00")*4*256
                        for i in range(256):
                            apply = lambda x, g: max(0, min(255, int(math.pow(x/255, g)*255)))
                            buf[i]     = apply(i, self.r)
                            buf[i+256] = apply(i, self.g)
                            buf[i+512] = apply(i, self.b)
                            buf[i+512+256] = apply(i, self.w)
                        ser.write(b"\x02")
                        ser.write(buf)
                        self.updateGamma = False
                if frame.channels == 3:
                    ser.write(b"\01")
                    ser.write(data)
                elif frame.channels == 4:
                    ser.write(b"\03")
                    ser.write(data)
                logging.debug("Time to gui: "+str(time.time()-frame.created))
                ser.flush()
            except Exception as e:
                if ser != None:
                    ser.close()
                    ser = None
                logging.warning("Serial was close because: "+str(e))
                should_connect = True
                time.sleep(5)
        logging.info("Closing SerialWriter")
    def join(self):
        with self.cv:
            self.cv.notify_all()
        super().join()
    def setGamma(self, r, g, b, w):
        with self.cv:
            self.r, self.g, self.b,  self.w = r, g, b, w
            self.updateGamma = True
            self.cv.notify_all()

########################################################################
#                                   App                                #
########################################################################
class App(threading.Thread):
    def __init__(self, cmd, param, listener, is_persistent, is_white=False, path="."):
        super().__init__()
        #start app
        if type(cmd) != list:
            cmd = [cmd,]
        args = cmd+[str(config.ScreenX), str(config.ScreenY), param]
        self.app = subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True, cwd=path)
        self.last_update = time.time()
        self.cv = threading.Condition()
        self.watchdog = WatchDog(lambda: self.isAppTimedOut(), lambda: self.terminateApp())
        self.watchdog.start()
        self.logreader = LogReader(self)
        self.logreader.start()
        self.datasource = DataSource(Frame(np.zeros((config.ScreenY, config.ScreenX, 3))))
        self.running = True
        self.listener = listener
        self.is_persistent = is_persistent
        self.is_white = is_white
    def run(self):
        while running and self.running and self.alive():
            oshandle = self.app.stdout.fileno()
            try:
                bytes = 4 if self.is_white else 3
                data = os.read(oshandle, config.ScreenX*config.ScreenY*bytes)
                assert len(data) == config.ScreenX*config.ScreenY*bytes
                buffer = np.frombuffer(data, dtype=np.uint8, count=config.ScreenX*config.ScreenY*bytes)
                buffer = buffer.reshape((config.ScreenY, config.ScreenX, bytes))
                
                frame = Frame(buffer, channels=bytes)
                self.last_update = time.time()
                self.datasource.pushData(frame)
            except Exception as e:
                logging.debug("Exception in App.run")
        with self.listener:
            self.listener.notify_all()
        self.watchdog.stop()
        self.logreader.stop()
        self.watchdog.join()
        self.logreader.join()
        self.app.wait()
        logging.debug("App stopped")
    def alive(self):
        return self.app.poll() == None
    def stop(self):
        self.running = False
        self.app.kill()
        self.app.stdout.close()
        self.app.stderr.close()
        self.watchdog.stop()
        self.logreader.stop()
        self.app.wait()
        logging.debug("App stopped")
    def getLog(self):
        return self.logreader.getLog()
    def terminateApp(self):
        logging.error("Terminate app!")
        self.stop()
    def isAppTimedOut(self):
        return time.time()-self.last_update > config.NoDataTimeout

########################################################################
#                                  Main                                #
########################################################################
class AppRunner(threading.Thread):
    def __init__(self):
        super().__init__()
        self.last_crashlog = ""
        self.currentApp = -1
        self.requestedApp = 0
        self.app = None
        self.cv = threading.Condition()
        self.param = ""
        self.datasource = DataSource(Frame(np.zeros((config.ScreenY, config.ScreenX, 3))))
        self.serial = SerialWriter(self.datasource)
        self.serial.start()
        self.persistent_apps = {}
        self.filters = OrderedDict()
        #start persistent apps
        for app, i in zip(config.Apps, range(len(config.Apps))):
            if app["persistent"]:
                self.startApp(i)
        if config.UseGui:
            self.gui = Gui(self.datasource)
            self.gui.start()
    def requestApp(self, app, param=""):
        with self.cv:
            self.requestedApp = app
            self.param = param
            self.cv.notify_all()
        logging.info("Requesting app: "+str(app))
    def startApp(self, i, param=""):
        app = config.Apps[i]
        newapp = App(app["cmd"], param, self.cv, is_persistent=app["persistent"], is_white=app["white"], path=app["path"])
        newapp.datasource.addListener(self.cv)
        newapp.start()
        if app["persistent"]:
          self.persistent_apps[self.currentApp] = newapp
        return newapp
    def updateApp(self):
        try:
          if self.app != None and not self.app.is_persistent:
            self.app.stop()
          self.currentApp = self.requestedApp
          if (self.currentApp in self.persistent_apps.keys() 
              and self.persistent_apps[self.currentApp].alive()):
                self.app = self.persistent_apps[self.currentApp]
          else:
            self.app = self.startApp(self.requestedApp, self.param)
        except FileNotFoundError as e:
          print(e)
    def run(self):
        logging.info("Starting Apprunner")
        while running:
            with self.cv:
                if self.app == None or not self.app.alive():
                    if self.app != None:
                        self.last_crashlog = self.app.getLog()
                    self.requestedApp = 0   
                if self.requestedApp != None:
                    self.updateApp()
                    self.requestedApp = None
                frame = self.app.datasource.getData().clone()
                #logging.debug("Runner in time: "+str(time.time()-frame.created))
                for _, f in self.filters.items():
                    frame.buffer = f(frame.buffer)
                #logging.debug("Runner out  time: "+str(time.time()-frame.created))
                self.datasource.pushData(frame)
                self.cv.wait()
        self.serial.join()
        if config.UseGui:
            self.gui.join()
        logging.info("Close Apprunner")
    def getLog(self):
        if self.app == None:
            return ""
        return self.app.getLog()
    def setGamma(self, r, g, b, w):
        self.serial.setGamma(r, g, b, w)
    def setFilter(self, name, filter):
        self.filters[name] = filter
    def removeFilter(self, name):
        if name in self.filters.keys():
            del self.filters[name]

########################################################################
#                           Filter Api                                 #
########################################################################
def MakeBrightnessFilter(intensity):
    def filter(img):
        return (img*intensity).astype(np.uint8)
    return filter

def FlipXFilter(intensity):
    return intensity[:,::-1,:]

def FlipYFilter(intensity):
    return intensity[::-1,:,:]

def MakeBrightnessImageFilter(name):
    img = scipy.misc.imread("filter/"+name+".png", flatten=True)/255
    #img = np.transpose(img)
    def filter(intensity):
        intensity = intensity.astype(float)
        for i in range(intensity.shape[2]):
            intensity[:,:,i] *= img
        return intensity.astype(np.uint8)
    return filter

def strings(str):
    allowed_chars = string.ascii_letters+string.digits+"+-*/()."
    i = 0
    outlist = []
    while i != len(str):
        if str[i] not in allowed_chars:
            raise Exception("Unexpected char "+str[i])
        if str[i] not in string.ascii_letters:
            i+=1
            continue
        out = ""
        while i != len(str) and str[i] in string.ascii_letters+string.digits:
            out += str[i]
            i+=1
        outlist.append(out)
    return outlist

def eval_safer(expr, x, y, t):
    symbols = {"x": x, "y": y, "t": t, 
               "sin": np.sin, "cos": np.cos, "exp": np.exp, "tan": np.tan}
    strs = strings(expr)
    for s in strs:
        if s not in symbols.keys():
            raise Exception("unexpected symbol: "+s)
    return eval(expr, {}, symbols)
    
def MakeBrightnessExprFilter(expr):
    t0 = time.time()
    x, y = np.meshgrid(np.arange(config.ScreenX), np.arange(config.ScreenY))
    eval_safer(expr, 0, 0, 0)
    def filter(intensity):
        t = time.time()-t0
        intensity = intensity.astype(float)
        filter = 0*x+eval_safer(expr, x, y, t)
        filter = np.clip(np.nan_to_num(filter), 0, 1)
        for i in range(intensity.shape[2]):
            intensity[:,:,i] *= filter
        return intensity.astype(np.uint8)
    return filter

########################################################################
#                              Web Api                                 #
########################################################################
@bottle.route('/<:re:.*>', method='OPTIONS')
def enable_cors_generic_route():
    add_cors_headers()

@bottle.hook('after_request')
def enable_cors_after_request_hook():
    add_cors_headers()

def add_cors_headers():
    bottle.response.headers['Access-Control-Allow-Origin'] = '*'
    bottle.response.headers['Access-Control-Allow-Methods'] = \
        'GET, POST, PUT, OPTIONS'
    bottle.response.headers['Access-Control-Allow-Headers'] = \
        'Origin, Accept, Content-Type, X-Requested-With, X-CSRF-Token'

def startApp(name, param=""):
    for i in range(len(config.Apps)):
        if config.Apps[i]["name"] == name:
            runner.requestApp(i, param)
            return "ok"
    return "not_found"

@route("/apps/list")
def apps_list():
    s = []
    for app in config.Apps:
        s.append({"name":       app["name"],
                  "guiname":    app["guiname"],
                  "persistent": app["persistent"]})
    return json.dumps(s)
    
@route("/apps/start/<name>")
def apps_start_param(name):
    return startApp(name)

@post("/apps/start/<name>")
def apps_start_post(name):
    param = request.forms.get('param')
    return startApp(name, param)
    
@route("/apps/start/<name>/<param>")
def apps_start(name, param):
    return startApp(name, param)

@route("/apps/log")
def apps_log():
    return runner.getLog()

@route("/apps/crashlog")
def apps_log():
    return runner.last_crashlog

@route("/apps/running") 
def  apps_running():
    return config.Apps[runner.currentApp]["name"]

@route("/")
def index():
    return bottle.static_file("index.html", root='html')

@route("/setgamma/<r>/<g>/<b>/<w>")
def setGamma(r, g, b, w):
    r = float(r)
    g = float(g)
    b = float(b)
    w = float(w)
    runner.setGamma(r, g, b, w)
    return "ok"

@route("/setbrightness/<i>")
def setIntensity(i):
    i = float(i)
    if i < 0 or i > 1:
        return "bad_value"
    runner.setFilter("0_intensity", MakeBrightnessFilter(i))
    return "ok"

@route("/filter/flipx/<do>")
def flipx(do):
    if do == "true":
        runner.setFilter("1_flipx", FlipXFilter)
    else:
        runner.removeFilter("1_flipx")
    return "ok"

@route("/filter/flipy/<do>")
def flipy(do):
    if do == "true":
        runner.setFilter("1_flipy", FlipYFilter)
    else:
        runner.removeFilter("1_flipy")
    return "ok"

@route("/filter/img/<name>")
def setfilter(name):
    if name == "none":
        runner.removeFilter("3_imgfilter")
    else:
        runner.setFilter("3_imgfilter", MakeBrightnessImageFilter(name))
    return "ok"
@post("/filter/expr/")
def filter_expr():
    expr = request.forms.get('expr')
    if expr == "" or expr == "none":
        runner.removeFilter("5_brightnessfunction")
    else:
        runner.setFilter("5_brightnessfunction", MakeBrightnessExprFilter(expr))
    return "ok"
    

########################################################################
#                              Startup                                 #
########################################################################
#normalize config
for app in config.Apps:
    if "persistent" not in app.keys():
        app["persistent"] = False
    if "guiname" not in app.keys():
        app["guiname"] = app["name"]
    if "white" not in app.keys():
        app["white"] = False
    if "path" not in app.keys():
        app["path"] = "."
    cmd = app["cmd"]
    #remove non existing apps
    #if type(cmd) == str and not os.path.isfile(cmd):
        #config.Apps.remove(app)
        #logging.warning("Removed app "+app["name"])


runner = AppRunner()
runner.start()

#runner.setFilter("5_crazy", MakeBrightnessExprFilter("0.5+0.25*sin(x/3)/x"))
run(host=config.WebHost, port=config.WebPort)

########################################################################
#                              Shutdown                                #
########################################################################
running = False
runner.join()
