#!/usr/bin/env node
'use strict';
// ModelRailPro — moderne modeltreinbesturing (DCC-EX). Eén Node-proces:
//   1) serveert de PWA (public/)
//   2) WebSocket-brug tussen browsers en de centrale
//   3) DCC-EX adapter (sim / tcp)
//
// Starten:    node server.js
// Opties via omgeving:
//   PORT=3000           webpoort (default 3000)
//   DCC_MODE=sim|tcp    verbindingsmodus (default sim)
//   DCC_HOST=...        IP van DCC-EX WiFi (tcp-modus)
//   DCC_PORT=2560       poort van DCC-EX WiFi
const http = require('http');
const fs = require('fs');
const path = require('path');
const { createSSE } = require('./lib/sse');
const { DccEx } = require('./lib/dccex');
const { TwinCenter } = require('./lib/twincenter');
const { Z21 } = require('./lib/z21');
const { Ecos } = require('./lib/ecos');
const { Marklin } = require('./lib/marklin');
const { XpressNet } = require('./lib/xpressnet');
const { LocoNet } = require('./lib/loconet');
const store = require('./lib/store');
const license = require('./lib/license');
const updater = require('./lib/update');
const { Auto } = require('./lib/auto');
const { RelayAgent } = require('./lib/relay-agent');
const { createIntegrations } = require('./lib/integrations');
const { createLights } = require('./lib/lights');
const { createHomeAssistant } = require('./lib/homeassistant');
const paths = require('./lib/paths');

// Config uit data/config.json (zodat niet-technische gebruikers geen env-vars hoeven te typen).
// Env-variabelen hebben altijd voorrang.
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(path.join(paths.DATA, 'config.json'), 'utf8')); } catch (_) {}
const pick = (env, key, def) => process.env[env] !== undefined ? process.env[env] : (cfg[key] !== undefined ? cfg[key] : def);

const PORT = parseInt(pick('PORT', 'port', '3000'), 10);
const PUBLIC = paths.PUBLIC;

// Adapter kiezen op basis van DCC_MODE (= centrale/command station):
//   sim         → simulator (geen hardware)                          [default]
//   dccex / tcp → DCC-EX via WiFi (TCP)
//   twincenter  → Fleischmann Twin Center / Twin Control (P50X serieel)
//   z21         → Roco/Fleischmann Z21 (UDP LAN)
//   dr5000      → Digikeijs DR5000 (Z21-emulatie, UDP LAN)
//   ecos        → ESU ECoS / ECoS 2 (TCP)
// Bouw de juiste adapter op basis van mode + huidige config (host/poort/serieel).
function buildAdapter(mode) {
  const host = process.env.DCC_HOST !== undefined ? process.env.DCC_HOST : cfg.host;
  const port = (process.env.DCC_PORT || cfg.centralPort) ? parseInt(process.env.DCC_PORT || cfg.centralPort, 10) : undefined;
  const spath = process.env.TC_PORT !== undefined ? process.env.TC_PORT : cfg.serialPort;
  const baud = (process.env.TC_BAUD || cfg.baud) ? parseInt(process.env.TC_BAUD || cfg.baud, 10) : undefined;
  switch (mode) {
    case 'twincenter': return new TwinCenter({ path: spath, baud });
    case 'z21': case 'dr5000': return new Z21({ mode, host, port });
    case 'ecos': return new Ecos({ host, port });
    case 'marklin': return new Marklin({ host, port });
    case 'xpressnet': return new XpressNet({ path: spath, baud });
    case 'loconet': return new LocoNet({ path: spath, baud });
    default: return new DccEx({ mode: mode === 'dccex' ? 'tcp' : mode, host, port });
  }
}

// Stop een adapter netjes (sluit sockets/poorten/timers) zodat we kunnen wisselen.
function stopAdapter(d) {
  if (!d) return;
  try { if (d._sim) clearInterval(d._sim); } catch (_) {}
  try { if (d._poll) clearInterval(d._poll); } catch (_) {}
  try { if (d._ka) clearInterval(d._ka); } catch (_) {}
  try { if (d.sock && d.sock.destroy) d.sock.destroy(); } catch (_) {}
  try { if (d.sock && d.sock.close) d.sock.close(); } catch (_) {}
  try { if (d.port && d.port.isOpen) d.port.close(); } catch (_) {}
  try { d.removeAllListeners && d.removeAllListeners(); } catch (_) {}
  d.connected = false;
}

let dcc = buildAdapter(pick('DCC_MODE', 'mode', 'sim'));
const diag = { lastRx: 0, lastError: '', connectedAt: 0 };   // verbindingsdiagnose

let roster = store.load();

// Roster veilig overnemen (whitelist) + bewaren + uitzenden. Gebruikt door /api/roster én restore.
function applyRoster(r) {
  const _prev = roster;
  roster = {
    locos: r.locos || [], turnouts: r.turnouts || [], blocks: r.blocks || [], routes: r.routes || [],
    runs: r.runs || [], consists: r.consists || [], timetable: r.timetable || [],
    clockFactor: parseInt(r.clockFactor, 10) || 8, signals: r.signals || [], turntables: r.turntables || [],
    webcamUrl: (r.webcamUrl || '').toString().slice(0, 300),
    runtime: r.runtime || _prev.runtime || {}, maintenance: r.maintenance || _prev.maintenance || [],
    shuttles: r.shuttles || [], staging: r.staging || [],
  };
  if (typeof clock !== 'undefined') clock.factor = roster.clockFactor;
  const ok = store.save(roster);
  sse.broadcast(JSON.stringify({ type: 'roster', ...roster }));
  return ok;
}

// ---- statische bestanden ----------------------------------------------
const MIME = {
  '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8',
  '.webmanifest': 'application/manifest+json; charset=utf-8', '.svg': 'image/svg+xml',
  '.png': 'image/png', '.ico': 'image/x-icon',
};
function serveStatic(req, res) {
  let p = decodeURIComponent(req.url.split('?')[0]);
  if (p === '/') p = '/index.html';
  const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
  if (!file.startsWith(PUBLIC)) { res.writeHead(403); res.end(); return; }
  fs.readFile(file, (err, data) => {
    if (err) { res.writeHead(404); res.end('Niet gevonden'); return; }
    res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
    res.end(data);
  });
}

// Open-API-sleutel controleren (header X-MRP-Key of ?key=). Zonder ingestelde sleutel is de v1-API uit.
function apiKeyOk(req) {
  const key = (cfg.integrations && cfg.integrations.apiKey) || '';
  if (!key) return false;
  let given = req.headers['x-mrp-key'] || '';
  try { given = given || new URL(req.url, 'http://x').searchParams.get('key') || ''; } catch (_) {}
  return given && given === key;
}

