"""Excel IRTDServer implementation.

This module is a functional example of how to implement the IRTDServer interface
in python, using the pywin32 extensions. Further details, about this interface
and it can be found at:
     https://learn.microsoft.com/en-us/previous-versions/office/developer/office-xp/aa140060(v=office.10)
"""

# Copyright (c) 2003-2004 by Chris Nilsson <chris@slort.org>
#
# By obtaining, using, and/or copying this software and/or its
# associated documentation, you agree that you have read, understood,
# and will comply with the following terms and conditions:
#
# Permission to use, copy, modify, and distribute this software and
# its associated documentation for any purpose and without fee is
# hereby granted, provided that the above copyright notice appears in
# all copies, and that both that copyright notice and this permission
# notice appear in supporting documentation, and that the name of
# Christopher Nilsson (the author) not be used in advertising or publicity
# pertaining to distribution of the software without specific, written
# prior permission.
#
# THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD
# TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANT-
# ABILITY AND FITNESS.  IN NO EVENT SHALL THE AUTHOR
# BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY
# DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS
# ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE
# OF THIS SOFTWARE.

import datetime  # For the example classes...
import threading

import pythoncom
import win32com.client
from win32com import universal
from win32com.client import gencache
from win32com.server.exception import COMException

# Typelib info for version 10 - aka Excel XP.
# This is the minimum version of excel that we can work with as this is when
# Microsoft introduced these interfaces.
EXCEL_TLB_GUID = "{00020813-0000-0000-C000-000000000046}"
EXCEL_TLB_LCID = 0
EXCEL_TLB_MAJOR = 1
EXCEL_TLB_MINOR = 4

# Import the excel typelib to make sure we've got early-binding going on.
# The "ByRef" parameters we use later won't work without this.
gencache.EnsureModule(EXCEL_TLB_GUID, EXCEL_TLB_LCID, EXCEL_TLB_MAJOR, EXCEL_TLB_MINOR)

# Tell pywin to import these extra interfaces.
# --
# QUESTION: Why? The interfaces seem to descend from IDispatch, so
# I'd have thought, for example, calling callback.UpdateNotify() (on the
# IRTDUpdateEvent callback excel gives us) would work without molestation.
# But the callback needs to be cast to a "real" IRTDUpdateEvent type. Hmm...
# This is where my small knowledge of the pywin framework / COM gets hazy.
# --
# Again, we feed in the Excel typelib as the source of these interfaces.
universal.RegisterInterfaces(
    EXCEL_TLB_GUID,
    EXCEL_TLB_LCID,
    EXCEL_TLB_MAJOR,
    EXCEL_TLB_MINOR,
    ["IRtdServer", "IRTDUpdateEvent"],
)


