#! /usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Copyright 2022, Nils Hilbricht, Germany ( https://www.hilbricht.net )

This file is part of the Laborejo Software Suite ( https://www.laborejo.org ),
more specifically its template base application.

The Template Base Application is free software: you can redistribute it and/or modify
it under the terms of the GNU 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 General Public License for more details.

You should have received a copy of the GNU General Public License
along with this program.  If not, see <http://www.gnu.org/licenses/>.
"""

import logging; logger = logging.getLogger("nsm-data"); logger.info("import")

URL="https://www.laborejo.org/agordejo/nsm-data"
HARD_LIMIT = 512 # no single message longer than this
VERSION= 1.1

#In case the user tries to run this standalone.
import argparse
parser = argparse.ArgumentParser(description="nsm-data is a module for Agordejo. It only communicates over OSC in an NSM-Session and has no standalone functionality.")
parser.add_argument("-v", "--version", action='version', version=str(VERSION))
args = parser.parse_args()

import json
import pathlib
from time import sleep
from sys import exit as sysexit

from nsmclient import NSMClient
from nsmclient import NSMNotRunningError

def chunkstring(string):
    return [string[0+i:HARD_LIMIT+i] for i in range(0, len(string), HARD_LIMIT)]

class DataClient(object):
    """
    Keys are strings,
    While nsmd OSC support int, str and float we use json exclusively.
    We expect a json string and will parse it here.

    All message consist of two arguments maximum: a key and, if a create-function, a json string.

    Rule: all client-keys are send as strings, even in replies. All client-values are send as
    json-string, even if originally just a string.

    Description is a multi-part message, a string.

    DataClient will register itself as Data-Storage. All other communication is done via osc.
    In theory every application can read and write us (like a book!)

    We listen to OSC paths and reply to the sender, which must give its address explicitly.
        /agordejo/datastorage/readall s:request-host i:request-port #Request all data
        /agordejo/datastorage/read s:key s:request-host i:request-port #Request one value

    The write functions have no reply. They will print out to stdout/err but not send an error
    message back.
        /agordejo/datastorage/create s:key any:value #Write/Create one value
        /agordejo/datastorage/update s:kecy any:value #Update a value, but only if it exists
        /agordejo/datastorage/delete s:key #Remove a key/value completely
    """

    def __init__(self):

        self.data = None #Dict. created in openOrNewCallbackFunction, saved as json
        self.absoluteJsonFilePath = None #pathlib.Path set by openOrNewCallbackFunction

        self._descriptionStringArray = {"identifier":None} #int:str
        self._descriptionId = None

        self.nsmClient = NSMClient(prettyName = "Data-Storage", #will raise an error and exit if this example is not run from NSM.
            saveCallback = self.saveCallbackFunction,
            openOrNewCallback = self.openOrNewCallbackFunction,
            supportsSaveStatus = True,         # Change this to True if your program announces it's save status to NSM
            exitProgramCallback = self.exitCallbackFunction,
            broadcastCallback = None,
            hideGUICallback = None, #replace with your hiding function. You need to answer in your function with nsmClient.announceGuiVisibility(False)
            showGUICallback = None,  #replace with your showing function. You need to answer in your function with nsmClient.announceGuiVisibility(True)
            sessionIsLoadedCallback = self.sessionIsLoadedCallback, #no parametersd
            loggingLevel = "error", #"info" for development or debugging, "error" for production. default is error.
            )

        #Add custom callbacks. They all receive _IncomingMessage(data)
        self.nsmClient.reactions["/agordejo/datastorage/setclientoverridename"] = self.setClientOverrideName
        self.nsmClient.reactions["/agordejo/datastorage/getclientoverridename"] = self.getClientOverrideName
        self.nsmClient.reactions["/agordejo/datastorage/getall"] = self.getAll
        self.nsmClient.reactions["/agordejo/datastorage/getdescription"] = self.getDescription
        self.nsmClient.reactions["/agordejo/datastorage/setdescription"] = self.setDescription
        self.nsmClient.reactions["/agordejo/datastorage/gettimelinemaximum"] = self.getTimelineMaximum
        self.nsmClient.reactions["/agordejo/datastorage/settimelinemaximum"] = self.setTimelineMaximum
        #self.nsmClient.reactions["/agordejo/datastorage/read"] = self.reactRead  #generic key/value storage
        #self.nsmClient.reactions["/agordejo/datastorage/readall"] = self.reactReadAll
        #self.nsmClient.reactions["/agordejo/datastorage/create"] = self.reactCreate
        #self.nsmClient.reactions["/agordejo/datastorage/update"] = self.reactUpdate
        #self.nsmClient.reactions["/agordejo/datastorage/delete"] = self.reactDelete

        #NsmClients only returns from init when it has a connection, and on top (for us) when session is ready. It is safe to announce now.
        self.nsmClient.broadcast("/agordejo/datastorage/announce", [self.nsmClient.ourClientId, HARD_LIMIT, self.nsmClient.ourOscUrl])

        while True:
            self.nsmClient.reactToMessage()
            sleep(0.05) #20fps update cycle

    def getAll(self, msg):
        """A complete data dump, intended to use once after startup.
        Will split into multiple reply messages, if needed.

        Our mirror datastructure in nsmservercontrol.py calls that on init.
        """
        senderHost, senderPort = msg.params
        path = "/agordejo/datastorage/reply/getall"
        encoded = json.dumps(self.data)
        chunks = chunkstring(encoded)
        l = len(chunks)
        for index, chunk in enumerate(chunks):
            listOfParameters = [index+0, l-1, chunk]
            self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)

    def getDescription(self, msg)->str:
        """Returns a normal string, not json"""
        senderHost, senderPort = msg.params
        path = "/agordejo/datastorage/reply/getdescription"
        chunks = chunkstring(self.data["description"])
        l = len(chunks)
        for index, chunk in enumerate(chunks):
            listOfParameters = [index+0, l-1, chunk]
            self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)

    def setDescription(self, msg):
        """
        Answers with descriptionId and index when data was received and saved.

        The GUI needs to buffer this a bit. Don't send every char as single message.

        This is for multi-part messages
        Index is 0 based,
        chunk is part of a simple string, not json.

        The descriptionId:int indicates the message the chunks belong to.
        If we see a new one we reset our storage.
        """
        descriptionId, index, chunk, senderHost, senderPort = msg.params #str, int, str, str, int
        if not self._descriptionId == descriptionId:
            self._descriptionId = descriptionId
            self._descriptionStringArray.clear()
        self._descriptionStringArray[index] = chunk
        buildString = "".join([v for k,v in sorted(self._descriptionStringArray.items())])
        self.data["description"] = buildString
        self.nsmClient.announceSaveStatus(False)

    def getClientOverrideName(self, msg):
        """Answers with empty string if clientId does not exist or has not data. This is a signal
        for the GUI/host to use the original name!"""
        clientId, senderHost, senderPort = msg.params
        path = "/agordejo/datastorage/reply/getclient"
        if clientId in self.data["clientOverrideNames"]:
            name = self.data["clientOverrideNames"][clientId]
        else:
            logger.info(f"We were instructed to read client {clientId}, but it does not exist")
            name = ""
        listOfParameters = [clientId, json.dumps(name)]
        self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)

    def setClientOverrideName(self, msg):
        """We accept empty string as a name to remove the name override.
        """
        clientId, jsonValue = msg.params
        name = json.loads(jsonValue)[:HARD_LIMIT]
        if name:
            self.data["clientOverrideNames"][clientId] = name
        else:
            #It is possible that a client not present in our storage will send an empty string. Protect.
            if clientId in self.data["clientOverrideNames"]:
                del self.data["clientOverrideNames"][clientId]
        self.nsmClient.announceSaveStatus(False)

    def getTimelineMaximum(self, msg):
        """
        In minutes

        If the GUI supports global jack transport controls this can be used to remember
        the users setting for the maximum timeline duration. JACKs own data is without an upper
        bound."""
        senderHost, senderPort = msg.params
        path = "/agordejo/datastorage/reply/gettimelinemaximum"
        if "timelineMaximumDuration" in self.data:
            numericValue = self.data["timelineMaximumDuration"]
        else:
            logger.info(f"We were instructed to read the timeline maximum duration, but it does not exist yet")
            numericValue = 5# minutes.
        listOfParameters = [json.dumps(numericValue)]
        self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)

    def setTimelineMaximum(self, msg):
        """In minutes"""
        jsonValue = msg.params[0] #list of 1
        numericValue = json.loads(jsonValue)
        if numericValue <= 1:
            numericValue = 1
        self.data["timelineMaximumDuration"] = numericValue
        self.nsmClient.announceSaveStatus(False)

    #Generic Functions. Not in use and not ready.
    #Callback Reactions to OSC. They all receive _IncomingMessage(data)
    def reactReadAll(self, msg):
        senderHost, senderPort = msg.params
        path = "/agordejo/datastorage/reply/readall"
        encoded = json.dumps("")
        chunks = chunkstring(encoded, 512)
        l = len(chunks)
        for index, chunk in enumerate(chunks):
            listOfParameters = [index+0, l-1, chunk]
            self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)

    def reactRead(self, msg):
        key, senderHost, senderPort = msg.params
        if key in self.data:
            path = "/agordejo/datastorage/reply/read"
            listOfParameters = [key, json.dumps(self.data[key])]
            self.nsmClient.send(path, listOfParameters, host=senderHost, port=senderPort)
        else:
            logger.warning(f"We were instructed to read key {key}, but it does not exist")

    def reactCreate(self, msg):
        key, jsonValue = msg.params
        value = json.loads(jsonValue)
        self.data[key] = value
        self.nsmClient.announceSaveStatus(False)

    def reactUpdate(self, msg):
        key, jsonValue = msg.params
        value = json.loads(jsonValue)
        if key in self.data:
            self.data[key] = value
            self.nsmClient.announceSaveStatus(False)
        else:
            logger.warning(f"We were instructed to update key {key} with value {value}, but it does not exist")

    def reactDelete(self, msg):
        key = msg.params[0]
        if key in self.data:
            del self.data[key]
            self.nsmClient.announceSaveStatus(False)
        else:
            logger.warning(f"We were instructed to delete key {key}, but it does not exist")

    #NSM Callbacks and File Handling

    def saveCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM):
        result = self.data
        result["origin"] = URL
        result["version"] = VERSION
        jsonData = json.dumps(result, indent=2)

        try:
            with open(self.absoluteJsonFilePath, "w", encoding="utf-8") as f:
                f.write(jsonData)
        except Exception as e:
            logging.error("Will not load or save because: " + e.__repr__())
        return self.absoluteJsonFilePath
        #nsmclient.py will send save status clean

    def openOrNewCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM):
        self.absoluteJsonFilePath = pathlib.Path(ourPath)
        try:
            self.data = self.openFromJson(self.absoluteJsonFilePath)
        except FileNotFoundError:
            self.data = None #This makes debugging output nicer. If we init Data() here all errors will be presented as follow-up error "while handling exception FileNotFoundError".
        except (NotADirectoryError, PermissionError) as e:
            self.data = None
            logger.error("Will not load or save because: " + e.__repr__())

        #Version 1.1 save file updates
        if self.data:
            if not "timelineMaximumDuration" in self.data:
                self.data["timelineMaximumDuration"] = 5 #5 minutes as sensible default

        else:
            self.data = {"clientOverrideNames":{}, "description":"", "timelineMaximumDuration":5} #5 minutes as sensible default
        logger.info("New/Open complete")
        #Data is not send here. Instead the gui calls the getAll message later.

    def openFromJson(self, absoluteJsonFilePath):
        with open(absoluteJsonFilePath, "r", encoding="utf-8") as f:
            try:
                text = f.read()
                result = json.loads(text)
            except Exception as error:
                result = None
                logger.error(error)

        if result and "version" in result and "origin" in result and result["origin"] == URL:
            if result["version"] <= VERSION:
                assert type(result) is dict, (result, type(result))
                logger.info("Loading file from json complete")
                return result
            else:
                logger.error(f"""{absoluteJsonFilePath} was saved with {result["version"]} but we need {VERSION}""")
                #self.nsmClient.setLabel... We cannot use nsm client here because at this point we are still in the open/new callback. and self.nsmClient does not exist yet.
                sysexit()
        else:
            logger.error(f"""Error. {absoluteJsonFilePath} not loaded. Not a sane agordejo/nsm-data file in json format""")
            sysexit()


    def sessionIsLoadedCallback(self):
        """At one point I thought we could send our data when session is ready, so the GUI actually
        has clients to rename. However, that turned out impossible or impractical.
        Instead the GUI now just fails if nameOverrides that we send are not available yet and tries
        again later.

        Leave that in for documentation.
        """
        pass



    #def broadcastCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM, messagePath, listOfArguments):
    #    print (__file__, "broadcast")

    def exitCallbackFunction(self, ourPath, sessionName, ourClientNameUnderNSM):
        sysexit(0)

if __name__ == '__main__':
    """Creating an instance starts the client and does not return"""
    try:
        DataClient()
    except NSMNotRunningError:
        parser.print_help()
