#!/usr/bin/env python

"""
Change Of Value Service
"""

from ..debugging import bacpypes_debugging, DebugContents, ModuleLogger
from ..capability import Capability

from ..core import deferred
from ..task import OneShotTask, RecurringFunctionTask, TaskManager
from ..iocb import IOCB

from ..basetypes import DeviceAddress, COVSubscription, PropertyValue, \
    Recipient, RecipientProcess, ObjectPropertyReference
from ..constructeddata import ListOf, Any
from ..apdu import ConfirmedCOVNotificationRequest, \
    UnconfirmedCOVNotificationRequest, \
    SimpleAckPDU, Error, RejectPDU, AbortPDU
from ..errors import ExecutionError

from ..object import Property
from .detect import DetectionAlgorithm, monitor_filter

# some debugging
_debug = 0
_log = ModuleLogger(globals())

#
#   SubscriptionList
#

@bacpypes_debugging
class SubscriptionList:

    def __init__(self):
        if _debug: SubscriptionList._debug("__init__")

        self.cov_subscriptions = []

    def append(self, cov):
        if _debug: SubscriptionList._debug("append %r", cov)

        self.cov_subscriptions.append(cov)

    def remove(self, cov):
        if _debug: SubscriptionList._debug("remove %r", cov)

        self.cov_subscriptions.remove(cov)

    def find(self, client_addr, proc_id, obj_id):
        if _debug: SubscriptionList._debug("find %r %r %r", client_addr, proc_id, obj_id)

        for cov in self.cov_subscriptions:
            all_equal = (cov.client_addr == client_addr) and \
                (cov.proc_id == proc_id) and \
                (cov.obj_id == obj_id)
            if _debug: SubscriptionList._debug("    - cov, all_equal: %r %r", cov, all_equal)

            if all_equal:
                return cov

        return None

    def __len__(self):
        if _debug: SubscriptionList._debug("__len__")

        return len(self.cov_subscriptions)

    def __iter__(self):
        if _debug: SubscriptionList._debug("__iter__")

        for cov in self.cov_subscriptions:
            yield cov


#
#   Subscription
#

@bacpypes_debugging
class Subscription(OneShotTask, DebugContents):

    _debug_contents = (
        'obj_ref',
        'client_addr',
        'proc_id',
        'obj_id',
        'confirmed',
        'lifetime',
        )

    def __init__(self, obj_ref, client_addr, proc_id, obj_id, confirmed, lifetime):
        if _debug: Subscription._debug("__init__ %r %r %r %r %r %r", obj_ref, client_addr, proc_id, obj_id, confirmed, lifetime)
        OneShotTask.__init__(self)

        # save the reference to the related object
        self.obj_ref = obj_ref

        # save the parameters
        self.client_addr = client_addr
        self.proc_id = proc_id
        self.obj_id = obj_id
        self.confirmed = confirmed
        self.lifetime = lifetime

        # if lifetime is non-zero, schedule the subscription to expire
        if lifetime != 0:
            self.install_task(delta=self.lifetime)

    def cancel_subscription(self):
        if _debug: Subscription._debug("cancel_subscription")

        # suspend the task
        self.suspend_task()

        # tell the application to cancel us
        self.obj_ref._app.cancel_subscription(self)

        # break the object reference
        self.obj_ref = None

    def renew_subscription(self, lifetime):
        if _debug: Subscription._debug("renew_subscription")

        # suspend iff scheduled
        if self.isScheduled:
            self.suspend_task()

        # reschedule the task if its not infinite
        if lifetime != 0:
            self.install_task(delta=lifetime)

    def process_task(self):
        if _debug: Subscription._debug("process_task")

        # subscription is canceled
        self.cancel_subscription()

#
#   COVDetection
#

