"""
Modbus TCP Server Engine for digimat.mbio

Implements a Modbus TCP server that exposes MBIO values as Modbus registers.
Runs in a dedicated thread with its own asyncio event loop (same pattern as bacnetserver.py).

Register spaces:
    Holding Registers (4x / 'hr') -- R/W analog values
    Input Registers   (3x / 'ir') -- RO analog values and exposed values
    Coils             (0x / 'co') -- R/W digital values
    Discrete Inputs   (1x / 'di') -- RO digital values and exposed values
"""

import ast
import asyncio
import logging
import threading
import time
from dataclasses import dataclass, field
from typing import Optional, Callable, Dict, List, Tuple, Any

try:
    from pymodbus.datastore import ModbusSequentialDataBlock, ModbusSlaveContext, ModbusServerContext
    from pymodbus.server import StartAsyncTcpServer
except ImportError as e:
    raise ImportError('pymodbus is required for ModbusServerEngine: %s' % e)

from .binarypayload import MyBinaryPayloadBuilder, MyBinaryPayloadDecoder, Endian


# --- Type aliases and constants ---

_DTYPE_ALIASES = {
    'FLOAT':          'FLOAT32',
    'REAL':           'FLOAT32',
    'FLOAT-SWAPPED':  'FLOAT32-SWAPPED',
    'REAL-SWAPPED':   'FLOAT32-SWAPPED',
    'DOUBLE':         'FLOAT64',
    'HALF':           'FLOAT16',
    'SHORT':          'INT16',
    'SWORD':          'INT16',
    'WORD':           'UINT16',
    'USHORT':         'UINT16',
    'DWORD':          'INT32',
    'INT':            'INT32',
    'LONG':           'INT32',
    'DWORD-SWAPPED':  'INT32-SWAPPED',
    'LONG-SWAPPED':   'INT32-SWAPPED',
    'UDWORD':         'UINT32',
    'UINT':           'UINT32',
    'ULONG':          'UINT32',
    'UDWORD-SWAPPED': 'UINT32-SWAPPED',
    'ULONG-SWAPPED':  'UINT32-SWAPPED',
}

_DTYPE_N_REGS = {
    'FLOAT32':         2,
    'FLOAT32-SWAPPED': 2,
    'FLOAT64':         4,
    'FLOAT16':         1,
    'INT16':           1,
    'UINT16':          1,
    'INT32':           2,
    'INT32-SWAPPED':   2,
    'UINT32':          2,
    'UINT32-SWAPPED':  2,
}

_INSTANCE_MAP_EXPIRY_DAYS = 30


def _normalize_dtype(dtype):
    """Normalize a dtype string to its canonical form."""
    if dtype is None:
        return 'FLOAT32'
    s = dtype.upper().strip()
    return _DTYPE_ALIASES.get(s, s)


def _dtype_n_regs(dtype):
    """Return the number of Modbus registers required for a given dtype."""
    return _DTYPE_N_REGS.get(dtype, 2)


def _word_order_for_dtype(dtype):
    """Return (byteorder, wordorder) for encoder/decoder based on dtype suffix."""
    if dtype and dtype.endswith('-SWAPPED'):
        return Endian.BIG, Endian.LITTLE
    return Endian.BIG, Endian.BIG


