# This file is part of the source code of Gradint
# (c) Silas S. Brown.
#    This program 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.

# Start of booktime.py - handle booking events into the lesson

# Bin-packing algorithm is a bit primitive but it
# should do

# Event - a single event, such as a Chinese phrase or an
# English question - has a length (including any reasonable
# pause after it).  ALSO 'glue' between events (e.g. a
# graduated interval) that is flexible and that can be
# interspersed with other events.  (A sequence of
# repetitions should START with glue.)

def initialGlue(): return Glue(0,maxLenOfLesson)

try: from bisect import insort
except:
    def insort(l,item):
        l.append(item) ; l.sort()
class Schedule(object):
    # A sorted list of (start,finish) times that are booked
    def __init__(self): self.bookedList = []
    def book(self,start,finish): insort(self.bookedList,(start,finish))

earliestAllowedEvent = 0 # for "don't start before" hacks, so can keep all initial glue starting at 0

class GlueOrEvent(object):
    # 'invisible' is non-0 if this is "glue" and other
    # events can take place at the same time.
    def __init__(self,length=0,plusMinus=0,invisible=0):
        self.length = length
        self.plusMinus = plusMinus
        self.invisible = invisible
        # assert invisible or length > 0, "Length is %s" % (length,) # no longer valid now synth.py can generate a 0-length final event
    def makesSenseToLog(self): return 0
    def bookIn(self,schedule,start):
        if not self.invisible:
            schedule.book(start,start+self.length)
    def addToEvents(self,events,startTime):
        assert not self.invisible
        events.append((startTime,self))
    def overlaps(self,start,schedule,direction):
        # Returns how much it has to move, given 'start'
        # direction is +1 or -1 - which way to move
        # (return value is always unsigned)
        if self.invisible: return 0 # never has to move
        if not schedule.bookedList: return 0 # assume earliestAllowedEvent does not apply when schedule is empty
        oldStart = start
        if direction==1: # moving forwards
            start = max(start,earliestAllowedEvent)
            count = 0 ; blLen = len(schedule.bookedList)
            while count<blLen and schedule.bookedList[count][1] <= start: # finishes before or as we start - irrelevant
                count += 1
            while count<blLen and schedule.bookedList[count][0]<start+self.length: # starts before we finish
                start = schedule.bookedList[count][1]
                count += 1
            return start-oldStart
        else: # moving backwards
            if start<earliestAllowedEvent: return oldStart+1 # hack: force being placed before the beginning of the lesson, which should result in rejecting it
            count = len(schedule.bookedList)-1
            finish=start+self.length
            while count>=0 and schedule.bookedList[count][0] >= finish: # starts after or as we finish - irrelevant
                count -= 1
            while count>=0 and schedule.bookedList[count][1]>start: # finishes after we start
                start = schedule.bookedList[count][0]-self.length
                count -= 1
            return oldStart-start
    def will_be_played(self): pass
    def play(self): pass
    def setOnLeaves(self,name,value):
        # Set some value that should propagate to the "leaves" (only) of the event tree
        # i.e. to the events that matter to addToEvents.
        # (and only if they don't already have such an attribute, so we can put the exceptions first.)
        if not hasattr(self,name): exec('self.'+name+'='+repr(value))
    def setOnLastLeaf(self,name,value): self.setOnLeaves(name,value)
class Event (GlueOrEvent):
    def __init__(self,length):
        GlueOrEvent.__init__(self,length)
    def play(self):
        # Playing a "silence event".  Normally don't need to do anything.
        # However, if playing in real time and the scheduler is behind, then
        # this delay is probably important - so have at least most of it.
        # AND if not real-time then we DON'T want to beep during this silence
        # (long-phrase/pause/long-answer is different from pause between sequences)
        if soundCollector: soundCollector.addSilence(self.length*0.7,False)
        else: mysleep(min(3,self.length*0.7))