@bacpypes_debugging
class COVDetection(DetectionAlgorithm):

    properties_tracked = ()
    properties_reported = ()
    monitored_property_reference = None

    def __init__(self, obj):
        if _debug: COVDetection._debug("__init__ %r", obj)
        DetectionAlgorithm.__init__(self)

        # keep track of the object
        self.obj = obj

        # build a list of parameters and matching object property references
        kwargs = {}
        for property_name in self.properties_tracked:
            setattr(self, property_name, None)
            kwargs[property_name] = (obj, property_name)

        # let the base class set up the bindings and initial values
        self.bind(**kwargs)

        # list of all active subscriptions
        self.cov_subscriptions = SubscriptionList()

    def add_subscription(self, cov):
        if _debug: COVDetection._debug("add_subscription %r", cov)

        # add it to the subscription list for its object
        self.cov_subscriptions.append(cov)

    def cancel_subscription(self, cov):
        if _debug: COVDetection._debug("cancel_subscription %r", cov)

        # cancel the subscription timeout
        if cov.isScheduled:
            cov.suspend_task()
            if _debug: COVDetection._debug("    - task suspended")

        # remove it from the subscription list for its object
        self.cov_subscriptions.remove(cov)

    def execute(self):
        if _debug: COVDetection._debug("execute")

        # something changed, send out the notifications
        self.send_cov_notifications()

    def send_cov_notifications(self, subscription=None):
        if _debug: COVDetection._debug("send_cov_notifications %r", subscription)

        # check for subscriptions
        if not len(self.cov_subscriptions):
            return

        # get the current time from the task manager
        current_time = TaskManager().get_time()
        if _debug: COVDetection._debug("    - current_time: %r", current_time)

        # create a list of values
        list_of_values = []
        for property_name in self.properties_reported:
            if _debug: COVDetection._debug("    - property_name: %r", property_name)

            # get the class
            property_datatype = self.obj.get_datatype(property_name)
            if _debug: COVDetection._debug("        - property_datatype: %r", property_datatype)

            # build the value
            bundle_value = property_datatype(self.obj._values[property_name])
            if _debug: COVDetection._debug("        - bundle_value: %r", bundle_value)

            # bundle it into a sequence
            property_value = PropertyValue(
                propertyIdentifier=property_name,
                value=Any(bundle_value),
                )

            # add it to the list
            list_of_values.append(property_value)
        if _debug: COVDetection._debug("    - list_of_values: %r", list_of_values)

        # if the specific subscription was provided, that is the notification
        # list, otherwise send it to all of them
        if subscription is not None:
            notification_list = [subscription]
        else:
            notification_list = self.cov_subscriptions

        # loop through the subscriptions and send out notifications
        for cov in notification_list:
            if _debug: COVDetection._debug("    - cov: %s", repr(cov))

            # calculate time remaining
            if not cov.lifetime:
                time_remaining = 0
            else:
                time_remaining = int(cov.taskTime - current_time)

                # make sure it is at least one second
                if not time_remaining:
                    time_remaining = 1

            # build a request with the correct type
            if cov.confirmed:
                request = ConfirmedCOVNotificationRequest()
            else:
                request = UnconfirmedCOVNotificationRequest()

            # fill in the parameters
            request.pduDestination = cov.client_addr
            request.subscriberProcessIdentifier = cov.proc_id
            request.initiatingDeviceIdentifier = self.obj._app.localDevice.objectIdentifier
            request.monitoredObjectIdentifier = cov.obj_id
            request.timeRemaining = time_remaining
            request.listOfValues = list_of_values
            if _debug: COVDetection._debug("    - request: %s", repr(request))

            # let the application send it
            self.obj._app.cov_notification(cov, request)

    def __str__(self):
        return "<" + self.__class__.__name__ + \
            "(" + ','.join(self.properties_tracked) + ')' + \
            ">"


class GenericCriteria(COVDetection):

    properties_tracked = (
        'presentValue',
        'statusFlags',
        )
    properties_reported = (
        'presentValue',
        'statusFlags',
        )
    monitored_property_reference = 'presentValue'


