#MUDPyE - (M)ulti-(U)ser (D)imension (Py)thon (E)ngine
#Copyright (C) 2005  Corey Staten

#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 2
#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, write to the Free Software
#Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.

#Send feedback/questions to MUDPyE@gmail.com

import copy
import gzip
import mudPickle
import operator
import os
import re
import time
import types
import mpmudobject
import mptraceback
import whichdb
import zlib

from mpmudobject import *

def ExtractStringList(listString):
  stringList = listString.split(",")
  stringList = [element.strip().replace("'", "").replace("\"", "") for element in stringList]
  stringList = [element for element in stringList if (len(element) > 0)]
  return stringList

class PseudoModule(object):
  """Simple class which routes calls to __getattribute__ and __setattribute__ to a dict."""
  def __init__(self, modType, sourceDB):
    object.__setattr__(self, "modDict", None)
    object.__setattr__(self, "modType", modType)
    object.__setattr__(self, "sourceDB", sourceDB)

  def __getattribute__(self, key):
    modDict = object.__getattribute__(self, "modDict")
    modType = object.__getattribute__(self, "modType")
    #Make sure the object is loaded.
    if modDict is None:
      modDict = object.__getattribute__(self, "sourceDB").GetContext(modType)
      if modDict is None:
        object.__getattribute__(self, "sourceDB").mudWorld.loggers["mud.scripterror"].error("Invalid existant pseudo-module [%s]." % modType)
        return None
      object.__setattr__(self, "modDict", modDict)
    return modDict[key]

  def __setattr__(self, key, value):
    raise AttributeError, "PseudoModule's are read-only."

  def __delattr__(self, key):
    raise AttributeError, "PseudoModule's are read-only."

  def __getitem__(self, key):
    modDict = object.__getattribute__(self, "modDict")
    modType = object.__getattribute__(self, "modType")
    #Make sure the object is loaded.
    if modDict is None:
      modDict = object.__getattribute__(self, "sourceDB").GetContext(modType)
      if modDict is None:
        MudObjectRef.mudWorld.loggers["mud.scripterror"].error("Invalid existant pseudo-module [%s]." % modType)
        return None
      object.__setattr__(self, "modDict", modDict)
    return modDict[key]

  def __setitem__(self, key, value):
    raise AttributeError, "PseudoModule's are read-only."

  def __delitem__(self, key):
    raise AttributeError, "PseudoModule's are read-only."