class ExcelRTDServer:
    """Base RTDServer class.

    Provides most of the features needed to implement the IRtdServer interface.
    Manages topic adding, removal, and packing up the values for excel.

    Shouldn't be instanciated directly.

    Instead, descendant classes should override the CreateTopic() method.
    Topic objects only need to provide a GetValue() function to play nice here.
    The values given need to be atomic (eg. string, int, float... etc).

    Also note: nothing has been done within this class to ensure that we get
    time to check our topics for updates. I've left that up to the subclass
    since the ways, and needs, of refreshing your topics will vary greatly. For
    example, the sample implementation uses a timer thread to wake itself up.
    Whichever way you choose to do it, your class needs to be able to wake up
    occaisionally, since excel will never call your class without being asked to
    first.

    Excel will communicate with our object in this order:
      1. Excel instanciates our object and calls ServerStart, providing us with
         an IRTDUpdateEvent callback object.
      2. Excel calls ConnectData when it wants to subscribe to a new "topic".
      3. When we have new data to provide, we call the UpdateNotify method of the
         callback object we were given.
      4. Excel calls our RefreshData method, and receives a 2d SafeArray (row-major)
         containing the Topic ids in the 1st dim, and the topic values in the
         2nd dim.
      5. When not needed anymore, Excel will call our DisconnectData to
         unsubscribe from a topic.
      6. When there are no more topics left, Excel will call our ServerTerminate
         method to kill us.

    Throughout, at undetermined periods, Excel will call our Heartbeat
    method to see if we're still alive. It must return a non-zero value, or
    we'll be killed.

    NOTE: By default, excel will at most call RefreshData once every 2 seconds.
          This is a setting that needs to be changed excel-side. To change this,
          you can set the throttle interval like this in the excel VBA object model:
            Application.RTD.ThrottleInterval = 1000 ' milliseconds
    """

    _com_interfaces_ = ["IRtdServer"]
    _public_methods_ = [
        "ConnectData",
        "DisconnectData",
        "Heartbeat",
        "RefreshData",
        "ServerStart",
        "ServerTerminate",
    ]
    _reg_clsctx_ = pythoncom.CLSCTX_INPROC_SERVER
    # _reg_clsid_ = "# subclass must provide this class attribute"
    # _reg_desc_ = "# subclass should provide this description"
    # _reg_progid_ = "# subclass must provide this class attribute"

    ALIVE = 1
    NOT_ALIVE = 0

    def __init__(self):
        """Constructor"""
        super().__init__()
        self.IsAlive = self.ALIVE
        self.__callback = None
        self.topics = {}

    def SignalExcel(self):
        """Use the callback we were given to tell excel new data is available."""
        if self.__callback is None:
            raise COMException(desc="Callback excel provided is Null")
        self.__callback.UpdateNotify()

    def ConnectData(self, TopicID, Strings, GetNewValues):
        """Creates a new topic out of the Strings excel gives us."""
        try:
            self.topics[TopicID] = self.CreateTopic(Strings)
        except Exception as why:
            raise COMException(desc=str(why))
        GetNewValues = True
        result = self.topics[TopicID]
        if result is None:
            result = "# %s: Waiting for update" % self.__class__.__name__
        else:
            result = result.GetValue()

        # fire out internal event...
        self.OnConnectData(TopicID)

        # GetNewValues as per interface is ByRef, so we need to pass it back too.
        return result, GetNewValues

    def DisconnectData(self, TopicID):
        """Deletes the given topic."""
        self.OnDisconnectData(TopicID)

        if TopicID in self.topics:
            self.topics[TopicID] = None
            del self.topics[TopicID]

    def Heartbeat(self):
        """Called by excel to see if we're still here."""
        return self.IsAlive

    def RefreshData(self, TopicCount):
        """Packs up the topic values. Called by excel when it's ready for an update.

        Needs to:
          * Return the current number of topics, via the "ByRef" TopicCount
          * Return a 2d SafeArray of the topic data.
            - 1st dim: topic numbers
            - 2nd dim: topic values

        We could do some caching, instead of repacking everytime...
        But this works for demonstration purposes."""
        TopicCount = len(self.topics)
        self.OnRefreshData()

        # Grow the lists, so we don't need a heap of calls to append()
        results = [[None] * TopicCount, [None] * TopicCount]

        # Excel expects a 2-dimensional array. The first dim contains the
        # topic numbers, and the second contains the values for the topics.
        # In true VBA style (yuck), we need to pack the array in row-major format,
        # which looks like:
        #   ( (topic_num1, topic_num2, ..., topic_numN), \
        #     (topic_val1, topic_val2, ..., topic_valN) )
        for idx, topicdata in enumerate(self.topics.items()):
            topicNum, topic = topicdata
            results[0][idx] = topicNum
            results[1][idx] = topic.GetValue()

        # TopicCount is meant to be passed to us ByRef, so return it as well, as per
        # the way pywin32 handles ByRef arguments.
        return tuple(results), TopicCount

    def ServerStart(self, CallbackObject):
        """Excel has just created us... We take its callback for later, and set up shop."""
        self.IsAlive = self.ALIVE

        if CallbackObject is None:
            raise COMException(desc="Excel did not provide a callback")

        # Need to "cast" the raw PyIDispatch object to the IRTDUpdateEvent interface
        IRTDUpdateEventKlass = win32com.client.CLSIDToClass.GetClass(
            "{A43788C1-D91B-11D3-8F39-00C04F3651B8}"
        )
        self.__callback = IRTDUpdateEventKlass(CallbackObject)

        self.OnServerStart()

        return self.IsAlive

    def ServerTerminate(self):
        """Called when excel no longer wants us."""
        self.IsAlive = self.NOT_ALIVE  # On next heartbeat, excel will free us
        self.OnServerTerminate()

    def CreateTopic(self, TopicStrings=None):
        """Topic factory method. Subclass must override.

        Topic objects need to provide:
          * GetValue() method which returns an atomic value.

        Will raise NotImplemented if not overridden.
        """
        raise NotImplemented("Subclass must implement")

    # Overridable class events...
    def OnConnectData(self, TopicID):
        """Called when a new topic has been created, at excel's request."""
        pass

    def OnDisconnectData(self, TopicID):
        """Called when a topic is about to be deleted, at excel's request."""
        pass

    def OnRefreshData(self):
        """Called when excel has requested all current topic data."""
        pass

    def OnServerStart(self):
        """Called when excel has instanciated us."""
        pass

    def OnServerTerminate(self):
        """Called when excel is about to destroy us."""
        pass


