#!/bin/python
"""
MBIOTaskModbusServer -- MBIO task that exposes values via Modbus TCP server.

Configured via <ModbusServer> XML tag. Exposes MBIO values as Modbus registers:
    <av>  -- Analog value (HR if writable, IR if read-only)
    <bv>  -- Digital value (CO if writable, DI if read-only)
    <expose> -- Expose existing MBIO values by wildcard pattern (IR/DI)

XML attributes for <ModbusServer>:
    name       -- task name (default: 'mbs')
    port       -- TCP port (default: 502)
    interface  -- bind address (default: '0.0.0.0')
    unitid     -- Modbus slave unit ID (default: 1)
    addrstart  -- first register address to allocate (default: 0)
    writeacl   -- comma-separated CIDR networks allowed to write (optional)

XML children for <ModbusServer>:
    <av name="..." [writable="true"] [addr="N"] [type="FLOAT32"] [unit="C"] [default="0.0"] [scale="1.0"] [offset="0.0"]>
    <bv name="..." [writable="true"] [addr="N"] [default="false"]>
    <expose name="value.key.pattern*" [type="FLOAT32"] [scale="1.0"] [offset="0.0"] [instance="0"]>

The <expose> 'instance' attribute sets the starting register address for that group.

Scale / offset convention (for integer types):
    encode (MBIO → register):  raw = value / scale + offset
    decode (register → MBIO):  value = (raw - offset) * scale

    Example: temperature 21.5 °C, type=INT16, scale=0.1, offset=20
        raw = 21.5 / 0.1 + 20 = 235   (stored in register)
        value = (235 - 20) * 0.1 = 21.5 °C  (restored from register)

    For FLOAT types scale and offset are ignored (raw == value).
"""

import time
import ipaddress

from .task import MBIOTask
from .xmlconfig import XMLConfig


class MyModbusServerInterface:
    """Thin subclass that wires ModbusServerInterface error callback to the parent task."""

    def __init__(self, parent, *args, **kwargs):
        from .modbusserverengine import ModbusServerInterface
        self._impl = ModbusServerInterface(*args, **kwargs)
        self._impl._error_cb = self._on_error
        self._parent = parent
        # Expose ModbusServerInterface methods directly
        self.__dict__.update({k: getattr(self._impl, k) for k in dir(self._impl)
                               if not k.startswith('_')})

    def _on_error(self, error):
        self._parent.logger.error('[ModbusServer] Fatal server error: %s' % error)
        try:
            self._parent.values.comerr.updateValue(True)
        except Exception:
            pass

    def __getattr__(self, name):
        return getattr(self._impl, name)