def encode_value(value, dtype, scale=1.0, offset=0.0):
    """Encode a Python scalar into a list of Modbus register words.

    Applies: raw = value / scale + offset  before encoding.
    Inverse of decode: value = (raw - offset) * scale

    Example: 21.5 °C, scale=0.1, offset=20 → raw = 21.5/0.1 + 20 = 235

    Returns a list of int register values, or None on error.
    """
    try:
        v = float(value)
        if scale and scale != 0.0:
            v = v / scale + offset
        else:
            v = v + offset

        byteorder, wordorder = _word_order_for_dtype(dtype)
        b = MyBinaryPayloadBuilder(byteorder=byteorder, wordorder=wordorder)

        # Strip swapped suffix for dispatch
        base = dtype.replace('-SWAPPED', '') if dtype else 'FLOAT32'

        if base == 'FLOAT32':
            b.add_32bit_float(v)
        elif base == 'FLOAT64':
            b.add_64bit_float(v)
        elif base == 'FLOAT16':
            b.add_16bit_float(v)
        elif base == 'INT16':
            b.add_16bit_int(int(round(v)))
        elif base == 'UINT16':
            b.add_16bit_uint(int(round(v)) & 0xFFFF)
        elif base == 'INT32':
            b.add_32bit_int(int(round(v)))
        elif base == 'UINT32':
            b.add_32bit_uint(int(round(v)) & 0xFFFFFFFF)
        else:
            # Fallback: FLOAT32
            b.add_32bit_float(v)

        return b.to_registers()
    except Exception:
        return None


def encode_value_safe(value, dtype, scale=1.0, offset=0.0, logger=None):
    """Like encode_value but logs errors and never raises."""
    try:
        return encode_value(value, dtype, scale, offset)
    except Exception as e:
        if logger:
            logger.warning('[ModbusServer] encode_value_safe error dtype=%s: %s' % (dtype, e))
        return None


def decode_value(regs, dtype, scale=1.0, offset=0.0):
    """Decode a list of Modbus register words into a Python float.

    Applies: value = (raw - offset) * scale  after decoding.
    Inverse of encode: raw = value / scale + offset

    Example: raw=235, scale=0.1, offset=20 → value = (235-20)*0.1 = 21.5

    Returns a float, or None on error.
    """
    try:
        if not regs:
            return None

        byteorder, wordorder = _word_order_for_dtype(dtype)
        d = MyBinaryPayloadDecoder.fromRegisters(regs, byteorder=byteorder, wordorder=wordorder)

        base = dtype.replace('-SWAPPED', '') if dtype else 'FLOAT32'

        if base == 'FLOAT32':
            v = d.decode_32bit_float()
        elif base == 'FLOAT64':
            v = d.decode_64bit_float()
        elif base == 'FLOAT16':
            v = d.decode_16bit_float()
        elif base == 'INT16':
            v = float(d.decode_16bit_int())
        elif base == 'UINT16':
            v = float(d.decode_16bit_uint())
        elif base == 'INT32':
            v = float(d.decode_32bit_int())
        elif base == 'UINT32':
            v = float(d.decode_32bit_uint())
        else:
            v = d.decode_32bit_float()

        return (v - offset) * scale
    except Exception:
        return None


# --- Data structures ---

@dataclass
class RegisterEntry:
    """Describes a single Modbus register mapping.

    scale / offset apply only to integer dtypes (INT16, UINT16, INT32, UINT32).
    Convention:
        encode (MBIO → register):  raw = value / scale + offset
        decode (register → MBIO):  value = (raw - offset) * scale
    For FLOAT types scale and offset are unused (raw == value).
    """
    name: str
    space: str          # 'hr', 'ir', 'co', 'di'
    addr: int
    n_regs: int
    dtype: Optional[str]    # None for digitals (co/di)
    unit: str
    description: str
    scale: float            # encode: raw = value / scale + offset
    offset: float           # decode: value = (raw - offset) * scale
    writable: bool
    is_expose: bool


# --- Modbus data block with write interception ---

class MBIOModbusDataBlock(ModbusSequentialDataBlock):
    """ModbusSequentialDataBlock that intercepts external write operations.

    Distinguishes between internal updates (from MBIO -> Modbus) and
    external writes (from Modbus client -> MBIO) via a callback.
    """

    def __init__(self, address, values, write_callback=None):
        super().__init__(address, values)
        self._write_callback = write_callback
        self._internal_update = False

    def _get_values_internal(self, address, count=1):
        """Read directly without going through getValues (used by process_pending_writes)."""
        return super().getValues(address, count)

    def _set_values_internal(self, address, values):
        """Set register values without triggering the write callback (MBIO->Modbus direction)."""
        self._internal_update = True
        try:
            super().setValues(address, values)
        finally:
            self._internal_update = False

    def setValues(self, address, values):
        """Override to intercept Modbus client writes and trigger the callback."""
        super().setValues(address, values)
        if not self._internal_update and self._write_callback:
            try:
                self._write_callback(address, values)
            except Exception:
                pass


