const SENSOR_COUNT = 16;
const VALVE_COUNT = 32;
const DEFAULT_OPEN_DEGREES_MAX = 90;
const CHANNEL_LOCATIONS = [
  "DUO прав",
  "DUO лев",
  "TRIO",
  "SOLO",
  "ОСНОВА 7",
  "ОСНОВА 6",
  "ОСНОВА 5",
  "ОСНОВА 4",
  "ОСНОВА 3",
  "ОСНОВА 2",
  "ОСНОВА 1",
];

function defaultChannelLocation(index) {
  return CHANNEL_LOCATIONS[index % CHANNEL_LOCATIONS.length];
}

function makeDefaultSensors() {
  return Array.from({ length: SENSOR_COUNT }, (_, index) => {
    const number = index + 1;
    return {
      id: `zone_${number}`,
      name: `Датчик ${number}`,
      value: 0,
      setpoint: 28,
      unit: "°C",
      zone: String(number),
      location: defaultChannelLocation(index),
      ds18b20Id: `28-00-00-00-00-00-00-${number.toString(16).toUpperCase().padStart(2, "0")}`,
    };
  });
}

function makeDefaultValves() {
  return Array.from({ length: VALVE_COUNT }, (_, index) => {
    const number = index + 1;
    const zone = (index % SENSOR_COUNT) + 1;
    return {
      id: `valve_${number}`,
      name: `Клапан ${number}`,
      zone: String(zone),
      mode: "auto",
      position: 0,
      targetTemp: 28,
      isOpen: false,
      openDegrees: 0,
      openDegreesMax: DEFAULT_OPEN_DEGREES_MAX,
    };
  });
}

const defaultSensors = makeDefaultSensors();
const defaultValves = makeDefaultValves();

const API_PATHS = {
  sensorsRead: ["/api/sensors", "/sensors", "/api/data", "/state"],
  valvesRead: ["/api/valves", "/valves", "/api/data", "/state"],
  sensorWrite: [
    (id) => `/api/sensors/${encodeURIComponent(id)}`,
    (id) => `/sensors/${encodeURIComponent(id)}`,
    () => "/api/sensors",
    () => "/sensors",
  ],
  valveWrite: [
    (id) => `/api/valves/${encodeURIComponent(id)}`,
    (id) => `/valves/${encodeURIComponent(id)}`,
    () => "/api/valves",
    () => "/valves",
  ],
  valveCalibrate: [
    (id) => `/api/valves/${encodeURIComponent(id)}/calibrate`,
    (id) => `/valves/${encodeURIComponent(id)}/calibrate`,
  ],
  valvesCalibrateAll: ["/api/valves/calibrate-all", "/api/calibration/all", "/calibration/all"],
};

const SERIAL_API_PATHS = {
  ports: [
    "/api/serial/ports",
    "/api/ports",
    "/ports",
    "/serial/ports",
    "/status/ports",
  ],
  status: [
    "/api/serial/status",
    "/api/state",
    "/state",
    "/serial/status",
  ],
  connect: [
    "/api/serial/connect",
    "/api/connect",
    "/connect",
    "/serial/connect",
  ],
  disconnect: [
    "/api/serial/disconnect",
    "/api/disconnect",
    "/disconnect",
    "/serial/disconnect",
  ],
};

const state = {
  sensors: [...defaultSensors],
  valves: [...defaultValves],
  apiBase: "",
  timer: null,
};

let serialConnected = false;
let selectedPort = "";

const sensorsEl = document.getElementById("sensors");
const valvesEl = document.getElementById("valves");
const statusEl = document.getElementById("status");
const globalStatus = document.getElementById("globalStatus");
const apiInput = document.getElementById("apiBase");
const refreshBtn = document.getElementById("refreshBtn");
const saveApiBtn = document.getElementById("saveApiBtn");
const refreshPortsBtn = document.getElementById("refreshPortsBtn");
const connectPortBtn = document.getElementById("connectPortBtn");
const comPortSelect = document.getElementById("comPortSelect");
const modbusTransport = document.getElementById("modbusTransport");
const tcpHost = document.getElementById("tcpHost");
const tcpPort = document.getElementById("tcpPort");
const modbusSlaveId = document.getElementById("modbusSlaveId");
const serialStatus = document.getElementById("serialStatus");
const calibrateAllBtn = document.getElementById("calibrateAllBtn");

function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function parseGuiNumber(value, fallback = 0) {
  const parsed = Number(String(value ?? "").replace(",", "."));
  return Number.isFinite(parsed) ? parsed : fallback;
}

function storageGet(name, fallback) {
  const raw = localStorage.getItem(name);
  if (!raw) return fallback;
  try {
    return JSON.parse(raw);
  } catch {
    return fallback;
  }
}

function storageSet(name, value) {
  localStorage.setItem(name, JSON.stringify(value));
}

function getModbusTransport() {
  return (modbusTransport?.value || storageGet("modbusTransport", "rtu") || "rtu").toLowerCase();
}

function applyModbusTransportView() {
  const transport = getModbusTransport();
  const isTcp = transport === "tcp";

  document.querySelectorAll(".rtu-field").forEach((element) => {
    element.classList.toggle("hidden", isTcp);
  });
  document.querySelectorAll(".tcp-field").forEach((element) => {
    element.classList.toggle("hidden", !isTcp);
  });

  if (refreshPortsBtn) {
    refreshPortsBtn.disabled = isTcp;
  }
  if (serialStatus && !serialConnected) {
    serialStatus.textContent = isTcp ? "Modbus TCP: не подключен" : "COM: не подключен";
  }
}

function initModbusTransportControls() {
  if (modbusTransport) {
    modbusTransport.value = storageGet("modbusTransport", "rtu");
    modbusTransport.addEventListener("change", () => {
      storageSet("modbusTransport", modbusTransport.value);
      applyModbusTransportView();
    });
  }
  if (tcpHost) {
    tcpHost.value = storageGet("tcpHost", tcpHost.value || "192.168.0.10");
    tcpHost.addEventListener("change", () => storageSet("tcpHost", tcpHost.value.trim()));
  }
  if (tcpPort) {
    tcpPort.value = storageGet("tcpPort", tcpPort.value || "502");
    tcpPort.addEventListener("change", () => storageSet("tcpPort", tcpPort.value || "502"));
  }
  if (modbusSlaveId) {
    modbusSlaveId.value = storageGet("modbusSlaveId", modbusSlaveId.value || "3");
    modbusSlaveId.addEventListener("change", () => storageSet("modbusSlaveId", modbusSlaveId.value || "3"));
  }
  applyModbusTransportView();
}

function normalizeApiUrl(value) {
  const trimmed = (value || "").trim();
  if (trimmed && !/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
    return normalizeApiUrl(`http://${trimmed}`);
  }
  if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
    try {
      const parsed = new URL(trimmed);
      return `${parsed.protocol}//${parsed.host}`;
    } catch {
      return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
    }
  }
  return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
}