const server = http.createServer((req, res) => {
  if (req.url === '/api/stream' && req.method === 'GET') {
    sse.add(req, res);
    greet(res);
    return;
  }
  if (req.url === '/api/cmd' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', () => {
      try { const m = JSON.parse(body); handleCmd(m, clientRole(req, m)); } catch (_) {}
      res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}');
    });
    return;
  }
  if (req.url === '/api/import' && req.method === 'POST') {       // import Rocrail/JMRI-baanplan
    let body = ''; req.on('data', (c) => { body += c; if (body.length > 4e6) req.destroy(); });
    req.on('end', () => {
      const r = clientRole(req, {}); if (r.role !== 'host') { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"alleen host"}'); return; }
      let xml = body; try { const j = JSON.parse(body); if (j && j.xml) xml = j.xml; } catch (_) {}
      try {
        const imp = importXML(xml);
        const merged = Object.assign({}, roster);
        if (imp.locos.length) merged.locos = imp.locos;
        if (imp.turnouts.length) merged.turnouts = imp.turnouts;
        if (imp.blocks.length) merged.blocks = imp.blocks;
        applyRoster(merged);
        res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, locos: imp.locos.length, turnouts: imp.turnouts.length, blocks: imp.blocks.length }));
      } catch (e) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"kon niet importeren"}'); }
    });
    return;
  }
  if (req.url === '/api/scan' && req.method === 'GET') {          // zoek centrales op het netwerk
    scanCentrals((found) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ found })); });
    return;
  }
  if (req.url === '/api/club' && req.method === 'POST') {        // host: club-modus aan/uit
    let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e4) req.destroy(); });
    req.on('end', () => {
      let b = {}; try { b = JSON.parse(body); } catch (_) {}
      const r = clientRole(req, b);
      if (r.role === 'host') { club.enabled = !!b.enabled; pushState(); }
      res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: r.role === 'host', enabled: club.enabled }));
    });
    return;
  }
  if (req.url === '/api/operator' && req.method === 'POST') {     // operator joinen / present melden
    let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e4) req.destroy(); });
    req.on('end', () => {
      let b = {}; try { b = JSON.parse(body); } catch (_) {}
      let tok = (b.token || '').toString();
      let op = club.operators[tok];
      if (!op) { tok = crypto.randomBytes(12).toString('hex'); op = club.operators[tok] = { token: tok, pid: crypto.randomBytes(3).toString('hex'), name: '', role: 'guest', locos: [], seen: 0 }; }
      if (b.name != null) op.name = String(b.name).slice(0, 40);
      op.seen = Date.now();
      if (isLoopback(req)) op.role = 'host';
      pushState();
      res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ token: tok, pid: op.pid, role: op.role, locos: op.locos }));
    });
    return;
  }
  if (req.url === '/api/operator/set' && req.method === 'POST') { // host: rol + loc-toewijzing
    let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e4) req.destroy(); });
    req.on('end', () => {
      let b = {}; try { b = JSON.parse(body); } catch (_) {}
      const r = clientRole(req, b); let ok = false;
      if (r.role === 'host') {
        const op = Object.values(club.operators).find((o) => o.pid === b.pid);
        if (op) { if (['host', 'driver', 'guest'].includes(b.role)) op.role = b.role; if (Array.isArray(b.locos)) op.locos = b.locos.map((x) => parseInt(x, 10) || x); ok = true; pushState(); }
      }
      res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok }));
    });
    return;
  }
  if (req.url === '/api/roster' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(roster));
    return;
  }
  if (req.url === '/api/roster' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 5e5) req.destroy(); });
    req.on('end', () => {
      try {
        const r = JSON.parse(body);
        const ok = applyRoster(r);
        res.writeHead(ok ? 200 : 500, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ ok }));
      } catch (_) { res.writeHead(400); res.end('{"ok":false}'); }
    });
    return;
  }
  if (req.url === '/api/config' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({
      mode: pick('DCC_MODE', 'mode', 'sim'),
      host: cfg.host || '', serialPort: cfg.serialPort || '', baud: cfg.baud || 19200,
      relayCode: cfg.relayCode || '', relayActive: !!agent,
      port: PORT, active: dcc.mode, connected: dcc.connected,
    }));
    return;
  }
  if (req.url === '/api/config' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 1e4) req.destroy(); });
    req.on('end', () => {
      try {
        const n = JSON.parse(body);
        const merged = Object.assign({}, cfg, {
          mode: n.mode || cfg.mode || 'sim',
          host: n.host !== undefined ? n.host : cfg.host,
          serialPort: n.serialPort !== undefined ? n.serialPort : cfg.serialPort,
          baud: n.baud ? parseInt(n.baud, 10) : (cfg.baud || 19200),
          relayCode: n.relayCode !== undefined ? String(n.relayCode).trim() : (cfg.relayCode || ''),
          port: n.port ? parseInt(n.port, 10) : (cfg.port || 3000),
        });
        const relayChanged = (merged.relayCode || '') !== (cfg.relayCode || '');
        const centralChanged = (merged.mode !== dcc.mode) ||
          (merged.host !== cfg.host) || (merged.serialPort !== cfg.serialPort) || (merged.baud !== cfg.baud);
        const ok = store.saveConfig(merged);
        if (ok) cfg = merged;
        // alles live toepassen — geen herstart nodig
        if (ok && relayChanged) { try { applyRelay(merged.relayCode); } catch (_) {} }
        if (ok && centralChanged) { try { applyCentral(merged.mode); } catch (_) {} }
        res.writeHead(ok ? 200 : 500, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ ok, restartNeeded: false, relayActive: !!agent }));
      } catch (_) { res.writeHead(400); res.end('{"ok":false}'); }
    });
    return;
  }
  if (req.url === '/api/layout' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(store.loadLayout()));
    return;
  }
  // ---- Integratie-instellingen (host) -------------------------------
  if (req.url === '/api/integrations' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ integrations: cfg.integrations || {}, lights: cfg.lights || {}, mqtt: cfg.mqtt || {}, replay: integ.replayStatus(), ha: ha.status() }));
    return;
  }
  if (req.url === '/api/integrations' && req.method === 'POST') {
    let body = ''; req.on('data', (c) => { body += c; if (body.length > 2e5) req.destroy(); });
    req.on('end', () => {
      const r = clientRole(req, {}); if (r.role !== 'host') { res.writeHead(403, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"alleen host"}'); return; }
      try {
        const n = JSON.parse(body);
        const merged = Object.assign({}, cfg);
        if (n.integrations !== undefined) merged.integrations = n.integrations;
        if (n.lights !== undefined) merged.lights = n.lights;
        if (n.mqtt !== undefined) merged.mqtt = n.mqtt;
        const ok = store.saveConfig(merged);
        if (ok) { cfg = merged; applyIntegrations(); }
        res.writeHead(ok ? 200 : 500, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ ok }));
      } catch (_) { res.writeHead(400); res.end('{"ok":false}'); }
    });
    return;
  }
  // ---- Sessie-replay ------------------------------------------------
  if (req.url === '/api/replay' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(integ.replayData()));
    return;
  }
  // ---- Open API v1 (sleutel vereist) --------------------------------
  if (req.url.split('?')[0] === '/api/v1/state' && req.method === 'GET') {
    if (!apiKeyOk(req)) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end('{"error":"ongeldige of ontbrekende API-sleutel"}'); return; }
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(snapshot()));
    return;
  }
  if (req.url.split('?')[0] === '/api/v1/command' && req.method === 'POST') {
    if (!apiKeyOk(req)) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end('{"error":"ongeldige of ontbrekende API-sleutel"}'); return; }
    let body = ''; req.on('data', (c) => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', () => {
      try { const m = JSON.parse(body); handleCmd(m, { role: 'host' }); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('{"ok":true}'); }
      catch (_) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"ok":false}'); }
    });
    return;
  }
  if (req.url === '/api/update' && req.method === 'GET') {
    updater.check(true).then((u) => {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(u));
    });
    return;
  }
  if (req.url === '/api/license' && req.method === 'GET') {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify(license.status()));
    return;
  }
  if (req.url === '/api/license' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 1e5) req.destroy(); });
    req.on('end', () => {
      let b = {};
      try { b = JSON.parse(body) || {}; } catch (_) {}
      if (b.action === 'refresh') {                 // online-check nu forceren
        license.refresh(true).then((rr) => {
          if (rr.changed) sse.broadcast(JSON.stringify({ type: 'license', ...rr.status }));
          res.writeHead(200, { 'Content-Type': 'application/json' });
          res.end(JSON.stringify({ ok: true, changed: rr.changed, status: rr.status }));
        }).catch(() => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, status: license.status() })); });
        return;
      }
      const r = license.activate((b.key || '').toString());
      res.writeHead(r.ok ? 200 : 400, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(r));
    });
    return;
  }
  if (req.url === '/api/backup' && req.method === 'POST') {       // baanconfig naar de cloud (account)
    const key = license.currentKey();
    if (!key) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"geen licentie"}'); return; }
    const https = require('https');
    const payload = Buffer.from(JSON.stringify({ key, roster }));
    const rq = https.request('https://modelrailpro.com/backup.php', { method: 'POST', timeout: 15000, headers: { 'Content-Type': 'application/json', 'Content-Length': payload.length } }, (r2) => {
      let b = ''; r2.on('data', (c) => b += c); r2.on('end', () => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(b || '{"ok":false}'); });
    });
    rq.on('timeout', () => rq.destroy()); rq.on('error', () => { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"cloud onbereikbaar"}'); });
    rq.end(payload);
    return;
  }
  if (req.url === '/api/restore' && req.method === 'POST') {       // baanconfig terughalen uit de cloud
    const key = license.currentKey();
    if (!key) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"geen licentie"}'); return; }
    const https = require('https');
    const rq = https.request('https://modelrailpro.com/backup.php?key=' + encodeURIComponent(key), { method: 'GET', timeout: 15000 }, (r2) => {
      let b = ''; r2.on('data', (c) => b += c); r2.on('end', () => {
        try { const d = JSON.parse(b); if (d && d.ok && d.roster) { applyRoster(d.roster); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, saved: d.saved })); } else { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: false, error: 'geen back-up gevonden' })); } }
        catch (_) { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end('{"ok":false}'); }
      });
    });
    rq.on('timeout', () => rq.destroy()); rq.on('error', () => { res.writeHead(502, { 'Content-Type': 'application/json' }); res.end('{"ok":false,"error":"cloud onbereikbaar"}'); });
    rq.end();
    return;
  }
  if (req.url === '/api/layout' && req.method === 'POST') {
    let body = '';
    req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
    req.on('end', () => {
      try {
        const layout = JSON.parse(body);
        const ok = store.saveLayout(layout);
        res.writeHead(ok ? 200 : 500, { 'Content-Type': 'application/json' });
        res.end(JSON.stringify({ ok }));
      } catch (_) { res.writeHead(400); res.end('{"ok":false}'); }
    });
    return;
  }
  serveStatic(req, res);
});

