#!/bin/python

from typing import Optional, Any

from .task import MBIOTask
from .xmlconfig import XMLConfig

from .bacnetserver import BACnetInterface

BACNET_VENDOR_ID = 892

# Mapping des unites Digimat vers les unites BACnet standard (EngineeringUnits enum).
# Ref: ASHRAE 135-2020, clause 21 (BACnetEngineeringUnits)
UNIT_MAPPING = {
      'V':     5,    # volts
      'C':     62,   # degreesCelsius
      'Pa':    53,   # pascals
      'kPa':   54,   # kilopascals
      '%':     98,   # percent
      'l/h':   136,  # litersPerHour
      'bar':   55,   # bars
      'Hz':    27,   # hertz
      's':     73,   # seconds
      'ms':    159,  # milliseconds
      'min':   72,   # minutes
      'kW':    48,   # kilowatts
      'kWh':   19,   # kilowattHours
      'J':     16,   # joules
      'kJ':    17,   # kilojoules
      'm/s':   74,   # metersPerSecond
      'h':     71,   # hours
      'MWh':   146,  # megawattHours
      'MJ':    126,  # megajoules
      'W':     47,   # watts
      'MW':    49,   # megawatts
      'ml':    197,  # milliliters
      'l':     82,   # liters
      'm3':    80,   # cubicMeters
      'm3/h':  135,  # cubicMetersPerHour
      'Wh':    18,   # wattHours
      'K':     63,   # degreesKelvin
      'lx':    37,   # luxes
      't/min': 104,  # revolutionsPerMinute
      'kVar':  12,   # kilovoltAmperesReactive
      'kVarh': 243,  # kilovoltAmpereHoursReactive
      'mbar':  134,  # millibars
      'm':     31,   # meters
      'kJ/kg': 125,  # kilojoulesPerKilogram
      'g/kg':  210,  # gramsPerKilogram
      'ppm':   96,   # partsPerMillion
      'A':     3,    # amperes
      'kVA':   9,    # kilovoltAmperes
      'kVAh':  240,  # kilovoltAmpereHours
      'ohm':   4,    # ohms
      'mA':    2,    # milliamperes
      'mm':    30,   # millimeters
      'W/m2':  35,   # wattsPerSquareMeter
      'ppb':   97,   # partsPerBillion
      'g/m3':  217,  # gramsPerCubicMeter
  }


class MyBACnetInterface(BACnetInterface):
    """MBIO-specific BACnetInterface subclass.

    Implements persistence callbacks (pickle via MBIOTask) and propagates
    BACnet external writes to MBIO writable values.
    """

    def setParent(self, parent):
        """Bind this interface to the parent MBIOTaskBacnet."""
        self._parent=parent

    def getParent(self):
        """Return the parent MBIOTaskBacnet instance."""
        return self._parent

    def getMBIO(self):
        """Return the root MBIO instance."""
        try:
            return self.getParent().getMBIO()
        except:
            pass

    def on_persistence_load(self) -> Optional[dict]:
        """Load persisted data (schedules, instance map) from MBIOTask pickle storage."""
        # self.logger.debug(f'onPersistenceLoad()')
        parent=self.getParent()
        try:
            return parent.pickleRead('server.persistentdata')
        except:
            pass
        return None

    def on_persistence_save(self, data: dict):
        """Save persisted data (schedules, instance map) to MBIOTask pickle storage."""
        # self.logger.error(f'onPersistentSave({data}, type={type(data)})')
        parent=self.getParent()
        parent.pickleWrite('server.persistentdata', data)

    def on_external_write(self, name: str, v: Any, priority: int):
        """Handle BACnet external write: propagate value to the corresponding MBIO writable variable."""
        parent=self.getParent()
        key=f'{parent.key}_{name}'
        value=parent.values.getByKey(key)
        if value is not None:
            self.logger.debug(f'[BACnet] WRITE {name}={v}')
            value.updateValue(v)
            # parent.beep()

    def on_server_error(self, error: Exception):
        """Handle fatal BACnet thread error."""
        self.logger.error('onBacnetError()')