function ensureApiBase() {
  if (state.apiBase) {
    return state.apiBase;
  }
  if (window.location?.protocol?.startsWith("http") && window.location.host) {
    state.apiBase = `${window.location.protocol}//${window.location.host}`;
  } else {
    state.apiBase = "http://127.0.0.1:8080";
  }
  if (apiInput) {
    apiInput.value = state.apiBase;
  }
  storageSet("apiBase", state.apiBase);
  return state.apiBase;
}

function endpoint(path) {
  if (!state.apiBase) {
    throw new Error("demo");
  }
  return `${state.apiBase}${path}`;
}

async function parseResponseBody(response) {
  const body = await response.text();
  if (!body) {
    return {};
  }
  try {
    return JSON.parse(body);
  } catch {
    return body;
  }
}

async function apiGet(path) {
  const response = await fetch(endpoint(path), {
    method: "GET",
    headers: { accept: "application/json" },
  });
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`HTTP ${response.status}: ${body}`);
  }
  return parseResponseBody(response);
}

async function apiPut(path, payload) {
  const response = await fetch(endpoint(path), {
    method: "PUT",
    headers: { "content-type": "application/json", accept: "application/json" },
    body: JSON.stringify(payload),
  });
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`HTTP ${response.status}: ${body}`);
  }
  return parseResponseBody(response);
}

async function apiPost(path, payload) {
  const response = await fetch(endpoint(path), {
    method: "POST",
    headers: { "content-type": "application/json", accept: "application/json" },
    body: JSON.stringify(payload),
  });
  if (!response.ok) {
    const body = await response.text();
    throw new Error(`HTTP ${response.status}: ${body}`);
  }
  return parseResponseBody(response);
}

function extractPorts(payload) {
  if (Array.isArray(payload)) return payload;
  if (Array.isArray(payload?.ports)) return payload.ports;
  return [];
}

function extractSelectedPort(payload) {
  if (typeof payload?.selected === "string") return payload.selected;
  if (typeof payload?.port === "string") return payload.port;
  return "";
}