@bacpypes_debugging
class COVIncrementCriteria(COVDetection):

    properties_tracked = (
        'presentValue',
        'statusFlags',
        'covIncrement',
        )
    properties_reported = (
        'presentValue',
        'statusFlags',
        )
    monitored_property_reference = 'presentValue'

    def __init__(self, obj):
        if _debug: COVIncrementCriteria._debug("__init__ %r", obj)
        COVDetection.__init__(self, obj)

        # previous reported value
        self.previous_reported_value = None

    @monitor_filter('presentValue')
    def present_value_filter(self, old_value, new_value):
        if _debug: COVIncrementCriteria._debug("present_value_filter %r %r", old_value, new_value)

        # first time around initialize to the old value
        if self.previous_reported_value is None:
            if _debug: COVIncrementCriteria._debug("    - first value: %r", old_value)
            self.previous_reported_value = old_value

        # see if it changed enough to trigger reporting
        value_changed = (new_value <= (self.previous_reported_value - self.obj.covIncrement)) \
            or (new_value >= (self.previous_reported_value + self.obj.covIncrement))
        if _debug: COVIncrementCriteria._debug("    - value significantly changed: %r", value_changed)

        return value_changed

    def send_cov_notifications(self, subscription=None):
        if _debug: COVIncrementCriteria._debug("send_cov_notifications %r", subscription)

        # when sending out notifications, keep the current value
        self.previous_reported_value = self.presentValue

        # continue
        COVDetection.send_cov_notifications(self, subscription)


class AccessDoorCriteria(COVDetection):

    properties_tracked = (
        'presentValue',
        'statusFlags',
        'doorAlarmState',
        )
    properties_reported = (
        'presentValue',
        'statusFlags',
        'doorAlarmState',
        )


class AccessPointCriteria(COVDetection):

    properties_tracked = (
        'accessEventTime',
        'statusFlags',
        )
    properties_reported = (
        'accessEvent',
        'statusFlags',
        'accessEventTag',
        'accessEventTime',
        'accessEventCredential',
        'accessEventAuthenticationFactor',
        )
    monitored_property_reference = 'accessEvent'


class CredentialDataInputCriteria(COVDetection):

    properties_tracked = (
        'updateTime',
        'statusFlags'
        )
    properties_reported = (
        'presentValue',
        'statusFlags',
        'updateTime',
        )


class LoadControlCriteria(COVDetection):

    properties_tracked = (
        'presentValue',
        'statusFlags',
        'requestedShedLevel',
        'startTime',
        'shedDuration',
        'dutyWindow',
        )
    properties_reported = (
        'presentValue',
        'statusFlags',
        'requestedShedLevel',
        'startTime',
        'shedDuration',
        'dutyWindow',
        )


@bacpypes_debugging
class PulseConverterCriteria(COVIncrementCriteria):

    properties_tracked = (
        'presentValue',
        'statusFlags',
        'covPeriod',
        )
    properties_reported = (
        'presentValue',
        'statusFlags',
        )

    def __init__(self, obj):
        if _debug: PulseConverterCriteria._debug("__init__ %r", obj)
        COVIncrementCriteria.__init__(self, obj)

        # check for a period
        if self.covPeriod == 0:
            if _debug: PulseConverterCriteria._debug("    - no periodic notifications")
            self.cov_period_task = None
        else:
            if _debug: PulseConverterCriteria._debug("    - covPeriod: %r", self.covPeriod)
            self.cov_period_task = RecurringFunctionTask(self.covPeriod * 1000, self.send_cov_notifications)
            if _debug: PulseConverterCriteria._debug("    - cov period task created")

    def add_subscription(self, cov):
        if _debug: PulseConverterCriteria._debug("add_subscription %r", cov)

        # let the parent classes do their thing
        COVIncrementCriteria.add_subscription(self, cov)

        # if there is a COV period task, install it
        if self.cov_period_task:
            self.cov_period_task.install_task()
            if _debug: PulseConverterCriteria._debug("    - cov period task installed")

    def cancel_subscription(self, cov):
        if _debug: PulseConverterCriteria._debug("cancel_subscription %r", cov)

        # let the parent classes do their thing
        COVIncrementCriteria.cancel_subscription(self, cov)

        # if there are no more subscriptions, cancel the task
        if not len(self.cov_subscriptions):
            if self.cov_period_task and self.cov_period_task.isScheduled:
                self.cov_period_task.suspend_task()
                if _debug: PulseConverterCriteria._debug("    - cov period task suspended")
                self.cov_period_task = None

    @monitor_filter('covPeriod')
    def cov_period_filter(self, old_value, new_value):
        if _debug: PulseConverterCriteria._debug("cov_period_filter %r %r", old_value, new_value)

        # check for an old period
        if old_value != 0:
            if self.cov_period_task.isScheduled:
                self.cov_period_task.suspend_task()
                if _debug: PulseConverterCriteria._debug("    - canceled old task")
            self.cov_period_task = None

        # check for a new period
        if new_value != 0:
            self.cov_period_task = RecurringFunctionTask(new_value * 1000, self.cov_period_x)
            self.cov_period_task.install_task()
            if _debug: PulseConverterCriteria._debug("    - new task created and installed")

        return False

    def send_cov_notifications(self, subscription=None):
        if _debug: PulseConverterCriteria._debug("send_cov_notifications %r", subscription)

        # pass along to the parent class as if something changed
        COVIncrementCriteria.send_cov_notifications(self, subscription)