class MBIOTaskBacnet(MBIOTask):
    """BACnet/IP server task for the MBIO processor.

    Exposes MBIO values as BACnet objects (AV, BV, MSV) and manages
    BACnet Schedule objects. Configured via <Bacnet> XML tag.

    XML attributes:
        deviceid    -- BACnet device instance (unique on the network)
        interface   -- source IP in CIDR notation (e.g. '192.168.0.10/24')
        port        -- UDP port (default 47808)
        bindall     -- bind to all interfaces (default False)
        bbmd        -- BBMD address for Foreign Device Registration
        writeacl    -- comma-separated CIDR networks allowed to write

    XML children:
        <AV>      -- declare an Analog Value (R/W from BACnet, read-only in MBIO)
        <BV>      -- declare a Binary Value
        <SCH>     -- declare a Schedule (type determined by default value)
        <Expose>  -- expose existing MBIO values to BACnet (wildcard filter)
    """

    def initName(self):
        return 'bnet'

    def onInit(self):
        """Initialize config defaults, internal registries and subscribe to all value updates."""
        # we want to receive every value update here
        self.registerValuesUpdateNotifier(False)

        self._timeoutBacnetPoll=0
        mbio=self.getMBIO()
        self._bnet=None
        interface=mbio.interface
        self.config.set('interface', interface)
        self.config.set('bindall', False)
        self.config.set('port', 47808)
        self.config.set('deviceid', 0)
        self.config.set('bbmd')
        self.config.set('writeacl')
        self.valueDigital('comerr', default=False)
        self.value('exposedcount', default=0)

        self._av={}
        self._bv={}
        self._msv={}
        self._sch={}
        self._exposed={}
        self._exposedValues={}

    def onLoad(self, xml: XMLConfig):
        """Parse <Bacnet> XML config: AV, BV, SCH declarations and Expose filters."""
        mbio=self.getMBIO()
        self.config.update('deviceid', xml.getInt('deviceid'))
        self.config.update('port', xml.getInt('port'))
        self.config.update('bbmd', xml.get('bbmd'))
        self.config.update('writeacl', xml.get('writeacl'))
        self.config.update('bindall', xml.getBool('bindall'))
        self.config.update('interface', xml.get('interface'))
        if self.config.interface:
            (i,n)=self.config.interface.split('/')
            interface=mbio.resolveIpAliasToIpAddress(i)
            if n:
                interface = f'{interface}/{n}'
            self.config.update('interface', interface)

        if self.config.interface and '/' not in self.config.interface:
            self.logger.warning(f'BACnet interface {self.config.interface} must specify a network. Assuming it is /24.')
            self.config.interface += '/24'

        items=xml.children('expose')
        if items:
            for item in items:
                name=item.get('name')
                instance=item.getInt('instance', 100000)
                if not name in self._exposed:
                    data={'name': name, 'instance': instance}
                    self.logger.info(f'Adding EXPOSE({name}#{instance}) to BACnet MBIO variables list')
                    self._exposed[name]=data

        vtype='av'
        items=xml.children(vtype)
        if items:
            for item in items:
                name=item.get('name')
                unit=item.get('unit')
                resolution=item.getFloat('resolution', 0.1)
                if name:
                    data={}
                    name='%s_%s' % (vtype, name.lower())
                    value=self.value(name, writable=False, unit=unit, default=item.getFloat('default'), resolution=resolution)
                    data['value']=value
                    self._av[name]=data

        vtype='bv'
        items=xml.children(vtype)
        if items:
            for item in items:
                name=item.get('name')
                if name:
                    data={}
                    name='%s_%s' % (vtype, name.lower())
                    value=self.valueDigital(name, writable=False, default=item.getBool('default'))
                    data['value']=value
                    self._bv[name]=data

        # WARNING
        # If default value is bool (True) --> target should be BV
        # If default value is int (5) --> target should be MSV
        # If default value is float (21.0) --> target should be MSV

        vtype='sch'
        items=xml.children(vtype)
        if items:
            for item in items:
                name=item.get('name')
                if name:
                    data={}
                    name='%s_%s' % (vtype, name.lower())
                    data['prio']=item.getInt('priority', 15)
                    default=item.getFloatIntBool('default')
                    data['default']=default

                    value=None
                    if isinstance(default, bool):
                        data['type']='BV'
                        value=self.valueDigital(name, writable=False,
                            default=default, commissionable=True)
                    if isinstance(default, int):
                        data['type']='MSV'
                        vmax=item.getInt('vmax', 0)
                        # FIXME:
                        if vmax>0:
                            value=self.valueMultistate(name, vmax=vmax,
                                default=default, writable=False, commissionable=True)
                    if isinstance(default, float):
                        data['type']='AV'
                        unit=item.get('unit')
                        resolution=item.getFloat('resolution', 0.1)
                        value=self.value(name, writable=False, unit=unit,
                            default=default, resolution=resolution, commissionable=True)
                    else:
                        data['type']=None

                    data['value']=value
                    if value is None:
                        self.logger.warning(f"No value created for SCHEDULE {name} (type={data['type']})")

                    target=item.get('target')
                    data['target']=[]
                    data['targetlocked']=False
                    if target:
                        data['target']=target.split(',')
                        # out choice : lock targets by default if any given
                        data['targetlocked']=item.getBool('targetlocked', True)
                        self._sch[name]=data
                    else:
                        self.logger.error(f'No target found for SCHEDULE {name}. Skip.')

    def declareBacnetVariableFromValue(self, value, instance=0):
        """Declare a single MBIO value as a read-only BACnet object (AV or BV).

        Automatically selects BV for digital values, AV for analog values.
        Multistate values are not yet supported for exposure.
        """
        if self._bnet and value is not None:
            if value.key not in self._exposedValues:
                self._exposedValues[value.key]=True
                self.microsleep()
                if value.isDigital():
                    self.logger.debug(f'declaring bacnet BV {value.key}#{instance}')
                    self._bnet.declare_bv(name=value.key,
                        writable=False,
                        description=value.description,
                        active_text=value.config.iohigh,
                        inactive_text=value.config.iolow,
                        initial_value=value.value,
                        start_instance_id=instance)
                elif value.isMultistate():
                    self.logger.error(f'Currently {value} not supported for BACnet exposure!')
                else:
                    self.logger.debug(f'declaring bacnet AV {value.key}#{instance}')
                    self._bnet.declare_av(name=value.key,
                        writable=False,
                        description=value.description,
                        units=value.unitstr(),
                        initial_value=value.value,
                        start_instance_id=instance)

    def declareBacnetVariables(self):
        """Expose MBIO values matching <Expose> filters as read-only BACnet objects.

        Skips values already owned by this task (AV/BV/SCH) to avoid duplicates.
        """
        mbio=self.getMBIO()
        for item in self._exposed.keys():
            data=self._exposed[item]
            name=data['name'].lower().strip()
            instance=data['instance']
            values=mbio.values(name)
            for value in values:
                # deny exposure of already bacnet values
                if self.values.getByKey(value.key) is None:
                    self.declareBacnetVariableFromValue(value, instance)

    def createBacnetInterface(self):
        """Create the BACnet interface and declare all AV, BV and Schedule objects.

        Configures write ACL and BBMD/FDR if specified. Called once during poweron.
        """
        if self._bnet is not None:
            return True

        bindall=False
        if self.config.bindall or self.config.interface in [None, '', 'all', '0.0.0.0']:
            bindall=True

        self._bnet=MyBACnetInterface(app_logger=self.logger,
            device_name=f'MBIO:{self.key}',
            unit_mapping=UNIT_MAPPING,
            device_id=self.config.deviceid,
            ip_address=self.config.interface,
            port=self.config.port,
            vendor_id=BACNET_VENDOR_ID,
            vendor_name='digimat',
            app_watchdog_timeout=60,
            bind_to_all_interfaces=bindall)

        acl=self.config.writeacl
        if acl:
            acl=acl.split(',')
            self.logger.info(f'[BACnet] set write acl {acl}')
            self._bnet.set_write_acl(acl)

        self._bnet.setParent(self)

        bbmd=self.config.bbmd
        if bbmd:
            self._bnet.enable_fdr(bbmd, ttl=300)

        for key in self._av.keys():
            data=self._av[key]
            value=data['value']
            self.logger.debug(f'declaring bacnet AV {key}')
            self._bnet.declare_av(name=key,
                writable=True,
                description=value.description,
                units=value.unitstr(),
                initial_value=value.value)

        for key in self._bv.keys():
            data=self._bv[key]
            value=data['value']
            self.logger.debug(f'declaring bacnet BV {key}')
            self._bnet.declare_bv(name=key,
                writable=True,
                description=value.description,
                active_text=value.config.iohigh,
                inactive_text=value.config.iolow,
                initial_value=value.value)

        for key in self._sch.keys():
            data=self._sch[key]
            value=data['value']
            self.logger.debug(f'declaring bacnet SCHEDULE {key}')
            self._bnet.declare_schedule(name=key,
                description=value.description,
                target_names=data['target'],
                priority_for_writing=data['prio'],
                schedule_default=data['default'],
                lock_references=data['targetlocked'])

    def poweron(self):
        """Create the BACnet interface, expose MBIO variables and start the BACnet server thread."""
        if self._bnet is None:
            self.logger.info('Creating BACnet interface %s:%d (deviceid %d)...' % (self.config.interface, self.config.port, self.config.deviceid))
            self.createBacnetInterface()

        if self._bnet is not None:
            self.declareBacnetVariables()
            self.values.exposedcount.updateValue(len(self._exposedValues))
            self.logger.info('Starting BACnet interface...')
            self._bnet.start()
        return True

    def client(self, timeout=3, bbmd=None, priority=16):
        """Create a BACnetClient for interactive discovery/inspection."""
        if self._bnet:
            return self._bnet.client(timeout=timeout, bbmd=bbmd, priority=priority)
        return None

    def poweroff(self):
        """Stop the BACnet server thread."""
        if self._bnet is not None:
            self.logger.info('Stopping BACnet interface...')
            try:
                self._bnet.stop()
            except:
                pass
        return True

    def updateBacnetValue(self, name, value):
        """Push an MBIO value update to the corresponding BACnet object.

        Propagates value, error/manual flags and description changes.
        """
        if value is not None and self._bnet:
            self._bnet.update(name, value.value,
                              is_error=value.isError(),
                              is_manual=value.isManual(),
                              is_alarm=False)

            if value.isConfigUpdated():
                self._bnet.set_description(name, value.description)
                self._bnet.set_state_texts(name, [value.config.iolow, value.config.iohigh])

    def run(self):
        """Main task loop: process pending writes, push MBIO updates to BACnet,
        and periodically poll BACnet objects (AV/BV/SCH) back into MBIO values.
        """
        if self._bnet is None or not self._bnet.is_healthy():
            self.logger.error('Error detected in BACnet Interface!')
            self.signalError()
            self.values.comerr.updateValue(True)
            return

        self._bnet.feed_watchdog()
        self._bnet.process_pending_writes()

        count=64
        while count>0:
            count-=1
            value=self.getNextValueUpdateNotify()
            if value is None:
                break
            if value.value is None:
                continue
            if value.key in self._exposedValues:
                # FIXME: will generate a lot of log
                self.logger.debug(f'Bacnet update variable {value.key} with value {value}')
                self.updateBacnetValue(value.key, value)

        if self.isTimeout(self._timeoutBacnetPoll):
            self._timeoutBacnetPoll=self.timeout(15)

            error=False

            for items in [self._av, self._bv, self._sch]:
                for name in items.keys():
                    value=items[name].get('value')
                    if value is None:
                        continue

                    v=self._bnet.get_value(name)
                    if v is not None:
                        value.updateValue(v)
                        value.setError(False)
                        if value.isConfigUpdated():
                            self._bnet.set_description(name, value.description)
                            self._bnet.set_state_texts(name, [value.config.iolow, value.config.iohigh])
                        self.microsleep()
                    else:
                        error=True
                        value.setError(True)

            self.values.comerr.updateValue(error)

        return 0.5


if __name__ == "__main__":
    pass