class MBIOTaskModbusServer(MBIOTask):
    """Modbus TCP server task.

    Exposes MBIO values (analog and digital) as Modbus registers accessible
    to external Modbus TCP clients. Supports both declared values (<av>/<bv>)
    and dynamic exposure of existing MBIO values via wildcard patterns (<expose>).

    Writable registers propagate external Modbus writes back into MBIO values.
    Persistent address mapping ensures stable register addresses across restarts.
    """

    def initName(self):
        return 'mbs'

    def onInit(self):
        """Initialize configuration defaults and internal state."""
        self._port = 502
        self._interface = '0.0.0.0'
        self._unitid = 1
        self._addrstart = 0
        self._write_acl = None

        # AV declarations: name -> config dict
        self._av = {}
        # BV declarations: name -> config dict
        self._bv = {}
        # Expose patterns: pattern -> config dict
        self._exposed = {}
        # Keys of currently exposed values: source_key -> True
        self._exposedValues = {}
        # Keys that failed to expose (task/value not yet available)
        self._missing_expose = set()

        self._server = None

        # Timeouts
        self._t_expose_poll = 0
        self._t_save_values = 0
        self._t_restart = 0
        self._t_retry_expose = 0

        # Internal MBIO values for monitoring
        self.valueDigital('comerr', default=False)
        self.value('exposedcount', default=0)
        self.value('registercount', default=0)

        # Subscribe to all MBIO value updates (filtered to our own values later)
        self.registerValuesUpdateNotifier(False)

    def onLoad(self, xml: XMLConfig):
        """Parse <ModbusServer> XML configuration."""
        self._port = xml.getInt('port', 502)
        self._interface = xml.get('interface', '0.0.0.0')
        self._unitid = xml.getInt('unitid', 1)
        self._addrstart = xml.getInt('addrstart', 0)

        writeacl_str = xml.get('writeacl')
        if writeacl_str:
            networks = []
            for net_str in writeacl_str.split(','):
                net_str = net_str.strip()
                if net_str:
                    try:
                        networks.append(ipaddress.ip_network(net_str, strict=False))
                    except Exception:
                        self.logger.warning('[ModbusServer] Invalid writeacl network: %s' % net_str)
            self._write_acl = networks or None

        # Analog values
        for item in xml.children('av') or []:
            name = item.get('name')
            if not name:
                continue
            writable = item.getBool('writable', True)
            addr = item.getInt('addr', None)
            dtype = item.get('type', 'FLOAT32')
            unit = item.get('unit', '')
            default = item.getFloat('default', 0.0)
            scale = item.getFloat('scale', 1.0)
            offset = item.getFloat('offset', 0.0)

            vname = 'av_%s' % name.lower()
            v = self.value(vname, default=default, unit=unit)
            self._av[name] = {
                'value': v,
                'writable': writable,
                'addr': addr,
                'dtype': dtype,
                'scale': scale,
                'offset': offset,
                'unit': unit,
                'value_key': v.key,
            }

        # Digital/binary values
        for item in xml.children('bv') or []:
            name = item.get('name')
            if not name:
                continue
            writable = item.getBool('writable', True)
            addr = item.getInt('addr', None)
            default = item.getBool('default', False)

            vname = 'bv_%s' % name.lower()
            v = self.valueDigital(vname, default=default)
            self._bv[name] = {
                'value': v,
                'writable': writable,
                'addr': addr,
                'value_key': v.key,
            }

        # Expose patterns
        for item in xml.children('expose') or []:
            pattern = item.get('name')
            if not pattern:
                continue
            dtype = item.get('type', 'FLOAT32')
            scale = item.getFloat('scale', 1.0)
            offset = item.getFloat('offset', 0.0)
            instance = item.getInt('instance', 0)
            self._exposed[pattern] = {
                'type': dtype,
                'scale': scale,
                'offset': offset,
                'instance': instance,
            }

    def _build_server(self):
        """Instantiate and configure the ModbusServerInterface."""
        from .modbusserverengine import ModbusServerInterface

        def _load():
            return self.pickleRead('modbusserver_addrmap')

        def _save(data):
            self.pickleWrite('modbusserver_addrmap', data)

        def _on_error(error):
            self.logger.error('[ModbusServer] Fatal error: %s' % error)
            try:
                self.values.comerr.updateValue(True)
            except Exception:
                pass

        server = ModbusServerInterface(
            host=self._interface,
            port=self._port,
            unitid=self._unitid,
            addrstart=self._addrstart,
            write_acl=self._write_acl,
            logger=self.logger,
            load_cb=_load,
            save_cb=_save,
            error_cb=_on_error,
        )
        return server

    def poweron(self):
        """Start the Modbus TCP server: declare registers, restore values, push initial state."""
        # Stop any previously running server (handles the RESET path where poweroff was not called)
        if self._server is not None:
            try:
                self._server.stop()
            except Exception:
                pass
            self._server = None
            self._exposedValues = {}
        self._server = self._build_server()

        self._server.load_addr_map()

        # Declare AV registers
        for name, data in self._av.items():
            self._server.declare_av(
                name=name,
                value_key=data['value_key'],
                explicit_addr=data['addr'],
                dtype=data['dtype'],
                unit=data['unit'],
                default=data['value'].value,
                writable=data['writable'],
                scale=data['scale'],
                offset=data['offset'],
            )

        # Declare BV registers
        for name, data in self._bv.items():
            self._server.declare_bv(
                name=name,
                value_key=data['value_key'],
                explicit_addr=data['addr'],
                default=data['value'].value,
                writable=data['writable'],
            )

        # Expose existing MBIO values
        self._exposedValues = {}
        self._missing_expose = set()
        self.declareModbusExposeVariables()

        self._server.save_addr_map()

        # Restore persisted writable values from previous session
        saved = self.pickleRead('modbusserver_values') or {}
        for name, data in self._av.items():
            if data['writable'] and name in saved:
                data['value'].updateValue(saved[name])
        for name, data in self._bv.items():
            if data['writable'] and name in saved:
                data['value'].updateValue(saved[name])

        # Push initial values to server blocks (will be queued until server starts)
        for name, data in self._av.items():
            self._server.update(name, data['value'].value, data['value'].isError())
        for name, data in self._bv.items():
            self._server.update_bv(name, data['value'].value)

        # Push initial exposed values
        mbio = self.getMBIO()
        for source_key in list(self._exposedValues):
            v = mbio.value(source_key)
            if v is not None and v.value is not None:
                self._server.update_expose(source_key, v.value)

        self._server.start()

        # Update monitoring values
        self.values.exposedcount.updateValue(len(self._exposedValues))
        status = self._server.get_status()
        self.values.registercount.updateValue(len(status.get('registers', [])))

        return True

    def declareModbusExposeVariables(self, retry_missing=False):
        """Discover and declare MBIO values matching <expose> patterns as IR/DI registers.

        If retry_missing=True, only retries patterns that previously had missing values.
        """
        from .modbusserverengine import _normalize_dtype, _dtype_n_regs

        mbio = self.getMBIO()

        patterns = self._exposed
        if retry_missing and self._missing_expose:
            # Only process patterns that have at least one still-missing key
            patterns = {p: d for p, d in self._exposed.items()
                        if any(k.startswith(p.rstrip('*')) for k in self._missing_expose)}

        for pattern, data in patterns.items():
            grp_dtype = _normalize_dtype(data['type'])
            grp_scale = data['scale']
            grp_offset = data['offset']
            grp_instance = data['instance']

            values = mbio.values(pattern)
            if not values:
                self._missing_expose.add(pattern)
                continue

            for value in sorted(values, key=lambda v: v.key):
                # Skip values already owned by this task (AV/BV declarations)
                if self.values.getByKey(value.key) is not None:
                    continue
                # Skip already exposed
                if value.key in self._exposedValues:
                    continue

                if value.isDigital():
                    addr = self._server._get_register_addr('di', value.key, 1, None)
                    if addr is not None:
                        self._server.declare_expose(
                            source_key=value.key, addr=addr, dtype=None,
                            scale=1.0, offset=0.0, is_digital=True, space='di',
                        )
                        self._exposedValues[value.key] = True
                        self._missing_expose.discard(value.key)
                else:
                    n_regs = _dtype_n_regs(grp_dtype)
                    addr = self._server._get_register_addr('ir', value.key, n_regs, None)
                    if addr is not None:
                        self._server.declare_expose(
                            source_key=value.key, addr=addr, dtype=grp_dtype,
                            scale=grp_scale, offset=grp_offset, is_digital=False, space='ir',
                        )
                        self._exposedValues[value.key] = True
                        self._missing_expose.discard(value.key)

                self.microsleep()

    def run(self):
        """Main task loop: process value notifications, Modbus writes, periodic tasks."""
        if self._server and not self._server.isRunning():
            self.values.comerr.updateValue(True)
            if self.isTimeout(self._t_restart):
                self._t_restart = self.timeout(30)
                self.logger.warning('[ModbusServer] Server not running, attempting restart...')
                self._restart_server()
            return 1.0

        error = False

        # Process MBIO value update notifications -> push to Modbus
        count = 256
        while count > 0:
            count -= 1
            value = self.getNextValueUpdateNotify()
            if value is None:
                break

            matched = False

            # AV update
            for name, data in self._av.items():
                if data['value'] is value:
                    if self._server:
                        self._server.update(name, value.value, value.isError())
                    matched = True
                    break

            if not matched:
                # BV update
                for name, data in self._bv.items():
                    if data['value'] is value:
                        if self._server:
                            self._server.update_bv(name, value.value)
                        matched = True
                        break

            if not matched:
                # Exposed value update
                if value.key in self._exposedValues and self._server:
                    self._server.update_expose(value.key, value.value)

            self.microsleep()

        # Process Modbus client writes -> MBIO values
        if self._server:
            wrote = False
            for name, val in self._server.process_pending_writes():
                if name in self._av:
                    self._av[name]['value'].updateValue(val)
                    wrote = True
                elif name in self._bv:
                    self._bv[name]['value'].updateValue(bool(val))
                    wrote = True
            if wrote:
                self._save_writable_values()

        # Periodically poll exposed values and push current state to server
        if self.isTimeout(self._t_expose_poll):
            self._t_expose_poll = self.timeout(30)
            mbio = self.getMBIO()
            for source_key in list(self._exposedValues):
                v = mbio.value(source_key)
                if v is not None:
                    if self._server:
                        self._server.update_expose(source_key, v.value)
                else:
                    error = True
                self.microsleep()

        # Periodically save writable values
        if self.isTimeout(self._t_save_values):
            self._t_save_values = self.timeout(300)
            self._save_writable_values()

        # Retry previously missing expose patterns
        if self._missing_expose and self.isTimeout(self._t_retry_expose):
            self._t_retry_expose = self.timeout(60)
            self.declareModbusExposeVariables(retry_missing=True)
            self.values.exposedcount.updateValue(len(self._exposedValues))
            status = self._server.get_status() if self._server else {}
            self.values.registercount.updateValue(len(status.get('registers', [])))

        self.values.comerr.updateValue(error)
        return 0.5

    def poweroff(self):
        """Save writable values and stop the server on graceful shutdown."""
        self._save_writable_values()
        if self._server is not None:
            try:
                self._server.save_addr_map()
            except Exception:
                pass
            try:
                self._server.stop()
            except Exception:
                pass
            # Reset so poweron() creates a completely fresh server next time.
            # Without this the old thread (if join timed out) could still hold
            # the TCP port when the new server tries to bind it.
            self._server = None
            self._exposedValues = {}
        return True

    def _save_writable_values(self):
        """Persist the current state of all writable AV/BV values."""
        values_to_save = {}
        for name, data in self._av.items():
            if data['writable']:
                values_to_save[name] = data['value'].value
        for name, data in self._bv.items():
            if data['writable']:
                values_to_save[name] = data['value'].value
        if values_to_save:
            self.pickleWrite('modbusserver_values', values_to_save)

    def _restart_server(self):
        """Attempt to restart the Modbus server after a failure.
        poweron() handles stopping and cleaning up any previously running server.
        """
        try:
            self.poweron()
            self.logger.info('[ModbusServer] Restarted successfully')
        except Exception as e:
            self.logger.error('[ModbusServer] Restart failed: %s' % e)

    def getModbusStatus(self):
        """Return the server status dict enriched with current MBIOValue values."""
        if self._server:
            status = self._server.get_status()
        else:
            return {'running': False, 'registers': []}

        # Build a lookup: name -> current value (for AV/BV declared by this task)
        av_vals = {name: data['value'].value for name, data in self._av.items()}
        bv_vals = {name: data['value'].value for name, data in self._bv.items()}
        # For exposed values, read from MBIO
        mbio = self.getMBIO()
        expose_vals = {}
        for source_key in self._exposedValues:
            v = mbio.value(source_key)
            if v is not None:
                expose_vals[source_key] = v.value

        for reg in status.get('registers', []):
            name = reg.get('name', '')
            space = reg.get('space', '')
            scale = reg.get('scale', 1.0) or 1.0
            offset = reg.get('offset', 0.0) or 0.0

            if space in ('co', 'di'):
                v = bv_vals.get(name) if not reg.get('is_expose') else expose_vals.get(name)
                val = bool(v) if v is not None else None
                reg['value'] = val
                reg['raw'] = int(val) if val is not None else None
            else:
                v = expose_vals.get(name) if reg.get('is_expose') else av_vals.get(name)
                reg['value'] = v
                if v is not None:
                    try:
                        reg['raw'] = round(float(v) / scale + offset, 6)
                    except Exception:
                        reg['raw'] = None
                else:
                    reg['raw'] = None

        return status

    def metrics(self):
        m = super().metrics()
        status = self.getModbusStatus()
        m['modbus_server_running'] = status.get('running', False)
        m['modbus_server_port'] = self._port
        m['modbus_server_n_hr'] = status.get('n_hr', 0)
        m['modbus_server_n_ir'] = status.get('n_ir', 0)
        m['modbus_server_n_co'] = status.get('n_co', 0)
        m['modbus_server_n_di'] = status.get('n_di', 0)
        m['modbus_server_n_expose'] = status.get('n_expose', 0)
        return m


if __name__ == '__main__':
    pass