# mapping from object type to appropriate criteria class
criteria_type_map = {
#   'accessDoor': GenericCriteria,  #TODO: needs AccessDoorCriteria
    'accessPoint': AccessPointCriteria,
    'analogInput': COVIncrementCriteria,
    'analogOutput': COVIncrementCriteria,
    'analogValue': COVIncrementCriteria,
    'largeAnalogValue': COVIncrementCriteria,
    'integerValue': COVIncrementCriteria,
    'positiveIntegerValue': COVIncrementCriteria,
    'lightingOutput': COVIncrementCriteria,
    'binaryInput': GenericCriteria,
    'binaryOutput': GenericCriteria,
    'binaryValue': GenericCriteria,
    'lifeSafetyPoint': GenericCriteria,
    'lifeSafetyZone': GenericCriteria,
    'multiStateInput': GenericCriteria,
    'multiStateOutput': GenericCriteria,
    'multiStateValue': GenericCriteria,
    'octetString': GenericCriteria,
    'characterString': GenericCriteria,
    'timeValue': GenericCriteria,
    'dateTimeValue': GenericCriteria,
    'dateValue': GenericCriteria,
    'timePatternValue': GenericCriteria,
    'datePatternValue': GenericCriteria,
    'dateTimePatternValue': GenericCriteria,
    'credentialDataInput': CredentialDataInputCriteria,
    'loadControl': LoadControlCriteria,
    'loop': GenericCriteria,
    'pulseConverter': PulseConverterCriteria,
    }

#
#   ActiveCOVSubscriptions
#

@bacpypes_debugging
class ActiveCOVSubscriptions(Property):

    def __init__(self):
        Property.__init__(
            self, 'activeCovSubscriptions', ListOf(COVSubscription),
            default=None, optional=True, mutable=False,
            )

    def ReadProperty(self, obj, arrayIndex=None):
        if _debug: ActiveCOVSubscriptions._debug("ReadProperty %s arrayIndex=%r", obj, arrayIndex)

        # get the current time from the task manager
        current_time = TaskManager().get_time()
        if _debug: ActiveCOVSubscriptions._debug("    - current_time: %r", current_time)

        # start with an empty list
        cov_subscriptions = []

        # loop through the subscriptions
        for cov in obj._app.subscriptions():
            # calculate time remaining
            if not cov.lifetime:
                time_remaining = 0
            else:
                time_remaining = int(cov.taskTime - current_time)

                # make sure it is at least one second
                if not time_remaining:
                    time_remaining = 1

            recipient = Recipient(
                address=DeviceAddress(
                    networkNumber=cov.client_addr.addrNet or 0,
                    macAddress=cov.client_addr.addrAddr,
                    ),
                )
            if _debug: ActiveCOVSubscriptions._debug("    - recipient: %r", recipient)
            if _debug: ActiveCOVSubscriptions._debug("    - client MAC address: %r", cov.client_addr.addrAddr)

            recipient_process = RecipientProcess(
                recipient=recipient,
                processIdentifier=cov.proc_id,
                )
            if _debug: ActiveCOVSubscriptions._debug("    - recipient_process: %r", recipient_process)

            # look for the algorithm already associated with this object
            cov_detection = cov.obj_ref._app.cov_detections[cov.obj_ref]
            if _debug: ActiveCOVSubscriptions._debug("    - cov_detection: %r", cov_detection)

            cov_subscription = COVSubscription(
                recipient=recipient_process,
                monitoredPropertyReference=ObjectPropertyReference(
                    objectIdentifier=cov.obj_id,
                    propertyIdentifier=cov_detection.monitored_property_reference,
                    ),
                issueConfirmedNotifications=cov.confirmed,
                timeRemaining=time_remaining,
                )
            if hasattr(cov_detection, 'covIncrement'):
                cov_subscription.covIncrement = cov_detection.covIncrement
            if _debug: ActiveCOVSubscriptions._debug("    - cov_subscription: %r", cov_subscription)

            # add the list
            cov_subscriptions.append(cov_subscription)

        return cov_subscriptions

    def WriteProperty(self, obj, value, arrayIndex=None, priority=None):
        raise ExecutionError(errorClass='property', errorCode='writeAccessDenied')