# --- Modbus slave context with I/O counters ---

# Function code → (space, direction) mapping
_FC_READ_SPACE = {
    0x01: 'co',   # Read Coils
    0x02: 'di',   # Read Discrete Inputs
    0x03: 'hr',   # Read Holding Registers
    0x04: 'ir',   # Read Input Registers
}
_FC_WRITE_SPACE = {
    0x05: 'co',   # Write Single Coil
    0x0F: 'co',   # Write Multiple Coils
    0x06: 'hr',   # Write Single Register
    0x10: 'hr',   # Write Multiple Registers
}


class CountingModbusSlaveContext(ModbusSlaveContext):
    """ModbusSlaveContext that counts external reads and writes per space and per address.

    Counting is done at this level — directly called by the pymodbus server —
    rather than at the data block level, for guaranteed accuracy.

    Per-address counters use the start address of each Modbus request as the key.
    This works accurately for single-variable requests (the common case).
    """

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.counters = {
            'hr_reads': 0, 'ir_reads': 0, 'co_reads': 0, 'di_reads': 0,
            'hr_writes': 0, 'co_writes': 0,
        }
        # Per-address counters: (space, start_addr) -> count
        self.addr_reads: Dict[Tuple[str, int], int] = {}
        self.addr_writes: Dict[Tuple[str, int], int] = {}

    def getValues(self, fc_as_hex, address, count=1):
        space = _FC_READ_SPACE.get(fc_as_hex)
        if space:
            self.counters[space + '_reads'] += 1
            key = (space, address)
            self.addr_reads[key] = self.addr_reads.get(key, 0) + 1
        return super().getValues(fc_as_hex, address, count)

    def setValues(self, fc_as_hex, address, values):
        space = _FC_WRITE_SPACE.get(fc_as_hex)
        if space:
            self.counters[space + '_writes'] += 1
            key = (space, address)
            self.addr_writes[key] = self.addr_writes.get(key, 0) + 1
        return super().setValues(fc_as_hex, address, values)


# --- Modbus server service thread ---

