#########################################################################
# This file is part of Lyntin.
#
# Lyntin 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.
#
# Lyntin 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/>.
#
# copyright (c) Free Software Foundation 2001-2007
#
# $Id: scheduler.py,v 1.5 2007/07/24 00:39:03 willhelm Exp $
#########################################################################
"""
This module defines the ScheduleManager which manages scheduling 
events for Lyntin.  It's pretty intense.  It handles both events
oriented around "lyntin time" (what the current Lyntin tick is)
as well as "real time" (what time it really is).  Scheduled
events can be lyntin commands as well as functions with 
arguments.

This module implements the #schedule and #unschedule commands
as well as the completely re-implemented #tick* suite of
commands.
"""
import time
from lyntin import exported, manager, utils, event
from lyntin.modules import modutils

myscheduler = None
commands_dict = {}

def truncate(text, maxwidth):
  if len(text) > maxwidth:
    text = text[:maxwidth] + "..."
  return text

class SchedEvent:
  """ 
  Holds event data as well as handles representation of the data.
  """
  def __init__(self, offset, ses, cmd, repeat=0, quiet=0, tag="none"):
    self._id = "-1"
    self._tag = tag
    self._offset = offset
    self._ses = ses
    self._cmd = cmd
    self._repeat = repeat
    self._quiet = quiet
    self._args = []
    self._xargs = {}

  def __repr__(self):
    if self._repeat == 0:
      return truncate("%s [%s] %d {%s}" % (self._id, self._ses._name, self._offset, self._cmd), 60)
    return truncate("%s [%s] %d(r) {%s}" % (self._id, self._ses._name, self._offset, self._cmd), 60)

class SchedTimeEvent:
  """
  Holds event data as well as handles representation of the data.

  The only difference between this and the SchedEvent is that this
  displays the offset as if it were a real date/time (which it is).
  """
  def __init__(self, offset, ses, cmd, repeat=0, quiet=0, tag="none"):
    self._id = "-1"
    self._tag = tag
    self._offset = offset
    self._ses = ses
    self._cmd = cmd
    self._repeat = repeat
    self._quiet = quiet
    self._args = []
    self._xargs = {}
    self._time = ""

  def __repr__(self):
    return truncate("%s [%s] %s {%s}" % (self._id, self._ses._name, time.strftime("%d %b %H:%M:%S", time.localtime(self._offset)), self._cmd), 60)



