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

This file is part of the Laborejo Software Suite ( https://www.laborejo.org ).

This 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(__name__); logger.info("import")


#Python Standard Lib
import os.path

#Third Party
from calfbox import cbox

#Template Modules
from .data import Data
from .input_midi import MidiInput

#Client Modules
from engine.config import * #imports METADATA

class Sampler_sf2(Data):
    """
    Channels are base 1
    Programs and banks are base 0

    We cache the complete list of banks/programs in self.patchlist
    but we always fetch the active patches (16 channels) live from calfbox through
    self.activePatches()
    """
    def __init__(self, parentSession, filePath, activePatches, ignoreProgramChanges, mixer:bool, defaultSoundfont=None):
        super().__init__(parentSession)

        self._unlinkOnSave = []
        self.filePath = filePath
        self.patchlist = {} #bank:{program:name}
        self.buffer_ignoreProgramChanges = ignoreProgramChanges

        self.midiInput = MidiInput(session=parentSession, portName="all")
        cbox.JackIO.Metadata.set_port_order(f"{cbox.JackIO.status().client_name}:all", 0) #first one. The other port order later start at 1 because we loop 1-based for channels.

        #Set libfluidsynth! to 16 output pairs. We prepared 32 jack ports in the session start. "soundfont" is our given name, in the line below. This is a prepared config which will be looked up by add_new_instrument
        cbox.Config.set("instrument:soundfont", "engine", "fluidsynth")
        cbox.Config.set("instrument:soundfont", "output_pairs", 16) #this is not the same as session.py cbox.Config.set("io", "outputs", METADATA["cboxOutputs"]). It  is yet another layer and those two need connections
        #Create the permanent instrument which loads changing sf2
        layer = self.midiInput.scene.add_instrument_layer("soundfont")
        self.instrumentLayer = layer
        self.instrument = layer.get_instrument()
        self.loadSoundfont(filePath, defaultSoundfont)

        #Block incoming program and bank changes per midi.
        #Save and load is done via the instance variable.
        self.midiInput.scene.status().layers[0].set_ignore_program_changes(ignoreProgramChanges)
        assert self._ignoreProgramChanges == ignoreProgramChanges

        #Active patches is a dynamic value. Load the saved ones here, but do not save their state locally
        try:
            self.setActivePatches(activePatches)
        except:
            self._correctActivePatches()

        #Create a dynamic pair of audio output ports and route all stereo pairs to our summing channel.
        #This does _not_ need updating when loading another sf2 or changing instruments.

        assert not self.parentSession.standaloneMode is None
        if self.parentSession.standaloneMode:
            lmixUuid = cbox.JackIO.create_audio_output('left_mix', "#1")  #add "#1" as second parameter for auto-connection to system out 1
            rmixUuid = cbox.JackIO.create_audio_output('right_mix', "#2") #add "#2" as second parameter for auto-connection to system out 2
        else:
            lmixUuid = cbox.JackIO.create_audio_output('left_mix')
            rmixUuid = cbox.JackIO.create_audio_output('right_mix')

        for i in range(16):
            router = cbox.JackIO.create_audio_output_router(lmixUuid, rmixUuid)
            router.set_gain(-3.0)
            self.instrument.get_output_slot(i).rec_wet.attach(router) #output_slot is 0 based and means a pair
            #slot 17 is an error. cbox tells us there is only [1, 16], good.

        #Set port order. It is already correct because alphanumerial order, but explicit is better than implicit
        for channelNumber in range(1,33):
            portname = f"{cbox.JackIO.status().client_name}:out_{channelNumber}"
            try:
                cbox.JackIO.Metadata.set_port_order(portname, channelNumber)
            except Exception as e: #No Jack Meta Data
                logger.error(e)
                break

        #Also sort the mixing channels
        try:
            portname = f"{cbox.JackIO.status().client_name}:left_mix"
            cbox.JackIO.Metadata.set_port_order(portname, 33)
            portname = f"{cbox.JackIO.status().client_name}:right_mix"
            cbox.JackIO.Metadata.set_port_order(portname, 34)
        except Exception as e: #No Jack Meta Data
            logger.error(e)

        #If demanded create 16 routing midi channels, basically a jack port -> midi channel merger.
        if True:
            for midiRouterPortNum in range(1,17):
                portName = f"channel_{str(midiRouterPortNum).zfill(2)}"
                router_cboxMidiPortUid = cbox.JackIO.create_midi_input(portName)
                router_scene = cbox.Document.get_engine().new_scene()
                router_scene.clear()
                router_scene.set_enable_default_song_input(False)
                #router_scene.add_instrument_layer("soundfont") #This creates multiple sf2 instances
                router_midi_layer = router_scene.add_new_midi_layer(router_cboxMidiPortUid) #Create a midi layer for our input port. That layer supports manipulation like transpose or channel routing.
                #cbox.JackIO.route_midi_input(router_cboxMidiPortUid, router_scene.uuid)
                router_midi_layer.set_out_channel(midiRouterPortNum)
                router_midi_layer.set_in_channel(midiRouterPortNum)
                #router_midi_layer.set_enable(False)

                #cbox.JackIO.route_midi_input(router_cboxMidiPortUid, self.midiInput.scene.uuid) #routes directly to the instrument and ignores the layer options like channel forcing
                #cbox.JackIO.route_midi_input(router_cboxMidiPortUid, self.midiInput.cboxMidiPortUid) #runs, but does nothing
                #cbox.JackIO.route_midi_input(router_midi_layer, self.midiInput.cboxMidiPortUid) #does not run
                router_midi_layer.set_external_output(self.midiInput.cboxMidiPortUid)
                #router_midi_layer.set_external_output(self.midiInput.scene.uuid)
                #Port order
                fullPortname = f"{cbox.JackIO.status().client_name}:out_{channelNumber}"
                try:
                    cbox.JackIO.Metadata.set_port_order(fullPortname, midiRouterPortNum)
                except Exception as e: #No Jack Meta Data
                    pass #don't break here.

    @property
    def _ignoreProgramChanges(self):
        try:
            result = self.midiInput.scene.status().layers[0].status().ignore_program_changes
            self.buffer_ignoreProgramChanges = result
        except IndexError: #No layers means no loaded instrument yet. e.g. program start or in between loading.
            result = self.buffer_ignoreProgramChanges
        return result

    def unlinkUnusedSoundfonts(self, stopSession=False):
        if stopSession and self.parentSession.lastSavedData and not self.filePath == self.parentSession.lastSavedData["filePath"]:
            #The user might want to discard the current unsaved setup (self.filePath) by closing without saving.
            #self.filePath holds the current file, not the saved file. We override to not get the actual saved setting deleted on quit.
            self._unlinkOnSave.append(self.filePath)
            self.filePath = self.parentSession.lastSavedData["filePath"]

        for filePath in self._unlinkOnSave:
            if not filePath == self.filePath and os.path.exists(filePath):
                os.unlink(filePath)
        self._unlinkOnSave = []


    def _convertPatches(self, dictionary:dict):
        """Returns a dict of dicts:
            bank:{program:name}"""
        result = {}
        for value, name in dictionary.items():
            program = value & 127
            bank =  value >> 7
            if not bank in result:
                result[bank] = {}
            result[bank][program] = name
        return result

    def loadSoundfont(self, filePath, defaultSoundfont=None):
        """defaultSoundfont is a special case. The path is not saved"""

        logger.info(f"loading path: \"{filePath}\" defaultSoundfont parameter: {defaultSoundfont}")

        #Remove the old link, if present. We cannot unlink directly in loadSoundfont because it is quite possible that a user will try out another soundfont but decide not to save but close and reopen to get his old soundfont back.
        if self.filePath and os.path.islink(self.filePath):
            self._unlinkOnSave.append(self.filePath)

        #self.midiInput.scene.clear() do not call for the sf2 engine. Will kill our instrument.

        for stereoPair in range(16): #midi channels fluidsynth output    . 0-15
            #print (stereoPair)
            slot = self.instrument.get_output_slot(stereoPair) #slot is 0 based
            slot.set_output(stereoPair+1) #output is 1 based. setOutput routes to cbox jack ports, which are the same order.

        self.patchlist = {}
        try:
            if defaultSoundfont:
                self.instrument.engine.load_soundfont(defaultSoundfont)
                self.filePath = "" #redundant, but not wrong.
            else:
                self.instrument.engine.load_soundfont(filePath)
                self.filePath = filePath
            self.patchlist = self._convertPatches( self.instrument.engine.get_patches() )
            self._correctActivePatches()
            return True, ""
        except Exception as e: #throws a general Exception if not a good file
            #TODO: An error here leads to an undefined program state with no soundfont loaded. Either restore the old state perfectly (but why?) or go into a defined "nothing loaded" state.
            if os.path.exists(filePath) and os.path.islink(filePath):
                os.unlink(filePath) #the file was maybe already linked. undo
            return False, str(e)

    def _correctActivePatches(self):
        """Check if the current choice of bank and program actually exists in the soundfont
        and fall back to an existing value (or error).
        We keep no internal state of active patches but always ask cbox. Therefore we directly
        change cbox here and now."""
        if not self.patchlist:
            self.patchlist = self._convertPatches( self.instrument.engine.get_patches() ) #try again
            if not self.patchlist: raise ValueError("No programs in this sound font")

        for channel, (value, name) in self.instrument.engine.status().patch.items(): #channel is base 1
            program = value & 127
            bank =  value >> 7
            if bank in self.patchlist and program in self.patchlist[bank]:
                continue
            else:
                #just pick one at random
                for youchoose_bank in self.patchlist.keys():
                    for youchoose_program, youchoose_name in self.patchlist[youchoose_bank].items():
                        youchoose_bank, youchoose_program, youchoose_name
                        logger.info(METADATA["name"] + f": Channel {channel} Old bank/program not possible in new soundfont. Switching to first available program.")
                        self.setPatch(channel, youchoose_bank, youchoose_program)
                        break #inner loop. one instrument is enough.



    def activePatches(self):
        """Returns a dict of 16 tuples:
        channel(count from 1):(int bank, int program, str name)

        "Acoustic Piano" is the default name, since that is fluidsynth behaviour.

        Takes the current bank into consideration
        """
        result = {}
        for channel, (value, name) in self.instrument.engine.status().patch.items():
            program = value & 127
            bank =  value >> 7
            result[channel] = (bank,program,name) #the name doesn't matter for the program at all, it is only to get a human readable save file.
        return result

    def setActivePatches(self, activePatchesDict):
        """used for loading.
        receives an activePatches dict and sets all banks and programs accordingly"""
        assert self.patchlist, self.patchlist
        for channel, (bank, program, name) in activePatchesDict.items(): #the name doesn't matter for the program at all, it is only to get a human readable save file.
            self.setPatch(channel, bank, program)

    def setPatch(self, channel, bank, program):
        """An input error happens not often, but can happen if the saved data mismatches the
        soundfont. This happens on manual save file change or soundfont change (version update?)
        between program runs.
        We assume self.patchlist is up to date."""
        #After changing the bank we need to check if the program still exists.
        #If not fall back to an existing program
        if not program in self.patchlist[bank]:
            for i in range(128): #0-127
                if i in self.patchlist[bank]:
                    program = i
                    break
            else:
                return ValueError(f"No Program in Bank {bank}")

        newValue = program | (bank<<7)
        self.instrument.engine.set_patch(channel, newValue) #call to cbox

    def updateChannelAudioJackMetadaPrettyname(self, channel:int):
        """Audio output channel pairs 1-16"""
        bank, program, name = self.activePatches()[channel]  #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index.

        chanL = channel*2-1 #channel is 1-based. [1]*2-1 = 1.
        chanR = channel*2

        portnameL = f"{cbox.JackIO.status().client_name}:out_{chanL}"
        portnameR = f"{cbox.JackIO.status().client_name}:out_{chanR}"

        #Use the instrument name as port name: 03-L:Violin
        try:
            cbox.JackIO.Metadata.set_pretty_name(portnameL, f"{str(channel).zfill(2)}-L : {name}")
            cbox.JackIO.Metadata.set_pretty_name(portnameR, f"{str(channel).zfill(2)}-R : {name}")
        except Exception as e: #No Jack Meta Data
            logger.error(e)

    def updateChannelMidiInJackMetadaPrettyname(self, channel:int):
        """Midi in single channels 1-16"""
        bank, program, name = self.activePatches()[channel]  #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index.

        portName = f"channel_{str(channel).zfill(2)}"
        fullPortname = f"{cbox.JackIO.status().client_name}:{portName}"

        #Use the instrument name as port name: 03-L:Violin
        try:
            cbox.JackIO.Metadata.set_pretty_name(fullPortname, f"{str(channel).zfill(2)} : {name}")
        except Exception as e: #No Jack Meta Data
            logger.error(e)


    def updateAllChannelJackMetadaPrettyname(self):
        """Add this to a callback. It can't be run on program startup because sampler_sf2 is
        initiated before cbox creates its audio ports.

        It is advised to use this in a controlled manner. There is no internal check if
        instruments changed and subsequent renaming. Multiple changes in a row are common,
        therefore the place to update is in the API, where the new state is also sent to the UI.
        """
        for channel in range(1,17):
            self.updateChannelAudioJackMetadaPrettyname(channel)
            self.updateChannelMidiInJackMetadaPrettyname(channel)

        """
        for channel, (bank, program, name) in self.activePatches().items():
            chanL = channel*2-1 #channel is 1-based. [1]*2-1 = 1.
            chanR = channel*2

            portnameL = f"{cbox.JackIO.status().client_name}:out_{chanL}"
            portnameR = f"{cbox.JackIO.status().client_name}:out_{chanR}"

            #Use the instrument name as port name: 03-L:Violin
            cbox.JackIO.Metadata.set_pretty_name(portnameL, f"{str(channel).zfill(2)}-L : {name}")
            cbox.JackIO.Metadata.set_pretty_name(portnameR, f"{str(channel).zfill(2)}-R : {name}")
        """

    #Save / Load / Export
    def serialize(self)->dict:
        self.unlinkUnusedSoundfonts() #We do not want them to be in the save file.
        return {
            "filePath" : self.filePath,
            "activePatches" : self.activePatches(),
            "ignoreProgramChanges" : self.midiInput.scene.status().layers[0].status().ignore_program_changes, #includes bank changes
            }

    @classmethod
    def instanceFromSerializedData(cls, parentSession, serializedData):
        """The entry function to create a sampler state from saved data. It is called by the session.

        This functions triggers a tree of other createInstanceFromSerializedData which finally
        returns Sampler_sf2, which gets saved in the session.

        The serializedData is already converted to primitive python types from json,
        but nothing more. Here we create the actual objects."""
        self = cls(parentSession=parentSession,
            #Other parameters or set loaded data different from standard init after creation
            filePath=serializedData["filePath"],
            activePatches=serializedData["activePatches"],
            ignoreProgramChanges=serializedData["ignoreProgramChanges"],
            )
        return self

    def exportChannel(self, channel:int):
        bank, program, name = self.activePatches()[channel]  #activePatches returns a dict, not a list. channel is a 1-based key, not a 0-based index.
        return {
            "channel" : channel,
            "bank": bank,
            "program": program,
            "name": name,
        }

    def export(self)->dict:
        prettyName = os.path.basename(self.filePath)
        if prettyName.endswith(".sf2"):
            prettyName = prettyName[:-4]
        return {
            "filePath":self.filePath,
            "patchlist":self.patchlist,
            "activePatches":self.activePatches(),
            "name": prettyName.title(),
            }