// ---- SSE-brug (werkt onder Node én Bun) --------------------------------
const sse = createSSE();

// Stuur een nieuwe stream-client de begroeting.
function greet(res) {
  sse.sendTo(res, JSON.stringify({ type: 'roster', ...roster }));
  sse.sendTo(res, JSON.stringify({ type: 'license', ...license.status() }));
  sse.sendTo(res, JSON.stringify(snapshot()));
  updater.check(false).then((u) => { if (u && u.updateAvailable) sse.sendTo(res, JSON.stringify({ type: 'update', ...u })); }).catch(() => {});
}

// ---- Club-/multi-operator-modus (opt-in) ------------------------------------
const crypto = require('crypto');
const club = { enabled: false, operators: {} };   // token -> {token,pid,name,role,locos,seen}
function isLoopback(req) { const ip = (req && req.socket && req.socket.remoteAddress) || ''; return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; }
function clientRole(req, m) {
  if (!club.enabled) return { role: 'host', locos: [] };
  if (isLoopback(req)) return { role: 'host', locos: [] };     // de machine die de server draait = altijd host
  const op = club.operators[(m && m._op) || ''];
  return op ? { role: op.role, locos: op.locos || [] } : { role: 'guest', locos: [] };
}
function relayRole(m) {
  if (!club.enabled) return { role: 'host', locos: [] };
  const op = club.operators[(m && m._op) || ''];
  return op ? { role: op.role, locos: op.locos || [] } : { role: 'guest', locos: [] };
}

// ---- Centrale-autodiscovery: scan het lokale subnet naar bekende centrales ----
function localSubnet() {
  const os = require('os'); const ifs = os.networkInterfaces();
  for (const name of Object.keys(ifs)) for (const i of ifs[name]) if (i.family === 'IPv4' && !i.internal) return i.address.split('.').slice(0, 3).join('.');
  return null;
}
function scanCentrals(done) {
  const net2 = require('net'); const dgram2 = require('dgram');
  const sub = localSubnet(); if (!sub) return done([]);
  const found = []; const has = (ip) => found.some((f) => f.ip === ip);
  const targets = []; for (let h = 1; h <= 254; h++) { targets.push([sub + '.' + h, 2560, 'dcc-ex']); targets.push([sub + '.' + h, 15471, 'ecos']); }
  let idx = 0, active = 0, fin = false;
  const finish = () => { if (!fin) { fin = true; done(found); } };
  const overall = setTimeout(finish, 5000);
  function next() {
    if (fin) return;
    if (idx >= targets.length && active === 0) { clearTimeout(overall); finish(); return; }
    while (active < 60 && idx < targets.length) {
      const [ip, port, type] = targets[idx++]; active++;
      const s = net2.connect({ host: ip, port, timeout: 700 });
      s.once('connect', () => { if (!has(ip)) found.push({ ip, type, port }); s.destroy(); });
      s.once('error', () => {}); s.once('timeout', () => s.destroy());
      s.once('close', () => { active--; next(); });
    }
  }
  next();
  // Z21/DR5000 (UDP): stuur LAN_GET_SERIAL_NUMBER naar elk adres, verzamel antwoorders
  try {
    const sock = dgram2.createSocket('udp4');
    sock.on('message', (msg, ri) => { if (!has(ri.address)) found.push({ ip: ri.address, type: 'z21', port: 21105 }); });
    sock.bind(() => { const pkt = Buffer.from([0x04, 0x00, 0x10, 0x00]); for (let h = 1; h <= 254; h++) { try { sock.send(pkt, 21105, sub + '.' + h); } catch (_) {} } });
    setTimeout(() => { try { sock.close(); } catch (_) {} }, 3000);
  } catch (_) {}
}

