import os
import sys

try:
    from cPickle import load, dump
except ImportError:
    from pickle import load, dump

from MiscUtils.Configurable import Configurable
from MiscUtils import NoDefault


class ModelError(Exception):

    def __init__(self, error, line=None):
        self._line = line
        self._error = error
        if line is not None:
            args = (line, error)
        else:
            args = (error,)
        Exception.__init__(self, *args)

    def setLine(self, line):
        self._line = line

    def printError(self, filename):
        self.args = (filename,) + self.args
        if self._line:
            print '%s:%d: %s' % (filename, self._line, self._error)
        else:
            print '%s: %s' % (filename, self._error)


class Model(Configurable):
    """A Model defines the classes, attributes and enumerations of an application.

    It also provides access to the Python classes that implement these structures
    for use by other MiddleKit entities including code generators and object stores.
    """

    pickleProtocol = -1  # highest protocol available

    def __init__(self,
            filename=None, classesFilename=None, configFilename=None,
            customCoreClasses={}, rootModel=None, havePythonClasses=True):
        Configurable.__init__(self)
        self._havePythonClasses = havePythonClasses
        self._filename = None
        self._configFilename = configFilename or 'Settings.config'
        self._coreClasses = customCoreClasses
        self._klasses = None
        self._name = None
        self._parents = []  # e.g., parent models
        self._pyClassForName = {}

        # _allModelsByFilename is used to avoid loading the same parent model twice
        if rootModel:
            self._allModelsByFilename = rootModel._allModelsByFilename
        else:
            self._allModelsByFilename = {}
        self._rootModel = rootModel

        if filename or classesFilename:
            self.read(filename or classesFilename, classesFilename is not None)

    def name(self):
        if self._name is None:
            if self._filename:
                self._name = os.path.splitext(os.path.basename(self._filename))[0]
            else:
                self._name = 'unnamed-mk-model'
        return self._name

    def setName(self, name):
        self._name = name

    def filename(self):
        return self._filename

    def read(self, filename, isClassesFile=False):
        assert self._filename is None, 'Cannot read twice.'
        # Assume the .mkmodel extension if none is given
        if os.path.splitext(filename)[1] == '':
            filename += '.mkmodel'
        self._filename = os.path.abspath(filename)
        self._name = None
        if isClassesFile:
            self.dontReadParents()
        else:
            self.readParents()  # the norm
        try:
            if isClassesFile:
                self.readKlassesDirectly(filename)
            else:
                self.readKlassesInModelDir()  # the norm
            self.awakeFromRead()
        except ModelError as e:
            print
            print 'Error while reading model:'
            e.printError(filename)
            raise

    def readKlassesInModelDir(self):
        """Read the Classes.csv or Classes.pickle.cache file as appropriate."""
        path = None
        csvPath = os.path.join(self._filename, 'Classes.csv')
        if os.path.exists(csvPath):
            path = csvPath
        xlPath = os.path.join(self._filename, 'Classes.xls')
        if os.path.exists(xlPath):
            path = xlPath
        if path is None:
            open(csvPath)  # to get a properly constructed IOError

        self.readKlassesDirectly(path)

    def readKlassesDirectly(self, path):
        # read the pickled version of Classes if possible
        data = None
        shouldUseCache = self.setting('UsePickledClassesCache', False)
        if shouldUseCache:
            from MiscUtils.PickleCache import readPickleCache, writePickleCache
            data = readPickleCache(path,
                pickleProtocol=self.pickleProtocol, source='MiddleKit')

        # read the regular file if necessary
        if data is None:
            self.klasses().read(path)
            if shouldUseCache:
                writePickleCache(self._klasses, path,
                    pickleProtocol=self.pickleProtocol, source='MiddleKit')
        else:
            self._klasses = data
            self._klasses._model = self

    def __getstate__(self):
        raise Exception('Model instances were not designed to be pickled.')

    def awakeFromRead(self):
        # create containers for all klasses, uniqued by name
        byName = {}
        inOrder = []
        for model in reversed(self._searchOrder):
            for klass in model.klasses().klassesInOrder():
                name = klass.name()
                if name in byName:
                    for i in range(len(inOrder)):
                        if inOrder[i].name() == name:
                            inOrder[i] = klass
                else:
                    inOrder.append(klass)
                byName[name] = klass
        assert len(byName) == len(inOrder)
        for name, klass in byName.items():
            assert klass is self.klass(name)
        for klass in inOrder:
            assert klass is self.klass(klass.name())
        self._allKlassesByName = byName
        self._allKlassesInOrder = inOrder
        self._klasses.awakeFromRead(self)

    def readParents(self, parentFilenames=None):
        """Read parent models.

        Reads the parent models of the current model, as specified in the
        'Inherit' setting. The attributes _parents and _searchOrder are set.
        """
        if parentFilenames is None:
            parentFilenames = self.setting('Inherit', [])
        for filename in parentFilenames:
            filename = os.path.abspath(os.path.join(
                os.path.dirname(self._filename), filename))
            if filename in self._allModelsByFilename:
                model = self._allModelsByFilename[filename]
                assert model != self._rootModel
            else:
                model = self.__class__(filename,
                    customCoreClasses=self._coreClasses,
                    rootModel=self, havePythonClasses=self._havePythonClasses)
                self._allModelsByFilename[filename] = model
            self._parents.append(model)

        # establish the search order
        # algorithm taken from http://www.python.org/2.2/descrintro.html#mro
        searchOrder = self.allModelsDepthFirstLeftRight()

        # remove duplicates:
        searchSet = set()
        for i in reversed(range(len(searchOrder))):
            model = searchOrder[i]
            if model in searchSet:
                del searchOrder[i]
            else:
                searchSet.add(model)

        self._searchOrder = searchOrder

    def dontReadParents(self):
        """Set attributes _parents and _searchOrder.

        Used internally for the rare case of reading class files directly
        (instead of from a model directory).
        """
        self._parents = []
        self._searchOrder = [self]

    def allModelsDepthFirstLeftRight(self, parents=None):
        """Return ordered list of models.

        Returns a list of all models, including self, parents and
        ancestors, in a depth-first, left-to-right order. Does not
        remove duplicates (found in inheritance diamonds).

        Mostly useful for readParents() to establish the lookup
        order regarding model inheritance.
        """
        if parents is None:
            parents = []
        parents.append(self)
        for parent in self._parents:
            parent.allModelsDepthFirstLeftRight(parents)
        return parents

    def coreClass(self, className):
        """Return code class.

        For the given name, returns a class from MiddleKit.Core
        or the custom set of classes that were passed in via initialization.
        """
        pyClass = self._coreClasses.get(className)
        if pyClass is None:
            results = {}
            exec 'import MiddleKit.Core.%s as module'% className in results
            pyClass = getattr(results['module'], className)
            assert isinstance(pyClass, type)
            self._coreClasses[className] = pyClass
        return pyClass

    def coreClassNames(self):
        """Return a list of model class names found in MiddleKit.Core."""
        # a little cheesy, but it does the job:
        import MiddleKit.Core as Core
        return Core.__all__

    def klasses(self):
        """Get klasses.

        Returns an instance that inherits from Klasses, using the base
        classes passed to __init__, if any.

        See also: klass(), allKlassesInOrder(), allKlassesByName()
        """
        if self._klasses is None:
            Klasses = self.coreClass('Klasses')
            self._klasses = Klasses(self)
        return self._klasses

    def klass(self, name, default=NoDefault):
        """Get klass.

        Returns the klass with the given name, searching the parent
        models if necessary.
        """
        for model in self._searchOrder:
            klass = model.klasses().get(name)
            if klass:
                return klass
        if default is NoDefault:
            raise KeyError(name)
        else:
            return default

    def allKlassesInOrder(self):
        """Get klasses in order.

        Returns a sequence of all the klasses in this model, unique by
        name, including klasses inherited from parent models.

        The order is the order of declaration, top-down.
        """
        return self._allKlassesInOrder

    def allKlassesByName(self):
        """Get klasses by name.

        Returns a dictionary of all the klasses in this model, unique
        by name, including klasses inherited from parent models.
        """
        return self._allKlassesByName

    def allKlassesInDependencyOrder(self):
        """Get klasses in dependency order.

        Returns a sequence of all the klasses in this model, in an
        order such that klasses follow the klasses they refer to
        (via obj ref attributes).
        The typical use for such an order is to avoid SQL errors
        about foreign keys referring to tables that do not exist.

        A ModelError is raised if there is a dependency cycle
        since there can be no definitive order when a cycle exists.
        You can break cycles by setting Ref=False for some
        attribute in the cycle.
        """
        for klass in self._allKlassesInOrder:
            klass.willBuildDependencies()
        for klass in self._allKlassesInOrder:
            klass.buildDependencies()
        allKlasses = []
        visited = set()
        for klass in self._allKlassesInOrder:
            if not klass._dependents:
                # print '>>', klass.name()
                klass.recordDependencyOrder(allKlasses, visited)
        # The above loop fails to capture classes that are in cycles,
        # but in that case there really is no dependency order.
        if len(allKlasses) < len(self._allKlassesInOrder):
            raise ModelError("Cannot determine a dependency order"
                " among the classes due to a cycle. Try setting Ref=0"
                " for one of the attributes to break the cycle.")
        assert len(allKlasses) == len(self._allKlassesInOrder), \
            '%r, %r, %r' % (len(allKlasses), len(self._allKlassesInOrder), allKlasses)
        # print '>> allKlassesInDependencyOrder() =', ' '.join([k.name() for k in allKlasses])
        return allKlasses

    def pyClassForName(self, name):
        """Get Python class for name.

        Returns the Python class for the given name, which must be present
        in the object model. Accounts for self.setting('Package').

        If you already have a reference to the model klass, then you can
        just ask it for klass.pyClass().
        """
        pyClass = self._pyClassForName.get(name)
        if pyClass is None:
            results = {}
            pkg = self.setting('Package', '')
            if pkg:
                pkg += '.'
            try:
                exec 'import %s%s as module' % (pkg, name) in results
            except ImportError as exc:
                raise ModelError("Could not import module for class '%s' due to %r."
                    " If you've added this class recently,"
                    " you need to re-generate your model." % (name, exc.args[0]))
            pyClass = getattr(results['module'], 'pyClass', None)
            if pyClass is None:
                pyClass = getattr(results['module'], name)
            # Note: The 'pyClass' variable name that is first looked for is a hook for
            # those modules that have replaced the class variable by something else,
            # like a function. I did this in a project with a class called UniqueString()
            # in order to guarantee uniqueness per string.
            self._pyClassForName[name] = pyClass
        return pyClass


    ## Being configurable ##

    def configFilename(self):
        if self._filename is None:
            return None
        else:
            return os.path.join(self._filename, self._configFilename)

    def defaultConfig(self):
        return dict(
            Threaded = True,
            ObjRefSuffixes = ('ClassId', 'ObjId'),
            UseBigIntObjRefColumns = False,
            # SQLLog = {'File': 'stdout'},
            PreSQL = '',
            PostSQL = '',
            DropStatements = 'database',  # database, tables
            SQLSerialColumnName = 'serialNum',  # can use [cC]lassName, _ClassName
            AccessorStyle = 'methods',  # can be 'methods' or 'properties'
            ExternalEnumsSQLNames = dict(
                Enable = False,
                TableName = '%(ClassName)s%(AttrName)sEnum',
                ValueColName = 'value',
                NameColName = 'name',
            ),
            # can use: [cC]lassName, _ClassName, [aA]ttrName, _AttrName.
            # "_" prefix means "as is", the others control the case of the first character.
        )

    def usesExternalSQLEnums(self):
        flag = getattr(self, '_usesExternalSQLEnums', None)
        if flag is None:
            flag = self.setting('ExternalEnumsSQLNames')['Enable']
            self._usesExternalSQLEnums = flag
        return flag


    ## Warnings ##

    def printWarnings(self, out=None):
        if out is None:
            out = sys.stdout
        if len(self._klasses.klassesInOrder()) < 1:
            out.write("warning: Model '%s' doesn't contain any class definitions.\n"
                % self.name())
        for klass in self.klasses().klassesInOrder():
            klass.printWarnings(out)