class Scheduler:
  """
  Manages scheduled data.
  """
  def __init__(self):
    # mapping of tick time -> SchedEvent list
    self._events = {}

    # mapping of seconds since epoch -> SchedEvent list
    self._tevents = {}

    # the current Lyntin tick
    self._current_tick = 0

    # the event id index
    self._eid = 0

  def startup(self):
    exported.hook_register("timer_hook", self.timeUpdate)

  def shutdown(self):
    exported.hook_unregister("timer_hook", self.timeUpdate)
 
  def getEvents(self, ses):
    """
    Returns a list of the events for a given session.

    @param ses: the session to look at
    @type  ses: Session

    @returns: the list of session information
    @rtype: list of strings
    """
    output = []
    for listing in list(self._events.values()):
      for mem in listing:
        if mem._ses == ses:
          output.append(repr(mem))

    for listing in list(self._tevents.values()):
      for mem in listing:
        if mem._ses == ses:
          output.append(repr(mem))

    output.sort()
    return output

  def getEventById(self, id):
    """
    Finds an event by id or by tag.  It returns the event as well
    as adding a _next_tick attribute to the event telling you
    when the event is next scheduled to execute.  Sneaky, eh?

    @param id: the id or tag of the event to find
    @type  id: string

    @returns: the SchedEvent instance or None
    @rtype: SchedEvent
    """
    for key in list(self._events.keys()):
      listing = self._events[key]
      for mem in listing:
        if mem._id == id or mem._tag == id:
          mem._next_tick = key
          return mem

    for key in list(self._tevents.keys()):
      listing = self._tevents[key]
      for mem in listing:
        if mem._id == id or mem._tag == id:
          mem._next_tick = key
          return mem

    return []

  def removeById(self, id):
    """
    Removes an event from the scheduler by id or by tag or by '*'.

    @param id: the id or tag of the event to remove
    @type  id: string

    @returns: a list of the events unscheduled
    @rtype: list of strings
    """
    output = []
    for listing in list(self._events.values()):
      for mem in listing:
        if id == '*' or mem._id == id or mem._tag == id:
          output.append(repr(mem))
          listing.remove(mem)

    for listing in list(self._tevents.values()):
      for mem in listing:
        if id == '*' or mem._id == id or mem._tag == id:
          output.append(repr(mem))
          listing.remove(mem)

    output.sort()
    return output

  def addEvent(self, tick, sevent, real=0, id=-1):
    """
    Adds an event to the scheduler.

    @param tick: the tick that this event kicks off at
    @type  tick: int

    @param sevent: the SchedEvent object
    @type  sevent: SchedEvent

    @param real: whether or not this is a real event (tick is seconds since
        epoch) or a regular event (tick is an offset of seconds from now)
    @type  real: int
    """
    if id == -1:
      eid = self._eid
      self._eid += 1
    else:
      eid = id

    sevent._id = str(eid)

    if real == 0:
      eventdict = self._events
    else:
      eventdict = self._tevents

    if tick in eventdict:
      eventdict[tick].append(sevent)
    else:
      eventdict[tick] = [sevent]

  def timeUpdate(self, args):
    """
    This gets called by the timer_hook in the engine every
    second.  It goes through and executes all the events for this
    Lyntin tick as well as events who are supposed to execute at
    this seconds since the epoch or before.

    It also handles tossing events back in the schedule if they
    need repeating.
    """
    tick = args["tick"]

    events = []

    if tick in self._events:
      for mem in self._events[tick]:
        events.append(mem)

      del self._events[tick]

    self._current_tick = tick

    # we want to execute for this second and any previous seconds
    # that have been missed.
    sec = int(time.time())
    keys = list(self._tevents.keys())
    keys = [mem for mem in keys if mem < sec]
    for key in keys:
      for mem in self._tevents[key]:
        events.append(mem)
      del self._tevents[key]

    # go through and execute all the events we've found
    for mem in events:
      if not mem._quiet:
        exported.write_message("Executing %r." % mem)

      if not callable(mem._cmd):
        exported.lyntin_command(mem._cmd, internal=1, session=mem._ses)
      else:
        try:
          mem._cmd(*mem._args, **mem._xargs)
        except:
          exported.write_traceback("exception kicked up while trying to execute event.")

      # handles repeating events
      if mem._repeat == 1:
        self.addEvent(tick + mem._offset, mem, id=mem._id)

def schedule_cmd(ses, args, input):
  """
  With no arguments lets you view the scheduled events.

    lyntin: Scheduled events:
    lyntin: 1 [a] 200 {#showme Will is super duper!}

  First column is the event id.
  Second column is the session it's in.
  Third column is the tick offset or time it's going to kick off at.
  Fourth column is the command to execute.

  With arguments it creates a scheduled event to kick off (and 
  possibly repeat) at TICK seconds from now at which point it will 
  execute EVENT which could be any valid user input.

  examples:

    #schedule {5} {#showme blah}

  will kick off 5 ticks from now (a tick is approx one second) and
  will execute "#showme blah".

    #schedule {1m30s} {#showme blah}

  will kick off in 1 minute and 30 seconds.

    #schedule {10} {#showme blah} {true}

  will kick off every 10 seconds.

  category: commands
  """
  global myscheduler

  tick = args["tick"]
  cmd = args["event"]
  quiet = args["quiet"]
  repeat = args["repeat"]

  if not tick:
    output = myscheduler.getEvents(ses)
    if not output:
      exported.write_message("schedule: there are no scheduled events.")
      return

    if not quiet:
      exported.write_message("scheduled events:\n" + "\n".join(output))
    return

  setimespan = 0
  setime = 0
  try:
    setimespan = utils.parse_timespan(tick)
  except:
    try:
      setime = utils.parse_time(tick)
    except:
      exported.write_error("schedule: %s is not a valid time or timespan." % tick)
      return

  if setimespan != 0:
    sevent = SchedEvent(setimespan, ses, cmd, repeat, quiet)
    tick = setimespan + myscheduler._current_tick
    myscheduler.addEvent(tick, sevent)

  else:
    repeat = 0
    sevent = SchedTimeEvent(setime, ses, cmd, repeat, quiet)
    myscheduler.addEvent(setime, sevent, real=1)

  if not quiet:
    exported.write_message("schedule: event scheduled: %r" % sevent)