// ---- Import van Rocrail (plan.xml) / JMRI (roster.xml) -----------------------
function parseAttrs(s) { const o = {}; let m; const re = /([\w:-]+)\s*=\s*"([^"]*)"/g; while ((m = re.exec(s))) o[m[1]] = m[2]; return o; }
function importXML(xml) {
  const out = { locos: [], turnouts: [], blocks: [] };
  let m, re;
  const isJmri = /<locomotive\b/i.test(xml) && !/<plan\b|<rocrail\b|<lclist\b/i.test(xml);
  if (isJmri) {
    re = /<locomotive\b([^>]*)>/gi;
    while ((m = re.exec(xml))) { const a = parseAttrs(m[1]); const addr = parseInt(a.dccAddress || a.address || '0', 10); if (!addr) continue; const name = ((a.roadName || '') + ' ' + (a.roadNumber || '')).trim() || a.id || ('Loc ' + addr); out.locos.push({ addr, name, functions: [{ fn: 0, label: 'Licht' }] }); }
  } else {
    re = /<lc\b([^>]*?)\/?>/gi;
    while ((m = re.exec(xml))) { const a = parseAttrs(m[1]); const addr = parseInt(a.addr || '0', 10); if (!addr) continue; out.locos.push({ addr, name: a.id || ('Loc ' + addr), functions: [{ fn: 0, label: 'Licht' }] }); }
    re = /<sw\b([^>]*?)\/?>/gi;
    while ((m = re.exec(xml))) { const a = parseAttrs(m[1]); const id = parseInt(a.addr1 || a.addr || '0', 10) || a.id; if (id) out.turnouts.push({ id }); }
    re = /<bk\b([^>]*?)\/?>/gi;
    while ((m = re.exec(xml))) { const a = parseAttrs(m[1]); if (a.id) out.blocks.push({ id: a.id, name: a.id }); }
  }
  // ontdubbel op adres/id
  const uniq = (arr, key) => { const seen = new Set(); return arr.filter((x) => { const k = String(x[key]); if (seen.has(k)) return false; seen.add(k); return true; }); };
  out.locos = uniq(out.locos, 'addr'); out.turnouts = uniq(out.turnouts, 'id'); out.blocks = uniq(out.blocks, 'id');
  return out;
}

// Verwerk een commando van de browser (via POST /api/cmd).
function handleCmd(m, op) {
  if (!m || !m.type) return;
  op = op || { role: 'host', locos: [] };
  // Licentie-poort: bij afdwingen + geen geldige licentie mag je kijken maar niet besturen.
  if (license.status().locked && m.type !== 'estop') { sse.broadcast(JSON.stringify({ type: 'license', ...license.status() })); return; }
  // Club-rechten: host = alles · machinist = alleen eigen loc rijden · gast = alleen kijken (+ noodstop).
  if (op.role && op.role !== 'host') {
    if (m.type === 'estop') { /* noodstop mag iedereen */ }
    else if (op.role === 'driver' && (m.type === 'throttle' || m.type === 'func') && (op.locos || []).map(String).includes(String(m.addr))) { /* eigen loc */ }
    else return;
  }
  switch (m.type) {
    case 'throttle': dcc.throttle(m.addr, m.speed, m.dir); break;
    case 'func':     dcc.func(m.addr, m.fn, m.on); break;
    case 'power':    dcc.power(m.on); break;
    case 'estop':    dcc.estop(); break;
    case 'turnout':  dcc.turnout(m.id, m.thrown); break;
    case 'route': { const r = activateRoute(m.id, m.addr); sse.broadcast(JSON.stringify({ type: 'route_result', id: m.id, ...r })); if (r.ok) pushState(); break; }
    case 'release': releaseRoute(m.id); pushState(); break;
    case 'auto_start': { const r = auto.start(m.id); sse.broadcast(JSON.stringify({ type: 'route_result', id: m.id, ...r })); pushState(); break; }
    case 'auto_stop': auto.stop(parseInt(m.addr, 10)); pushState(); break;
    case 'learn_start': learn.recording = true; learn.seq = []; learn.seen = []; pushState(); break;
    case 'learn_stop': learn.recording = false; pushState(); break;
    case 'learn_clear': learn.recording = false; learn.seq = []; learn.seen = []; learn.lastFired = null; pushState(); break;
    case 'learn_apply': { learn.recording = false; const r = learnApply(); sse.broadcast(JSON.stringify({ type: 'route_result', id: 'learn', ...r })); pushState(); break; }
    case 'clock_start': clock.running = true; _clockLast = Date.now(); pushState(); break;
    case 'clock_stop':  clock.running = false; pushState(); break;
    case 'clock_reset': clock.model = 0; for (const e of (roster.timetable || [])) delete e.__fired; pushState(); break;
    case 'clock_set':
      if (m.factor != null) { clock.factor = Math.max(1, Math.min(120, parseInt(m.factor, 10) || 8)); roster.clockFactor = clock.factor; }
      if (m.time != null) { clock.model = ((parseInt(m.time, 10) || 0) % 1440 + 1440) % 1440; for (const e of (roster.timetable || [])) delete e.__fired; }
      pushState(); break;
    case 'cv_write':                       // decoder programmeren op het hoofdspoor (PoM)
      if (typeof dcc.writeCV === 'function') dcc.writeCV(m.addr, m.cv, m.value);
      else sse.broadcast(JSON.stringify({ type: 'log', line: '✎ CV-programmeren nog niet ondersteund voor deze centrale (' + dcc.mode + ')' }));
      break;
    case 'tt_goto': {                      // draaischijf naar positie
      const tt = (roster.turntables || []).find((x) => String(x.id) === String(m.id));
      if (tt) { tt.pos = parseInt(m.pos, 10) || 0; if (tt.addr) dcc.turnout(parseInt(tt.addr, 10) + tt.pos, true); sse.broadcast(JSON.stringify({ type: 'log', line: '🔄 draaischijf ' + tt.id + ' → spoor ' + tt.pos })); pushState(); }
      break;
    }
    case 'measure_start':                  // snelheidsmeting tussen twee melders
      measure.active = true; measure.addr = parseInt(m.addr, 10) || 0; measure.from = (m.from || '').toString();
      measure.to = (m.to || '').toString(); measure.dist = parseFloat(m.dist) || 0; measure.scale = parseInt(m.scale, 10) || 87; measure.t0 = 0;
      sse.broadcast(JSON.stringify({ type: 'log', line: '📏 snelheidsmeting aan: ' + measure.from + ' → ' + measure.to + ' (' + measure.dist + ' cm)' })); pushState(); break;
    case 'measure_stop': measure.active = false; measure.t0 = 0; pushState(); break;
    case 'shuttle_start': { const r = shuttleStart(m.id); sse.broadcast(JSON.stringify({ type: 'route_result', id: 'shuttle-' + m.id, ...r })); pushState(); break; }
    case 'shuttle_stop': shuttleStop(m.id); pushState(); break;
    case 'reconnect': try { applyCentral(dcc.mode); } catch (_) {} break;
    case 'replay_start': integ.startReplay(); pushState(); break;
    case 'replay_stop': integ.stopReplay(); pushState(); break;
    case 'light_scene': lights.applyScene(m.scene === 'night' ? 'night' : 'day'); break;
    case 'learn_autoschema': { const r = autoSchema(); sse.broadcast(JSON.stringify({ type: 'route_result', id: 'autoschema', ...r })); break; }
    case 'loco_service': {                 // onderhoud: draaiuren resetten + logregel
      const a = parseInt(m.addr, 10);
      roster.runtime = roster.runtime || {}; roster.runtime[a] = 0;
      roster.maintenance = roster.maintenance || [];
      roster.maintenance.unshift({ addr: a, date: new Date().toISOString().slice(0, 10), note: (m.note || '').toString().slice(0, 200) });
      roster.maintenance = roster.maintenance.slice(0, 100);
      try { store.save(roster); } catch (_) {}
      pushState(); break;
    }
  }
}

// Blokreservering (logische laag bovenop de centrale) — voor bloksturing.
const reservations = {};   // blockId -> locoAddr (of true)