class SourceDatabase(object):
  """A database which keeps track of compressed string representations of source files."""
  importRe = re.compile("^\\s*_imports\\s*=\\s*\\[\\s*((?:[\"']\\w*[\"']\\s*\\,?\\s*)*)\\s*\\]\\s*$", re.MULTILINE)
  parentRe = re.compile("^\\s*_parents\\s*=\\s*\\[\\s*((?:[\"']\\w*[\"']\\s*\\,?\\s*)*)\\s*\\]\\s*$", re.MULTILINE)
  def __init__(self, mudWorld, fileName, globalsFileName):
    self.mudWorld = mudWorld
    self.contextCache = {}
    self.fileName = fileName
    self.isOpen = False
    self.OpenDB()
    self.scriptGlobals = {"mudWorld":mudWorld}
    self.pseudoModules = {}
    try:
      globalsFile = file(globalsFileName, "rb")
    except IOError, excInst:
      self.mudWorld.loggers["engine.db"].warning("Unable to open script globals file [%s]: %s" % (globalsFileName, str(excInst)))
    else:
      try:
        globalsText = globalsFile.read()
        exec globalsText in self.scriptGlobals
      except IOError, excInst:
        self.mudWorld.loggers["engine.db"].warning("Unable to read from script globals file: %s" % str(excInst))
      except:
        self.mudWorld.loggers["mud.scripterror"].error("Error executing script globals:\r\n%s" % mptraceback.format_exc())
      else:
        globalsFile.close()

  def OpenDB(self):
    """Opens the database from the file in self.fileName.  Attempts to import whatever dbm is used by the file."""
    dbModuleName = whichdb.whichdb(self.fileName)
    if not((dbModuleName is None) or (len(dbModuleName) == 0)):
      try:
        dbModule = __import__(dbModuleName)
      except ImportError:
        self.mudWorld.loggers["engine.db"].critical("Module [%s] not available to open SourceDatabase file [%s]." % (dbModuleName, self.fileName))
        raise
    else:
      dbModule = __import__("anydbm")
    try:
      self.db = dbModule.open(self.fileName, "c")
    except IOError, excInst:
      self.mudWorld.loggers["engine.db"].critical("Unable to open SourceDatabase file [%s]: %s" % (self.fileName, str(excInst)))
      raise
    self.isOpen = True

  def CloseDB(self):
    """Internal function for closing the database.  Does not wipe the context cache!
       Note that calling most database functions after closing will cause an exception."""
    self.db.close()
    self.isOpen = False

  def EmptyCache(self):
    """Empties the cache of source context's."""
    for objType in self.contextCache.keys():
      self.UnloadContext(objType)

  def BackupDB(self, backupPath):
    """Backs up a zipped version of the database to backupPath.  Names the file based on the current time."""
    self.CloseDB()
    backupFileName = os.path.join(backupPath, "%s %s.gz" % (time.asctime(), os.path.basename(self.fileName))).replace(":", "-")
    try:
      backupFile = gzip.GzipFile(backupFileName, "wb", 9)
    except IOError, excInst:
      self.OpenDB()
      self.mudWorld.loggers["engine.db"].error("Unable to open SourceDatabase backup file [%s]: %s" % (backupFileName, str(excInst)))
    else:
      try:
        dbFile = file(self.fileName, "rb")
      except IOError, excInst:
        self.OpenDB()
        self.mudWorld.loggers["engine.db"].error("Unable to open SourceDatabase file [%s] for backup reading: %s" % (self.fileName, str(excInst)))
      else:
        try:
          buffer = dbFile.read(8192)
          while len(buffer):
            backupFile.write(buffer)
            buffer = dbFile.read(8192)
        except IOError, excInst:
          self.OpenDB()
          self.mudWorld.loggers["engine.db"].error("Error transferring SourceDatabase [%s] to backup [%s]: %s" % (self.fileName, backupFileName, str(excInst)))
        dbFile.close()
      backupFile.close()
    if not(self.isOpen):
      self.OpenDB()

  def GetModule(self, objType):
    try:
      return self.pseudoModules[objType]
    except KeyError:
      pseudoModule = PseudoModule(objType, self)
      self.pseudoModules[objType] = pseudoModule
      return pseudoModule

  def GetContext(self, objType):
    """Returns a source context dictionary for objType, loading it if necessary.
       Context dictionaries are used for executing code on Mud Objects."""
    try:
      return self.contextCache[objType]
    except KeyError:
      return self.LoadContext(objType)

  def LoadContext(self, objType):
    """Loads the object context for objType, either from its source file or the database."""
    #We set this so that we don't have to continually pass it to the recursive functions.
    try:
      srcFileName = self.mudWorld.objIndex[objType]
      srcFile = file(srcFileName, "rb")
    except (KeyError, IOError), excInst:
      self.mudWorld.loggers["engine.db"].warning("Cannot get source file for for objType [%s]: %s" % (objType, str(excInst)))
      try:
        zipText = self.db[objType]
        srcFileText = zlib.decompress(zipText)
      except KeyError:
        self.mudWorld.loggers["engine.db"].error("No source text available for type [%s]." % objType)
        return None
    else:
      srcFileText = srcFile.read()
      srcFileText = srcFileText.replace("\r", "").strip()
      srcFile.close()
      srcFileStats = os.stat(srcFileName)
      objStampName = "__mtime__%s" % (objType,)
      if not(self.db.has_key(objStampName) and (self.db[objStampName] == str(srcFileStats.st_mtime))):
        self.db[objStampName] = str(srcFileStats.st_mtime)
        self.db[objType] = zlib.compress(srcFileText,9)
    contextDict = {}
    self.contextCache[objType] = contextDict
    result = self._ConstructContextDict(contextDict, objType, srcFileText)
    if result == False:
      self.UnloadContext(objType)
      return None
    else:
      return contextDict

  def UnloadContext(self, objType):
    """Unloads the given type's source context.  Useful for making sure the program reloads a revision to the source."""
    if self.pseudoModules.has_key(objType):
      object.__setattr__(self.pseudoModules[objType], "modDict", None)
    try:
      del self.contextCache[objType]
    except KeyError:
      self.mudWorld.loggers["engine.db"].warning("Attempt to unload source context [%s]; not in database." % objType)

  def _ConstructContextDict(self, contextDict, objType, srcFileText):
    """Constructs a new context dictionary given the text of a source file."""
    contextDict.update(self.scriptGlobals)
    #First, we have to search for the _imports and _parents lines.
    contextDict["_srcType"] = objType
    contextDict["_imports"] = []
    contextDict["_parents"] = []
    contextDict["_version"] = 1
    importMatch = SourceDatabase.importRe.search(srcFileText)
    parentMatch = SourceDatabase.parentRe.search(srcFileText)
    if not(importMatch is None):
      importList = ExtractStringList(importMatch.group(1))
      for importName in importList:
        self._Import(contextDict, importName)
    if not(parentMatch is None):
      parentList = ExtractStringList(parentMatch.group(1))
      for parentName in parentList:
        self._Derive(contextDict, parentName)
    contextDict["_srcType"] = objType
    try:
      exec srcFileText in contextDict
    except:
      self.mudWorld.loggers["mud.scripterror"].error("Error constructing context for objtype [%s]:\r\n%s" % (objType, mptraceback.format_exc()))
      return False
    #Wrap all the functions.
    wrapKeys = [key for key,val in contextDict.items() if type(val) is types.FunctionType]
    for wrapKey in wrapKeys:
      contextDict[wrapKey] = mpmudobject.MudFunctionWrapper(contextDict[wrapKey], contextDict)
    return True

  def _Import(self, objDict, importType):
    """Imports the context of an objType as a PseudoModule(acts as a module in the context's global space)."""
    if self.mudWorld.transitions.has_key(importType):
      self.mudWorld.loggers["engine.db"].warning("Deprecated import type [%s] encountered in [%s]." % (importType, objDict["_srcType"]))
      newImportType = self.mudWorld.transitions[importType]
      objDict["_imports"][objDict["_imports"].index(importType)] = newImportType
      importType = newImportType
    if self.GetContext(importType) is None:
      self.mudWorld.loggers["mud.scripterror"].error("Attempt to import non-existant PseudoModule [%s].in [%s]" % (importType, objDict["_srcType"]))
      objDict[importType] = None
      return None
    pseudoModule = self.GetModule(importType)
    objDict[importType] = pseudoModule

  def _Derive(self, childDict, parentType):
    """Derives an object from the context of a given parentType."""
    if self.mudWorld.transitions.has_key(parentType):
      self.mudWorld.loggers["engine.db"].warning("Deprecated parent type [%s] encountered in [%s]." % (parentType, childDict["_srcType"]))
      newParentType = self.mudWorld.transitions[parentType]
      childDict["_parents"][childDict["_parents"].index(parentType)] = newParentType
      parentType = newParentType
    parentDict = self.GetContext(parentType)
    if parentDict is None:
      self.mudWorld.loggers["mud.scripterror"].error("Attempt to derive from non-existant objType [%s] in [%s]" % (parentType, childDict["_srcType"]))
      return None
    tempDict = {}
    tempDict.update(parentDict)
    tempDict.update(childDict)
    childDict.update(tempDict)