commands_dict["schedule"] = (schedule_cmd, "tick= event= repeat:boolean=false quiet:boolean=false")


def unschedule_cmd(ses, args, input):
  """
  Allows you to remove a scheduled event by id.  To remove all events
  scheduled use *.  To see a list of the events and ids for the current 
  session use the #sched command.

  examples:
    #unschedule *
    #unschedule 44

  category: commands
  """
  global myscheduler

  id = args["str"]
  quiet = args["quiet"]

  if id:
    ret = myscheduler.removeById(id)
    if not ret:
      if id == "*":
        exported.write_error("unschedule: no scheduled events to unschedule.")
      else:
        exported.write_error("unschedule: id '%s' is not valid." % id)
      return
    if not quiet:
      exported.write_message("events removed:\n%s" % "\n".join(ret))
    return
 
  exported.write_message("not implemented yet.")

commands_dict["unschedule"] = (unschedule_cmd, "str= quiet:boolean=false")


# this is a dict holding the DEFAULT_TICKER values.  we copy
# this whenever we don't have them.
DEFAULT_TICKER = {
      "len": 3,
      "warn_len": 2,
      "enabled": 0
   }

def _tickfunc(ses):
  """
  Handles executing the command or displaying a message to the
  user.

  @param ses: the Session instance
  @type  ses: Session
  """
  am = exported.get_manager("alias")
  if am:
    tickaction = am.getAlias(ses, "TICK!!!")
  if not tickaction:
    tickaction = am.getAlias(ses, "TICK")

  if tickaction:
    event.InputEvent(tickaction, internal=1, ses=ses).enqueue()
  else:
    exported.write_message("TICK!!!")

def _tickwarnfunc(ses, warnlen): 
  """
  Handles executing the command or displaying a message to the
  user.

  @param ses: the Session instance
  @type  ses: Session

  @param warnlen: the warning length
  @type  warnlen: int
  """
  am = exported.get_manager("alias")
  if am:
    tickaction = am.getAlias(ses, "TICKWARN!!!")
  if not tickaction:
    tickaction = am.getAlias(ses, "TICKWARN")

  if tickaction:
    event.InputEvent(tickaction, internal=1, ses=ses).enqueue()
  else:
    exported.write_message("ticker: %d seconds to tick!" % warnlen)


def _addtickevents(ses):
  """
  Utility function for adding the tick and tickwarn events to 
  the schedule.

  @param ses: the Session instance
  @type  ses: Session
  """
  global myscheduler

  tick_tagname = ses.getName() + "tick"
  tickwarn_tagname = ses.getName() + "tickwarn"

  # build the tick event, figure out when it should start,
  # and add it to the schedule
  sevent = SchedEvent(ses._ticker["len"], ses, _tickfunc, repeat=1, 
                      quiet=1, tag=tick_tagname)
  sevent._args = [ses]
  tick = myscheduler._current_tick + ses._ticker["len"]
  myscheduler.addEvent(tick, sevent)

  # build the tickwarn event, figure out when it should start,
  # and add it to the schedule
  warnsevent = SchedEvent(ses._ticker["len"], ses, _tickwarnfunc, 
                          repeat=1, quiet=1, tag=tickwarn_tagname)
  warnsevent._args = [ses, ses._ticker["warn_len"]]
  tick = tick - ses._ticker["warn_len"]
  myscheduler.addEvent(tick, warnsevent)


def _removetickevents(ses):
  """
  Utility function for removing the tick events (tick and tickwarn)
  for a given session.  We return all the events that we've removed
  so the caller knows whether there were events to remove or not.

  @param ses: the Session instance
  @type  ses: Session

  @returns: the SchedEvents removed
  @rtype: list of SchedEvents
  """
  global myscheduler

  sesname = ses.getName()

  # remove the tick and tickwarn events by id
  tick_tagname = sesname + "tick"
  tickwarn_tagname = sesname + "tickwarn"

  ret = myscheduler.removeById(tick_tagname)
  ret2 = myscheduler.removeById(tickwarn_tagname)
  return ret + ret2
 