function blockBusy(id) {
  // bezet als sensor het meldt, óf gereserveerd door iets anders
  return !!(dcc.state.sensors && dcc.state.sensors[id]) || (reservations[id] !== undefined);
}

// Activeer een rijweg: zet de wissels; reserveer de blokken als ze allemaal vrij zijn.
function activateRoute(routeId, locoAddr) {
  const route = (roster.routes || []).find((r) => String(r.id) === String(routeId));
  if (!route) return { ok: false, reason: 'route niet gevonden' };
  const need = route.blocks || [];
  const clash = need.filter((b) => reservations[b] !== undefined && String(reservations[b]) !== String(locoAddr || ''))
    .concat(need.filter((b) => dcc.state.sensors && dcc.state.sensors[b] && reservations[b] === undefined));
  if (clash.length) return { ok: false, reason: 'blok(ken) bezet: ' + clash.join(', '), busy: clash };
  // wissels zetten
  for (const t of (route.turnouts || [])) dcc.turnout(t.id, t.thrown);
  // blokken reserveren
  for (const b of need) reservations[b] = locoAddr || true;
  return { ok: true };
}
function releaseRoute(routeId) {
  const route = (roster.routes || []).find((r) => String(r.id) === String(routeId));
  if (route) for (const b of (route.blocks || [])) delete reservations[b];
  return { ok: true };
}

// Automatiserings-motor — gebruikt altijd de actuele dcc (getter), zodat centrale-wissel werkt.
const auto = new Auto({
  get dcc() { return dcc; },
  roster: () => roster,
  reservations,
  activateRoute,
  releaseRoute,
  isSim: () => dcc.mode === 'sim',
  drive: (a, s, d) => rampTo(a, s, d),               // vloeiend optrekken/afremmen
  log: (line) => sse.broadcast(JSON.stringify({ type: 'log', line })),
});
// ---- Modelklok (fast clock) + dienstregeling -------------------------------
const clock = { model: 0, factor: (roster.clockFactor || 8), running: false };  // model = minuten sinds 00:00
let _clockLast = Date.now();
function tmin(s) { const m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '')); return m ? (parseInt(m[1], 10) * 60 + parseInt(m[2], 10)) % 1440 : -1; }
function checkTimetable(before, after) {
  const tt = roster.timetable || [];
  const wrapped = after < before;                 // klok ging over middernacht
  if (wrapped) for (const e of tt) e.__fired = false;   // nieuwe dag → alle ritten weer scherp
  for (const e of tt) {
    const t = tmin(e.time); if (t < 0 || !e.run) continue;
    const due = wrapped ? (t <= after || t > before) : (t > before && t <= after);
    if (due && !e.__fired) {
      e.__fired = true;
      // meertrein: een tijdtabel-regel kan een rit (auto) óf een pendeltrein starten
      let r;
      if ((roster.runs || []).some((x) => String(x.id) === String(e.run))) r = auto.start(e.run);
      else if ((roster.shuttles || []).some((x) => String(x.id) === String(e.run))) r = shuttleStart(e.run);
      else r = { ok: false, reason: 'geen rit/pendel ' + e.run };
      sse.broadcast(JSON.stringify({ type: 'log', line: '🕒 ' + e.time + ' → ' + e.run + (r && r.ok ? '' : ' (' + ((r && r.reason) || 'kon niet starten') + ')') }));
    }
  }
}
function clockTick() {
  const now = Date.now();
  if (!clock.running) { _clockLast = now; return; }
  const dt = ((now - _clockLast) / 60000) * clock.factor;   // verstreken model-minuten
  _clockLast = now;
  if (dt <= 0) return;
  const before = clock.model;
  clock.model = (clock.model + dt) % 1440;
  checkTimetable(before, clock.model);
}

// ---- Seinlogica: seinbeeld volgt blokbezetting ------------------------------
// sein = { id, name, watch (blokId), next (volgend blokId, optioneel), addr (stop-lamp), addr2 (geel-lamp), sys ('nl'|'db'|'simple') }
const signalAspect = {};   // id -> 'red' | 'yellow' | 'green'
// Bloksein-aspect (ABS — automatische bloksein-logica):
//  rood  = eigen blok bezet (stop)
//  geel  = eigen blok vrij maar VOLGEND blok bezet (volgende sein staat op rood → op zicht/afremmen)
//  groen = eigen blok én volgend blok vrij
// Een sein zonder 'next' gedraagt zich als vanouds (alleen rood/groen).
function signalAspectFor(s) {
  if (blockBusy(s.watch)) return 'red';
  if (s.next && blockBusy(s.next)) return 'yellow';
  return 'green';
}
function computeSignals() {
  let changed = false;
  for (const s of (roster.signals || [])) {
    const aspect = signalAspectFor(s);
    if (signalAspect[s.id] !== aspect) {
      signalAspect[s.id] = aspect; changed = true;
      if (s.addr) dcc.turnout(parseInt(s.addr, 10), aspect === 'red');        // rood = afbuigend/aan
      if (s.addr2) dcc.turnout(parseInt(s.addr2, 10), aspect === 'yellow');   // geel-lamp via 2e adres
    }
  }
  return changed;
}

// ---- Draaiuren per loc (onderhoudslog) --------------------------------------
let _rtLast = Date.now(), _rtSaveAt = Date.now();
function accrueRuntime() {
  const now = Date.now(); const dt = (now - _rtLast) / 1000; _rtLast = now;
  if (dt <= 0 || dt > 10) return;                 // sla rare sprongen over
  roster.runtime = roster.runtime || {};
  for (const [addr, l] of Object.entries(dcc.state.locos || {})) {
    if (l && Math.abs(l.speed || 0) > 0) roster.runtime[addr] = (roster.runtime[addr] || 0) + dt;
  }
  if (now - _rtSaveAt > 60000) { _rtSaveAt = now; try { store.save(roster); } catch (_) {} }   // periodiek bewaren
}

// ---- Pendeltrein (shuttle): loc heen-en-weer tussen twee blokken --------------
const shuttleActive = {};   // id -> { target, dir, phase:'run'|'dwell', until, guardUntil }
function shuttleStart(id) {
  const sh = (roster.shuttles || []).find((x) => String(x.id) === String(id));
  if (!sh) return { ok: false, reason: 'pendel niet gevonden' };
  shuttleActive[id] = { target: String(sh.to), dir: 1, phase: 'run', until: 0, guardUntil: Date.now() + 2000 };
  rampTo(parseInt(sh.addr, 10), parseInt(sh.speed, 10) || 40, 1);
  return { ok: true };
}
function shuttleStop(id) {
  const sh = (roster.shuttles || []).find((x) => String(x.id) === String(id));
  if (sh) dcc.throttle(parseInt(sh.addr, 10), 0, (shuttleActive[id] || {}).dir ? 1 : 0);
  delete shuttleActive[id];
}
function tickShuttles() {
  for (const id of Object.keys(shuttleActive)) {
    const sh = (roster.shuttles || []).find((x) => String(x.id) === String(id));
    if (!sh) { delete shuttleActive[id]; continue; }
    const st = shuttleActive[id]; const addr = parseInt(sh.addr, 10);
    if (st.phase === 'dwell') {
      if (Date.now() >= st.until) {                       // wachttijd voorbij → omkeren
        st.target = st.target === String(sh.to) ? String(sh.from) : String(sh.to);
        st.dir = st.dir ? 0 : 1; st.phase = 'run'; st.guardUntil = Date.now() + 2000;
        rampTo(addr, parseInt(sh.speed, 10) || 40, st.dir);
      }
      continue;
    }
    if (Date.now() < st.guardUntil) continue;             // niet meteen "aangekomen" bij vertrek
    if (dcc.state.sensors && dcc.state.sensors[st.target]) {   // eindblok bereikt → vloeiend stoppen + wachten
      rampTo(addr, 0, st.dir);
      st.phase = 'dwell'; st.until = Date.now() + (parseInt(sh.wait, 10) || 3) * 1000;
    }
  }
}