async function apiGetFallback(paths) {
  let lastError;
  for (const path of paths) {
    try {
      return await apiGet(path);
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError || new Error("No available endpoint");
}

async function apiPostFallback(paths, payload) {
  let lastError;
  for (const path of paths) {
    try {
      return await apiPost(path, payload);
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError || new Error("No available endpoint");
}

function degreesFromPosition(position, maxDegrees = DEFAULT_OPEN_DEGREES_MAX) {
  const maxValue = Number(maxDegrees) > 0 ? Number(maxDegrees) : DEFAULT_OPEN_DEGREES_MAX;
  return Math.round(clamp(position, 0, 100) * maxValue / 100);
}

function positionFromDegrees(degrees, maxDegrees = DEFAULT_OPEN_DEGREES_MAX) {
  const maxValue = Number(maxDegrees) > 0 ? Number(maxDegrees) : DEFAULT_OPEN_DEGREES_MAX;
  return Math.round(clamp(degrees, 0, maxValue) * 100 / maxValue);
}

async function fetchComPorts() {
  if (!state.apiBase) {
    return { ports: [], selected: "" };
  }
  const payload = await apiGetFallback(SERIAL_API_PATHS.ports);
  return {
    ports: extractPorts(payload),
    selected: extractSelectedPort(payload),
  };
}

function applyPortOptions(ports, preferredPort = "") {
  if (!comPortSelect) return;
  const current = comPortSelect.value;
  comPortSelect.innerHTML = "<option value=\"\">Порт не выбран</option>";
  for (const item of ports) {
    const entry = typeof item === "string" ? { device: item, description: "" } : item;
    const option = document.createElement("option");
    const value = entry.device || entry.port || "";
    if (!value) continue;
    option.value = value;
    option.textContent = `${entry.description ? `${entry.description} (${value})` : value}`;
    option.dataset.hwid = entry.hwid || "";
    comPortSelect.appendChild(option);
  }
  const targetPort = preferredPort || current;
  if (ports.find((item) => (typeof item === "string" ? item : item.device) === targetPort)) {
    comPortSelect.value = targetPort;
  } else if (current) {
    comPortSelect.value = "";
  }
}

async function refreshPortsLegacy(silent = false) {
  const payload = await fetchComPorts();
  const ports = payload?.ports || [];
  applyPortOptions(ports, payload?.selected || "");
  if (!silent) {
    updateStatus(ports.length ? "Доступные порты обновлены" : "Порты не найдены", ports.length ? "ok" : "warn");
  }
}

async function connectSelectedPortLegacyOld() {
  if (!state.apiBase) {
    updateStatus("Укажите API endpoint, чтобы работать с COM-портами", "warn");
    return;
  }
  const value = comPortSelect.value;
  if (!value) {
    updateStatus("Выберите COM порт", "warn");
    return;
  }
  selectedPort = value;
  try {
    const payload = { port: value, baud: 115200, baudrate: 115200, parity: "N", stopbits: 1, bytesize: 8, timeout: 0.3 };
    await apiPostFallback(SERIAL_API_PATHS.connect, payload);
    serialConnected = true;
    serialStatus.textContent = `COM: подключён (${value})`;
    serialStatus.className = "status status-ok";
    updateStatus(`COM ${value} подключён`, "ok");
  } catch (error) {
    serialConnected = false;
    serialStatus.textContent = "COM: ошибка подключения";
    serialStatus.className = "status status-error";
    updateStatus(`Ошибка подключения COM: ${error.message}`, "error");
  }
}

async function disconnectPortLegacy() {
  if (!state.apiBase) return;
  try {
    await apiPostFallback(SERIAL_API_PATHS.disconnect, {});
  } catch {
    // ignore
  } finally {
    serialConnected = false;
    serialStatus.textContent = "COM: не подключён";
    serialStatus.className = "status status-warn";
    selectedPort = "";
  }
}

function normalizeSensor(raw = {}) {
  const rawId = raw.id ?? raw.sensorId ?? raw.key ?? raw.code;
  const zone = String(raw.zone ?? raw.channel ?? raw.sensorZone ?? "").trim() || String(rawId ?? "").replace(/\D+/g, "");
  const fallbackZone = zone ? `zone_${zone}` : "zone_1";
  const id = rawId || fallbackZone;
  return {
    id: String(id),
    name: String(raw.name ?? raw.label ?? `Термопара ${zone || 1}`),
    zone: String(zone || "1"),
    value: Number(raw.value ?? raw.current ?? raw.temperature ?? raw.temp ?? 0),
    setpoint: Number(raw.setpoint ?? raw.target ?? raw.targetTemp ?? raw.tSet ?? 0),
    unit: raw.unit ?? raw.units ?? "°C",
  };
}

function normalizeValve(raw = {}) {
  const rawId = raw.id ?? raw.valveId ?? raw.key ?? raw.code;
  const zone = String(raw.zone ?? raw.channel ?? raw.controlZone ?? "").trim() || String(rawId ?? "").replace(/\D+/g, "");
  const id = rawId || `valve_${zone || "1"}`;
  const mode = String(raw.mode ?? raw.workMode ?? "auto").toLowerCase() === "manual" ? "manual" : "auto";
  const maxOpenDegrees = Number(raw.openDegreesMax ?? raw.maxOpenDegrees ?? raw.degMax ?? DEFAULT_OPEN_DEGREES_MAX);
  const maxDegrees = Number.isFinite(maxOpenDegrees) && maxOpenDegrees > 0 ? maxOpenDegrees : DEFAULT_OPEN_DEGREES_MAX;
  let position = Number(raw.position ?? raw.pos ?? raw.percent ?? raw.value);
  let openDegrees = Number(raw.openDegrees ?? raw.degree ?? raw.posDeg ?? raw.opening ?? raw.openAngle ?? raw.angle);

  if (Number.isNaN(position)) {
    if (Number.isNaN(openDegrees)) {
      position = 0;
      openDegrees = 0;
    } else {
      position = positionFromDegrees(openDegrees, maxDegrees);
    }
  } else if (Number.isNaN(openDegrees)) {
    openDegrees = degreesFromPosition(position, maxDegrees);
  } else {
    position = positionFromDegrees(openDegrees, maxDegrees);
  }
  return {
    id: String(id),
    name: String(raw.name ?? raw.label ?? `Клапан ${zone || 1}`),
    zone: String(zone || "1"),
    mode,
    position: clamp(position, 0, 100),
    openDegrees: clamp(Math.round(openDegrees), 0, maxDegrees),
    openDegreesMax: maxDegrees,
    targetTemp: Number(raw.targetTemp ?? raw.targetTemperature ?? raw.setpoint ?? raw.tSet ?? 0),
    isOpen: Boolean(raw.isOpen ?? raw.open ?? position > 0),
  };
}

function extractCollection(payload, key) {
  if (!payload) return null;
  if (Array.isArray(payload)) return payload;
  if (Array.isArray(payload[key])) return payload[key];
  if (Array.isArray(payload.data?.[key])) return payload.data[key];
  if (Array.isArray(payload.result?.[key])) return payload.result[key];
  if (Array.isArray(payload.state?.[key])) return payload.state[key];
  if (Array.isArray(payload.data?.[`temperature_${key}`])) return payload.data[`temperature_${key}`];
  return null;
}

async function fetchCollection(paths, key, normalizer) {
  let lastError;
  for (const path of paths) {
    try {
      const payload = await apiGet(path);
      const collection = extractCollection(payload, key);
      if (!collection || !Array.isArray(collection)) continue;
      return collection.map(normalizer);
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError || new Error(`No available endpoint for ${key}`);
}

async function sendWithFallback(paths, id, payload) {
  let lastError;
  for (const resolvePath of paths) {
    try {
      const route = typeof resolvePath === "function" ? resolvePath(id) : resolvePath;
      return await apiPut(route, payload);
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError || new Error("No available write endpoint");
}

async function postWithFallback(paths, id, payload) {
  let lastError;
  for (const resolvePath of paths) {
    try {
      const route = typeof resolvePath === "function" ? resolvePath(id) : resolvePath;
      return await apiPost(route, payload);
    } catch (error) {
      lastError = error;
    }
  }
  throw lastError || new Error("No available write endpoint");
}

function writePayloadBase(id, type = "sensor") {
  const sensor = findSensorById(id);
  const valve = findValveById(id);
  const entity = type === "valve" ? valve : sensor;
  return {
    id,
    ...(entity ? { zone: entity.zone, name: entity.name } : {}),
  };
}

function mergeDefaults(stored, defaults, normalizer = (item) => item) {
  const source = Array.isArray(stored) ? stored : [];
  const byId = new Map(source.map((item) => [String(item.id), normalizer(item)]));
  return defaults.map((item) => ({
    ...item,
    ...(byId.get(String(item.id)) || {}),
  }));
}

function buildDemos() {
  state.sensors = mergeDefaults(storageGet("sensorState", []), defaultSensors, normalizeSensor);
  state.valves = mergeDefaults(storageGet("valveState", []), defaultValves, normalizeValve);
  storageSet("sensorState", state.sensors);
  storageSet("valveState", state.valves);
}

function updateStatus(message, type = "ok") {
  globalStatus.textContent = message;
  globalStatus.className = `status-${type}`;
}

function setConnectButtonText() {
  if (!connectPortBtn) {
    return;
  }
  connectPortBtn.textContent = serialConnected ? "Отключить" : "Подключить";
}

function setSerialBusyState(isBusy, message) {
  if (refreshPortsBtn) {
    refreshPortsBtn.disabled = isBusy;
  }
  if (connectPortBtn) {
    connectPortBtn.disabled = isBusy;
    connectPortBtn.textContent = isBusy ? "..." : (serialConnected ? "Отключить" : "Подключить");
  }
  if (calibrateAllBtn) {
    calibrateAllBtn.disabled = isBusy || !serialConnected;
  }
  if (message) {
    serialStatus.textContent = message;
  }
}

function applySerialStateFromPayload(payload = {}) {
  const connected = Boolean(payload?.connected);
  const transport = String(payload?.transport || getModbusTransport()).toLowerCase();
  const isTcp = transport === "tcp";
  const tcpAddress = payload?.address || (payload?.host || payload?.ip ? `${payload?.host || payload?.ip}:${payload?.tcpPort || payload?.tcp_port || 502}` : "");
  const port = isTcp ? (tcpAddress || payload?.port || "") : (payload?.port || payload?.selected || payload?.com || "");
  serialConnected = connected;
  selectedPort = connected && port ? port : "";
  if (comPortSelect && selectedPort) {
    comPortSelect.value = selectedPort;
  }
  if (serialStatus) {
    if (connected && selectedPort) {
      serialStatus.textContent = isTcp ? `Modbus TCP: connected (${selectedPort})` : `COM: connected (${selectedPort})`;
      serialStatus.className = "status status-ok";
    } else {
      serialStatus.textContent = isTcp ? "Modbus TCP: disconnected" : "COM: disconnected";
      serialStatus.className = "status status-warn";
    }
  }
  if (calibrateAllBtn) {
    calibrateAllBtn.disabled = !serialConnected;
  }
  setConnectButtonText();
  storageSet("comPort", selectedPort || "");
}

async function loadState() {
  if (!state.apiBase) {
    buildDemos();
    simulateSensorPhysics();
    statusEl.textContent = "Режим: демо (без API)";
    statusEl.className = "status status-warn";
    globalStatus.textContent = "Демо: все изменения сохраняются в браузере";
    return;
  }
  const [sensors, valves] = await Promise.all([
    fetchCollection(API_PATHS.sensorsRead, "sensors", normalizeSensor),
    fetchCollection(API_PATHS.valvesRead, "valves", normalizeValve),
  ]);
  state.sensors = sensors;
  state.valves = valves;
  statusEl.textContent = "Подключено к API";
  statusEl.className = "status status-ok";
  globalStatus.textContent = `API подключен: ${state.apiBase}`;
}

function findSensorByZone(zone) {
  return state.sensors.find((s) => String(s.zone) === String(zone));
}

function findValveByZone(zone) {
  return state.valves.find((v) => String(v.zone) === String(zone));
}

function findSensorById(id) {
  return state.sensors.find((s) => s.id === id);
}

function findValveById(id) {
  return state.valves.find((v) => v.id === id);
}

function renderSensors() {
  const sensorsRoot = document.getElementById("sensors");
  if (!sensorsRoot) return;
  sensorsRoot.innerHTML = "";
  const sensorPanel = sensorsRoot.closest(".panel");
  if (sensorPanel) {
    sensorPanel.hidden = false;
    const title = sensorPanel.querySelector("h2");
    if (title) title.textContent = "Каналы обработки датчиков";
  }
}

function renderValves() {
  const separateValvesRoot = document.getElementById("valves");
  if (separateValvesRoot) {
    separateValvesRoot.innerHTML = "";
    const separateValvesPanel = separateValvesRoot.closest(".panel");
    if (separateValvesPanel) separateValvesPanel.hidden = true;
  }

  const valvesRoot = document.getElementById("sensors");
  if (!valvesRoot) return;
  valvesRoot.innerHTML = "";
  const valvePanel = valvesRoot.closest(".panel");
  if (valvePanel) {
    valvePanel.hidden = false;
    const title = valvePanel.querySelector("h2");
    if (title) title.textContent = "Каналы обработки датчиков";
  }

  const sensorLocations = storageGet("sensorLocations", {});
  state.sensors.forEach((sensor, index) => {
    const channelNumber = index + 1;
    const openValve = state.valves[index * 2] || {};
    const closeValve = state.valves[index * 2 + 1] || {};
    const valve = openValve.id ? openValve : closeValve;
    const card = document.createElement("article");
    card.className = "item compact-item valve-item channel-card full-channel-card";
    card.dataset.id = valve.id || `valve_${channelNumber * 2 - 1}`;

    const rawTemp = Number(sensor.value);
    const temp = Number.isFinite(rawTemp) ? rawTemp : 0;
    const tempText = Number.isFinite(rawTemp) ? temp.toFixed(1) : "--";
    let setpoint = Number(valve.targetTemp ?? sensor.setpoint ?? 28);
    const positionRaw = Number(valve.position ?? 0);
    const position = Number.isFinite(positionRaw) ? Math.max(0, Math.min(100, Math.round(positionRaw))) : 0;
    const maxDegreesRaw = Number(valve.openDegreesMax ?? DEFAULT_OPEN_DEGREES_MAX);
    const maxDegrees = Number.isFinite(maxDegreesRaw) && maxDegreesRaw > 0 ? maxDegreesRaw : DEFAULT_OPEN_DEGREES_MAX;
    const angleRaw = Number(valve.openDegrees ?? ((position / 100) * maxDegrees));
    const openDegrees = Number.isFinite(angleRaw) ? Math.max(0, Math.min(maxDegrees, Math.round(angleRaw))) : 0;
    const openActive = Boolean(openValve.isOpen || position > 0 || openDegrees > 0);
    const closeActive = Boolean(closeValve.isOpen || position <= 0);
    const connected = Boolean(valve.connected ?? valve.isConnected ?? sensor.connected ?? state.connected);
    const tempFill = Math.max(0, Math.min(100, ((temp + 5) / 55) * 100));
    const mode = valve.mode === "manual" ? "manual" : "auto";
    const modeText = mode === "manual" ? "ручной" : "авто";
    const sensorName = sensor.name || `Датчик ${channelNumber}`;
    const sensorId = sensor.id || `zone_${channelNumber}`;
    const setpointDrafts = storageGet("setpointDrafts", {});
    const draftValue = setpointDrafts[sensorId] ?? setpointDrafts[valve.id];
    if (draftValue !== undefined) {
      setpoint = parseGuiNumber(draftValue, setpoint);
    }
    const ds18b20Id = sensor.ds18b20Id || sensor.romId || sensor.rom || sensor.address || "--";
    const location = sensorLocations[sensorId] || sensor.location || defaultChannelLocation(index);
    const locationOptions = CHANNEL_LOCATIONS.map((name) => (
      `<option value="${name}" ${name === location ? "selected" : ""}>${name}</option>`
    )).join("");
    const openValveId = openValve.id || `valve_${channelNumber * 2 - 1}`;
    const closeValveId = closeValve.id || `valve_${channelNumber * 2}`;
    const openHex = (Number(String(openValveId).replace(/\D/g, "")) || channelNumber * 2 - 1).toString(16).toUpperCase().padStart(2, "0");
    const closeHex = (Number(String(closeValveId).replace(/\D/g, "")) || channelNumber * 2).toString(16).toUpperCase().padStart(2, "0");
    const setpointText = Number.isFinite(setpoint) ? setpoint.toFixed(1) : "--";
    const delta = Number.isFinite(rawTemp) && Number.isFinite(setpoint) ? temp - setpoint : NaN;
    const deltaText = Number.isFinite(delta) ? `${delta > 0 ? "+" : ""}${delta.toFixed(1)}°C` : "--";
    const deltaClass = !Number.isFinite(delta) || Math.abs(delta) <= 0.5 ? "ok" : delta > 0 ? "hot" : "cold";
    const stateText = openActive ? "открытие" : closeActive ? "закрытие" : "стоп";

    card.innerHTML = `
      <div class="channel-head">
        <div class="channel-title">
          <strong>Канал ${channelNumber}</strong>
          <small>${sensorName} · ${location} · зона ${sensor.zone || channelNumber}</small>
        </div>
        <span>связь <i class="channel-lamp ${connected ? "on" : "alarm"}"></i></span>
      </div>

      <div class="channel-id-grid full-channel-id-grid">
        <label>уставка
          <input class="targetTemp" type="number" step="0.5" value="${setpoint}">
        </label>
        <label>Расположение
          <select class="sensorLocation" data-id="${sensorId}">
            ${locationOptions}
          </select>
        </label>
        <label>ID DS18B20
          <input readonly value="${ds18b20Id}">
        </label>
      </div>

      <div class="channel-body full-channel-body">
        <div class="temperature-widget" aria-label="Температура канала ${channelNumber}">
          <div class="temp-scale">
            <span>50</span><span>40</span><span>30</span><span>20</span><span>10</span><span>0</span><span>-5</span>
          </div>
          <div class="temp-bar"><b style="height: ${tempFill}%"></b></div>
          <div class="temp-now">${tempText}°C</div>
        </div>

        <div class="channel-workarea">
          <div class="top-metrics-row">
            <div class="angle-panel">
              <span>угол открытия</span>
              <strong>${openDegrees}°</strong>
              <small>максимум ${Math.round(maxDegrees)}°</small>
            </div>
          </div>

          <div class="channel-data-grid full-channel-data-grid">
            <div><span>температура</span><strong>${tempText}°C</strong></div>
            <div><span>уставка</span><strong>${setpointText}°C</strong></div>
            <div><span>отклонение</span><strong class="delta ${deltaClass}">${deltaText}</strong></div>
            <div><span>расположение</span><strong>${location}</strong></div>
            <div><span>ID DS18B20</span><strong>${ds18b20Id}</strong></div>
            <div><span>режим</span><strong>${modeText}</strong></div>
            <div><span>угол/max</span><strong>${openDegrees}° / ${Math.round(maxDegrees)}°</strong></div>
            <div><span>команда</span><strong>${stateText}</strong></div>
            <div><span>связь</span><strong>${connected ? "есть" : "нет"}</strong></div>
            <div><span>канал</span><strong>${channelNumber}</strong></div>
          </div>

          <label class="range-control channel-position">положение заслонки
            <input class="position" type="range" min="0" max="100" value="${position}">
            <strong>${position}%</strong>
          </label>

          <div class="valve-state-row full-state-row">
            <span><i class="channel-lamp ${openActive ? "on" : "off"}"></i> клапан откр ${channelNumber}</span>
            <span><i class="channel-lamp ${closeActive ? "on" : "off"}"></i> клапан закр ${channelNumber}</span>
          </div>

          <div class="channel-actions">
            <button class="quickPosition" data-position="100" type="button">откр ${channelNumber}</button>
            <button class="quickPosition" data-position="0" type="button">закр ${channelNumber}</button>
          </div>

          <div class="channel-actions channel-mode-line">
            <div class="toggle channel-mode">
              <button class="modeBtn valveAuto ${mode === "auto" ? "active" : ""}" data-mode="auto" type="button">авто</button>
              <button class="modeBtn valveManual ${mode === "manual" ? "active" : ""}" data-mode="manual" type="button">ручное</button>
            </div>
            <button class="applyTarget mini-btn" type="button">SP</button>
            <button class="applyManual mini-btn" type="button">OK</button>
            <button class="calibrateValve mini-btn" type="button">CAL</button>
          </div>
        </div>
      </div>
    `;

    valvesRoot.appendChild(card);
  });
}

function render() {
  renderSensors();
  renderValves();
}

async function applySetpoint(sensorId) {
  const sensor = findSensorById(sensorId);
  const input = document.querySelector(`.setpoint[data-id="${sensorId}"]`);
  const value = Number(input.value);
  if (Number.isNaN(value)) {
    updateStatus("Некорректное значение уставки", "warn");
    return;
  }

  if (!state.apiBase) {
    sensor.setpoint = value;
    storageSet("sensorState", state.sensors);
    render();
    updateStatus(`Демо: уставка ${sensor.name} = ${value.toFixed(1)} °C`, "ok");
    return;
  }

  const payload = {
    ...writePayloadBase(sensorId, "sensor"),
    setpoint: value,
    id: sensorId,
  };

  try {
    await sendWithFallback(API_PATHS.sensorWrite, sensorId, payload);
    await loadState();
    render();
    updateStatus(`Уставка ${sensor.name} обновлена`, "ok");
  } catch (error) {
    updateStatus(`Ошибка уставки ${sensor.name}: ${error.message}`, "error");
  }
}

async function applyValveMode(valveId, mode) {
  const valve = findValveById(valveId);
  if (!valve) return;
  valve.mode = mode;

  if (!state.apiBase) {
    storageSet("valveState", state.valves);
    render();
    updateStatus(`Демо: ${valve.name} → ${mode}`, "ok");
    return;
  }

  const payload = {
    ...writePayloadBase(valveId, "valve"),
    mode,
    id: valveId,
  };

  try {
    await sendWithFallback(API_PATHS.valveWrite, valveId, payload);
    await loadState();
    render();
    updateStatus(`Режим ${valve.name}: ${mode}`, "ok");
  } catch (error) {
    updateStatus(`Ошибка режима ${valve.name}: ${error.message}`, "error");
    await loadState();
    render();
  }
}

async function applyValveTarget(valveId) {
  const valve = findValveById(valveId);
  const input = document.querySelector(`.targetTemp[data-id="${valveId}"]`);
  const value = Number(input.value);
  if (Number.isNaN(value)) {
    updateStatus("Некорректная целевая температура", "warn");
    return;
  }

  if (!state.apiBase) {
    valve.targetTemp = value;
    storageSet("valveState", state.valves);
    updateStatus(`Демо: цель ${valve.name} = ${value.toFixed(1)} °C`, "ok");
    return;
  }

  const payload = {
    ...writePayloadBase(valveId, "valve"),
    targetTemp: value,
    id: valveId,
  };

  try {
    await sendWithFallback(API_PATHS.valveWrite, valveId, payload);
    await loadState();
    render();
    updateStatus(`Целевая температура ${valve.name} обновлена`, "ok");
  } catch (error) {
    updateStatus(`Ошибка цели ${valve.name}: ${error.message}`, "error");
  }
}

async function applyValvePosition(valveId) {
  const valve = findValveById(valveId);
  const input = document.querySelector(`.position[data-id="${valveId}"]`);
  const value = Number(input.value);
  if (Number.isNaN(value)) {
    updateStatus("Некорректная позиция клапана", "warn");
    return;
  }
  valve.position = clamp(Math.round(value), 0, 100);
  valve.openDegrees = degreesFromPosition(valve.position, valve.openDegreesMax);
  valve.isOpen = valve.position > 0;

  if (!state.apiBase) {
    storageSet("valveState", state.valves);
    render();
    updateStatus(`Демо: ручная позиция ${valve.name} = ${value}%`, "ok");
    return;
  }

  const payload = {
    ...writePayloadBase(valveId, "valve"),
    mode: "manual",
    position: valve.position,
    openDegrees: valve.openDegrees,
    openDegreesMax: valve.openDegreesMax,
    id: valveId,
  };

  try {
    await sendWithFallback(API_PATHS.valveWrite, valveId, payload);
    await loadState();
    render();
    updateStatus(`Позиция ${valve.name} обновлена`, "ok");
  } catch (error) {
    updateStatus(`Ошибка позиции ${valve.name}: ${error.message}`, "error");
  }
}

async function calibrateValve(valveId) {
  const valve = findValveById(valveId);
  if (!valve) return;

  if (!state.apiBase) {
    valve.position = 0;
    valve.openDegrees = 0;
    valve.isOpen = false;
    storageSet("valveState", state.valves);
    render();
    updateStatus(`Демо: калибровка ${valve.name} выполнена`, "ok");
    return;
  }

  try {
    const response = await postWithFallback(API_PATHS.valveCalibrate, valveId, {});
    const updated = response?.valve || response;
    if (updated && updated.id) {
      const index = state.valves.findIndex((item) => item.id === valveId);
      if (index !== -1) {
        state.valves[index] = normalizeValve(updated);
      } else {
        await loadState();
      }
    } else {
      await loadState();
    }
    render();
    updateStatus(`Калибровка ${valve.name} выполнена`, "ok");
  } catch (error) {
    updateStatus(`Ошибка калибровки ${valve.name}: ${error.message}`, "error");
  }
}

async function calibrateAllValves(silent = false) {
  if (!state.apiBase) {
    state.valves = state.valves.map((valve) => ({
      ...valve,
      position: 0,
      openDegrees: 0,
      isOpen: false,
    }));
    render();
    if (!silent) {
      updateStatus("Демо: калибровка всех клапанов выполнена", "ok");
    }
    return;
  }

  try {
    const response = await apiPostFallback(API_PATHS.valvesCalibrateAll, {});
    const payload = Array.isArray(response?.valves) ? response.valves : null;
    if (payload) {
      state.valves = payload.map(normalizeValve);
    } else {
      await loadState();
    }
    render();
    if (!silent) {
      updateStatus("Калибровка всех клапанов выполнена", "ok");
    }
  } catch (error) {
    if (!silent) {
      updateStatus(`Ошибка калибровки всех клапанов: ${error.message}`, "error");
    }
  }
}

function simulateSensorPhysics() {
  if (state.timer) clearInterval(state.timer);
  state.timer = setInterval(() => {
    for (const sensor of state.sensors) {
      const valve = findValveByZone(sensor.zone);
      const v = valve || {};
      let target;
      if (v.mode === "manual") {
        target = 20 + (clamp(v.position ?? 0, 0, 100) / 100) * 70;
      } else {
        target = v.targetTemp ?? sensor.setpoint ?? 30;
      }
      const drift = target - sensor.value;
      const noise = (Math.random() - 0.5) * 0.2;
      sensor.value = clamp(sensor.value + drift * 0.08 + noise, -40, 150);
      sensor.value = Number(sensor.value.toFixed(2));
    }
    for (const valve of state.valves) {
      const maxOpenDegrees = Number(valve.openDegreesMax) > 0 ? Number(valve.openDegreesMax) : DEFAULT_OPEN_DEGREES_MAX;
      valve.openDegrees = Math.round(degreesFromPosition(valve.position ?? 0, maxOpenDegrees));
      valve.isOpen = (valve.position ?? 0) > 0;
    }
    storageSet("sensorState", state.sensors);
    storageSet("valveState", state.valves);
    render();
  }, 2500);
}

function attachEvents() {
  refreshBtn.addEventListener("click", () => {
    const url = normalizeApiUrl(apiInput.value);
    state.apiBase = url;
    storageSet("apiBase", state.apiBase);
    refreshAll(true);
    refreshPorts(false);
  });

  refreshPortsBtn.addEventListener("click", () => {
    refreshPorts();
  });

  connectPortBtn.addEventListener("click", async () => {
    if (serialConnected) {
      await disconnectPort();
      setConnectButtonText();
      return;
    }
    await connectSelectedPort();
    setConnectButtonText();
  });

  if (calibrateAllBtn) {
    calibrateAllBtn.addEventListener("click", () => {
      calibrateAllValves(false);
    });
  }

  saveApiBtn.addEventListener("click", () => {
    const url = normalizeApiUrl(apiInput.value);
    state.apiBase = url;
    storageSet("apiBase", url);
    if (!url) {
      updateStatus("API отключён; переход в демо", "warn");
      statusEl.textContent = "Режим: демо (без API)";
      statusEl.className = "status status-warn";
    } else {
      updateStatus(`Сохранён API: ${state.apiBase}`, "ok");
      statusEl.textContent = "Сохранён адрес API";
      statusEl.className = "status status-ok";
    }
    render();
    refreshPorts(false);
    if (!url) {
      serialStatus.textContent = "COM: не подключён";
      serialStatus.className = "status status-warn";
      connectPortBtn.textContent = "Подключить";
      selectedPort = "";
      serialConnected = false;
    }
  });

  sensorsEl.addEventListener("click", (event) => {
    const target = event.target;
    if (target.classList.contains("applySetpoint")) {
      applySetpoint(target.dataset.id);
    }
  });

  valvesEl.addEventListener("click", (event) => {
    const target = event.target;
    if (target.classList.contains("modeBtn")) {
      const button = target.closest(".toggle");
      const valveId = button.dataset.id;
      const mode = target.dataset.mode;
      const card = button.closest(".item");
      if (!card) return;
      const autoBlock = card.querySelector(".valveAuto");
      const manualBlock = card.querySelector(".valveManual");
      const applyManualBtn = card.querySelector(".applyManual");

      button.querySelectorAll(".modeBtn").forEach((b) => b.classList.remove("active"));
      target.classList.add("active");
      if (mode === "auto") {
        autoBlock.classList.remove("hidden");
        manualBlock.classList.add("hidden");
        applyManualBtn.classList.add("hidden");
      } else {
        autoBlock.classList.add("hidden");
        manualBlock.classList.remove("hidden");
        applyManualBtn.classList.remove("hidden");
      }
      applyValveMode(valveId, mode);
    }
    if (target.classList.contains("applyTarget")) {
      applyValveTarget(target.dataset.id);
    }
    if (target.classList.contains("applyManual")) {
      applyValvePosition(target.dataset.id);
    }
    if (target.classList.contains("calibrateValve")) {
      calibrateValve(target.dataset.id);
    }
  });

  valvesEl.addEventListener("input", (event) => {
    const target = event.target;
    if (target.classList.contains("position")) {
      const label = target.closest("label");
      const span = label ? label.querySelector("strong") : null;
      if (span) span.textContent = `${target.value}%`;
    }
  });
}

async function refreshAll(silent = false) {
  try {
    await loadState();
    render();
    if (!silent) updateStatus("Данные обновлены", "ok");
  } catch (error) {
    if (state.apiBase) {
      statusEl.textContent = `Ошибка API: ${error.message}`;
      statusEl.className = "status status-error";
      globalStatus.textContent = "Не удалось получить данные с API. Откат в демо";
      globalStatus.className = "status-warn";
      await refreshPorts();
      render();
    } else {
      updateStatus(`Демо инициализирован: ${error.message}`, "warn");
    }
  }
}

async function connectSelectedPortLegacy() {
  if (!state.apiBase) {
    updateStatus("Укажите API endpoint для работы с COM-портами", "warn");
    return;
  }
  const value = comPortSelect.value;
  if (!value) {
    updateStatus("Выберите COM порт", "warn");
    return;
  }
  const payload = { port: value, baud: 115200, baudrate: 115200, parity: "N", stopbits: 1, bytesize: 8, timeout: 0.3 };
  try {
    await apiPostFallback(SERIAL_API_PATHS.connect, payload);
    selectedPort = value;
    serialConnected = true;
    storageSet("comPort", value);
    serialStatus.textContent = `COM: подключен (${value})`;
    serialStatus.className = "status status-ok";
    connectPortBtn.textContent = "Отключить";
    updateStatus(`COM ${value} подключен`, "ok");
  } catch (error) {
    serialConnected = false;
    serialStatus.textContent = "COM: ошибка подключения";
    serialStatus.className = "status status-error";
    connectPortBtn.textContent = "Подключить";
    storageSet("comPort", "");
    selectedPort = "";
    updateStatus(`Ошибка подключения COM: ${error.message}`, "error");
  }
}

async function disconnectPortLegacy() {
  if (!state.apiBase) return;
  try {
    await apiPostFallback(SERIAL_API_PATHS.disconnect, {});
  } catch {
    // ignore
  } finally {
    serialConnected = false;
    selectedPort = "";
    storageSet("comPort", "");
    serialStatus.textContent = "COM: не подключен";
    serialStatus.className = "status status-warn";
    connectPortBtn.textContent = "Подключить";
  }
}

async function restoreSerialUiLegacy() {
  if (!state.apiBase) return;
  try {
    const status = await apiGetFallback(SERIAL_API_PATHS.status);
    serialConnected = Boolean(status?.connected);
    const port = status?.port || status?.selected;
    if (serialConnected && port) {
      selectedPort = port;
      comPortSelect.value = selectedPort;
      serialStatus.textContent = `COM: подключен (${selectedPort})`;
      serialStatus.className = "status status-ok";
      connectPortBtn.textContent = "Отключить";
      storageSet("comPort", selectedPort);
    } else {
      selectedPort = "";
      storageSet("comPort", "");
      serialStatus.textContent = "COM: не подключен";
      serialStatus.className = "status status-warn";
      connectPortBtn.textContent = "Подключить";
    }
  } catch {
    // no-op
  }
}

async function refreshPorts(silent = false) {
  if (!state.apiBase) {
    ensureApiBase();
  }
  if (!state.apiBase) {
    if (!silent) {
      updateStatus("Set API base URL first", "warn");
    }
    return;
  }
  setSerialBusyState(true, "Scanning COM ports...");
  try {
    const payload = await fetchComPorts();
    const ports = Array.isArray(payload?.ports) ? payload.ports : [];
    const preferredPort = payload?.selected || selectedPort || storageGet("comPort", "");
    applyPortOptions(ports, preferredPort);
    if (!silent) {
      updateStatus(
        ports.length ? `Found COM ports: ${ports.length}` : "No COM ports found",
        ports.length ? "ok" : "warn"
      );
    }
  } catch (error) {
    applyPortOptions([], "");
    updateStatus(`Port scan error: ${error.message}`, "error");
  } finally {
    setSerialBusyState(false);
  }
}

async function connectSelectedPort() {
  if (!state.apiBase) {
    updateStatus("Set API base URL first", "warn");
    return;
  }
  const requestedPort = comPortSelect.value;
  if (!requestedPort) {
    updateStatus("Select COM port", "warn");
    return;
  }
  setSerialBusyState(true, `Connecting ${requestedPort}...`);
  try {
    const payload = {
      port: requestedPort,
      baud: 115200,
      baudrate: 115200,
      parity: "N",
      stopBits: 1,
      stopbits: 1,
      byteSize: 8,
      bytesize: 8,
      timeout: 0.3,
    };
    const status = await apiPostFallback(SERIAL_API_PATHS.connect, payload);
    applySerialStateFromPayload({
      connected: true,
      port: status?.port || requestedPort,
      selected: status?.selected || requestedPort,
    });
    await calibrateAllValves(true);
    updateStatus(`COM ${requestedPort} connected`, "ok");
    await refreshPorts(true);
  } catch (error) {
    applySerialStateFromPayload({ connected: false });
    updateStatus(`COM connect error: ${error.message}`, "error");
  } finally {
    setSerialBusyState(false);
  }
}

async function connectRtuPort() {
  if (!state.apiBase) {
    updateStatus("Укажи API endpoint для Modbus RTU", "warn");
    return;
  }

  const value = comPortSelect?.value || selectedPort;
  if (!value) {
    updateStatus("Выберите COM порт", "warn");
    return;
  }

  const unitId = Number(modbusSlaveId?.value || 3);
  storageSet("modbusTransport", "rtu");
  storageSet("comPort", value);
  storageSet("modbusSlaveId", String(unitId));
  setSerialBusyState(true, `Modbus RTU: подключение ${value}, slave ${unitId}...`);
  try {
    const status = await apiPostFallback(SERIAL_API_PATHS.connect, {
      transport: "rtu",
      mode: "rtu",
      port: value,
      baud: 115200,
      baudrate: 115200,
      parity: "N",
      stopbits: 1,
      bytesize: 8,
      timeout: 0.8,
      unitId,
      slaveId: unitId,
    });
    applySerialStateFromPayload({
      ...(status || {}),
      connected: Boolean(status?.connected ?? status?.ok),
      transport: "rtu",
      port: value,
    });
    updateStatus(`Modbus RTU connected: ${value}, slave ${unitId}`, "ok");
  } catch (error) {
    applySerialStateFromPayload({ connected: false, transport: "rtu" });
    updateStatus(`Modbus RTU error: ${error.message}`, "error");
  } finally {
    setSerialBusyState(false);
    applyModbusTransportView();
  }
}

async function connectTcpPort() {
  if (!state.apiBase) {
    updateStatus("Укажи API endpoint для Modbus TCP", "warn");
    return;
  }

  const host = (tcpHost?.value || "").trim();
  const port = Number(tcpPort?.value || 502);
  const unitId = Number(modbusSlaveId?.value || 3);
  if (!host) {
    updateStatus("Укажи IP адрес Modbus TCP", "warn");
    return;
  }
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
    updateStatus("Некорректный TCP порт", "warn");
    return;
  }

  storageSet("modbusTransport", "tcp");
  storageSet("tcpHost", host);
  storageSet("tcpPort", String(port));
  storageSet("modbusSlaveId", String(unitId));
  setSerialBusyState(true, `Modbus TCP: подключение ${host}:${port}, slave ${unitId}...`);
  try {
    const status = await apiPostFallback(SERIAL_API_PATHS.connect, {
      transport: "tcp",
      mode: "tcp",
      host,
      ip: host,
      tcpPort: port,
      tcp_port: port,
      port,
      unitId,
      slaveId: unitId,
      timeout: 0.8,
    });
    applySerialStateFromPayload({
      ...(status || {}),
      connected: Boolean(status?.connected ?? status?.ok),
      transport: "tcp",
      host,
      tcpPort: port,
      address: `${host}:${port}`,
    });
    updateStatus(`Modbus TCP connected: ${host}:${port}, slave ${unitId}`, "ok");
  } catch (error) {
    applySerialStateFromPayload({ connected: false, transport: "tcp" });
    updateStatus(`Modbus TCP error: ${error.message}`, "error");
  } finally {
    setSerialBusyState(false);
    applyModbusTransportView();
  }
}

if (connectPortBtn) {
  connectPortBtn.addEventListener("click", async (event) => {
    event.preventDefault();
    event.stopImmediatePropagation();

    if (serialConnected) {
      await disconnectPort();
      setConnectButtonText();
      applyModbusTransportView();
      return;
    }

    if (getModbusTransport() === "tcp") {
      await connectTcpPort();
    } else {
      await connectRtuPort();
    }
    setConnectButtonText();
  }, true);
}

async function disconnectPort() {
  if (!state.apiBase) {
    applySerialStateFromPayload({ connected: false });
    return;
  }
  setSerialBusyState(true, "Disconnecting...");
  try {
    const status = await apiPostFallback(SERIAL_API_PATHS.disconnect, {});
    applySerialStateFromPayload(status || { connected: false });
  } catch {
    // ignore
  } finally {
    applySerialStateFromPayload({ connected: false });
    setSerialBusyState(false);
  }
}

async function restoreSerialUi() {
  if (!state.apiBase) {
    return;
  }
  try {
    const status = await apiGetFallback(SERIAL_API_PATHS.status);
    applySerialStateFromPayload(status);
    if (status?.connected) {
      await calibrateAllValves(true);
    }
  } catch {
    applySerialStateFromPayload({ connected: false });
  }
}



// Channel location selector
document.addEventListener("change", (event) => {
  const select = event.target.closest(".sensorLocation");
  if (!select) return;

  const sensorId = select.dataset.id;
  const sensor = state.sensors.find((item) => item.id === sensorId);
  if (sensor) {
    sensor.location = select.value;
    storageSet("sensorState", state.sensors);
  }

  const locations = storageGet("sensorLocations", {});
  locations[sensorId] = select.value;
  storageSet("sensorLocations", locations);

  render();
});

// Controls inside sensor processing channel cards
document.addEventListener("input", (event) => {
  const targetInput = event.target.closest(".full-channel-card .targetTemp");
  if (targetInput) {
    const card = targetInput.closest(".full-channel-card");
    const valveId = card?.dataset.id;
    const targetTemp = parseGuiNumber(targetInput.value, 0);
    const valve = state.valves.find((item) => item.id === valveId);
    if (valve) {
      valve.targetTemp = targetTemp;
    }

    const valveNumber = Number(String(valveId || "").replace(/\D/g, "")) || 1;
    const sensorIndex = Math.max(0, Math.floor((valveNumber - 1) / 2));
    const sensor = state.sensors[sensorIndex];
    if (sensor) {
      sensor.setpoint = targetTemp;
    }

    const setpointDrafts = storageGet("setpointDrafts", {});
    if (sensor?.id) {
      setpointDrafts[sensor.id] = targetInput.value;
    }
    if (valveId) {
      setpointDrafts[valveId] = targetInput.value;
    }
    storageSet("setpointDrafts", setpointDrafts);
    storageSet("sensorState", state.sensors);
    storageSet("valveState", state.valves);
    return;
  }

  const positionInput = event.target.closest(".full-channel-card .position");
  if (!positionInput) return;
  const label = positionInput.closest("label");
  const value = Number(positionInput.value || 0);
  const strong = label?.querySelector("strong");
  if (strong) strong.textContent = `${value}%`;
});

document.addEventListener("click", async (event) => {
  const button = event.target.closest(".full-channel-card button");
  if (!button) return;

  const card = button.closest(".full-channel-card");
  const valveId = card?.dataset.id;
  if (!card || !valveId) return;

  if (button.classList.contains("modeBtn")) {
    const mode = button.dataset.mode === "manual" ? "manual" : "auto";
    card.querySelectorAll(".modeBtn").forEach((item) => item.classList.remove("active"));
    button.classList.add("active");
    const valve = state.valves.find((item) => item.id === valveId);
    if (valve) valve.mode = mode;
    storageSet("valveState", state.valves);
    await sendWithFallback(API_PATHS.valveWrite, valveId, {
      ...writePayloadBase(valveId, "valve"),
      id: valveId,
      mode,
    });
    return;
  }

  if (button.classList.contains("applyTarget")) {
    const targetInput = card.querySelector(".targetTemp");
    const targetTemp = parseGuiNumber(targetInput?.value, 0);
    const valve = state.valves.find((item) => item.id === valveId);
    if (valve) valve.targetTemp = targetTemp;
    const valveNumber = Number(String(valveId || "").replace(/\D/g, "")) || 1;
    const sensorIndex = Math.max(0, Math.floor((valveNumber - 1) / 2));
    const sensor = state.sensors[sensorIndex];
    if (sensor) sensor.setpoint = targetTemp;
    const setpointDrafts = storageGet("setpointDrafts", {});
    if (sensor?.id) {
      setpointDrafts[sensor.id] = targetInput?.value ?? String(targetTemp);
    }
    setpointDrafts[valveId] = targetInput?.value ?? String(targetTemp);
    storageSet("setpointDrafts", setpointDrafts);
    storageSet("sensorState", state.sensors);
    storageSet("valveState", state.valves);
    await sendWithFallback(API_PATHS.valveWrite, valveId, {
      ...writePayloadBase(valveId, "valve"),
      id: valveId,
      targetTemp,
    });
    await loadState();
    return;
  }

  if (button.classList.contains("applyManual")) {
    const positionInput = card.querySelector(".position");
    const position = Number(positionInput?.value || 0);
    const valve = state.valves.find((item) => item.id === valveId);
    if (valve) {
      valve.position = position;
      valve.isOpen = position > 0;
    }
    storageSet("valveState", state.valves);
    await sendWithFallback(API_PATHS.valveWrite, valveId, {
      ...writePayloadBase(valveId, "valve"),
      id: valveId,
      position,
    });
    await loadState();
    return;
  }

  if (button.classList.contains("calibrateValve")) {
    await postWithFallback(API_PATHS.valveCalibrate, valveId, {
      ...writePayloadBase(valveId, "valve"),
      id: valveId,
    });
    await loadState();
  }
});

// Quick manual open/close buttons for channel cards
document.addEventListener("click", (event) => {
  const quickButton = event.target.closest(".quickPosition");
  if (!quickButton) return;

  const card = quickButton.closest(".valve-item");
  if (!card) return;

  const manualButton = card.querySelector(".valveManual");
  if (manualButton && !manualButton.classList.contains("active")) {
    manualButton.click();
  }

  const positionInput = card.querySelector(".position");
  if (positionInput) {
    positionInput.value = quickButton.dataset.position || "0";
    positionInput.dispatchEvent(new Event("input", { bubbles: true }));
  }

  const applyButton = card.querySelector(".applyManual");
  if (applyButton) applyButton.click();
});
window.addEventListener("load", async () => {
  apiInput.value = storageGet("apiBase", "");
  state.apiBase = normalizeApiUrl(apiInput.value);
  initModbusTransportControls();
  ensureApiBase();
  selectedPort = storageGet("comPort", "");
  attachEvents();
  await refreshAll(true);
  if (getModbusTransport() !== "tcp") {
    await refreshPorts(true);
  }
  render();
  if (selectedPort) {
    comPortSelect.value = selectedPort;
  }
  await restoreSerialUi();
  setInterval(() => {
    if (state.apiBase) {
      loadState().then(render).catch(() => {});
    }
  }, 3000);
});
