import argparse
import json
import os
import re
import socket
import threading
import time
from http.server import HTTPServer, SimpleHTTPRequestHandler
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse

import serial
from serial.tools import list_ports


SENSOR_COUNT = 16
VALVE_COUNT = 32
DEFAULT_OPEN_DEGREES_MAX = 90
MODBUS_UNIT_ID = 3
MODBUS_TEMP_INPUT_BASE = 0
MODBUS_DS18B20_ID_INPUT_BASE = 1000
MODBUS_ROOM_INPUT_BASE = 400
MODBUS_ROOM_INPUT_REGS = 18
MODBUS_SETPOINT_HOLDING_BASE = 0
MODBUS_TEMPERATURE_SCALE = 10.0
MODBUS_ROOM_HOLDING_BASE = 300
MODBUS_ROOM_HOLDING_REGS = 8
MODBUS_ROOM_HOLDING_SETPOINT_X10 = 0
MODBUS_ROOM_HOLDING_HYST_X10 = 1
MODBUS_ROOM_HOLDING_POSITION_PCT = 2
MODBUS_ROOM_HOLDING_ANGLE_MAX = 3
MODBUS_ROOM_HOLDING_MODE = 4
MODBUS_ROOM_HOLDING_COMMAND = 5
MODBUS_ROOM_HOLDING_LOCATION = 6
MODBUS_ROOM_HOLDING_APPLY = 7
MODBUS_SENSOR_STATUS_COIL_BASE = 128
MODBUS_VALVE_OPEN_COIL_BASE = 256
MODBUS_VALVE_CLOSE_COIL_BASE = 288
MODBUS_APPLY_PARAMS_COIL = 384
MODBUS_POLL_INTERVAL = 1.0
ROOM_MODE_AUTO = 0
ROOM_MODE_MANUAL = 1
ROOM_COMMAND_STOP = 0
ROOM_COMMAND_OPEN = 1
ROOM_COMMAND_CLOSE = 2


def modbus_crc16(data: bytes) -> int:
    crc = 0xFFFF
    for byte in data:
        crc ^= byte
        for _ in range(8):
            if crc & 1:
                crc = (crc >> 1) ^ 0xA001
            else:
                crc >>= 1
    return crc & 0xFFFF


def to_signed16(value: int) -> int:
    value = int(value) & 0xFFFF
    return value - 0x10000 if value & 0x8000 else value


def numeric_suffix(value: object, fallback: int = 1) -> int:
    match = re.search(r"\d+", str(value or ""))
    return int(match.group(0)) if match else fallback


def ds18b20_id_from_registers(registers: list[int]) -> str:
    if len(registers) < 4:
        return ""
    data = bytearray()
    for value in registers[:4]:
        data.extend((int(value) & 0xFFFF).to_bytes(2, "little"))
    if not any(data):
        return ""
    return "-".join(f"{byte:02X}" for byte in data)


def make_default_sensors():
    return [
        {
            "id": f"zone_{number}",
            "name": f"Датчик {number}",
            "value": 0.0,
            "setpoint": 28.0,
            "unit": "°C",
            "zone": str(number),
            "ds18b20Id": f"28-00-00-00-00-00-00-{number:02X}",
        }
        for number in range(1, SENSOR_COUNT + 1)
    ]


def make_default_valves():
    valves = []
    for index in range(VALVE_COUNT):
        number = index + 1
        zone = (index % SENSOR_COUNT) + 1
        valves.append(
            {
                "id": f"valve_{number}",
                "name": f"Клапан {number}",
                "zone": str(zone),
                "mode": "auto",
                "position": 0,
                "targetTemp": 28,
                "isOpen": False,
                "openDegrees": 0,
                "openDegreesMax": DEFAULT_OPEN_DEGREES_MAX,
            }
        )
    return valves


DEFAULT_SENSORS = make_default_sensors()
DEFAULT_VALVES = make_default_valves()


def clamp(value, min_value, max_value):
    try:
        v = float(value)
    except Exception:
        return min_value
    if v < min_value:
        return min_value
    if v > max_value:
        return max_value
    return v


def discover_serial_ports() -> list[dict[str, str]]:
    ports: dict[str, dict[str, str]] = {}
    try:
        available_ports = list_ports.comports(include_links=True)
    except TypeError:
        available_ports = list_ports.comports()
    except Exception:
        available_ports = []

    for item in available_ports:
        add_serial_port(ports, item.device, item.description, item.hwid)

    if os.name == "nt":
        try:
            import winreg

            with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DEVICEMAP\SERIALCOMM") as key:
                index = 0
                while True:
                    try:
                        value_name, device, _ = winreg.EnumValue(key, index)
                    except OSError:
                        break
                    if str(device).upper() not in ports:
                        add_serial_port(ports, device, "Windows serial port", f"registry:{value_name}")
                    index += 1
        except (ImportError, OSError):
            pass

    return sorted(ports.values(), key=port_sort_key)


class BridgeState:
    def __init__(self):
        self.sensors = [dict(item) for item in DEFAULT_SENSORS]
        self.valves = [dict(item) for item in DEFAULT_VALVES]


state = BridgeState()
PREFERRED_PORT_TOKENS = (
    "st-link",
    "stm",
    "stmicro",
    "usb",
    "vcp",
    "virtual com",
    "usb serial",
)