class RTDTopic:
    """Base RTD Topic.
    Only method required by our RTDServer implementation is GetValue().
    The others are more for convenience."""

    def __init__(self, TopicStrings):
        super().__init__()
        self.TopicStrings = TopicStrings
        self.__currentValue = None
        self.__dirty = False

    def Update(self, sender):
        """Called by the RTD Server.
        Gives us a chance to check if our topic data needs to be
        changed (eg. check a file, quiz a database, etc)."""
        raise NotImplemented("subclass must implement")

    def Reset(self):
        """Call when this topic isn't considered "dirty" anymore."""
        self.__dirty = False

    def GetValue(self):
        return self.__currentValue

    def SetValue(self, value):
        self.__dirty = True
        self.__currentValue = value

    def HasChanged(self):
        return self.__dirty


# -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=

######################################
# Example classes
######################################


class TimeServer(ExcelRTDServer):
    """Example Time RTD server.

    Sends time updates back to excel.

    example of use, in an excel sheet:
      =RTD("Python.RTD.TimeServer","","seconds","5")

    This will cause a timestamp string to fill the cell, and update its value
    every 5 seconds (or as close as possible depending on how busy excel is).

    The empty string parameter denotes the com server is running on the local
    machine. Otherwise, put in the hostname to look on. For more info
    on this, lookup the Excel help for its "RTD" worksheet function.

    Obviously, you'd want to wrap this kind of thing in a friendlier VBA
    function.

    Also, remember that the RTD function accepts a maximum of 28 arguments!
    If you want to pass more, you may need to concatenate arguments into one
    string, and have your topic parse them appropriately.
    """

    # win32com.server setup attributes...
    # Never copy the _reg_clsid_ value in your own classes!
    _reg_clsid_ = "{EA7F2CF1-11A2-45E4-B2D5-68E240DB8CB1}"
    _reg_progid_ = "Python.RTD.TimeServer"
    _reg_desc_ = "Python class implementing Excel IRTDServer -- feeds time"

    # other class attributes...
    INTERVAL = 0.5  # secs. Threaded timer will wake us up at this interval.

    def __init__(self):
        super().__init__()

        # Simply timer thread to ensure we get to update our topics, and
        # tell excel about any changes. This is a pretty basic and dirty way to
        # do this. Ideally, there should be some sort of waitable (eg. either win32
        # event, socket data event...) and be kicked off by that event triggering.
        # As soon as we set up shop here, we _must_ return control back to excel.
        # (ie. we can't block and do our own thing...)
        self.ticker = threading.Timer(self.INTERVAL, self.Update)

    def OnServerStart(self):
        self.ticker.start()

    def OnServerTerminate(self):
        if not self.ticker.finished.is_set():
            self.ticker.cancel()  # Cancel our wake-up thread. Excel has killed us.

    def Update(self):
        # Get our wake-up thread ready...
        self.ticker = threading.Timer(self.INTERVAL, self.Update)
        try:
            # Check if any of our topics have new info to pass on
            if len(self.topics):
                refresh = False
                for topic in self.topics.values():
                    topic.Update(self)
                    if topic.HasChanged():
                        refresh = True
                    topic.Reset()

                if refresh:
                    self.SignalExcel()
        finally:
            self.ticker.start()  # Make sure we get to run again

    def CreateTopic(self, TopicStrings=None):
        """Topic factory. Builds a TimeTopic object out of the given TopicStrings."""
        return TimeTopic(TopicStrings)


class TimeTopic(RTDTopic):
    """Example topic for example RTD server.

    Will accept some simple commands to alter how long to delay value updates.

    Commands:
      * seconds, delay_in_seconds
      * minutes, delay_in_minutes
      * hours, delay_in_hours
    """

    def __init__(self, TopicStrings):
        super().__init__(TopicStrings)
        try:
            self.cmd, self.delay = self.TopicStrings
        except Exception as E:
            # We could simply return a "# ERROR" type string as the
            # topic value, but explosions like this should be able to get handled by
            # the VBA-side "On Error" stuff.
            raise ValueError("Invalid topic strings: %s" % str(TopicStrings))

        # self.cmd = str(self.cmd)
        self.delay = float(self.delay)

        # setup our initial value
        self.checkpoint = self.timestamp()
        self.SetValue(str(self.checkpoint))

    def timestamp(self):
        return datetime.datetime.now()

    def Update(self, sender):
        now = self.timestamp()
        delta = now - self.checkpoint
        refresh = False
        if self.cmd == "seconds":
            if delta.seconds >= self.delay:
                refresh = True
        elif self.cmd == "minutes":
            if delta.minutes >= self.delay:
                refresh = True
        elif self.cmd == "hours":
            if delta.hours >= self.delay:
                refresh = True
        else:
            self.SetValue("#Unknown command: " + self.cmd)

        if refresh:
            self.SetValue(str(now))
            self.checkpoint = now


if __name__ == "__main__":
    import win32com.server.register

    # Register/Unregister TimeServer example
    # eg. at the command line: excelrtd.py --register
    # Then type in an excel cell something like:
    # =RTD("Python.RTD.TimeServer","","seconds","5")
    win32com.server.register.UseCommandLine(TimeServer)