#
#   ChangeOfValueServices
#

@bacpypes_debugging
class ChangeOfValueServices(Capability):

    def __init__(self):
        if _debug: ChangeOfValueServices._debug("__init__")
        Capability.__init__(self)

        # map from an object to its detection algorithm
        self.cov_detections = {}

        # if there is a local device object, make sure it has an active COV
        # subscriptions property
        if self.localDevice and self.localDevice.activeCovSubscriptions is None:
            self.localDevice.add_property(ActiveCOVSubscriptions())

    def add_subscription(self, cov):
        if _debug: ChangeOfValueServices._debug("add_subscription %r", cov)

        # let the detection algorithm know this is a new or additional subscription
        self.cov_detections[cov.obj_ref].add_subscription(cov)

    def cancel_subscription(self, cov):
        if _debug: ChangeOfValueServices._debug("cancel_subscription %r", cov)

        # get the detection algorithm object
        cov_detection = self.cov_detections[cov.obj_ref]

        # let the detection algorithm know this subscription is going away
        cov_detection.cancel_subscription(cov)

        # if the detection algorithm doesn't have any subscriptions, remove it
        if not len(cov_detection.cov_subscriptions):
            if _debug: ChangeOfValueServices._debug("    - no more subscriptions")

            # unbind all the hooks into the object
            cov_detection.unbind()

            # delete it from the object map
            del self.cov_detections[cov.obj_ref]

    def subscriptions(self):
        """Generator for the active subscriptions."""
        if _debug: ChangeOfValueServices._debug("subscriptions")

        # loop through the object and detection list
        for obj, cov_detection in self.cov_detections.items():
            for cov in cov_detection.cov_subscriptions:
                yield cov

    def cov_notification(self, cov, request):
        if _debug: ChangeOfValueServices._debug("cov_notification %s %s", str(cov), str(request))

        # create an IOCB with the request
        iocb = IOCB(request)
        if _debug: ChangeOfValueServices._debug("    - iocb: %r", iocb)

        # add a callback for the response, even if it was unconfirmed
        iocb.cov = cov
        iocb.add_callback(self.cov_confirmation)

        # send the request via the ApplicationIOController
        self.request_io(iocb)

    def cov_confirmation(self, iocb):
        if _debug: ChangeOfValueServices._debug("cov_confirmation %r", iocb)

        # do something for success
        if iocb.ioResponse:
            if _debug: ChangeOfValueServices._debug("    - ack")
            self.cov_ack(iocb.cov, iocb.args[0], iocb.ioResponse)

        elif isinstance(iocb.ioError, Error):
            if _debug: ChangeOfValueServices._debug("    - error: %r", iocb.ioError.errorCode)
            self.cov_error(iocb.cov, iocb.args[0], iocb.ioError)

        elif isinstance(iocb.ioError, RejectPDU):
            if _debug: ChangeOfValueServices._debug("    - reject: %r", iocb.ioError.apduAbortRejectReason)
            self.cov_reject(iocb.cov, iocb.args[0], iocb.ioError)

        elif isinstance(iocb.ioError, AbortPDU):
            if _debug: ChangeOfValueServices._debug("    - abort: %r", iocb.ioError.apduAbortRejectReason)
            self.cov_abort(iocb.cov, iocb.args[0], iocb.ioError)

    def cov_ack(self, cov, request, response):
        if _debug: ChangeOfValueServices._debug("cov_ack %r %r %r", cov, request, response)

    def cov_error(self, cov, request, response):
        if _debug: ChangeOfValueServices._debug("cov_error %r %r %r", cov, request, response)

    def cov_reject(self, cov, request, response):
        if _debug: ChangeOfValueServices._debug("cov_reject %r %r %r", cov, request, response)

    def cov_abort(self, cov, request, response):
        if _debug: ChangeOfValueServices._debug("cov_abort %r %r %r", cov, request, response)

        ### delete the rest of the pending requests for this client

    def do_SubscribeCOVRequest(self, apdu):
        if _debug: ChangeOfValueServices._debug("do_SubscribeCOVRequest %r", apdu)

        # extract the pieces
        client_addr = apdu.pduSource
        proc_id = apdu.subscriberProcessIdentifier
        obj_id = apdu.monitoredObjectIdentifier
        confirmed = apdu.issueConfirmedNotifications
        lifetime = apdu.lifetime

        # request is to cancel the subscription
        cancel_subscription = (confirmed is None) and (lifetime is None)

        # find the object
        obj = self.get_object_id(obj_id)
        if _debug: ChangeOfValueServices._debug("    - object: %r", obj)
        if not obj:
            raise ExecutionError(errorClass='object', errorCode='unknownObject')

        # check to see if the object supports COV
        if not obj._object_supports_cov:
            raise ExecutionError(errorClass='services', errorCode='covSubscriptionFailed')

        # look for an algorithm already associated with this object
        cov_detection = self.cov_detections.get(obj, None)

        # if there isn't one, make one and associate it with the object
        if not cov_detection:
            # look for an associated class and if it's not there it's not supported
            criteria_class = criteria_type_map.get(obj_id[0], None)
            if not criteria_class:
                raise ExecutionError(errorClass='services', errorCode='covSubscriptionFailed')

            # make one of these and bind it to the object
            cov_detection = criteria_class(obj)

            # keep track of it for other subscriptions
            self.cov_detections[obj] = cov_detection
        if _debug: ChangeOfValueServices._debug("    - cov_detection: %r", cov_detection)

        # can a match be found?
        cov = cov_detection.cov_subscriptions.find(client_addr, proc_id, obj_id)
        if _debug: ChangeOfValueServices._debug("    - cov: %r", cov)

        # if a match was found, update the subscription
        if cov:
            if cancel_subscription:
                if _debug: ChangeOfValueServices._debug("    - cancel the subscription")
                self.cancel_subscription(cov)
            else:
                if _debug: ChangeOfValueServices._debug("    - renew the subscription")
                cov.renew_subscription(lifetime)
        else:
            if cancel_subscription:
                if _debug: ChangeOfValueServices._debug("    - cancel a subscription that doesn't exist")
            else:
                if _debug: ChangeOfValueServices._debug("    - create a subscription")

                # make a subscription
                cov = Subscription(obj, client_addr, proc_id, obj_id, confirmed, lifetime)
                if _debug: ChangeOfValueServices._debug("    - cov: %r", cov)

                # add it to our subscriptions lists
                self.add_subscription(cov)

        # success
        response = SimpleAckPDU(context=apdu)

        # return the result
        self.response(response)

        # if the subscription is not being canceled, it is new or renewed,
        # so send it a notification when you get a chance.
        if not cancel_subscription:
            if _debug: ChangeOfValueServices._debug("    - send a notification")
            deferred(cov_detection.send_cov_notifications, cov)