// ---- Vloeiende snelheid (ramp): optrekken/afremmen i.p.v. abrupt -------------
const ramps = {};   // addr -> { target, dir }
function rampTo(addr, target, dir) {
  addr = parseInt(addr, 10); target = Math.max(0, Math.min(126, parseInt(target, 10) || 0)); dir = dir ? 1 : 0;
  const l = dcc.state.locos[addr] || { speed: 0, dir: 1 };
  if ((l.speed || 0) === target && l.dir === dir) { dcc.throttle(addr, target, dir); delete ramps[addr]; return; }
  ramps[addr] = { target, dir };
}
function tickRamps() {
  for (const a of Object.keys(ramps)) {
    const r = ramps[a]; const cur = (dcc.state.locos[a] || {}).speed || 0;
    const loco = (roster.locos || []).find((l) => parseInt(l.addr, 10) === parseInt(a, 10));
    const accel = Math.max(1, parseInt(loco && loco.accel, 10) || 9);   // optrekstappen/tick (lager = trager/zwaarder)
    const decel = Math.max(1, parseInt(loco && loco.decel, 10) || 9);   // afremstappen/tick
    const next = cur < r.target ? Math.min(r.target, cur + accel) : cur > r.target ? Math.max(r.target, cur - decel) : r.target;
    dcc.throttle(parseInt(a, 10), next, r.dir);
    if (next === r.target) delete ramps[a];
  }
}

// ---- Integraties: open API/webhooks, anomalie, replay, lichtscènes, Home Assistant ----
const integLog = (line) => sse.broadcast(JSON.stringify({ type: 'log', line }));
const integ = createIntegrations({ log: integLog });
const lights = createLights({ log: integLog });
const ha = createHomeAssistant({ log: integLog, command: (kind, args) => {
  if (kind === 'power') handleCmd({ type: 'power', on: args.on }, { role: 'host' });
  else if (kind === 'turnout') handleCmd({ type: 'turnout', id: args.id, thrown: args.thrown }, { role: 'host' });
  else if (kind === 'throttle') handleCmd({ type: 'throttle', addr: args.addr, speed: args.speed, dir: 1 }, { role: 'host' });
} });
// extra event-sink: anomalieën ook als duidelijke logregel tonen
integ.onEvent((ev) => { if (ev.type === 'anomaly') integLog('⚠️ ' + (ev.title || 'Anomalie') + (ev.message ? ' — ' + ev.message : '')); });
function haSnap() {
  const s = snapshot();
  s.turnoutsList = (roster.turnouts || []).map((t) => t.id);
  s.blocksList = (roster.blocks || []).map((b) => b.id);
  s.locoAddrs = (roster.locos || []).map((l) => l.addr);
  return s;
}
function applyIntegrations() {
  const ic = Object.assign({}, cfg.integrations || {});
  // Anomalie-push standaard naar de centrale modelrailpro.com-relay; sleutel = je cloud-code als niets is ingevuld.
  ic.cloudPush = Object.assign({ url: 'https://modelrailpro.com/push/notify.php', key: '' }, ic.cloudPush || {});
  if (!ic.cloudPush.key && cfg.relayCode) ic.cloudPush.key = cfg.relayCode;
  integ.configure(ic);
  lights.configure(cfg.lights || {});
  ha.configure(cfg.mqtt || {}, haSnap());
}

setInterval(() => {
  dcc._suspendSim = auto.hasActive(); auto.tick(); clockTick(); accrueRuntime(); tickShuttles(); tickRamps();
  const sigChanged = computeSignals();
  const snap = snapshot();
  integ.tick(snap); ha.tick(snap); lights.checkClock(Math.floor(clock.model));
  if (auto.hasActive() || clock.running || sigChanged || Object.keys(shuttleActive).length || Object.keys(ramps).length) pushState();
}, 500).unref();

function lanURL() {
  const os = require('os'); const ifs = os.networkInterfaces();
  for (const name of Object.keys(ifs)) for (const i of ifs[name]) if (i.family === 'IPv4' && !i.internal) return 'http://' + i.address + ':' + PORT;
  return 'http://localhost:' + PORT;
}

function snapshot() {
  return {
    type: 'state',
    mode: dcc.mode,
    connected: dcc.connected,
    serverUrl: lanURL(),
    power: dcc.state.power,
    locos: dcc.state.locos,
    turnouts: dcc.state.turnouts,
    sensors: dcc.state.sensors || {},
    reservations: reservations,
    auto: auto.status(),
    learn: { recording: learn.recording, seen: learn.seen, steps: learn.seq.length, lastFired: learn.lastFired },
    measure: { active: measure.active, from: measure.from, to: measure.to, last: measure.last },
    shuttles: (roster.shuttles || []).map((s) => ({ id: s.id, name: s.name, addr: s.addr, from: s.from, to: s.to, active: !!shuttleActive[s.id] })),
    club: { enabled: club.enabled, operators: Object.values(club.operators).filter((o) => o.role === 'host' || Date.now() - o.seen < 150000).map((o) => ({ pid: o.pid, name: o.name, role: o.role, locos: o.locos })) },
    diag: { mode: dcc.mode, connected: dcc.connected, lastRxAgo: diag.lastRx ? Date.now() - diag.lastRx : null, lastError: diag.lastError, upMs: diag.connectedAt ? Date.now() - diag.connectedAt : null },
    railcom: Object.keys(dcc.state.railcom || {}).map((a) => parseInt(a, 10)),
    staging: (roster.staging || []).map((y) => ({ id: y.id, name: y.name, tracks: (y.tracks || []).map((b) => ({ block: b, busy: blockBusy(b) })) })),
    clock: { model: Math.floor(clock.model), factor: clock.factor, running: clock.running },
    timetable: roster.timetable || [],
    signals: (roster.signals || []).map((s) => ({ id: s.id, name: s.name, watch: s.watch, next: s.next || '', sys: s.sys || 'simple', aspect: signalAspect[s.id] || signalAspectFor(s) })),
    turntables: roster.turntables || [],
    webcamUrl: roster.webcamUrl || '',
    runtime: roster.runtime || {},
    maintenance: roster.maintenance || [],
    replay: typeof integ !== 'undefined' ? integ.replayStatus() : { recording: false, count: 0 },
    lights: typeof lights !== 'undefined' ? lights.status() : { devices: 0, scene: null },
    ha: typeof ha !== 'undefined' ? ha.status() : { enabled: false, connected: false },
    version: updater.currentVersion(),
  };
}