class ModbusServerService:
    """Runs the async pymodbus TCP server in a dedicated daemon thread.

    Communication with the MBIO task thread:
        MBIO -> Modbus: push_registers() queues updates consumed by _update_loop()
        Modbus -> MBIO: write callbacks queue entries in _pending_writes consumed by process_pending_writes()
    """

    _UPDATE_INTERVAL = 0.05     # s: poll interval for pending MBIO->Modbus updates
    _MAX_PENDING = 500

    def __init__(self, interface, host, port, unitid, logger):
        self._interface_obj = interface
        self._host = host
        self._port = port
        self._unitid = unitid
        self._logger = logger
        self._running = False
        self._thread = None
        self._loop = None
        self._server = None
        self._tasks = []
        self._update_lock = threading.Lock()
        self._write_lock = threading.Lock()
        self._pending_updates: Dict[Tuple[str, int], List[int]] = {}
        self._pending_writes: List[Tuple[str, int, List]] = []

        self._hr_block: Optional[MBIOModbusDataBlock] = None
        self._co_block: Optional[MBIOModbusDataBlock] = None
        self._ir_block: Optional[ModbusSequentialDataBlock] = None
        self._di_block: Optional[ModbusSequentialDataBlock] = None
        self._slave_ctx: Optional[CountingModbusSlaveContext] = None

        # Silence pymodbus loggers
        for name in ('pymodbus', 'pymodbus.server', 'pymodbus.factory',
                      'pymodbus.framer', 'pymodbus.logging'):
            logging.getLogger(name).setLevel(logging.CRITICAL)

    def isRunning(self):
        """Return True if the server thread is alive and the server started successfully."""
        return self._running and (self._thread is not None) and self._thread.is_alive()

    def start(self, hr_size, ir_size, co_size, di_size):
        """Initialize data blocks and start the server thread."""
        self._hr_block = MBIOModbusDataBlock(0, [0] * max(hr_size, 1), self._on_hr_write)
        self._co_block = MBIOModbusDataBlock(0, [False] * max(co_size, 1), self._on_co_write)
        self._ir_block = ModbusSequentialDataBlock(0, [0] * max(ir_size, 1))
        self._di_block = ModbusSequentialDataBlock(0, [False] * max(di_size, 1))

        self._slave_ctx = CountingModbusSlaveContext(
            di=self._di_block,
            co=self._co_block,
            hr=self._hr_block,
            ir=self._ir_block,
            zero_mode=True,
        )
        self._context = ModbusServerContext(slaves={self._unitid: self._slave_ctx}, single=False)

        self._running = True
        self._thread = threading.Thread(target=self._thread_main, daemon=True, name='ModbusTCPServer')
        self._thread.start()

    def stop(self):
        """Signal the server to stop and wait for thread termination."""
        self._running = False
        if self._loop and self._loop.is_running():
            self._loop.call_soon_threadsafe(self._loop.stop)
        if self._thread and self._thread.is_alive():
            self._thread.join(timeout=8.0)
            if self._thread.is_alive():
                self._logger.warning('[ModbusServer] Service thread still alive after 8s — TCP port may not be released yet')

    def _thread_main(self):
        """Main entry point for the server thread."""
        self._loop = asyncio.new_event_loop()
        asyncio.set_event_loop(self._loop)
        self._loop.set_exception_handler(self._asyncio_exception_handler)
        try:
            self._loop.run_until_complete(self._main())
        except Exception as e:
            self._logger.error('[ModbusServer] Thread error: %s' % e)
        finally:
            self._running = False
            try:
                self._loop.close()
            except Exception:
                pass

    def _asyncio_exception_handler(self, loop, context):
        exc = context.get('exception')
        msg = context.get('message', '')
        if exc:
            self._logger.warning('[ModbusServer] Async exception: %s' % exc)
        else:
            self._logger.warning('[ModbusServer] Async error: %s' % msg)

    async def _main(self):
        """Async entry point: start update loop then launch the TCP server."""
        update_task = asyncio.ensure_future(self._update_loop())
        self._tasks.append(update_task)
        try:
            await StartAsyncTcpServer(self._context, address=(self._host, self._port))
        except Exception as e:
            self._logger.error('[ModbusServer] start failed (port=%d): %s' % (self._port, e))
            self._running = False
            if self._interface_obj and hasattr(self._interface_obj, 'on_server_error'):
                self._interface_obj.on_server_error(e)
        finally:
            for task in self._tasks:
                task.cancel()

    async def _update_loop(self):
        """Consume pending MBIO->Modbus register updates."""
        while self._running:
            await asyncio.sleep(self._UPDATE_INTERVAL)
            with self._update_lock:
                if not self._pending_updates:
                    continue
                updates = dict(self._pending_updates)
                self._pending_updates.clear()
            for (space, addr), regs in updates.items():
                try:
                    if space == 'ir':
                        self._ir_block.setValues(addr, regs)
                    elif space == 'di':
                        self._di_block.setValues(addr, regs)
                    elif space == 'hr':
                        self._hr_block._set_values_internal(addr, regs)
                    elif space == 'co':
                        self._co_block._set_values_internal(addr, regs)
                except Exception as e:
                    self._logger.warning('[ModbusServer] update error space=%s addr=%d: %s' % (space, addr, e))

    def push_registers(self, space, addr, regs):
        """Queue a register update from MBIO to be pushed to the server (thread-safe)."""
        if not regs:
            return
        with self._update_lock:
            if len(self._pending_updates) < self._MAX_PENDING:
                self._pending_updates[(space, addr)] = regs

    def _on_hr_write(self, address, values):
        """Callback invoked when a Modbus client writes to Holding Registers."""
        with self._write_lock:
            if len(self._pending_writes) < self._MAX_PENDING:
                self._pending_writes.append(('hr', address, list(values)))

    def _on_co_write(self, address, values):
        """Callback invoked when a Modbus client writes to Coils."""
        with self._write_lock:
            if len(self._pending_writes) < self._MAX_PENDING:
                self._pending_writes.append(('co', address, list(values)))

    def get_hr_values(self, addr, count):
        """Read raw HR register values internally (does not increment read counter)."""
        try:
            return self._hr_block._get_values_internal(addr, count)
        except Exception:
            return []

    def get_co_values(self, addr, count):
        """Read raw CO values internally (does not increment read counter)."""
        try:
            return self._co_block._get_values_internal(addr, count)
        except Exception:
            return []

    def get_stats(self):
        """Return I/O counters for all four Modbus spaces, plus per-address counters."""
        if not self._slave_ctx:
            return {'counters': {}, 'addr_reads': {}, 'addr_writes': {}}
        return {
            'counters': dict(self._slave_ctx.counters),
            # Serialise tuple keys as "space:addr" strings for JSON compatibility
            'addr_reads': {'%s:%d' % k: v for k, v in self._slave_ctx.addr_reads.items()},
            'addr_writes': {'%s:%d' % k: v for k, v in self._slave_ctx.addr_writes.items()},
        }