class CompositeEvent (Event):
    # An event made up of several others in sequence with
    # nothing intervening
    def __init__(self,eventList):
        length = 0
        for i in eventList: length += i.length
        Event.__init__(self,length)
        self.eventList = eventList
    def addToEvents(self,events,startTime):
        for i in self.eventList:
            i.addToEvents(events,startTime)
            startTime = startTime + i.length
    def play(self): # not normally called, except perhaps by partials, so don't worry about gap-events etc in eventList
        for e in self.eventList: e.play()
    def setOnLeaves(self,name,value):
        for e in self.eventList: e.setOnLeaves(name,value)
    def setOnLastLeaf(self,name,value): self.eventList[-1].setOnLastLeaf(name,value)
    def makesSenseToLog(self):
        if hasattr(self,"is_prompt"): return not self.is_prompt
        for e in self.eventList:
            if e.makesSenseToLog(): return True
    def __repr__(self): return "{"+(" ".join([str(e) for e in self.eventList]))+"}"
class Glue (GlueOrEvent):
    def __init__(self,length,plusMinus):
        GlueOrEvent.__init__(self,length,plusMinus,1)

def sgn(a): return [1,-1][a<0]

class StretchedTooFar(Exception): pass
class GluedEvent(object):
    # Some glue before an event
    def __init__(self,glue,event):
        self.glue = glue
        self.event = event
        self.glue.adjustment = 0
        self.glue.preAdjustment = None
    def randomPreAdjustment(self):
        if self.glue.length < randomAdjustmentThreshold: self.glue.preAdjustment = 0
        elif is_first_lesson: # hack - bias against making the silences TOO long
            self.glue.preAdjustment = random.gauss(0,self.glue.plusMinus)
            if self.glue.preAdjustment<0: self.glue.preAdjustment = - self.glue.preAdjustment
            if self.glue.preAdjustment > self.glue.plusMinus: self.glue.preAdjustment = 0
            self.glue.preAdjustment -= self.glue.plusMinus
        else:
            self.glue.preAdjustment = random.gauss(0,self.glue.plusMinus)
            if abs(self.glue.preAdjustment) > self.glue.plusMinus:
                self.glue.preAdjustment = self.glue.plusMinus # err on the +ve side
    def adjustGlue(self,glueStart,schedule,direction):
        # How far does it need to move?
        # Use preAdjustment, not adjustment, in case
        # re-adjusting due to a change of direction
        needMove = self.event.overlaps(glueStart+self.glue.length+self.glue.preAdjustment,schedule,direction)
        # needMove*direction now gives delta from the
        # pre-adjusted glue end; need to add preAdjustment
        # to get delta from the unadjusted
        needMove=needMove*direction+self.glue.preAdjustment
        direction=sgn(needMove) ; needMove=abs(needMove)
        if needMove > self.glue.plusMinus \
           or (direction<0 and needMove > self.glue.length)\
           or glueStart+self.glue.length+needMove*direction+self.event.length > maxLenOfLesson:
            raise StretchedTooFar()
        self.glue.adjustment = needMove * direction
    def getAdjustedEnd(self,glueStart):
        return glueStart+self.glue.length+self.glue.adjustment+self.event.length
    def bookIn(self,schedule,glueStart):
        self.event.bookIn(schedule,glueStart+self.glue.length+self.glue.adjustment)
    def getEventStart(self,glueStart):
        return glueStart+self.glue.length+self.glue.adjustment
    def setOnLeaves(self,name,value): self.event.setOnLeaves(name,value)
    def setOnLastLeaf(self,name,value): self.event.setOnLastLeaf(name,value)

def setGlue(gluedEventList, schedule, glueStart = 0):
    # Uses tail recursion / backtracking with exceptions
    # (Note: can throw an exception to outermost level if
    # there is no solution)
    if not gluedEventList: return
    try:
        if gluedEventList[0].glue.preAdjustment==None: gluedEventList[0].randomPreAdjustment() # (Do NOT re-do on backtrack: should find a fit regardless of the pre-adjustment)
        gluedEventList[0].adjustGlue(glueStart,schedule,1)
        setGlue(gluedEventList[1:],schedule,gluedEventList[0].getAdjustedEnd(glueStart))
    except StretchedTooFar:
        if glueStart==0: raise StretchedTooFar # don't even try pushing it the other way (NB randomPreAdjustment will be 0 for the initial glue)
        gluedEventList[0].adjustGlue(glueStart,schedule,-1)
        setGlue(gluedEventList[1:],schedule,gluedEventList[0].getAdjustedEnd(glueStart))