// ---- Leer-modus: bezetmelders ontdekken + baan-topologie leren ---------
const learn = { recording: false, seen: [], seq: [], lastFired: null };
const measure = { active: false, addr: 0, from: '', to: '', dist: 0, scale: 87, t0: 0, last: null };
let _prevSensors = {};
// De (snelst) rijdende loc — gebruikt om afstanden uit snelheid×tijd te schatten.
function drivingLoco() {
  let best = null, bs = 0;
  for (const [a, l] of Object.entries(dcc.state.locos || {})) { const sp = Math.abs((l && l.speed) || 0); if (sp > bs) { bs = sp; best = { addr: parseInt(a, 10), step: sp }; } }
  return best;
}
function detectSensors() {
  const s = dcc.state.sensors || {};
  for (const id of Object.keys(s)) {
    const now = !!s[id], was = !!_prevSensors[id];
    if (now && !was) {                     // rijzende flank = trein komt dit blok binnen
      learn.lastFired = id;
      if (!learn.seen.includes(id)) learn.seen.push(id);
      if (learn.recording) {
        const turnouts = Object.assign({}, dcc.state.turnouts);
        learn.seq.push({ sensor: id, turnouts, t: Date.now(), dl: drivingLoco() });
      }
      // snelheidsmeting: tijd tussen de twee gekozen melders → echte snelheid
      if (measure.active) {
        if (String(id) === String(measure.from)) { measure.t0 = Date.now(); }
        else if (String(id) === String(measure.to) && measure.t0) {
          const dt = (Date.now() - measure.t0) / 1000; measure.t0 = 0;
          if (dt > 0.05 && measure.dist > 0) {
            const cmps = measure.dist / dt;
            const kmh = cmps * measure.scale * 0.036;       // model cm/s → schaal-km/h
            const loco = (roster.locos || []).find((l) => parseInt(l.addr, 10) === parseInt(measure.addr, 10));
            const step = loco ? Math.abs((dcc.state.locos[measure.addr] || {}).speed || 0) : 0;
            if (loco && step) { loco.speedProfile = loco.speedProfile || {}; loco.speedProfile[step] = Math.round(cmps * 100) / 100; loco.scale = measure.scale; try { store.save(roster); } catch (_) {} }
            measure.last = { addr: measure.addr, step, cmps: Math.round(cmps * 100) / 100, kmh: Math.round(kmh * 10) / 10 };
            sse.broadcast(JSON.stringify({ type: 'measure_result', ...measure.last }));
            sse.broadcast(JSON.stringify({ type: 'log', line: '📏 ' + measure.dist + ' cm in ' + dt.toFixed(1) + ' s → ' + measure.last.cmps + ' cm/s ≈ ' + measure.last.kmh + ' km/h (stap ' + step + ')' }));
          }
        }
      }
    }
  }
  _prevSensors = Object.assign({}, s);
}
// Bouw blokken + rijwegen uit de opgenomen volgorde.
function learnApply() {
  // blokken: elk uniek gedetecteerd contact wordt een blok (als nog niet bekend)
  const blocks = (roster.blocks || []).slice();
  const haveBlockForSensor = (sid) => blocks.find((b) => String(b.sensor) === String(sid) || String(b.id) === String(sid));
  for (const sid of learn.seen) {
    if (!haveBlockForSensor(sid)) blocks.push({ id: parseInt(sid, 10) || sid, name: 'Blok ' + sid, sensor: parseInt(sid, 10) || sid });
  }
  const blockIdForSensor = (sid) => { const b = haveBlockForSensor(sid) || blocks.find((x) => String(x.sensor) === String(sid)); return b ? b.id : (parseInt(sid, 10) || sid); };
  // rijwegen: opeenvolgende contacten in de opname → route met de wisselstanden van dat moment
  const routes = (roster.routes || []).slice();
  let added = 0;
  for (let i = 1; i < learn.seq.length; i++) {
    const from = blockIdForSensor(learn.seq[i - 1].sensor);
    const to = blockIdForSensor(learn.seq[i].sensor);
    if (from === to) continue;
    const exists = routes.find((r) => r._from === from && r._to === to);
    if (exists) continue;
    const turnouts = Object.entries(learn.seq[i].turnouts || {}).map(([id, thrown]) => ({ id: parseInt(id, 10) || id, thrown: !!thrown }));
    routes.push({ id: 'L' + (++added) + '-' + Date.now().toString().slice(-4), name: 'Blok ' + from + ' → ' + to, turnouts, blocks: [from, to], _from: from, _to: to });
  }
  // lengtes: tijd tussen melders × snelheid → relatieve lengte (cm als de loc gekalibreerd is)
  const acc = {};   // blockId -> {rel:[], cm:[]}
  for (let i = 1; i < learn.seq.length; i++) {
    const a = learn.seq[i - 1], b = learn.seq[i];
    if (!a.t || !b.t || !a.dl || !a.dl.step) continue;
    const dt = (b.t - a.t) / 1000; if (dt <= 0 || dt > 120) continue;
    const bid = blockIdForSensor(a.sensor);
    (acc[bid] = acc[bid] || { rel: [], cm: [] });
    acc[bid].rel.push(a.dl.step * dt);
    const loco = (roster.locos || []).find((l) => parseInt(l.addr, 10) === parseInt(a.dl.addr, 10));
    if (loco && loco.speedProfile && loco.speedProfile[a.dl.step]) acc[bid].cm.push(loco.speedProfile[a.dl.step] * dt);
  }
  const avg = (arr) => arr.length ? arr.reduce((x, y) => x + y, 0) / arr.length : 0;
  for (const b of blocks) {
    const m = acc[b.id]; if (!m) continue;
    if (m.rel.length) b.len = Math.round(avg(m.rel));
    if (m.cm.length) b.cm = Math.round(avg(m.cm));
  }
  roster = Object.assign({}, roster, { blocks, routes });
  store.save(roster);
  sse.broadcast(JSON.stringify({ type: 'roster', ...roster }));
  return { ok: true, blocks: blocks.length, routes: routes.length };
}

// Auto-schema: teken uit de geleerde blok-volgorde + lengtes een schematische lus op het raster.
function autoSchema() {
  const blocks = (roster.blocks || []);
  if (!blocks.length) return { ok: false, reason: 'geen blokken' };
  // cel="aandeel" per blok op basis van lengte (min 2), zodat de lus proportioneel oogt
  const weight = (b) => Math.max(2, Math.round((b.len || b.cm || 8) / 4));
  const seg = blocks.map((b) => ({ id: b.id, w: weight(b) }));
  const total = seg.reduce((s, x) => s + x.w, 0);
  // rechthoek met perimeter ≈ total cellen (compact, ~vierkant)
  const W = Math.max(4, Math.ceil(total / 4) + 1);
  const H = Math.max(4, Math.ceil((total - 2 * W) / 2) + 2);
  // perimeter-coördinaten met de klok mee vanaf (1,1)
  const peri = [];
  for (let c = 1; c <= W; c++) peri.push([c, 1]);
  for (let r = 2; r <= H; r++) peri.push([W, r]);
  for (let c = W - 1; c >= 1; c--) peri.push([c, H]);
  for (let r = H - 1; r >= 2; r--) peri.push([1, r]);
  const tiles = {};
  const cornerAt = (c, r) => (c === 1 && r === 1) ? 'se' : (c === W && r === 1) ? 'sw' : (c === W && r === H) ? 'nw' : (c === 1 && r === H) ? 'ne' : null;
  const edgeType = (c, r) => (r === 1 || r === H) ? 'h' : 'v';
  for (const [c, r] of peri) { const co = cornerAt(c, r); tiles[c + ',' + r] = { t: co || edgeType(c, r) }; }
  // blokken evenredig verdelen over de perimeter (niet op een hoek)
  let pos = 0;
  for (const s of seg) {
    let idx = pos % peri.length;
    const [c, r] = peri[idx];
    if (!cornerAt(c, r)) tiles[c + ',' + r] = { t: (r === 1 || r === H) ? 'bh' : 'bv', ref: (parseInt(s.id, 10) || s.id) };
    else { const [c2, r2] = peri[(idx + 1) % peri.length]; tiles[c2 + ',' + r2] = { t: (r2 === 1 || r2 === H) ? 'bh' : 'bv', ref: (parseInt(s.id, 10) || s.id) }; }
    pos += s.w;
  }
  const layout = { cols: W + 1, rows: H + 1, tiles };
  store.saveLayout(layout);
  sse.broadcast(JSON.stringify({ type: 'layout', ...layout }));
  return { ok: true, blocks: blocks.length };
}