def add_serial_port(ports: dict[str, dict[str, str]], device: object, description: object = "", hwid: object = "") -> None:
    device_text = str(device).strip()
    if not device_text:
        return
    key = device_text.upper()
    ports[key] = {
        "device": device_text,
        "description": str(description or "").strip(),
        "hwid": str(hwid or "").strip(),
    }


def port_text(port: dict[str, str]) -> str:
    return " ".join([port.get("device", ""), port.get("description", ""), port.get("hwid", "")]).lower()


def port_com_number(port: dict[str, str]) -> int:
    device = port.get("device", "").upper()
    if device.startswith("COM") and device[3:].isdigit():
        return int(device[3:])
    return 0


def port_sort_key(port: dict[str, str]) -> tuple[int, int, str]:
    text = port_text(port)
    rank = 0 if any(token in text for token in PREFERRED_PORT_TOKENS) else 1
    com_num = port_com_number(port)
    if 1 <= com_num <= 4:
        rank = 2
    return (rank, com_num, port.get("device", "").upper())


def preferred_serial_port(ports: list[dict[str, str]]) -> str | None:
    if not ports:
        return None
    return sorted(ports, key=port_sort_key)[0]["device"]


state_lock = threading.Lock()


def valve_open_degrees_max(valve: dict) -> int:
    return int(clamp(valve.get("openDegreesMax", DEFAULT_OPEN_DEGREES_MAX), 1, 10000))


def position_from_degrees(degrees: int, max_degrees: int) -> int:
    if max_degrees <= 0:
        return 0
    return int(round(clamp(degrees, 0, max_degrees) * 100 / max_degrees))


def degrees_from_position(position: int, max_degrees: int) -> int:
    return int(round(clamp(position, 0, 100) * max_degrees / 100))


