#!/usr/bin/env python3
"""ODNFC-RS485 access-control demo over Modbus RTU."""

from __future__ import annotations

import argparse
import sys
import time
from dataclasses import dataclass

import minimalmodbus
import serial


UID_REGISTER = 0x0000
UID_REGISTER_COUNT = 7
COMMAND_REGISTER = 0x00F0

ACTION_OK = 1
ACTION_ERROR = 2

DEFAULT_BAUDRATE = 115200
DEFAULT_REARM_MS = 750
MAX_CONSECUTIVE_ERRORS = 5
MODBUS_TIMEOUT_SECONDS = 0.5

MODBUS_ERRORS = (minimalmodbus.ModbusException, serial.SerialException)


@dataclass
class ReaderState:
    name: str
    registers: slice
    action: int
    result: str
    uid_bytes: int
    armed: bool = False
    absent_since: float | None = None

    def update(
        self,
        values: list[int],
        now: float,
        rearm_seconds: float,
    ) -> list[int] | None:
        if not any(values):
            if self.absent_since is None:
                self.absent_since = now
            if now - self.absent_since >= rearm_seconds:
                self.armed = True
            return None

        self.absent_since = None
        if not self.armed:
            return None

        self.armed = False
        return values


def create_client(
    port: str,
    unit: int,
    baudrate: int,
) -> minimalmodbus.Instrument:
    client = minimalmodbus.Instrument(
        port,
        unit,
        mode=minimalmodbus.MODE_RTU,
        close_port_after_each_call=False,
    )
    client.serial.baudrate = baudrate
    client.serial.bytesize = 8
    client.serial.parity = serial.PARITY_NONE
    client.serial.stopbits = 1
    client.serial.timeout = MODBUS_TIMEOUT_SECONDS
    client.clear_buffers_before_each_transaction = True
    return client


def uid_hex(registers: list[int], byte_count: int) -> str:
    raw = b"".join(value.to_bytes(2, "big") for value in registers)
    return raw[:byte_count].hex().upper()


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "port",
        help="serial port, for example /dev/ttyUSB0 or COM3",
    )
    parser.add_argument("--unit", type=int, default=1)
    parser.add_argument("--baudrate", type=int, default=DEFAULT_BAUDRATE)
    parser.add_argument(
        "--rearm-ms",
        type=float,
        default=DEFAULT_REARM_MS,
        help="continuous absence required before a tag can trigger again",
    )
    args = parser.parse_args()
    if not 1 <= args.unit <= 247:
        parser.error("--unit must be in range 1..247")
    if args.baudrate <= 0:
        parser.error("--baudrate must be positive")
    if args.rearm_ms < 0:
        parser.error("--rearm-ms must be non-negative")
    return args


def run(args: argparse.Namespace) -> int:
    readers = (
        ReaderState("HF", slice(0, 4), ACTION_OK, "OK", 8),
        ReaderState("EM-Marine", slice(4, 7), ACTION_ERROR, "ERROR", 5),
    )
    rearm_seconds = args.rearm_ms / 1000
    total_errors = 0
    consecutive_errors = 0

    try:
        client = create_client(args.port, args.unit, args.baudrate)
    except serial.SerialException as exc:
        print(f"Serial port error: {exc}", file=sys.stderr)
        return 1

    print(
        f"ODNFC-RS485 ready on {args.port}: "
        f"unit={args.unit}, {args.baudrate} 8N1",
        flush=True,
    )
    print(
        f"HF -> OK, EM-Marine -> ERROR; rearm={args.rearm_ms:g} ms",
        flush=True,
    )

    try:
        while True:
            try:
                registers = client.read_registers(
                    UID_REGISTER,
                    UID_REGISTER_COUNT,
                    functioncode=3,
                )

                for reader in readers:
                    event = reader.update(
                        registers[reader.registers],
                        time.monotonic(),
                        rearm_seconds,
                    )
                    if event is None:
                        continue

                    client.write_register(
                        COMMAND_REGISTER,
                        reader.action,
                        number_of_decimals=0,
                        functioncode=6,
                        signed=False,
                    )
                    print(
                        f"{reader.name} uid="
                        f"{uid_hex(event, reader.uid_bytes)} -> "
                        f"{reader.result}",
                        flush=True,
                    )

                consecutive_errors = 0
            except MODBUS_ERRORS as exc:
                total_errors += 1
                consecutive_errors += 1
                try:
                    client.serial.reset_input_buffer()
                except serial.SerialException:
                    pass
                print(
                    f"WARN Modbus: {exc}; "
                    f"consecutive={consecutive_errors}, total={total_errors}",
                    file=sys.stderr,
                    flush=True,
                )
                if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
                    print(
                        "Too many consecutive Modbus errors; stopping.",
                        file=sys.stderr,
                    )
                    return 1
                time.sleep(0.02)
    except KeyboardInterrupt:
        print(f"\nStopped. Modbus errors: {total_errors}.")
        return 0
    finally:
        client.serial.close()


def main() -> int:
    return run(parse_args())


if __name__ == "__main__":
    raise SystemExit(main())