# --- High-level Modbus server interface ---

class ModbusServerInterface:
    """High-level interface for the Modbus TCP server.

    Manages the register map (address assignment, persistence, encoding/decoding)
    and delegates the actual server to ModbusServerService.
    """

    def __init__(self, host, port, unitid, addrstart, write_acl, logger,
                 load_cb=None, save_cb=None, error_cb=None):
        self._host = host
        self._port = port
        self._unitid = unitid
        self._addrstart = addrstart
        self._write_acl = write_acl
        self._logger = logger
        self._load_cb = load_cb
        self._save_cb = save_cb
        self._error_cb = error_cb

        self._service = ModbusServerService(self, host, port, unitid, logger)

        # Address persistence: (space, value_key) -> {'addr': int, 'ts': float}
        self._addr_map: Dict[Tuple[str, str], Dict] = {}
        # Tracking of used addresses per space
        self._used_addrs: Dict[str, set] = {'hr': set(), 'ir': set(), 'co': set(), 'di': set()}
        # Register entries by name
        self._entries: Dict[str, RegisterEntry] = {}
        # Expose entries by source_key
        self._expose_map: Dict[str, RegisterEntry] = {}
        # Lookup (space, addr) -> RegisterEntry for write dispatch
        self._addr_index: Dict[Tuple[str, int], RegisterEntry] = {}
        # Pending initial HR defaults to push after server start
        self._pending_hr_init: Dict[int, List[int]] = {}

        self._running = False

    def isRunning(self):
        """Return True if the server is started and the thread is alive."""
        return self._running and self._service.isRunning()

    def on_server_error(self, error):
        """Handle a fatal server error from the service thread."""
        self._running = False
        if self._error_cb:
            try:
                self._error_cb(error)
            except Exception:
                pass

    # --- Address management ---

    def _validate_addr(self, addr, n_regs, name):
        if addr < 0 or addr + n_regs - 1 > 65535:
            self._logger.error('[ModbusServer] Address %d+%d out of range for %s - skipped' % (addr, n_regs, name))
            return False
        return True

    def _mark_used(self, space, addr, n_regs):
        for i in range(n_regs):
            self._used_addrs[space].add(addr + i)

    def _find_free_addr(self, space, n_regs):
        used = self._used_addrs[space]
        addr = self._addrstart
        while addr <= 65535 - n_regs + 1:
            if all((addr + i) not in used for i in range(n_regs)):
                return addr
            addr += 1
        self._logger.error('[ModbusServer] No free address in space %s for %d regs' % (space, n_regs))
        return None

    def _get_register_addr(self, space, value_key, n_regs, explicit_addr=None):
        """Resolve or allocate a register address for a value.

        If explicit_addr is given, use it directly (with validation).
        Otherwise, reuse a persisted address or allocate a fresh one.
        """
        map_key = (space, value_key)

        if explicit_addr is not None:
            if not self._validate_addr(explicit_addr, n_regs, value_key):
                return None
            self._addr_map[map_key] = {'addr': explicit_addr, 'ts': time.time()}
            self._mark_used(space, explicit_addr, n_regs)
            return explicit_addr

        if map_key in self._addr_map:
            addr = self._addr_map[map_key]['addr']
            self._addr_map[map_key]['ts'] = time.time()
            if not self._validate_addr(addr, n_regs, value_key):
                return None
            self._mark_used(space, addr, n_regs)
            return addr

        addr = self._find_free_addr(space, n_regs)
        if addr is None:
            return None
        self._addr_map[map_key] = {'addr': addr, 'ts': time.time()}
        self._mark_used(space, addr, n_regs)
        return addr

    # --- Persistence ---

    def load_addr_map(self):
        """Load persisted address map from storage (called before declaring registers)."""
        try:
            if self._load_cb:
                raw = self._load_cb()
                if raw:
                    expiry = time.time() - _INSTANCE_MAP_EXPIRY_DAYS * 86400
                    for k_str, v in raw.items():
                        if not isinstance(v, dict):
                            continue
                        if v.get('ts', 0) <= expiry:
                            continue
                        try:
                            k = ast.literal_eval(k_str)
                            if isinstance(k, (list, tuple)) and len(k) == 2:
                                k = tuple(k)
                                self._addr_map[k] = v
                                space = k[0]
                                addr = v.get('addr')
                                if addr is not None and space in self._used_addrs:
                                    self._used_addrs[space].add(addr)
                        except Exception:
                            pass
        except Exception as e:
            self._logger.warning('[ModbusServer] load_addr_map error: %s' % e)

    def save_addr_map(self):
        """Persist the address map to storage."""
        try:
            if self._save_cb:
                data = {str(k): v for k, v in self._addr_map.items()}
                self._save_cb(data)
        except Exception as e:
            self._logger.warning('[ModbusServer] save_addr_map error: %s' % e)

    # --- Register declaration ---

    def declare_av(self, name, value_key, explicit_addr, dtype, unit, default, writable, scale, offset):
        """Declare an analog value register (HR if writable, IR if read-only).

        Returns the RegisterEntry or None if address allocation fails.
        """
        dtype = _normalize_dtype(dtype)
        n_regs = _dtype_n_regs(dtype)
        space = 'hr' if writable else 'ir'
        addr = self._get_register_addr(space, value_key, n_regs, explicit_addr)
        if addr is None:
            return None
        entry = RegisterEntry(
            name=name, space=space, addr=addr, n_regs=n_regs,
            dtype=dtype, unit=unit or '', description='',
            scale=scale, offset=offset, writable=writable, is_expose=False,
        )
        self._entries[name] = entry
        self._addr_index[(space, addr)] = entry
        if writable and default is not None:
            regs = encode_value_safe(default, dtype, scale, offset, self._logger)
            if regs:
                self._pending_hr_init[addr] = regs
        return entry

    def declare_bv(self, name, value_key, explicit_addr, default, writable):
        """Declare a binary/digital value register (CO if writable, DI if read-only).

        Returns the RegisterEntry or None if address allocation fails.
        """
        space = 'co' if writable else 'di'
        addr = self._get_register_addr(space, value_key, 1, explicit_addr)
        if addr is None:
            return None
        entry = RegisterEntry(
            name=name, space=space, addr=addr, n_regs=1,
            dtype=None, unit='', description='',
            scale=1.0, offset=0.0, writable=writable, is_expose=False,
        )
        self._entries[name] = entry
        self._addr_index[(space, addr)] = entry
        if writable and default is not None:
            self._pending_hr_init[addr] = [bool(default)]
        return entry

    def declare_expose(self, source_key, addr, dtype, scale, offset, is_digital, space):
        """Declare an exposed value register (always read-only from Modbus perspective).

        Returns the RegisterEntry.
        """
        n_regs = 1 if is_digital else _dtype_n_regs(dtype)
        entry = RegisterEntry(
            name=source_key, space=space, addr=addr, n_regs=n_regs,
            dtype=dtype, unit='', description='',
            scale=scale, offset=offset, writable=False, is_expose=True,
        )
        self._expose_map[source_key] = entry
        self._addr_index[(space, addr)] = entry
        return entry

    def get_entry(self, name):
        """Return the RegisterEntry for a declared AV/BV by name, or None."""
        return self._entries.get(name)

    def get_expose_entry(self, source_key):
        """Return the RegisterEntry for an exposed value by source_key, or None."""
        return self._expose_map.get(source_key)

    # --- Server lifecycle ---

    def start(self):
        """Calculate block sizes, start the service thread and push initial defaults."""
        def _max_addr(space):
            addrs = [e.addr + e.n_regs - 1 for e in self._entries.values() if e.space == space]
            addrs += [e.addr + e.n_regs - 1 for e in self._expose_map.values() if e.space == space]
            return max(addrs) + 1 if addrs else 1

        hr_size = _max_addr('hr')
        ir_size = _max_addr('ir')
        co_size = _max_addr('co')
        di_size = _max_addr('di')

        self._service.start(hr_size, ir_size, co_size, di_size)

        # Push initial defaults into HR/CO blocks after the service has created them
        for addr, regs in self._pending_hr_init.items():
            self._service.push_registers('hr', addr, regs)
            self._service.push_registers('co', addr, regs)
        self._pending_hr_init.clear()

        self._running = True
        self._logger.info('[ModbusServer] Started on %s:%d (unitid=%d, hr=%d ir=%d co=%d di=%d)' % (
            self._host, self._port, self._unitid, hr_size, ir_size, co_size, di_size))

    def stop(self):
        """Stop the server service."""
        self._running = False
        try:
            self._service.stop()
        except Exception as e:
            self._logger.warning('[ModbusServer] stop error: %s' % e)

    # --- Value update (MBIO -> Modbus) ---

    def update(self, name, value, is_error=False):
        """Push an analog value update to the Modbus server registers."""
        entry = self._entries.get(name)
        if entry is None:
            return
        if entry.space in ('hr', 'ir'):
            if is_error or value is None:
                return
            regs = encode_value_safe(value, entry.dtype, entry.scale, entry.offset, self._logger)
            if regs:
                self._service.push_registers(entry.space, entry.addr, regs)
        else:
            # co or di
            bool_val = bool(value) if value is not None else False
            self._service.push_registers(entry.space, entry.addr, [bool_val])

    def update_bv(self, name, value):
        """Push a digital value update to the Modbus server coil/DI registers."""
        entry = self._entries.get(name)
        if entry is None:
            return
        bool_val = bool(value) if value is not None else False
        self._service.push_registers(entry.space, entry.addr, [bool_val])

    def update_expose(self, source_key, value):
        """Push an exposed value update to the appropriate IR or DI register."""
        entry = self._expose_map.get(source_key)
        if entry is None:
            return
        if entry.space == 'di':
            bool_val = bool(value) if value is not None else False
            self._service.push_registers('di', entry.addr, [bool_val])
        else:
            if value is None:
                return
            regs = encode_value_safe(value, entry.dtype, entry.scale, entry.offset, self._logger)
            if regs:
                self._service.push_registers('ir', entry.addr, regs)

    # --- Write processing (Modbus -> MBIO) ---

    def process_pending_writes(self):
        """Consume and decode pending Modbus client writes.

        Returns a list of (name, decoded_value) tuples for writable entries.
        """
        result = []
        with self._service._write_lock:
            pending = list(self._service._pending_writes)
            self._service._pending_writes.clear()

        for (space, addr, raw_values) in pending:
            entry = self._addr_index.get((space, addr))
            if entry is None or not entry.writable:
                continue

            if space == 'co':
                decoded = bool(raw_values[0]) if raw_values else False
            else:
                # For HR: re-read the full n_regs from the block for proper multi-reg decode
                try:
                    full_regs = self._service.get_hr_values(entry.addr, entry.n_regs)
                    if not full_regs:
                        continue
                    decoded = decode_value(full_regs, entry.dtype, entry.scale, entry.offset)
                    if decoded is None:
                        continue
                except Exception as e:
                    self._logger.warning('[ModbusServer] decode error for %s: %s' % (entry.name, e))
                    continue

            result.append((entry.name, decoded))

        return result

    # --- Status ---

    def _detect_overlaps(self):
        """Return the set of entry names whose address ranges overlap with another entry."""
        all_entries = list(self._entries.values()) + list(self._expose_map.values())
        by_space: Dict[str, list] = {}
        for e in all_entries:
            by_space.setdefault(e.space, []).append(e)

        overlap_names = set()
        for space_entries in by_space.values():
            n = len(space_entries)
            for i in range(n):
                ea = space_entries[i]
                a1, b1 = ea.addr, ea.addr + ea.n_regs - 1
                for j in range(i + 1, n):
                    eb = space_entries[j]
                    a2, b2 = eb.addr, eb.addr + eb.n_regs - 1
                    if a1 <= b2 and a2 <= b1:
                        overlap_names.add(ea.name)
                        overlap_names.add(eb.name)
        return overlap_names

    def get_status(self):
        """Return a status dict for monitoring / API responses."""
        stats = self._service.get_stats()
        global_counters = stats.get('counters', {})
        addr_reads = stats.get('addr_reads', {})
        addr_writes = stats.get('addr_writes', {})

        overlap_names = self._detect_overlaps()

        registers = []
        all_entries = list(self._entries.values()) + list(self._expose_map.values())
        for entry in all_entries:
            addr_key = '%s:%d' % (entry.space, entry.addr)
            reg = {
                'name': entry.name,
                'space': entry.space,
                'addr': entry.addr,
                'n_regs': entry.n_regs,
                'dtype': entry.dtype,
                'unit': entry.unit,
                'description': entry.description,
                'scale': entry.scale,
                'offset': entry.offset,
                'writable': entry.writable,
                'is_expose': entry.is_expose,
                'overlap': entry.name in overlap_names,
                'reads': addr_reads.get(addr_key, 0),
                'writes': addr_writes.get(addr_key, 0) if entry.writable else None,
            }
            registers.append(reg)

        return {
            'running': self.isRunning(),
            'host': self._host,
            'port': self._port,
            'unitid': self._unitid,
            'registers': registers,
            'n_hr': sum(1 for e in self._entries.values() if e.space == 'hr'),
            'n_ir': sum(1 for e in self._entries.values() if e.space == 'ir')
                  + sum(1 for e in self._expose_map.values() if e.space == 'ir'),
            'n_co': sum(1 for e in self._entries.values() if e.space == 'co'),
            'n_di': sum(1 for e in self._entries.values() if e.space == 'di')
                  + sum(1 for e in self._expose_map.values() if e.space == 'di'),
            'n_expose': len(self._expose_map),
            'stats': global_counters,
        }