class ObjectDatabase:
  """Database used to store MudObject data."""

  def __init__(self, mudWorld, fileName):
    self.mudWorld = mudWorld
    self.fileName = fileName
    self.objCache = {}
    self.dirtyObjs = []
    self.refDict = {}
    self.isOpen = False
    self.mudObjectRefType = GenMudObjectRefType(mudWorld)
    self.OpenDB()

  def OpenDB(self):
    """Opens the database from the file in self.fileName.  Attempts to import whatever dbm is used by the file."""
    dbModuleName = whichdb.whichdb(self.fileName)
    if not((dbModuleName is None) or (len(dbModuleName) == 0)):
      try:
        dbModule = __import__(dbModuleName)
      except ImportError:
        mudWorld.loggers["engine.db"].critical("Module [%s] not available to open ObjectDatabase file [%s]." % (dbModuleName, self.fileName))
        raise
    else:
      dbModule = __import__("anydbm")
    try:
      self.db = dbModule.open(self.fileName, "c")
    except IOError, excInst:
      mudWorld.loggers["engine.db"].critical("Unable to open ObjectDatabase file [%s]: %s" % (self.fileName, str(excInst)))
      raise
    self.isOpen = True

  def CloseDB(self):
    """Internal function for closing the database.  Does not wipe the context cache!
       Note that calling most database functions after closing will cause an exception."""
    self.db.sync()
    self.db.close()
    self.isOpen = False

  def EmptyCache(self):
    """Saves dirtied objects and then clears the memory cache."""
    self._SaveDirtyObjs()
    self.objCache = {}
    self.dirtyObjs = []

  def BackupDB(self, backupPath):
    """Backs up a zipped version of the database to backupPath.  Names the file based on the current time."""
    self._SaveDirtyObjs()
    self.CloseDB()
    backupFileName = os.path.join(backupPath, "%s %s.gz" % (time.asctime(), os.path.basename(self.fileName))).replace(":", "-")
    try:
      backupFile = gzip.GzipFile(backupFileName, "wb", 9)
    except IOError, excInst:
      self.OpenDB()
      self.mudWorld.loggers["engine.db"].error("Unable to open ObjectDatabase backup file [%s]: %s" % (backupFileName, str(excInst)))
    else:
      try:
        dbFile = file(self.fileName, "rb")
      except IOError, excInst:
        self.OpenDB()
        self.mudWorld.loggers["engine.db"].error("Unable to open ObjectDatabase file [%s] for backup reading: %s" % (self.fileName, str(excInst)))
      else:
        try:
          buffer = dbFile.read(8192)
          while len(buffer):
            backupFile.write(buffer)
            buffer = dbFile.read(8192)
        except IOError, excInst:
          self.OpenDB()
          self.mudWorld.loggers["engine.db"].error("Error transferring ObjectDatabase [%s] to backup [%s]: %s" % (self.fileName, backupFileName, str(excInst)))
        dbFile.close()
      backupFile.close()
    if not(self.isOpen):
      self.OpenDB()


  def Update(self):
    """Generalized update function which should be called periodically.
       Currently, all it does is save dirtied objects."""
    self._SaveDirtyObjs()

  def DirtyObj(self, ID):
    """Dirties an object if it is not dirty already.  Note, MudObject's inline this, so this should only be
       used under special circumstances."""
    if ID not in self.dirtyObjs:
      self.dirtyObjs.append(ID)

  def HasObj(self, ID):
    return self.db.has_key(ID)

  def GetObjRef(self, ID):
    """Should be 'GetObj' for scripts, used to get a proxy object for MudObject's."""
    if not(self.refDict.has_key(ID)):
      self.refDict[ID] = self.mudObjectRefType(ID)
    return self.refDict[ID]

  def GetObjDict(self, ID):
    """Finds the object with ID in the cache or loads it from disk.  This is where cache-ing is
       handled."""
    if not(self.objCache.has_key(ID)):
      if self.db.has_key(ID):
        loadDict = self.LoadObjDict(ID)
        if loadDict is None:
          self.mudWorld.loggers["engine.db"].debug("Attempt to access non-existant object [%s]." % ID)
          return None
        self.objCache[ID] = loadDict
      else:
        self.mudWorld.loggers["engine.db"].debug("Attempt to access non-existant object [%s]." % ID)
        return None
    return self.objCache[ID]

  def LoadObjDict(self, ID):
    """Loads an object from the hard-disk database.  Will throw an exception if object isn't present."""
    loadDict = mudPickle.loads(self.db[ID], self.GetObjRef)
    objType = loadDict["_objType"]
    if self.mudWorld.transitions.has_key(objType):
      newObjType = self.mudWorld.transitions[objType]
      loadDict["_objType"] = newObjType
      self.mudWorld.loggers["engine.db"].info("Transitioned object [%s] from [%s] to [%s]" % (ID, objType, newObjType))
    objRef = self.GetObjRef(ID)
    object.__setattr__(objRef, "objDict", loadDict)
    ExParentToChild(objRef, "_Sys_Load")
    return loadDict


  def UnloadObjDict(self, ID):
    """Unloads the object with the given ID, even if it is persistant."""
    if ID in self.dirtyObjs:
      ExChildToParent(self.GetObjRef(ID), "_Sys_Unload")
      self.db[ID] = mudPickle.dumps(self.objCache[ID], 2)
      self.dirtyObjs.remove(ID)
      object.__setattr__(self.refDict[ID], "objDict", None)
    del self.objCache[ID]

  def _SaveDirtyObjs(self):
    """Saves dirtied objects to the database, and then synchronizes."""
    for dirtyObj in self.dirtyObjs:
      if not(self.objCache.has_key(dirtyObj)):
        self.mudWorld.loggers["engine.db"].error("Dirtied object ID [%s] not in database." % dirtyObj)
        continue
      self.db[dirtyObj] = mudPickle.dumps(self.objCache[dirtyObj], 2)
    self.dirtyObjs = []
    self.db.sync()

  def CreateObj(self, objType, **createArgs):
    """Creates an object with the next ID in line."""
    nextIDStr = "__db__nextid_%s" % objType
    try:
      IDNum = int(self.db[nextIDStr])
      self.db[nextIDStr] = str(IDNum + 1)
    except KeyError:
      IDNum = 1
      self.db[nextIDStr] = "2"
    ID = "%s%d" % (objType, IDNum)
    return self.CreateObjAsID(ID, objType, **createArgs)

  def CreateObjAsID(self, ID, objType, **createArgs):
    """Does the actual work of creating an object.  Returns a reference to the object.  If an object
       currently has this ID, it will be deleted."""
    if objType not in self.mudWorld.objIndex:
      return None
    if self.mudWorld.transitions.has_key(objType):
      self.mudWorld.loggers["engine.db"].warning("Object created with deprecated object type [%s]." % (objType))
      objType = transitions[objType]
    newObjDict = {"_ID":ID, "_objType":objType, "_persist":False, "_versions":{}}
    if self.HasObj(ID):
      self.DelObj(ID)
    self.objCache[ID] = newObjDict
    newObjRef = self.GetObjRef(ID)
    ExParentToChild(newObjRef, "_Sys_Create", **createArgs)
    ExParentToChild(newObjRef, "_Sys_Load")
    self.db[ID] = mudPickle.dumps(newObjDict)
    return newObjRef

  def DelObj(self, objRef):
    ExChildToParent(objRef, "_Sys_Unload")
    ExChildToParent(objRef, "_Sys_Destroy")
    ID = objRef._ID
    self.UnloadObjDict(ID)
    del self.db[ID]
    del self.refDict[ID]