class SerialBridge:
    def __init__(self, port: str, baudrate: int, parity: str, stopbits: float, bytesize: int, timeout: float):
        self.port = port
        self.baudrate = baudrate
        self.parity = parity
        self.stopbits = stopbits
        self.bytesize = bytesize
        self.timeout = timeout
        self.transport = "rtu"
        self.tcp_host = ""
        self.tcp_port = 502
        self.tcp_socket = None
        self.tcp_file = None
        self.unit_id = MODBUS_UNIT_ID
        self.transaction_id = 0
        self.request_lock = threading.Lock()
        self.serial = None
        self.running = False
        self.thread = None

    def configure_rtu(self, port: str, baudrate: int, parity: str, stopbits: float, bytesize: int, timeout: float):
        self.transport = "rtu"
        self.port = port
        self.baudrate = baudrate
        self.parity = parity
        self.stopbits = stopbits
        self.bytesize = bytesize
        self.timeout = timeout

    def configure_tcp(self, host: str, tcp_port: int = 502, timeout: float = 0.8):
        self.transport = "tcp"
        self.tcp_host = host
        self.tcp_port = int(tcp_port)
        self.timeout = timeout
        self.port = f"{self.tcp_host}:{self.tcp_port}"

    def connect(self):
        if self.running and self.thread and self.thread.is_alive():
            self.disconnect()
        if self.transport == "tcp":
            self.tcp_socket = socket.create_connection((self.tcp_host, self.tcp_port), timeout=self.timeout)
            self.tcp_socket.settimeout(self.timeout)
            self.tcp_file = self.tcp_socket.makefile("rb")
        else:
            self.serial = serial.Serial(
                self.port,
                self.baudrate,
                parity=self.parity,
                stopbits=self.stopbits,
                bytesize=self.bytesize,
                timeout=self.timeout,
            )
        self.running = True
        self.thread = threading.Thread(target=self.modbus_poll_loop, daemon=True)
        self.thread.start()

    def disconnect(self):
        self.running = False
        if self.thread and self.thread.is_alive():
            try:
                self.thread.join(timeout=0.5)
            except Exception:
                pass
        if self.serial and self.serial.is_open:
            self.serial.close()
        if self.tcp_file:
            try:
                self.tcp_file.close()
            except Exception:
                pass
            self.tcp_file = None
        if self.tcp_socket:
            try:
                self.tcp_socket.close()
            except Exception:
                pass
            self.tcp_socket = None

    def is_connected(self):
        if self.transport == "tcp":
            return self.tcp_socket is not None
        return bool(self.serial and self.serial.is_open)

    def status(self):
        connected = self.is_connected()
        if self.transport == "tcp":
            return {
            "ok": True,
            "connected": connected,
            "transport": "tcp",
                "unitId": self.unit_id,
            "host": self.tcp_host,
                "tcpPort": self.tcp_port,
                "address": f"{self.tcp_host}:{self.tcp_port}" if self.tcp_host else "",
                "port": f"{self.tcp_host}:{self.tcp_port}" if connected and self.tcp_host else None,
            }
        return {
            "ok": True,
            "connected": connected,
            "transport": "rtu",
            "unitId": self.unit_id,
            "port": self.port if connected else None,
            "baudrate": self.baudrate,
        }

    def send(self, payload: dict):
        if not self.is_connected():
            return
        typ = str(payload.get("type", "")).lower()
        if typ == "sensor":
            self.write_sensor_payload(payload)
            return
        if typ == "valve":
            self.write_valve_payload(payload)
            return

    def _recv_exact_tcp(self, size: int) -> bytes:
        data = bytearray()
        while len(data) < size:
            chunk = self.tcp_socket.recv(size - len(data))
            if not chunk:
                raise ConnectionError("Modbus TCP connection closed")
            data.extend(chunk)
        return bytes(data)

    def _read_exact_serial(self, size: int) -> bytes:
        data = bytearray()
        deadline = time.monotonic() + max(float(self.timeout or 0.3), 0.3)
        while len(data) < size and time.monotonic() < deadline:
            chunk = self.serial.read(size - len(data))
            if chunk:
                data.extend(chunk)
            else:
                time.sleep(0.005)
        if len(data) != size:
            raise TimeoutError("Modbus RTU response timeout")
        return bytes(data)

    def modbus_request(self, function: int, payload: bytes) -> bytes:
        pdu = bytes([function]) + payload
        with self.request_lock:
            if self.transport == "tcp":
                self.transaction_id = (self.transaction_id + 1) & 0xFFFF
                mbap = (
                    self.transaction_id.to_bytes(2, "big")
                    + b"\x00\x00"
                    + (len(pdu) + 1).to_bytes(2, "big")
                    + bytes([self.unit_id])
                )
                self.tcp_socket.sendall(mbap + pdu)
                header = self._recv_exact_tcp(7)
                length = int.from_bytes(header[4:6], "big")
                response_pdu = self._recv_exact_tcp(max(0, length - 1))
            else:
                frame = bytes([self.unit_id]) + pdu
                crc = modbus_crc16(frame)
                request = frame + crc.to_bytes(2, "little")
                try:
                    self.serial.reset_input_buffer()
                except Exception:
                    pass
                self.serial.write(request)
                header = self._read_exact_serial(3)
                if header[0] != self.unit_id:
                    raise ValueError("Unexpected Modbus RTU unit id")
                if header[1] & 0x80:
                    tail = self._read_exact_serial(2)
                    frame = header + tail
                    if modbus_crc16(frame[:-2]) != int.from_bytes(frame[-2:], "little"):
                        raise ValueError("Bad Modbus RTU CRC")
                    raise RuntimeError(f"Modbus exception {header[2]}")
                if header[1] in (0x01, 0x02, 0x03, 0x04):
                    tail = self._read_exact_serial(header[2] + 2)
                else:
                    tail = self._read_exact_serial(5)
                frame = header + tail
                if modbus_crc16(frame[:-2]) != int.from_bytes(frame[-2:], "little"):
                    raise ValueError("Bad Modbus RTU CRC")
                response_pdu = frame[1:-2]

            if not response_pdu:
                raise TimeoutError("Empty Modbus response")
            if response_pdu[0] & 0x80:
                code = response_pdu[1] if len(response_pdu) > 1 else 0
                raise RuntimeError(f"Modbus exception {code}")
            if response_pdu[0] != function:
                raise ValueError("Unexpected Modbus function")
            return response_pdu

    def read_registers(self, function: int, address: int, count: int) -> list[int]:
        response = self.modbus_request(function, address.to_bytes(2, "big") + count.to_bytes(2, "big"))
        byte_count = response[1]
        data = response[2:2 + byte_count]
        return [int.from_bytes(data[index:index + 2], "big") for index in range(0, len(data), 2)]

    def read_input_registers(self, address: int, count: int) -> list[int]:
        return self.read_registers(0x04, address, count)

    def read_holding_registers(self, address: int, count: int) -> list[int]:
        return self.read_registers(0x03, address, count)

    def read_coils(self, address: int, count: int) -> list[bool]:
        response = self.modbus_request(0x01, address.to_bytes(2, "big") + count.to_bytes(2, "big"))
        data = response[2:2 + response[1]]
        bits: list[bool] = []
        for byte in data:
            for bit in range(8):
                bits.append(bool(byte & (1 << bit)))
                if len(bits) >= count:
                    return bits
        return bits

    def write_register(self, address: int, value: int):
        self.modbus_request(0x06, address.to_bytes(2, "big") + (int(value) & 0xFFFF).to_bytes(2, "big"))

    def write_coil(self, address: int, value: bool):
        raw_value = 0xFF00 if value else 0x0000
        self.modbus_request(0x05, address.to_bytes(2, "big") + raw_value.to_bytes(2, "big"))

    def write_sensor_payload(self, payload: dict):
        sensor_number = numeric_suffix(payload.get("id"), 1)
        channel = max(0, min(SENSOR_COUNT - 1, sensor_number - 1))
        if "setpoint" in payload:
            setpoint_x10 = round(float(payload["setpoint"]) * MODBUS_TEMPERATURE_SCALE)
            self.write_register(MODBUS_SETPOINT_HOLDING_BASE + channel, setpoint_x10)
            self.write_register(MODBUS_ROOM_HOLDING_BASE + channel * MODBUS_ROOM_HOLDING_REGS + MODBUS_ROOM_HOLDING_SETPOINT_X10, setpoint_x10)
            self.write_register(MODBUS_ROOM_HOLDING_BASE + channel * MODBUS_ROOM_HOLDING_REGS + MODBUS_ROOM_HOLDING_APPLY, 1)
            self.write_coil(MODBUS_APPLY_PARAMS_COIL, True)

    def write_valve_payload(self, payload: dict):
        valve_number = numeric_suffix(payload.get("id"), 1)
        channel = max(0, min(SENSOR_COUNT - 1, (valve_number - 1) // 2))
        room_base = MODBUS_ROOM_HOLDING_BASE + channel * MODBUS_ROOM_HOLDING_REGS
        if "targetTemp" in payload:
            setpoint_x10 = round(float(payload["targetTemp"]) * MODBUS_TEMPERATURE_SCALE)
            self.write_register(MODBUS_SETPOINT_HOLDING_BASE + channel, setpoint_x10)
            self.write_register(room_base + MODBUS_ROOM_HOLDING_SETPOINT_X10, setpoint_x10)
            self.write_register(room_base + MODBUS_ROOM_HOLDING_APPLY, 1)
            self.write_coil(MODBUS_APPLY_PARAMS_COIL, True)
        if "mode" in payload:
            mode = ROOM_MODE_MANUAL if str(payload.get("mode")).lower() == "manual" else ROOM_MODE_AUTO
            self.write_register(room_base + MODBUS_ROOM_HOLDING_MODE, mode)
        if "position" not in payload and "isOpen" not in payload:
            return
        if "position" in payload:
            position_pct = int(clamp(payload.get("position") or 0, 0, 100))
            should_open = position_pct > 0
        else:
            should_open = bool(payload.get("isOpen"))
            position_pct = 100 if should_open else 0
        self.write_register(room_base + MODBUS_ROOM_HOLDING_POSITION_PCT, position_pct)
        self.write_register(room_base + MODBUS_ROOM_HOLDING_COMMAND, ROOM_COMMAND_OPEN if should_open else ROOM_COMMAND_CLOSE)
        self.write_coil(MODBUS_VALVE_OPEN_COIL_BASE + channel, should_open)
        self.write_coil(MODBUS_VALVE_CLOSE_COIL_BASE + channel, not should_open)

    def modbus_poll_loop(self):
        while self.running:
            try:
                self.poll_modbus_state()
            except Exception:
                time.sleep(0.2)
            time.sleep(MODBUS_POLL_INTERVAL)

    def apply_room_registers(self, room_regs: list[int]):
        with state_lock:
            for index in range(SENSOR_COUNT):
                start = index * MODBUS_ROOM_INPUT_REGS
                room = room_regs[start:start + MODBUS_ROOM_INPUT_REGS]
                if len(room) < MODBUS_ROOM_INPUT_REGS:
                    continue

                sensor = state.sensors[index]
                sensor["value"] = round(to_signed16(room[6]) / MODBUS_TEMPERATURE_SCALE, 1)
                sensor["setpoint"] = round(to_signed16(room[7]) / MODBUS_TEMPERATURE_SCALE, 1)
                sensor["ds18b20Id"] = ds18b20_id_from_registers(room[2:6])
                sensor["connected"] = bool(room[12])
                sensor["locationCode"] = int(room[1])

                position = int(clamp(room[9], 0, 100))
                open_degrees_max = int(room[11] or DEFAULT_OPEN_DEGREES_MAX)
                open_degrees = int(clamp(room[10], 0, open_degrees_max))
                mode = "manual" if int(room[15]) == ROOM_MODE_MANUAL else "auto"
                open_state = bool(room[13])
                close_state = bool(room[14])

                open_index = index * 2
                close_index = open_index + 1
                if open_index < len(state.valves):
                    valve = state.valves[open_index]
                    valve["targetTemp"] = sensor["setpoint"]
                    valve["mode"] = mode
                    valve["position"] = position
                    valve["openDegrees"] = open_degrees
                    valve["openDegreesMax"] = open_degrees_max
                    valve["isOpen"] = open_state or position > 0
                    valve["commandState"] = int(room[16])

                if close_index < len(state.valves):
                    valve = state.valves[close_index]
                    valve["targetTemp"] = sensor["setpoint"]
                    valve["mode"] = mode
                    valve["position"] = 100 if close_state else 0
                    valve["openDegrees"] = degrees_from_position(valve["position"], open_degrees_max)
                    valve["openDegreesMax"] = open_degrees_max
                    valve["isOpen"] = close_state
                    valve["commandState"] = int(room[16])

    def poll_modbus_state(self):
        if not self.is_connected():
            return
        try:
            room_regs = self.read_input_registers(MODBUS_ROOM_INPUT_BASE, SENSOR_COUNT * MODBUS_ROOM_INPUT_REGS)
            self.apply_room_registers(room_regs)
            return
        except Exception:
            pass
        temperatures = self.read_input_registers(MODBUS_TEMP_INPUT_BASE, SENSOR_COUNT)
        sensor_ids = self.read_input_registers(MODBUS_DS18B20_ID_INPUT_BASE, SENSOR_COUNT * 4)
        setpoints = self.read_holding_registers(MODBUS_SETPOINT_HOLDING_BASE, SENSOR_COUNT)
        try:
            sensor_connected = self.read_coils(MODBUS_SENSOR_STATUS_COIL_BASE, SENSOR_COUNT)
        except Exception:
            sensor_connected = [True] * SENSOR_COUNT
        try:
            valve_open = self.read_coils(MODBUS_VALVE_OPEN_COIL_BASE, SENSOR_COUNT)
        except Exception:
            valve_open = [False] * SENSOR_COUNT
        try:
            valve_close = self.read_coils(MODBUS_VALVE_CLOSE_COIL_BASE, SENSOR_COUNT)
        except Exception:
            valve_close = [False] * SENSOR_COUNT

        with state_lock:
            for index, sensor in enumerate(state.sensors[:SENSOR_COUNT]):
                if index < len(temperatures):
                    sensor["value"] = round(to_signed16(temperatures[index]) / 10.0, 1)
                id_start = index * 4
                ds18b20_id = ds18b20_id_from_registers(sensor_ids[id_start:id_start + 4])
                if ds18b20_id:
                    sensor["ds18b20Id"] = ds18b20_id
                if index < len(setpoints):
                    sensor["setpoint"] = round(to_signed16(setpoints[index]) / MODBUS_TEMPERATURE_SCALE, 1)
                if index < len(sensor_connected):
                    sensor["connected"] = bool(sensor_connected[index])

            for index in range(SENSOR_COUNT):
                open_index = index * 2
                close_index = open_index + 1
                open_state = bool(valve_open[index]) if index < len(valve_open) else False
                close_state = bool(valve_close[index]) if index < len(valve_close) else False
                position = 100 if open_state else 0 if close_state else int(state.valves[open_index].get("position", 0))

                if open_index < len(state.valves):
                    valve = state.valves[open_index]
                    valve["isOpen"] = open_state
                    valve["position"] = position
                    valve["openDegrees"] = degrees_from_position(position, valve_open_degrees_max(valve))
                    if index < len(setpoints):
                        valve["targetTemp"] = round(to_signed16(setpoints[index]) / MODBUS_TEMPERATURE_SCALE, 1)

                if close_index < len(state.valves):
                    valve = state.valves[close_index]
                    valve["isOpen"] = close_state
                    valve["position"] = 100 if close_state else 0
                    valve["openDegrees"] = degrees_from_position(valve["position"], valve_open_degrees_max(valve))
                    if index < len(setpoints):
                        valve["targetTemp"] = round(to_signed16(setpoints[index]) / MODBUS_TEMPERATURE_SCALE, 1)

    def read_loop(self):
        while self.running:
            try:
                raw = self.tcp_file.readline() if self.transport == "tcp" else self.serial.readline()
                if not raw:
                    continue
                try:
                    line = raw.decode("utf-8", errors="ignore").strip()
                except Exception:
                    line = ""
                if not line:
                    continue
                updated = parse_input_line(line)
                if not updated:
                    continue
                with state_lock:
                    apply_updates(updated)
            except Exception:
                time.sleep(0.05)


def zone_to_id(zone: Optional[str], kind: str):
    if zone is None:
        return None
    idx = str(zone).strip().replace("zone_", "")
    if kind == "sensor":
        return f"zone_{idx}"
    return f"valve_{idx}"


def parse_input_line(line: str):
    # JSON:
    # {"sensors":[...], "valves":[...]} or {"s1": {"value": 24.6}, ...}
    try:
        payload = json.loads(line)
    except Exception:
        payload = None

    if isinstance(payload, dict):
        if isinstance(payload.get("sensors"), list) or isinstance(payload.get("valves"), list):
            return {
                "sensors": payload.get("sensors") if isinstance(payload.get("sensors"), list) else None,
                "valves": payload.get("valves") if isinstance(payload.get("valves"), list) else None,
            }
        if any(isinstance(k, str) and (k.startswith("T") or k.startswith("S")) for k in payload.keys()):
            return {"kv": payload}

    if isinstance(payload, list):
        # allow list with {"type":"sensor",...}
        return {"items": payload}

    # key=value format:
    # T1=24.7;T2=25.1;V1_MODE=auto;V1_POS=45;...
    pairs = [p.strip() for p in line.split(";") if "=" in p]
    updates = {"kv": {}}
    for pair in pairs:
        key, value = pair.split("=", 1)
        updates["kv"][key.strip().upper()] = value.strip()

    if not updates["kv"]:
        return None
    return updates


def apply_updates(payload):
    if not payload:
        return

    if "sensors" in payload and isinstance(payload["sensors"], list):
        for item in payload["sensors"]:
            sid = str(item.get("id") or item.get("sensorId") or item.get("code") or "").strip()
            sensor = next((s for s in state.sensors if s["id"] == sid), None)
            if not sensor:
                continue
            if "value" in item:
                sensor["value"] = clamp(item["value"], -1000, 1000)
            if "setpoint" in item:
                sensor["setpoint"] = clamp(item["setpoint"], -1000, 1000)
            if "unit" in item:
                sensor["unit"] = item["unit"]

    if "valves" in payload and isinstance(payload["valves"], list):
        for item in payload["valves"]:
            vid = str(item.get("id") or item.get("valveId") or item.get("code") or "").strip()
            valve = next((v for v in state.valves if v["id"] == vid), None)
            if not valve:
                continue
            if "mode" in item:
                mode = str(item["mode"]).lower()
                valve["mode"] = "manual" if mode == "manual" else "auto"
            if "position" in item:
                valve["position"] = int(clamp(item["position"], 0, 100))
                valve["openDegrees"] = degrees_from_position(valve["position"], valve_open_degrees_max(valve))
            if "openDegreesMax" in item:
                valve["openDegreesMax"] = int(clamp(item["openDegreesMax"], 1, 10000))
            if "openDegrees" in item:
                max_degrees = valve_open_degrees_max(valve)
                valve["openDegrees"] = int(clamp(item["openDegrees"], 0, max_degrees))
                valve["position"] = position_from_degrees(valve["openDegrees"], max_degrees)
            if "targetTemp" in item:
                valve["targetTemp"] = clamp(item["targetTemp"], -1000, 1000)
            if "isOpen" in item:
                valve["isOpen"] = bool(item["isOpen"])

    if "items" in payload and isinstance(payload["items"], list):
        for item in payload["items"]:
            if not isinstance(item, dict):
                continue
            typ = str(item.get("type", "")).lower()
            if typ == "sensor":
                sid = str(item.get("id") or item.get("sensorId") or item.get("zone") or "")
                sensor = next((s for s in state.sensors if s["id"] == sid), None)
                if sensor:
                    if "value" in item:
                        sensor["value"] = clamp(item["value"], -1000, 1000)
            elif typ == "valve":
                vid = str(item.get("id") or item.get("valveId") or item.get("zone") or "")
                valve = next((v for v in state.valves if v["id"] == vid), None)
                if valve:
                    if "mode" in item:
                        mode = str(item["mode"]).lower()
                        valve["mode"] = "manual" if mode == "manual" else "auto"
                    if "openDegreesMax" in item:
                        valve["openDegreesMax"] = int(clamp(item["openDegreesMax"], 1, 10000))
                    if "openDegrees" in item:
                        max_degrees = valve_open_degrees_max(valve)
                        valve["openDegrees"] = int(clamp(item["openDegrees"], 0, max_degrees))
                        valve["position"] = position_from_degrees(valve["openDegrees"], max_degrees)
                    if "position" in item and "openDegrees" not in item:
                        max_degrees = valve_open_degrees_max(valve)
                        valve["position"] = int(clamp(item["position"], 0, 100))
                        valve["openDegrees"] = degrees_from_position(valve["position"], max_degrees)

    kv = payload.get("kv", {})
    if not kv:
        return

    sensor_map = {sensor["id"].split("_")[1]: sensor for sensor in state.sensors if "_" in sensor["id"]}
    valve_map = {valve["id"].split("_")[1]: valve for valve in state.valves if "_" in valve["id"]}

    # Temperature value keys
    # T1=24.5 | TEMP1=24.5 | Z1_TEMP=24.5
    for key, raw_value in kv.items():
        value = raw_value
        key_upper = key.upper()
        num = re.search(r"\d+", key_upper)
        idx = num.group(0) if num else None

        if key_upper.startswith("T") or key_upper.startswith("TEMP") or "TEMP" in key_upper:
            zone = idx
            if zone and zone in sensor_map:
                try:
                    sensor_map[zone]["value"] = clamp(value, -1000, 1000)
                except Exception:
                    pass
            continue

        if key_upper.startswith("S") and idx and "SETPOINT" in key_upper:
            zone = idx
            if zone in sensor_map:
                try:
                    sensor_map[zone]["setpoint"] = clamp(value, -1000, 1000)
                except Exception:
                    pass
            continue

        # valve keys: V1_MODE=auto, V1_POS=45, V1_TGT=28
        if key_upper.startswith("V") and idx:
            valve = valve_map.get(idx)
            if not valve:
                continue
            if "MODE" in key_upper:
                valve["mode"] = "manual" if "MANUAL" in key_upper and str(value).lower() == "manual" else str(value).lower()
            if "POS" in key_upper:
                try:
                    valve["position"] = int(clamp(value, 0, 100))
                    valve["openDegrees"] = degrees_from_position(valve["position"], valve_open_degrees_max(valve))
                except Exception:
                    pass
            if "TGT" in key_upper:
                try:
                    valve["targetTemp"] = clamp(value, -1000, 1000)
                except Exception:
                    pass
            if "MAXDEG" in key_upper:
                max_degrees = int(clamp(value, 1, 10000))
                valve["openDegreesMax"] = max_degrees
                max_degrees = valve_open_degrees_max(valve)
                try:
                    valve["openDegrees"] = int(
                        clamp(valve.get("openDegrees", degrees_from_position(valve.get("position", 0), max_degrees)), 0, max_degrees)
                    )
                    valve["position"] = position_from_degrees(valve["openDegrees"], max_degrees)
                except Exception:
                    pass
            elif "DEG" in key_upper:
                try:
                    max_degrees = valve_open_degrees_max(valve)
                    valve["openDegrees"] = int(clamp(value, 0, max_degrees))
                    valve["position"] = position_from_degrees(valve["openDegrees"], max_degrees)
                except Exception:
                    pass

    for valve in state.valves:
        max_degrees = valve_open_degrees_max(valve)
        valve["openDegrees"] = int(clamp(valve.get("openDegrees", degrees_from_position(valve.get("position", 0), max_degrees)), 0, max_degrees))
        valve["isOpen"] = int(valve.get("position", 0)) > 0


def update_sensor(id_, patch):
    sid = str(id_)
    with state_lock:
        sensor = next((item for item in state.sensors if item["id"] == sid), None)
        if not sensor:
            return False
        sensor.update(patch)
    return True


def update_valve(id_, patch):
    vid = str(id_)
    with state_lock:
        valve = next((item for item in state.valves if item["id"] == vid), None)
        if not valve:
            return False
        if "openDegreesMax" in patch:
            valve["openDegreesMax"] = int(clamp(patch["openDegreesMax"], 1, 10000))

        if "mode" in patch:
            mode = str(patch["mode"]).lower()
            valve["mode"] = "manual" if mode == "manual" else "auto"
        if "targetTemp" in patch:
            valve["targetTemp"] = clamp(patch["targetTemp"], -1000, 1000)
        if "isOpen" in patch:
            valve["isOpen"] = bool(patch["isOpen"])
        if "name" in patch:
            valve["name"] = patch["name"]

        if "openDegrees" in patch:
            max_degrees = valve_open_degrees_max(valve)
            valve["openDegrees"] = int(clamp(patch["openDegrees"], 0, max_degrees))
            valve["position"] = position_from_degrees(valve["openDegrees"], max_degrees)
        if "position" in patch:
            max_degrees = valve_open_degrees_max(valve)
            valve["position"] = int(clamp(patch["position"], 0, 100))
            valve["openDegrees"] = degrees_from_position(valve["position"], max_degrees)
        if "openDegreesMax" in patch and "openDegrees" not in patch and "position" not in patch:
            max_degrees = valve_open_degrees_max(valve)
            valve["openDegrees"] = int(clamp(valve.get("openDegrees", 0), 0, max_degrees))
        valve["isOpen"] = int(valve.get("position", 0)) > 0
    return True


def _calibrate_valve_locked(valve: dict) -> dict:
    valve["position"] = 0
    valve["openDegrees"] = 0
    valve["isOpen"] = False
    return dict(valve)


def calibrate_valve(channel_id: str) -> dict | None:
    with state_lock:
        valve = next((item for item in state.valves if item["id"] == str(channel_id)), None)
        if not valve:
            return None
        updated = _calibrate_valve_locked(valve)

    bridge.send({
        "type": "valve",
        "id": str(channel_id),
        "command": "calibrate",
        "position": 0,
        "openDegrees": 0,
        "close": True,
    })
    return updated


def calibrate_all_valves() -> list[dict]:
    with state_lock:
        calibrated = [_calibrate_valve_locked(valve) for valve in state.valves]

    for item in calibrated:
        bridge.send({
            "type": "valve",
            "id": item["id"],
            "command": "calibrate",
            "position": 0,
            "openDegrees": 0,
            "close": True,
        })
    return [dict(item) for item in calibrated]


def snapshot_sensors():
    with state_lock:
        return [dict(item) for item in state.sensors]


def snapshot_valves():
    with state_lock:
        return [dict(item) for item in state.valves]


class Handler(SimpleHTTPRequestHandler):

    def end_headers(self):
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
        self.send_header("Pragma", "no-cache")
        self.send_header("Expires", "0")
        super().end_headers()
    def _send_json(self, payload, code=200):
        body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_OPTIONS(self):
        self.send_response(204)
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, PUT, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_GET(self):
        parsed = urlparse(self.path)
        if parsed.path == "/api/sensors":
            self._send_json(snapshot_sensors())
            return
        if parsed.path == "/api/valves":
            self._send_json(snapshot_valves())
            return
        if parsed.path in ("/api/serial/ports", "/api/ports"):
            ports = discover_serial_ports()
            self._send_json(
                {"ok": True, "ports": ports, "selected": bridge.port or preferred_serial_port(ports)}
            )
            return
        if parsed.path in ("/api/serial/status", "/api/state"):
            status = bridge.status()
            if status.get("connected") and bridge.port:
                status["selected"] = bridge.port
            self._send_json(status)
            return
        if parsed.path == "/":
            self.path = "/index.html"
        return super().do_GET()

    def do_POST(self):
        parsed = urlparse(self.path)
        parts = [seg for seg in parsed.path.split("/") if seg]
        if len(parts) == 3 and parts[0] == "api" and parts[1] == "valves" and parts[2] == "calibrate-all":
            status = {"ok": True, "valves": calibrate_all_valves(), "message": "calibration started"}
            self._send_json(status)
            return

        if len(parts) == 4 and parts[0] == "api" and parts[1] == "valves" and parts[3] == "calibrate":
            valve_id = parts[2]
            updated = calibrate_valve(valve_id)
            if not updated:
                self.send_response(404)
                self.end_headers()
                return
            self._send_json({"ok": True, "valve": updated, "message": "calibration started"})
            return

        if parsed.path in ("/api/serial/connect", "/api/connect"):
            length = int(self.headers.get("Content-Length", "0") or "0")
            body = self.rfile.read(length)
            try:
                payload = json.loads(body.decode("utf-8", errors="ignore")) if body else {}
            except Exception:
                payload = {}
            transport = str(payload.get("transport", payload.get("mode", "rtu"))).lower()
            timeout = float(payload.get("timeout", 0.8 if transport == "tcp" else 0.3))
            try:
                unit_id = int(payload.get("unitId", payload.get("slaveId", payload.get("slave", bridge.unit_id))))
            except Exception:
                unit_id = MODBUS_UNIT_ID
            bridge.unit_id = int(clamp(unit_id, 1, 247))

            if transport in ("tcp", "modbus_tcp", "modbus-tcp"):
                host = str(payload.get("host") or payload.get("ip") or payload.get("address") or "").strip()
                if not host:
                    self._send_json({"ok": False, "error": "TCP host/IP is required"}, 400)
                    return
                tcp_port = int(payload.get("tcpPort", payload.get("tcp_port", payload.get("modbusPort", payload.get("port", 502)))))
                bridge.configure_tcp(host, tcp_port, timeout)
            else:
                port = str(payload.get("port", "")).strip()
                if not port:
                    self.send_response(400)
                    self.end_headers()
                    return
                baud = payload.get("baudrate", payload.get("baud", 115200))
                parity = payload.get("parity", "N")
                stopbits = payload.get("stopbits", payload.get("stopBits", 1))
                bytesize = payload.get("bytesize", payload.get("byteSize", 8))
                bridge.configure_rtu(port, baud, parity, stopbits, bytesize, timeout)
            try:
                bridge.connect()
            except Exception as exc:
                self._send_json({"ok": False, "error": str(exc)}, 500)
                return
            calibrate_all_valves()
            status = bridge.status()
            status["ok"] = True
            self._send_json(status)
            return

        if parsed.path in ("/api/serial/disconnect", "/api/disconnect"):
            transport = bridge.transport
            bridge.disconnect()
            self._send_json({"ok": True, "connected": False, "transport": transport})
            return

        self.send_response(404)
        self.end_headers()

    def do_PUT(self):
        parsed = urlparse(self.path)
        parts = [seg for seg in parsed.path.split("/") if seg]
        if len(parts) != 3 or parts[0] != "api":
            self.send_response(404)
            self.end_headers()
            return
        _, resource, raw_id = parts
        item_id = raw_id
        length = int(self.headers.get("Content-Length", "0") or "0")
        body = self.rfile.read(length)
        try:
            payload = json.loads(body.decode("utf-8", errors="ignore")) if body else {}
        except Exception:
            payload = {}

        if resource == "sensors":
            patch = {}
            if "setpoint" in payload:
                patch["setpoint"] = float(payload["setpoint"])
            if not patch:
                patch = {k: v for k, v in payload.items() if k in ("setpoint", "value", "unit", "name")}
            if not update_sensor(item_id, patch):
                self.send_response(404)
                self.end_headers()
                return
            if bridge.is_connected():
                try:
                    bridge.send({"type": "sensor", "id": item_id, **patch})
                except Exception as exc:
                    self._send_json({"ok": False, "error": str(exc)}, 500)
                    return
            self._send_json(next(item for item in state.sensors if item["id"] == item_id))
            return

        if resource == "valves":
            patch = {}
            if "mode" in payload:
                mode = str(payload["mode"]).lower()
                patch["mode"] = "manual" if mode == "manual" else "auto"
            if "position" in payload:
                patch["position"] = int(clamp(payload["position"], 0, 100))
            if "targetTemp" in payload:
                patch["targetTemp"] = float(payload["targetTemp"])
            if not patch:
                patch = {k: v for k, v in payload.items() if k in ("mode", "position", "targetTemp", "isOpen", "name")}
            if not update_valve(item_id, patch):
                self.send_response(404)
                self.end_headers()
                return
            if bridge.is_connected():
                try:
                    bridge.send({"type": "valve", "id": item_id, **patch})
                except Exception as exc:
                    self._send_json({"ok": False, "error": str(exc)}, 500)
                    return
            self._send_json(next(item for item in state.valves if item["id"] == item_id))
            return

        self.send_response(404)
        self.end_headers()


def parse_args():
    parser = argparse.ArgumentParser(description="Serial bridge for MCU data to Web GUI")
    parser.add_argument("--host", default="0.0.0.0")
    parser.add_argument("--port", type=int, default=8080)
    parser.add_argument("--serial-port", default="")
    parser.add_argument("--baudrate", type=int, default=115200)
    parser.add_argument("--parity", default="N")
    parser.add_argument("--stopbits", type=float, default=1)
    parser.add_argument("--bytesize", type=int, default=8)
    parser.add_argument("--timeout", type=float, default=0.3)
    return parser.parse_args()


if __name__ == "__main__":
    args = parse_args()
    bridge = SerialBridge(
        args.serial_port,
        baudrate=args.baudrate,
        parity=args.parity,
        stopbits=args.stopbits,
        bytesize=args.bytesize,
        timeout=args.timeout,
    )

    if args.serial_port:
        try:
            bridge.connect()
            print(f"COM connected: {args.serial_port} @ {args.baudrate}")
            calibrate_all_valves()
        except Exception as exc:
            print(f"Не удалось открыть {args.serial_port}: {exc}. Запуск только в памяти.")
    else:
        print("COM порт не указан. Сервер стартует в локальном режиме без чтения порта.")

    server = HTTPServer((args.host, args.port), Handler)
    print(f"Server started: http://{args.host}:{args.port}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        bridge.disconnect()
        server.server_close()