def setGlue_wrapper(gluedEventList, schedule):
    # Normally setGlue will do "first fit" and will throw
    # an exception if the first fit can't possibly work with
    # the REST of the sequence.  Really want a good algorithm
    # that sorts it out properly, but meanwhile we can try
    # just a bit harder to fit it in if first-fit fails.
    # gluedEventList[0].glue.length should be 0 but we can increase it here (only).
    if len(gluedEventList)==1: return setGlue(gluedEventList,schedule) # no point doing the stuff below on length-1 lists
    worked = 0
    while (not worked) and gluedEventList[0].glue.length < maxLenOfLesson:
        try:
            setGlue(gluedEventList, schedule)
            worked = 1
        except StretchedTooFar:
            # try harder
            gluedEventList[0].glue.length += (10+gluedEventList[0].glue.adjustment) # (NB preAdjustment will be 0, so don't have to worry about that)
            # (TODO the 10 should be a constant)
    if not worked: raise StretchedTooFar()

def bookIn(gluedEventList,schedule):
    setGlue_wrapper(gluedEventList,schedule)
    glueStart = 0
    for i in gluedEventList:
        i.bookIn(schedule,glueStart)
        glueStart = i.getAdjustedEnd(glueStart)

gluedListTracker=None # set to [] if want to track them (used in utils/diagram.py)

class Lesson(object):
    def __init__(self):
        self.schedule = Schedule()
        self.events = [] # list of (time,event)
        self.newWords = self.oldWords = 0
        self.eventListCounter = 0
        if startAnnouncement:
            w = fileToEvent(startAnnouncement,"")
            w.bookIn(self.schedule,0)
            w.addToEvents(self.events,0)
        if endAnnouncement:
            w = fileToEvent(endAnnouncement,"")
            wStart = maxLenOfLesson-w.length
            w.bookIn(self.schedule,wStart)
            w.addToEvents(self.events,wStart)
    def message(self):
        t,event = max(self.events)
        finish = int(0.5+t+event.length)
        teacher_extra = ""
        if teacherMode:
            self.events.sort()
            for t,event in self.events:
                if event.makesSenseToLog():
                    teacher_extra="\nFirst word will be "+maybe_unicode(str(event))
                    break
        l=cond(app,localise,lambda x:x)
        if self.oldWords or teacherMode:
            return l("Today's lesson teaches %d new words\nand revises %d old words\n\nPlaying time: %d %s %d %s") % (self.newWords,self.oldWords,finish/60,singular(finish/60,"minutes"),finish%60,singular(finish%60,"seconds"))+teacher_extra
        else:
            # less confusing message for a beginner
            return l("Today we will learn %d words\nThis will require %d %s %d %s\nFollow the spoken instructions carefully") % (self.newWords,finish/60,singular(finish/60,"minutes"),finish%60,singular(finish%60,"seconds"))
    def addSequence(self,gluedEventList,canTrack=True):
        bookIn(gluedEventList,self.schedule)
        if not gluedListTracker==None and canTrack: gluedListTracker.append(gluedEventList)
        glueStart = 0 ; lastI = None
        for i in gluedEventList:
            i.event.setOnLeaves("sequenceID",self.eventListCounter) # for max_lateness stuff
            i.event.setOnLeaves("importance",len(gluedEventList)) # ditto
            startTime = i.getEventStart(glueStart)
            i.event.addToEvents(self.events,startTime)
            glueStart = i.getAdjustedEnd(glueStart)
            if lastI: lastI.event.setOnLeaves("max_lateness",max(1,10/len(gluedEventList))+min(lastI.glue.plusMinus-lastI.glue.adjustment,i.glue.plusMinus+i.glue.adjustment))
            # (Note that this setting of max_lateness is not particularly clever - it can detect if a sequence's existing scheduling has been pushed beyond its limits, but it can't dynamically re-schedule the sequence as a whole when that happens.  Hopefully people's emergency interruptions won't be too long.)
            lastI = i
        if lastI:
            lastI.event.setOnLeaves("max_lateness",max(1,10/len(gluedEventList))+lastI.glue.plusMinus-lastI.glue.adjustment)
            if hasattr(gluedEventList[0],"timesDone"): lastI.event.setOnLastLeaf("endseq",not gluedEventList[0].timesDone)
        self.eventListCounter += 1
    def cap_max_lateness(self):
        # if an event of importance I has a max lateness of M, then all previous events with importance <I have to cap their max lateness to M+(intervening gaps) so as not to make it late.
        # (sorts events as a side-effect)
        self.events.sort() ; self.events.reverse()
        latenessCap = {} ; nextStart = 0
        for t,event in self.events:
            if nextStart:
                for k in list(latenessCap.keys()): latenessCap[k] += (nextStart-(t+event.length)) # the gap
            nextStart = t
            if not hasattr(event,"importance"): continue # (wasn't added via addSequence, probably not a normal lesson)
            event.max_lateness=min(event.max_lateness,latenessCap.get(event.importance,maxLenOfLesson))
            for i in range(event.importance): latenessCap[i]=min(latenessCap.get(i,maxLenOfLesson),event.max_lateness)
            del event.importance # (might as well save some filespace in saveLesson while we're here)
        self.events.reverse() # so sorted again
    def play(self):
        if (synthCache_test_mode or synthCache_test_mode==[]) and not hasattr(self,"doneSubst"):
            subst_some_synth_for_synthcache(self.events)
            self.doneSubst=1
        global runner, finishTime, lessonLen, wordsLeft
        wordsLeft={False:self.oldWords,True:self.newWords}
        initLogFile()
        for (t,event) in self.events: event.will_be_played()
        if soundCollector:
            for synth in viable_synths: synth.finish_makefile() # TODO: might want to do that if realtime also, but on most reasonably-specified machines it should be OK to start playing before everything has been synth'd (see also play method in synth.py which makes allowance for slow start)
        finishTime = None # unspecified
        if self.events:
            lessonLen = self.events[-1][0]+self.events[-1][1].length
            if lessonLen>60: # otherwise probably a small justSynthesize job - don't clutter the terminal with progress
                finishTime = int(0.5+(time.time() + lessonLen))
                if (riscos_sound or winCEsound) and not app and not soundCollector: show_info("Started at %s, will finish at %s\n" % (time.strftime("%H:%M",time.localtime(time.time())),time.strftime("%H:%M",time.localtime(finishTime))))
        global sequenceIDs_to_cancel ; sequenceIDs_to_cancel = {}
        global copy_of_runner_events ; copy_of_runner_events = []
        global lessonStartTime ; lessonStartTime = 0 # will be set to time.time() on 1st event
        disable_lid(0)
        try:
          # make the runner as late as possible
          if soundCollector: runner = sched.scheduler(collector_time,collector_sleep)
          else: runner = sched.scheduler(time.time,mysleep)
          for (t,event) in self.events: copy_of_runner_events.append((event,runner.enter(t,1,play,(event,)),t))
          # TODO what if Brief Interrupt appears during that events loop and someone presses it (will act as a Cancel and go back to main)
          try: runner.run()
          except KeyboardInterrupt: handleInterrupt()
        finally: disable_lid(1)
        runner = None
        if soundCollector: soundCollector.finished()
        if logFileHandle: logFileHandle.close()

