from panda3d.core import *
from panda3d.direct import *
from direct.task import Task
from direct.directnotify.DirectNotifyGlobal import directNotify
from direct.distributed.DoInterestManager import DoInterestManager
from direct.distributed.DoCollectionManager import DoCollectionManager
from direct.showbase import GarbageReport
from .PyDatagramIterator import PyDatagramIterator

import gc

__all__ = ["ConnectionRepository", "GCTrigger"]

class ConnectionRepository(
        DoInterestManager, DoCollectionManager, CConnectionRepository):
    """
    This is a base class for things that know how to establish a
    connection (and exchange datagrams) with a gameserver.  This
    includes ClientRepository and AIRepository.
    """
    notify = directNotify.newCategory("ConnectionRepository")
    taskPriority = -30
    taskChain = None

    CM_HTTP=0
    CM_NET=1
    CM_NATIVE=2

    gcNotify = directNotify.newCategory("GarbageCollect")

    GarbageCollectTaskName = "allowGarbageCollect"
    GarbageThresholdTaskName = "adjustGarbageCollectThreshold"

    def __init__(self, connectMethod, config, hasOwnerView = False,
                 threadedNet = None):
        assert self.notify.debugCall()
        if threadedNet is None:
            # Default value.
            threadedNet = config.GetBool('threaded-net', False)

        # let the C connection repository know whether we're supporting
        # 'owner' views of distributed objects (i.e. 'receives ownrecv',
        # 'I own this object and have a separate view of it regardless of
        # where it currently is located')
        CConnectionRepository.__init__(self, hasOwnerView, threadedNet)
        self.setWantMessageBundling(config.GetBool('want-message-bundling', 1))
        # DoInterestManager.__init__ relies on CConnectionRepository being
        # initialized
        DoInterestManager.__init__(self)
        DoCollectionManager.__init__(self)
        self.setPythonRepository(self)

        # Create a unique ID number for each ConnectionRepository in
        # the world, helpful for sending messages specific to each one.
        self.uniqueId = hash(self)

        # Accept this hook so that we can respond to lost-connection
        # events in the main thread, instead of within the network
        # thread (if there is one).
        self.accept(self._getLostConnectionEvent(), self.lostConnection)

        self.config = config

        if self.config.GetBool('verbose-repository'):
            self.setVerbose(1)

        # Set this to 'http' to establish a connection to the server
        # using the HTTPClient interface, which ultimately uses the
        # OpenSSL socket library (even though SSL is not involved).
        # This is not as robust a socket library as NET's, but the
        # HTTPClient interface does a good job of negotiating the
        # connection over an HTTP proxy if one is in use.
        #
        # Set it to 'net' to use Panda's net interface
        # (e.g. QueuedConnectionManager, etc.) to establish the
        # connection.  This is a higher-level layer build on top of
        # the low-level "native net" library.  There is no support for
        # proxies.  This is a good, general choice.
        #
        # Set it to 'native' to use Panda's low-level native net
        # interface directly.  This is much faster than either http or
        # net for high-bandwidth (e.g. server) applications, but it
        # doesn't support the simulated delay via the start_delay()
        # call.
        #
        # Set it to 'default' to use an appropriate interface
        # according to the type of ConnectionRepository we are
        # creating.
        userConnectMethod = self.config.GetString('connect-method', 'default')
        if userConnectMethod == 'http':
            connectMethod = self.CM_HTTP
        elif userConnectMethod == 'net':
            connectMethod = self.CM_NET
        elif userConnectMethod == 'native':
            connectMethod = self.CM_NATIVE

        self.connectMethod = connectMethod
        if self.connectMethod == self.CM_HTTP:
            self.notify.info("Using connect method 'http'")
        elif self.connectMethod == self.CM_NET:
            self.notify.info("Using connect method 'net'")
        elif self.connectMethod == self.CM_NATIVE:
            self.notify.info("Using connect method 'native'")

        self.connectHttp = None
        self.http = None

        # This DatagramIterator is constructed once, and then re-used
        # each time we read a datagram.
        self.private__di = PyDatagramIterator()

        self.recorder = None
        self.readerPollTaskObj = None

        # This is the string that is appended to symbols read from the
        # DC file.  The AIRepository will redefine this to 'AI'.
        self.dcSuffix = ''

        self._serverAddress = ''

        if self.config.GetBool('gc-save-all', 1):
            # set gc to preserve every object involved in a cycle, even ones that
            # would normally be freed automatically during garbage collect
            # allows us to find and fix these cycles, reducing or eliminating the
            # need to run garbage collects
            # garbage collection CPU usage is O(n), n = number of Python objects
            gc.set_debug(gc.DEBUG_SAVEALL)

        if self.config.GetBool('want-garbage-collect-task', 1):
            # manual garbage-collect task
            taskMgr.add(self._garbageCollect, self.GarbageCollectTaskName, 200)
            # periodically increase gc threshold if there is no garbage
            taskMgr.doMethodLater(self.config.GetFloat('garbage-threshold-adjust-delay', 5 * 60.),
                                  self._adjustGcThreshold, self.GarbageThresholdTaskName)

        self._gcDefaultThreshold = gc.get_threshold()

    def _getLostConnectionEvent(self):
        return self.uniqueName('lostConnection')

    def _garbageCollect(self, task=None):
        # allow a collect
        # enable automatic garbage collection
        gc.enable()
        # creating an object with gc enabled causes garbage collection to trigger if appropriate
        gct = GCTrigger()
        # disable the automatic garbage collect during the rest of the frame
        gc.disable()
        return Task.cont

    def _adjustGcThreshold(self, task):
        # do an unconditional collect to make sure gc.garbage has a chance to be
        # populated before we start increasing the auto-collect threshold
        # don't distribute the leak check from the client to the AI, they both
        # do these garbage checks independently over time
        numGarbage = GarbageReport.checkForGarbageLeaks()
        if numGarbage == 0:
            self.gcNotify.debug('no garbage found, doubling gc threshold')
            a, b, c = gc.get_threshold()
            gc.set_threshold(min(a * 2, 1 << 30), b, c)

            task.delayTime = task.delayTime * 2
            retVal = Task.again

        else:
            self.gcNotify.warning('garbage found, reverting gc threshold')
            # the process is producing garbage, stick to the default collection threshold
            gc.set_threshold(*self._gcDefaultThreshold)
            retVal = Task.done

        return retVal

    def generateGlobalObject(self, doId, dcname, values=None):
        def applyFieldValues(distObj, dclass, values):
            for i in range(dclass.getNumInheritedFields()):
                field = dclass.getInheritedField(i)
                if field.asMolecularField() == None:
                    value = values.get(field.getName(), None)
                    if value is None and field.isRequired():
                        # Gee, this could be better.  What would really be
                        # nicer is to get value from field.getDefaultValue
                        # or similar, but that returns a binary string, not
                        # a python tuple, like the following does.  If you
                        # want to change something better, please go ahead.
                        packer = DCPacker()
                        packer.beginPack(field)
                        packer.packDefaultValue()
                        packer.endPack()

                        unpacker = DCPacker()
                        unpacker.setUnpackData(packer.getString())
                        unpacker.beginUnpack(field)
                        value = unpacker.unpackObject()
                        unpacker.endUnpack()
                    if value is not None:
                        function = getattr(distObj, field.getName())
                        if function is not None:
                            function(*value)
                        else:
                            self.notify.error("\n\n\nNot able to find %s.%s"%(
                                distObj.__class__.__name__, field.getName()))

        # Look up the dclass
        dclass = self.dclassesByName.get(dcname+self.dcSuffix)
        if dclass is None:
            #print "\n\n\nNeed to define", dcname+self.dcSuffix
            self.notify.warning("Need to define %s" % (dcname+self.dcSuffix))
            dclass = self.dclassesByName.get(dcname+'AI')
        if dclass is None:
            dclass = self.dclassesByName.get(dcname)
        # Create a new distributed object, and put it in the dictionary
        #distObj = self.generateWithRequiredFields(dclass, doId, di)

        # Construct a new one
        classDef = dclass.getClassDef()
        if classDef == None:
            self.notify.error("Could not create an undefined %s object."%(
                dclass.getName()))
        distObj = classDef(self)
        distObj.dclass = dclass
        # Assign it an Id
        distObj.doId = doId
        # Put the new do in the dictionary
        self.doId2do[doId] = distObj
        # Update the required fields
        distObj.generateInit()  # Only called when constructed
        distObj.generate()
        if values is not None:
            applyFieldValues(distObj, dclass, values)
        distObj.announceGenerate()
        distObj.parentId = 0
        distObj.zoneId = 0
        # updateRequiredFields calls announceGenerate
        return  distObj

    def readDCFile(self, dcFileNames = None):
        """
        Reads in the dc files listed in dcFileNames, or if
        dcFileNames is None, reads in all of the dc files listed in
        the Config.prc file.
        """

        dcFile = self.getDcFile()
        dcFile.clear()
        self.dclassesByName = {}
        self.dclassesByNumber = {}
        self.hashVal = 0

        if isinstance(dcFileNames, str):
            # If we were given a single string, make it a list.
            dcFileNames = [dcFileNames]

        dcImports = {}
        if dcFileNames == None:
            readResult = dcFile.readAll()
            if not readResult:
                self.notify.error("Could not read dc file.")
        else:
            searchPath = getModelPath().getValue()
            for dcFileName in dcFileNames:
                pathname = Filename(dcFileName)
                vfs = VirtualFileSystem.getGlobalPtr()
                vfs.resolveFilename(pathname, searchPath)
                readResult = dcFile.read(pathname)
                if not readResult:
                    self.notify.error("Could not read dc file: %s" % (pathname))

        #if not dcFile.allObjectsValid():
        #    names = []
        #    for i in range(dcFile.getNumTypedefs()):
        #        td = dcFile.getTypedef(i)
        #        if td.isBogusTypedef():
        #            names.append(td.getName())
        #    nameList = ', '.join(names)
        #    self.notify.error("Undefined types in DC file: " + nameList)

        self.hashVal = dcFile.getHash()

        # Now import all of the modules required by the DC file.
        for n in range(dcFile.getNumImportModules()):
            moduleName = dcFile.getImportModule(n)[:]

            # Maybe the module name is represented as "moduleName/AI".
            suffix = moduleName.split('/')
            moduleName = suffix[0]
            suffix=suffix[1:]
            if self.dcSuffix in suffix:
                moduleName += self.dcSuffix
            elif self.dcSuffix == 'UD' and 'AI' in suffix: #HACK:
                moduleName += 'AI'

            importSymbols = []
            for i in range(dcFile.getNumImportSymbols(n)):
                symbolName = dcFile.getImportSymbol(n, i)

                # Maybe the symbol name is represented as "symbolName/AI".
                suffix = symbolName.split('/')
                symbolName = suffix[0]
                suffix=suffix[1:]
                if self.dcSuffix in suffix:
                    symbolName += self.dcSuffix
                elif self.dcSuffix == 'UD' and 'AI' in suffix: #HACK:
                    symbolName += 'AI'

                importSymbols.append(symbolName)

            self.importModule(dcImports, moduleName, importSymbols)

        # Now get the class definition for the classes named in the DC
        # file.
        import inspect

        for i in range(dcFile.getNumClasses()):
            dclass = dcFile.getClass(i)
            number = dclass.getNumber()
            className = dclass.getName() + self.dcSuffix

            # Does the class have a definition defined in the newly
            # imported namespace?
            classDef = dcImports.get(className)
            if classDef is None and self.dcSuffix == 'UD': #HACK:
                className = dclass.getName() + 'AI'
                classDef = dcImports.get(className)

            # Also try it without the dcSuffix.
            if classDef == None:
                className = dclass.getName()
                classDef = dcImports.get(className)
            if classDef is None:
                self.notify.debug("No class definition for %s." % (className))
            else:
                if inspect.ismodule(classDef):
                    if not hasattr(classDef, className):
                        self.notify.warning("Module %s does not define class %s." % (className, className))
                        continue
                    classDef = getattr(classDef, className)

                if not inspect.isclass(classDef):
                    self.notify.error("Symbol %s is not a class name." % (className))
                else:
                    dclass.setClassDef(classDef)

            self.dclassesByName[className] = dclass
            if number >= 0:
                self.dclassesByNumber[number] = dclass

        # Owner Views
        if self.hasOwnerView():
            ownerDcSuffix = self.dcSuffix + 'OV'
            # dict of class names (without 'OV') that have owner views
            ownerImportSymbols = {}

            # Now import all of the modules required by the DC file.
            for n in range(dcFile.getNumImportModules()):
                moduleName = dcFile.getImportModule(n)

                # Maybe the module name is represented as "moduleName/AI".
                suffix = moduleName.split('/')
                moduleName = suffix[0]
                suffix=suffix[1:]
                if ownerDcSuffix in suffix:
                    moduleName = moduleName + ownerDcSuffix

                importSymbols = []
                for i in range(dcFile.getNumImportSymbols(n)):
                    symbolName = dcFile.getImportSymbol(n, i)

                    # Check for the OV suffix
                    suffix = symbolName.split('/')
                    symbolName = suffix[0]
                    suffix=suffix[1:]
                    if ownerDcSuffix in suffix:
                        symbolName += ownerDcSuffix
                    importSymbols.append(symbolName)
                    ownerImportSymbols[symbolName] = None

                self.importModule(dcImports, moduleName, importSymbols)

            # Now get the class definition for the owner classes named
            # in the DC file.
            for i in range(dcFile.getNumClasses()):
                dclass = dcFile.getClass(i)
                if ((dclass.getName()+ownerDcSuffix) in ownerImportSymbols):
                    number = dclass.getNumber()
                    className = dclass.getName() + ownerDcSuffix

                    # Does the class have a definition defined in the newly
                    # imported namespace?
                    classDef = dcImports.get(className)
                    if classDef is None:
                        self.notify.error("No class definition for %s." % className)
                    else:
                        if inspect.ismodule(classDef):
                            if not hasattr(classDef, className):
                                self.notify.error("Module %s does not define class %s." % (className, className))
                            classDef = getattr(classDef, className)
                        dclass.setOwnerClassDef(classDef)
                        self.dclassesByName[className] = dclass

    def importModule(self, dcImports, moduleName, importSymbols):
        """
        Imports the indicated moduleName and all of its symbols
        into the current namespace.  This more-or-less reimplements
        the Python import command.
        """
        module = __import__(moduleName, globals(), locals(), importSymbols)

        if importSymbols:
            # "from moduleName import symbolName, symbolName, ..."
            # Copy just the named symbols into the dictionary.
            if importSymbols == ['*']:
                # "from moduleName import *"
                if hasattr(module, "__all__"):
                    importSymbols = module.__all__
                else:
                    importSymbols = module.__dict__.keys()

            for symbolName in importSymbols:
                if hasattr(module, symbolName):
                    dcImports[symbolName] = getattr(module, symbolName)
                else:
                    raise Exception('Symbol %s not defined in module %s.' % (symbolName, moduleName))
        else:
            # "import moduleName"

            # Copy the root module name into the dictionary.

            # Follow the dotted chain down to the actual module.
            components = moduleName.split('.')
            dcImports[components[0]] = module

    def getServerAddress(self):
        return self._serverAddress

    def connect(self, serverList,
                successCallback = None, successArgs = [],
                failureCallback = None, failureArgs = []):
        """
        Attempts to establish a connection to the server.  May return
        before the connection is established.  The two callbacks
        represent the two functions to call (and their arguments) on
        success or failure, respectively.  The failure callback also
        gets one additional parameter, which will be passed in first:
        the return status code giving reason for failure, if it is
        known.
        """

        ## if self.recorder and self.recorder.isPlaying():

        ##     # If we have a recorder and it's already in playback mode,
        ##     # don't actually attempt to connect to a gameserver since
        ##     # we don't need to.  Just let it play back the data.
        ##     self.notify.info("Not connecting to gameserver; using playback data instead.")

        ##     self.connectHttp = 1
        ##     self.tcpConn = SocketStreamRecorder()
        ##     self.recorder.addRecorder('gameserver', self.tcpConn)

        ##     self.startReaderPollTask()
        ##     if successCallback:
        ##         successCallback(*successArgs)
        ##     return

        hasProxy = 0
        if self.checkHttp():
            proxies = self.http.getProxiesForUrl(serverList[0])
            hasProxy = (proxies != 'DIRECT')

        if hasProxy:
            self.notify.info("Connecting to gameserver via proxy list: %s" % (proxies))
        else:
            self.notify.info("Connecting to gameserver directly (no proxy).")

        #Redefine the connection to http or net in the default case

        self.bootedIndex = None
        self.bootedText = None
        if self.connectMethod == self.CM_HTTP:
            # In the HTTP case, we can't just iterate through the list
            # of servers, because each server attempt requires
            # spawning a request and then coming back later to check
            # the success or failure.  Instead, we start the ball
            # rolling by calling the connect callback, which will call
            # itself repeatedly until we establish a connection (or
            # run out of servers).

            ch = self.http.makeChannel(0)
            self.httpConnectCallback(
                    ch, serverList, 0,
                    successCallback, successArgs,
                    failureCallback, failureArgs)
        elif self.connectMethod == self.CM_NET or (not hasattr(self,"connectNative")):
            # Try each of the servers in turn.
            for url in serverList:
                self.notify.info("Connecting to %s via NET interface." % (url))
                if self.tryConnectNet(url):
                    self.startReaderPollTask()
                    if successCallback:
                        successCallback(*successArgs)
                    return

            # Failed to connect.
            if failureCallback:
                failureCallback(0, '', *failureArgs)
        elif self.connectMethod == self.CM_NATIVE:
            for url in serverList:
                self.notify.info("Connecting to %s via Native interface." % (url))
                if self.connectNative(url):
                    self.startReaderPollTask()
                    if successCallback:
                        successCallback(*successArgs)
                    return

            # Failed to connect.
            if failureCallback:
                failureCallback(0, '', *failureArgs)
        else:
            print("uh oh, we aren't using one of the tri-state CM variables")
            failureCallback(0, '', *failureArgs)

    def disconnect(self):
        """
        Closes the previously-established connection.
        """
        self.notify.info("Closing connection to server.")
        self._serverAddress = ''
        CConnectionRepository.disconnect(self)
        self.stopReaderPollTask()

    def shutdown(self):
        self.ignoreAll()
        CConnectionRepository.shutdown(self)

    def httpConnectCallback(self, ch, serverList, serverIndex,
                            successCallback, successArgs,
                            failureCallback, failureArgs):
        if ch.isConnectionReady():
            self.setConnectionHttp(ch)
            self._serverAddress = serverList[serverIndex-1]
            self.notify.info("Successfully connected to %s." % (self._serverAddress))

            ## if self.recorder:
            ##     # If we have a recorder, we wrap the connect inside a
            ##     # SocketStreamRecorder, which will trap incoming data
            ##     # when the recorder is set to record mode.  (It will
            ##     # also play back data when the recorder is in playback
            ##     # mode, but in that case we never get this far in the
            ##     # code, since we just create an empty
            ##     # SocketStreamRecorder without actually connecting to
            ##     # the gameserver.)
            ##     stream = SocketStreamRecorder(self.tcpConn, 1)
            ##     self.recorder.addRecorder('gameserver', stream)

            ##     # In this case, we pass ownership of the original
            ##     # connection to the SocketStreamRecorder object.
            ##     self.tcpConn.userManagesMemory = 0
            ##     self.tcpConn = stream

            self.startReaderPollTask()
            if successCallback:
                successCallback(*successArgs)
        elif serverIndex < len(serverList):
            # No connection yet, but keep trying.

            url = serverList[serverIndex]
            self.notify.info("Connecting to %s via HTTP interface." % (url))
            ch.preserveStatus()

            ch.beginConnectTo(DocumentSpec(url))
            ch.spawnTask(name = 'connect-to-server',
                         callback = self.httpConnectCallback,
                         extraArgs = [ch, serverList, serverIndex + 1,
                                      successCallback, successArgs,
                                      failureCallback, failureArgs])
        else:
            # No more servers to try; we have to give up now.
            if failureCallback:
                failureCallback(ch.getStatusCode(), ch.getStatusString(),
                                *failureArgs)

    def checkHttp(self):
        # Creates an HTTPClient, if possible, if we don't have one
        # already.  This might fail if the OpenSSL library isn't
        # available.  Returns the HTTPClient (also self.http), or None
        # if not set.

        if self.http == None:
            try:
                self.http = HTTPClient()
            except:
                pass

        return self.http

    def startReaderPollTask(self):
        # Stop any tasks we are running now
        self.stopReaderPollTask()
        self.accept(CConnectionRepository.getOverflowEventName(),
                    self.handleReaderOverflow)
        self.readerPollTaskObj = taskMgr.add(
            self.readerPollUntilEmpty, self.uniqueName("readerPollTask"),
            priority = self.taskPriority, taskChain = self.taskChain)

    def stopReaderPollTask(self):
        if self.readerPollTaskObj:
            taskMgr.remove(self.readerPollTaskObj)
            self.readerPollTaskObj = None
        self.ignore(CConnectionRepository.getOverflowEventName())

    def readerPollUntilEmpty(self, task):
        while self.readerPollOnce():
            pass
        return Task.cont

    def readerPollOnce(self):
        if self.checkDatagram():
            self.getDatagramIterator(self.private__di)
            self.handleDatagram(self.private__di)
            return 1

        # Unable to receive a datagram: did we lose the connection?
        if not self.isConnected():
            self.stopReaderPollTask()
            messenger.send(self.uniqueName('lostConnection'), taskChain = 'default')
        return 0

    def handleReaderOverflow(self):
        # this is called if the incoming-datagram queue overflowed and
        # we lost some data. Override and handle if desired.
        pass

    def lostConnection(self):
        # This should be overrided by a derived class to handle an
        # unexpectedly lost connection to the gameserver.
        self.notify.warning("Lost connection to gameserver.")

    def handleDatagram(self, di):
        # This class is meant to be pure virtual, and any classes that
        # inherit from it need to make their own handleDatagram method
        pass

    def send(self, datagram):
        # Zero-length datagrams might freak out the server.  No point
        # in sending them, anyway.
        if datagram.getLength() > 0:
##             if self.notify.getDebug():
##                 print "ConnectionRepository sending datagram:"
##                 datagram.dumpHex(ostream)

            self.sendDatagram(datagram)

    # debugging funcs for simulating a network-plug-pull
    def pullNetworkPlug(self):
        self.notify.warning('*** SIMULATING A NETWORK-PLUG-PULL ***')
        self.setSimulatedDisconnect(1)

    def networkPlugPulled(self):
        return self.getSimulatedDisconnect()

    def restoreNetworkPlug(self):
        if self.networkPlugPulled():
            self.notify.info('*** RESTORING SIMULATED PULLED-NETWORK-PLUG ***')
            self.setSimulatedDisconnect(0)

    def uniqueName(self, idString):
        return ("%s-%s" % (idString, self.uniqueId))

class GCTrigger:
    # used to trigger garbage collection
    pass