def tick_cmd(ses, args, input):
  """
  Displays the number of seconds left before this session's
  ticker ticks.

  When a tick happens, it will look for a TICK!!! alias then a TICK
  alias.  Finding none, it will print TICK!!! to the ui.

  When a tickwarning happens, it will look for a TICKWARN!!! alias
  and then a TICKWARN alias.  Finding none, it will print a tickwarning
  message to the ui.

  This allows you to perform an event every x number of seconds.

  see also: tick, tickon, tickoff, ticksize, tickwarnsize

  category: commands
  """
  global myscheduler
  if not hasattr(ses, "_ticker"):
    ses._ticker = DEFAULT_TICKER.copy()

  if ses._ticker["enabled"] == 1:
    tick = myscheduler._current_tick
    sevent = myscheduler.getEventById(ses.getName() + "tick")
    delta = sevent._next_tick - tick

    exported.write_message("tick: next tick in %d seconds." % delta, ses)
  else:
    exported.write_message("tick: ticker is not enabled.", ses)

commands_dict["tick"] = (tick_cmd, "")


def tickon_cmd(ses, args, input):
  """
  Turns on the ticker for this session.

  see also: tick, tickon, tickoff, ticksize, tickwarnsize

  category: commands
  """
  global myscheduler
  if not hasattr(ses, "_ticker"):
    ses._ticker = DEFAULT_TICKER.copy()

  tick_tagname = ses.getName() + "tick"
  tickwarn_tagname = ses.getName() + "tickwarn"

  # quick check to make sure there isn't already a tick event
  # for this session
  if myscheduler.getEventById(tick_tagname):
    exported.write_error("tickon: ticker is already enabled.", ses)
    return

  _addtickevents(ses)
  ses._ticker["enabled"] = 1

  exported.write_message("tickon: session %s ticker enabled." % ses.getName(), ses)
 
commands_dict["tickon"] = (tickon_cmd, "")


def tickoff_cmd(ses, args, input):
  """
  Turns off the ticker for this session.

  see also: tick, tickon, tickoff, ticksize, tickwarnsize

  category: commands
  """
  _removetickevents(ses)
  ses._ticker["enabled"] = 0

  exported.write_message("tickoff: session %s ticker disabled." 
                         % ses.getName(), ses)

commands_dict["tickoff"] = (tickoff_cmd, "")


def ticksize_cmd(ses, args, input):
  """
  Sets and displays the number of seconds between ticks for this
  session.

  examples:
    #ticksize
    #ticksize 6
    #ticksize 1h2m30s

  see also: tick, tickon, tickoff, ticksize, tickwarnsize

  category: commands
  """
  if not hasattr(ses, "_ticker"):
    ses._ticker = DEFAULT_TICKER.copy()

  size = args["size"]

  if size == 0:
    exported.write_message("ticksize: ticksize is %d seconds." % 
                           ses._ticker["len"], ses)
    return

  ses._ticker["len"] = size

  ret = _removetickevents(ses)
  if ret:
    _addtickevents(ses)
 
  exported.write_message("ticksize: tick length set to %s." % str(size), ses)

commands_dict["ticksize"] = (ticksize_cmd, "size:timespan=0")


def tickwarnsize_cmd(ses, args, input):
  """
  Sets and displays the number of seconds you get warned before a
  Tick actually happens.

  examples:
    #tickwarnsize
    #tickwarnsize 6
    #tickwarnsize 0

  see also: tick, tickon, tickoff, ticksize, tickwarnsize

  category: commands
  """
  if not hasattr(ses, "_ticker"):
    ses._ticker = DEFAULT_TICKER.copy()

  size = args["size"]

  if size == 0:
    exported.write_message("tickwarnsize: tickwarnsize is %d seconds." % 
                           ses._ticker["warn_len"], ses)
    return

  if size > ses._ticker["len"]:
    exported.write_error("tickwarnsize: tickwarn length cannot be >= " +
                         "to ticksize.\nCurrent ticksize is %s." %
                         ses._ticker["len"], ses)
    return

  ses._ticker["warn_len"] = size

  ret = _removetickevents(ses)
  if ret:
    _addtickevents(ses)
 
  exported.write_message("tickwarnsize: tickwarn length set to %s." %
                         str(size), ses)

commands_dict["tickwarnsize"] = (tickwarnsize_cmd, "size:timespan=0")


def load():
  global myscheduler

  myscheduler = Scheduler()
  myscheduler.startup()
  modutils.load_commands(commands_dict)

def unload():
  global myscheduler

  myscheduler.shutdown()
  myscheduler = None
  modutils.unload_commands(commands_dict)