subst_synth_counters = {} # global so it carries over when using justSynthesize in repeat mode
def decide_subst_synth(cache_fname):
    subst_synth_counters[cache_fname] = subst_synth_counters.get(cache_fname,0)+1
    return subst_synth_counters[cache_fname] in [2,4] or (subst_synth_counters[cache_fname]>5 and random.choice([1,2])==1)
def subst_some_synth_for_synthcache(events):
    # turn SOME synthcache events back into synth events (for testing new synths etc)
    reverse_transTbl = {}
    for k,v in list(synthCache_transtbl.items()): reverse_transTbl[v]=k
    for i in range(len(events)):
        if hasattr(events[i][1],"file") and events[i][1].file.startswith(synthCache+os.sep):
            cache_fname = B(events[i][1].file[len(synthCache+os.sep):])
            cache_fname = reverse_transTbl.get(cache_fname,cache_fname)
            if cache_fname[:1]==B("_"): continue # a sporadically-used synthCache entry anyway
            if type(synthCache_test_mode)==type([]):
                found=0
                for str in synthCache_test_mode:
                    if (re and re.search(str,cache_fname)) or cache_fname.find(str)>=0:
                        found=1 ; break
                if found: continue
            lang = languageof(cache_fname)
            if get_synth_if_possible(lang) and decide_subst_synth(cache_fname): events[i] = (events[i][0],synth_event(lang,cache_fname[:cache_fname.rindex(B("_"))]))