let pending = false;
function pushState() {
  if (pending) return; pending = true;
  setImmediate(() => { pending = false; detectSensors(); sse.broadcast(JSON.stringify(snapshot())); });
}

function wireDcc() {
  dcc.on('state', () => { diag.lastRx = Date.now(); pushState(); });
  dcc.on('open', () => { diag.connectedAt = Date.now(); diag.lastError = ''; pushState(); });
  dcc.on('close', pushState);
  dcc.on('log', (line) => {
    if (/fout|error/i.test(line)) diag.lastError = String(line).slice(0, 200);
    else if (String(line)[0] === '↩') diag.lastRx = Date.now();
    sse.broadcast(JSON.stringify({ type: 'log', line }));
  });
}
function startDcc() {
  wireDcc();
  dcc.connect();
  if (typeof dcc.startSim === 'function') dcc.startSim(roster.blocks || []);
}

// Wissel van centrale zonder herstart: stop de oude, bouw de nieuwe, koppel + verbind.
function applyCentral(mode) {
  try { auto.stopAll(); } catch (_) {}
  for (const k of Object.keys(reservations)) delete reservations[k];
  stopAdapter(dcc);
  dcc = buildAdapter(mode);
  startDcc();
  sse.broadcast(JSON.stringify({ type: 'log', line: '🔌 Centrale gewisseld naar: ' + dcc.mode }));
  pushState();
}

startDcc();

// ---- Relay-agent (cloud-brug naar de webversie) ------------------------
// Aanzetten met een koppelcode in data/config.json ("relayCode"). Het agentje verbindt
// uitgaand met de relay op p33k.nl; vul dezelfde code in de webversie in.
let agent = null;
const RELAY_URL = pick('BB_RELAY_URL', 'relayUrl', 'https://modelrailpro.com/relay.php');

// alle uitgaande berichten ook naar de relay sturen (hook één keer instellen)
const _origBroadcast = sse.broadcast.bind(sse);
sse.broadcast = (str) => { _origBroadcast(str); if (agent) { try { agent.push(JSON.parse(str)); } catch (_) {} } };
// hartslag: periodiek volledige toestand zodat een net-verbonden webclient bijkomt
setInterval(() => {
  if (!agent) return;
  agent.push({ type: 'roster', ...roster });
  agent.push({ type: 'license', ...license.status() });
  agent.push(snapshot());
}, 3000).unref();

// Relay aan/uit/wijzigen — werkt live, zonder herstart. Code moet >= 6 tekens.
function applyRelay(code) {
  code = (code || '').trim();
  if (agent) { agent.stop(); agent = null; }
  if (code.length >= 6) {
    agent = new RelayAgent({
      code,
      url: RELAY_URL,
      onCmd: (m) => handleCmd(m, relayRole(m)),
      log: (line) => { console.log(line); _origBroadcast(JSON.stringify({ type: 'log', line })); },
    });
    agent.start();
  }
}
applyRelay(pick('BB_RELAY_CODE', 'relayCode', ''));

function localIPs() {
  const os = require('os'); const out = [];
  const ifs = os.networkInterfaces();
  for (const name of Object.keys(ifs)) for (const i of ifs[name]) if (i.family === 'IPv4' && !i.internal) out.push(i.address);
  return out;
}
// bij start eenmalig op updates checken (stil; meldt alleen in de console als er iets is)
updater.check(true).then((u) => {
  if (u && u.updateAvailable) console.log('  ⬆️  Nieuwe versie beschikbaar: ' + u.latest + ' (jij hebt ' + u.current + ') → ' + u.url);
}).catch(() => {});

// Online licentie-check: haalt de actuele sleutel bij de uitgever (verlenging/wijziging/intrekking).
// Faalt stil bij geen internet — dan blijft de offline ondertekende sleutel gelden.
function syncLicense(force) {
  license.refresh(force).then((r) => {
    if (r && r.changed) {
      sse.broadcast(JSON.stringify({ type: 'license', ...r.status }));
      console.log('  🔑  Licentie bijgewerkt via online-check: ' + r.status.message);
    }
  }).catch(() => {});
}
syncLicense(true);                                  // bij start
setInterval(() => syncLicense(false), 30 * 60e3).unref();   // periodiek (echte netcall hooguit 1×/6u)

// WiThrottle-server: laat throttle-apps (Engine Driver / WiThrottle) de baan bedienen.
// Standaardpoort 12090; uitzetten met BB_WITHROTTLE_PORT=0.
const wtPort = process.env.BB_WITHROTTLE_PORT != null ? (parseInt(process.env.BB_WITHROTTLE_PORT, 10) || 0) : 12090;
let withrottle = null;
if (wtPort) {
  try {
    const { WiThrottleServer } = require('./lib/withrottle');
    withrottle = new WiThrottleServer({
      port: wtPort,
      getRoster: () => roster,
      getPower: () => !!(dcc.state && dcc.state.power),
      onCmd: (m) => handleCmd(m, relayRole(m)),
      log: (line) => { console.log(line); _origBroadcast(JSON.stringify({ type: 'log', line })); },
    });
    withrottle.start();
  } catch (e) { console.log('[withrottle] niet gestart: ' + e.message); }
}

applyIntegrations();   // webhooks/anomalie/lichtscènes/Home Assistant uit config activeren

server.listen(PORT, () => {
  console.log('\n  🚂  ModelRailPro draait  (centrale: ' + dcc.mode + ', versie ' + updater.currentVersion() + ')\n');
  console.log('  Op dit apparaat:   http://localhost:' + PORT);
  for (const ip of localIPs()) console.log('  Op telefoon/tablet: http://' + ip + ':' + PORT + '   (zelfde wifi)');
  console.log('\n  Stoppen: sluit dit venster of druk Ctrl+C.\n');
  // Open automatisch de browser (handig voor dubbelklik-start op de pc).
  // Uitzetten kan met BB_NO_OPEN=1 (bv. op een Raspberry Pi als service).
  if (process.env.BB_NO_OPEN !== '1') {
    const url = 'http://localhost:' + PORT;
    try {
      const { spawn } = require('child_process');
      const plat = process.platform;
      const cmd = plat === 'win32' ? 'cmd' : (plat === 'darwin' ? 'open' : 'xdg-open');
      const args = plat === 'win32' ? ['/c', 'start', '', url] : [url];
      spawn(cmd, args, { stdio: 'ignore', detached: true }).on('error', () => {}).unref();
    } catch (_) {}
  }
});
