'use strict';
// ModelRailPro PWA — realtime besturing via WebSocket.
(function () {
  const $ = (s) => document.querySelector(s);
  let roster = { locos: [], turnouts: [], blocks: [], routes: [], runs: [], consists: [] };
  let state = { power: false, connected: false, mode: '', locos: {}, turnouts: {}, sensors: {} };
  let sel = null;          // actief loc-adres
  let selConsist = null;   // actieve tractie (consist) of null
  let camUrl = '';         // huidige webcam-URL (voor periodieke verversing)
  let opToken = '', opPid = '', myRole = 'host', myLocos = null;   // club-/multi-operator
  let es = null, wsReady = false;
  let lic = { enforced: false, locked: false, tier: 'free', message: '' };

  // ---- Transport: lokaal (SSE) of via de relay-cloud (poll) ------------
  // Relay-modus als de app op p33k.nl/baanbrein/app draait. Dan praat 'ie via de relay
  // met het lokale agentje (koppelcode), i.p.v. rechtstreeks met een lokale server.
  const RELAY_MODE = location.pathname.indexOf('/app') === 0 || location.pathname.indexOf('/baanbrein/app') === 0;
  const RELAY_URL = (location.pathname.indexOf('/baanbrein/') === 0 ? '/baanbrein/relay.php' : '/relay.php');
  let relayCode = null, relayOutSeq = 0, relayTimer = null;

  function handleMsg(m) {
    if (m.type === 'roster') { roster = { locos: m.locos || [], turnouts: m.turnouts || [], blocks: m.blocks || [], routes: m.routes || [], runs: m.runs || [], consists: m.consists || [] }; renderRoster(); renderTurnouts(); renderBlocks(); renderRoutes(); renderRuns(); if (window.BBManage) window.BBManage.refresh(); }
    else if (m.type === 'state') { const _prev = state; state = m; onState(); if (window.BBSound) window.BBSound.onState(m, _prev); }
    else if (m.type === 'log') { addLog(m.line); }
    else if (m.type === 'license') { lic = m; renderLicense(); }
    else if (m.type === 'update') { showUpdate(m); }
    else if (m.type === 'route_result') { if (!m.ok) alert((window.t ? window.t('route_busy') : 'bezet') + ': ' + (m.reason || '')); }
    else if (m.type === 'layout') { if (window.BBSchema && window.BBSchema.reload) window.BBSchema.reload(); }
    else if (m.type === 'measure_result') { const el = document.getElementById('measure-result'); if (el) el.textContent = m.cmps + ' cm/s ≈ ' + m.kmh + ' km/h (' + (window.t ? window.t('measure_step') : 'stap') + ' ' + m.step + ')'; }
  }

  function connect() {
    if (RELAY_MODE) { relayConnect(); return; }
    try { if (es) es.close(); } catch (e) {}
    es = new EventSource('api/stream');
    es.onopen = () => { wsReady = true; setConn(); };
    es.onerror = () => { wsReady = false; setConn(); };
    es.onmessage = (ev) => { let m; try { m = JSON.parse(ev.data); } catch (e) { return; } handleMsg(m); };
  }

  function relayConnect() {
    relayCode = null;
    try { relayCode = localStorage.getItem('bb-relay-code'); } catch (e) {}
    const urlCode = new URLSearchParams(location.search).get('code');
    if (urlCode) { relayCode = urlCode; try { localStorage.setItem('bb-relay-code', urlCode); } catch (e) {} }
    if (!relayCode) {
      relayCode = (prompt(window.t ? window.t('relay_prompt') : 'Koppelcode (zie het agentje thuis):') || '').trim();
      if (relayCode) { try { localStorage.setItem('bb-relay-code', relayCode); } catch (e) {} }
    }
    if (!relayCode) return;
    clearInterval(relayTimer);
    relayTimer = setInterval(relayPoll, 400);
    relayPoll();
  }
  async function relayPoll() {
    try {
      const r = await (await fetch(RELAY_URL + '?code=' + encodeURIComponent(relayCode) + '&role=out&since=' + relayOutSeq)).json();
      if (r && Array.isArray(r.msgs)) {
        relayOutSeq = r.seq || relayOutSeq;
        wsReady = true;
        for (const it of r.msgs) handleMsg(it.v);
        if (!r.msgs.length) setConn();
      }
    } catch (e) { wsReady = false; setConn(); }
  }

  function helloOperator(name) {
    if (RELAY_MODE) return;                        // club-modus alleen voor lokale (LAN-)operators
    try { if (!opToken) opToken = localStorage.getItem('bb-op') || ''; } catch (e) {}
    let nm = name; try { if (nm == null) nm = localStorage.getItem('bb-opname') || ''; } catch (e) {}
    fetch('api/operator', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: opToken, name: nm }) })
      .then((r) => r.json()).then((d) => { if (d && d.token) { opToken = d.token; opPid = d.pid; try { localStorage.setItem('bb-op', opToken); } catch (e) {} } }).catch(() => {});
  }
  function renderClub() {
    const c = state && state.club; const banner = document.getElementById('club-banner');
    const T = window.t || ((k) => k);
    if (!c || !c.enabled) { if (banner) banner.hidden = true; myRole = 'host'; myLocos = null; document.documentElement.removeAttribute('data-role'); if (window.BBManage && window.BBManage.club) window.BBManage.club({ enabled: false, operators: [] }); return; }
    const me = (c.operators || []).find((o) => o.pid === opPid);
    myRole = me ? me.role : 'guest'; myLocos = me ? (me.locos || []) : [];
    document.documentElement.dataset.role = myRole;
    if (banner) { banner.hidden = false; banner.textContent = '👥 ' + T('club_you') + ': ' + T('role_' + myRole) + (myRole === 'driver' && myLocos.length ? ' · loc ' + myLocos.join(', ') : ''); }
    if (window.BBManage && window.BBManage.club) window.BBManage.club(c);
  }
  function allowed(o) {
    if (!myRole || myRole === 'host') return true;
    if (o.type === 'estop') return true;
    if (myRole === 'driver' && (o.type === 'throttle' || o.type === 'func')) return (myLocos || []).map(String).indexOf(String(o.addr)) > -1;
    return false;
  }

  function send(o) {
    if (!allowed(o)) return;                      // club-modus: blokkeer wat deze rol niet mag
    if (opToken) o._op = opToken;                 // club-modus: identificeer de operator
    if (RELAY_MODE) {
      if (!relayCode) return;
      try { fetch(RELAY_URL + '?code=' + encodeURIComponent(relayCode) + '&role=cmd', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o), keepalive: true }); } catch (e) {}
      return;
    }
    try { fetch('api/cmd', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(o), keepalive: true }); } catch (e) {}
  }
  // Bediening die rekening houdt met tractie: rijden/functies waaieren uit over alle leden.
  function tx(o) {
    if (selConsist && (o.type === 'throttle' || o.type === 'func')) {
      (selConsist.members || []).forEach((mb) => {
        if (o.type === 'throttle') send({ type: 'throttle', addr: mb.addr, speed: o.speed, dir: mb.reverse ? (o.dir ? 0 : 1) : o.dir });
        else send({ type: 'func', addr: mb.addr, fn: o.fn, on: o.on });
      });
    } else send(o);
  }

  // ---- render ----------------------------------------------------------
  function setConn() {
    const el = $('#conn');
    const T = window.t || ((k) => k);
    if (wsReady && state.connected) { el.className = 'pill on'; el.textContent = (state.mode === 'sim' ? T('sim_live') : T('connected_live')); }
    else if (wsReady) { el.className = 'pill'; el.textContent = T('waiting_central'); }
    else { el.className = 'pill off'; el.textContent = T('offline'); }
  }

  function renderRoster() {
    const r = $('#roster'); r.innerHTML = '';
    roster.locos.forEach((l) => {
      const c = document.createElement('div');
      c.className = 'lococard' + (sel === l.addr ? ' sel' : '');
      c.style.borderLeftColor = l.color || 'var(--accent)';
      const st = state.locos[l.addr] || { speed: 0, dir: 1 };
      const photo = l.photo ? '<div class="locphoto" style="background-image:url(' + JSON.stringify(l.photo) + ')"></div>' : '';
      c.innerHTML = photo + '<div class="ln"></div><div class="la"></div>' +
        '<div class="ls">' + (st.dir ? '▶' : '◀') + ' <b>' + (st.speed || 0) + '</b></div>';
      c.querySelector('.ln').textContent = l.name;
      c.querySelector('.la').textContent = (window.t?window.t('address'):'adres') + ' ' + l.addr;
      c.onclick = () => { sel = l.addr; selConsist = null; renderRoster(); renderCab(); };
      r.appendChild(c);
    });
    // tractie-kaarten (consists)
    (roster.consists || []).forEach((cs) => {
      const c = document.createElement('div');
      c.className = 'lococard consist' + (selConsist && selConsist.id === cs.id ? ' sel' : '');
      c.style.borderLeftColor = cs.color || 'var(--warn)';
      c.innerHTML = '<div class="ln"></div><div class="la"></div>';
      c.querySelector('.ln').textContent = '🔗 ' + (cs.name || 'Tractie');
      c.querySelector('.la').textContent = (cs.members || []).map((m) => m.addr).join(' + ');
      c.onclick = () => { selConsist = cs; sel = (cs.members[0] || {}).addr; renderRoster(); renderCab(); };
      r.appendChild(c);
    });
    if (sel === null && roster.locos.length) { sel = roster.locos[0].addr; renderCab(); }
  }

  function locoDef(addr) { return roster.locos.find((l) => l.addr === addr); }

  function renderCab() {
    const def = locoDef(sel);
    const cab = $('#cab');
    if (!def) { cab.hidden = true; return; }
    cab.hidden = false;
    const st = state.locos[sel] || { speed: 0, dir: 1, fns: {} };
    $('#cab-dot').style.background = (selConsist ? (selConsist.color || 'var(--warn)') : (def.color || 'var(--accent)'));
    const cabPhoto = $('#cab-photo');
    if (cabPhoto) { if (!selConsist && def.photo) { cabPhoto.style.backgroundImage = 'url(' + JSON.stringify(def.photo) + ')'; cabPhoto.hidden = false; } else cabPhoto.hidden = true; }
    $('#cab-name').textContent = selConsist ? ('🔗 ' + (selConsist.name || 'Tractie')) : def.name;
    $('#cab-addr').textContent = selConsist ? (selConsist.members || []).map((m) => m.addr).join(' + ') : ((window.t?window.t('address'):'adres') + ' ' + def.addr);
    setSpeedUI(st.speed || 0);
    setDirUI(st.dir);
    renderFuncs(def, st);
  }

  function setSpeedUI(v) {
    $('#speedval').textContent = v;
    const t = $('#throttle'); t.value = v;
    const pct = (v / 126) * 100;
    t.style.background = 'linear-gradient(90deg,var(--accent) ' + pct + '%,var(--panel2) ' + pct + '%)';
  }
  function setDirUI(dir) {
    $('#dir-fwd').classList.toggle('on', !!dir);
    $('#dir-rev').classList.toggle('on', !dir);
  }

  function renderFuncs(def, st) {
    const f = $('#funcs'); f.innerHTML = '';
    (def.functions || []).forEach((fn) => {
      const b = document.createElement('button');
      const on = !!(st.fns && st.fns[fn.fn]);
      b.className = 'fbtn' + (on ? ' on' : '');
      b.innerHTML = '<span class="fi">' + (fn.icon || '⚙') + '</span>' + fn.label +
        (fn.momentary ? '<span class="mo">'+(window.t?window.t('hold'):'(hou vast)')+'</span>' : '');
      if (fn.momentary) {
        const down = (e) => { e.preventDefault(); tx({ type: 'func', addr: sel, fn: fn.fn, on: true }); };
        const up = (e) => { e.preventDefault(); tx({ type: 'func', addr: sel, fn: fn.fn, on: false }); };
        b.addEventListener('pointerdown', down);
        b.addEventListener('pointerup', up);
        b.addEventListener('pointerleave', up);
        b.addEventListener('pointercancel', up);
      } else {
        b.onclick = () => tx({ type: 'func', addr: sel, fn: fn.fn, on: !on });
      }
      f.appendChild(b);
    });
  }

  function turnoutGrid(grid) {
    grid.innerHTML = '';
    roster.turnouts.forEach((t) => {
      const thrown = !!state.turnouts[t.id];
      const b = document.createElement('button');
      b.className = 'tbtn' + (thrown ? ' thrown' : '');
      b.innerHTML = '<span></span><span class="tstate">' + (thrown ? '↱ ' + (window.t?window.t('diverging'):'AFBUIGEND') : '↑ ' + (window.t?window.t('straight'):'RECHTDOOR')) + '</span>';
      b.querySelector('span').textContent = t.name;
      b.onclick = () => send({ type: 'turnout', id: t.id, thrown: !thrown });
      grid.appendChild(b);
    });
  }
  function renderTurnouts() {
    const sec = $('#turnouts-sec');
    if (!roster.turnouts.length) { sec.hidden = true; }
    else { sec.hidden = false; turnoutGrid($('#turnouts')); turnoutGrid($('#turnouts2')); }
  }

  function renderBlocks() {
    const grid = $('#blocks');
    grid.innerHTML = '';
    if (!roster.blocks.length) { grid.innerHTML = '<p class="muted small">'+(window.t?window.t('no_blocks'):'Geen blokken gedefinieerd.')+'</p>'; return; }
    roster.blocks.forEach((bl) => {
      const bezet = !!state.sensors[bl.id];
      const d = document.createElement('div');
      d.className = 'block' + (bezet ? ' bezet' : '');
      d.innerHTML = '<span class="dot"></span><span></span><span class="bs">' + (bezet ? (window.t?window.t('block_busy'):'BEZET') : (window.t?window.t('block_free'):'vrij')) + '</span>';
      const len = bl.cm ? (bl.cm + ' cm') : (bl.len ? ('~' + bl.len) : '');
      d.querySelectorAll('span')[1].innerHTML = bl.name + (len ? ' <small class="muted">· ' + len + '</small>' : '');
      grid.appendChild(d);
    });
  }

  function renderRuns() {
    const grid = $('#runs'); if (!grid) return;
    const runs = roster.runs || [];
    const autoState = state.auto || {};
    grid.innerHTML = '';
    if (!runs.length) { grid.innerHTML = '<p class="muted small">' + (window.t ? window.t('no_runs') : '—') + '</p>'; return; }
    runs.forEach((run) => {
      const a = autoState[run.addr];
      const running = a && String(a.runId) === String(run.id);
      const b = document.createElement('button');
      b.className = 'rbtn' + (running ? ' active' : '');
      b.innerHTML = '<span></span><span class="rst"></span>';
      b.querySelectorAll('span')[0].textContent = (running ? '⏹ ' : '▶ ') + (run.name || ('Rit ' + run.id));
      const T = window.t || ((k) => k);
      b.querySelector('.rst').textContent = running
        ? (T('auto_running') + ' · ' + T('auto_step') + ' ' + a.step + '/' + a.steps + ' (' + a.phase + ')')
        : (T('auto_start_lbl') + ' · loc ' + run.addr);
      b.onclick = () => running ? send({ type: 'auto_stop', addr: run.addr }) : send({ type: 'auto_start', id: run.id });
      grid.appendChild(b);
    });
  }

  function renderRoutes() {
    const grid = $('#routes'); if (!grid) return;
    const routes = roster.routes || [];
    grid.innerHTML = '';
    routes.forEach((r) => {
      const blocks = r.blocks || [];
      const busy = blocks.some((b) => state.sensors && state.sensors[b]);
      const reserved = blocks.some((b) => state.reservations && state.reservations[b] !== undefined);
      const b = document.createElement('button');
      b.className = 'rbtn' + (busy ? ' busy' : (reserved ? ' active' : ''));
      const T = window.t || ((k) => k);
      const st = busy ? T('route_busy') : (reserved ? T('route_reserved') : T('route_free'));
      b.innerHTML = '<span></span><span class="rst"></span>';
      b.querySelectorAll('span')[0].textContent = r.name || ('Route ' + r.id);
      b.querySelector('.rst').textContent = (window.t ? window.t('route_set') : 'Instellen') + ' · ' + st;
      b.onclick = () => {
        if (reserved && !busy) send({ type: 'release', id: r.id });
        else send({ type: 'route', id: r.id, addr: sel });
      };
      grid.appendChild(b);
    });
  }

  // ---- versie + update-check (footer + topbar-chip) --------------------
  let updateInfo = null;
  function showVersion() {
    if (state.version) {
      const v = $('#app-version'); if (v) v.textContent = 'v' + state.version;
      const vb = $('#ver-btn'); if (vb && !updateInfo) vb.textContent = 'v' + state.version;
    }
  }
  function markUpdateChip(u) {
    updateInfo = u;
    const vb = $('#ver-btn'); if (!vb) return;
    const T = window.t || ((k) => k);
    vb.classList.add('has-update');
    vb.textContent = '⬆️ ' + (u.latest || '');
    vb.title = T('update_to') + ' ' + u.latest;
  }
  const _verBtn = $('#ver-btn');
  if (_verBtn) _verBtn.onclick = async () => {
    if (updateInfo && updateInfo.url) { window.open(updateInfo.url, '_blank', 'noopener'); return; }
    const T = window.t || ((k) => k);
    try {
      const u = await (await fetch('api/update')).json();
      if (u.updateAvailable) { markUpdateChip(u); window.open(u.url || 'https://p33k.nl/baanbrein/', '_blank', 'noopener'); }
      else alert('v' + (u.current || state.version || '') + ' — ' + T('up_to_date'));
    } catch (e) { /* offline */ }
  };
  const _chk = $('#checkupd');
  if (_chk) _chk.onclick = async () => {
    const r = $('#upd-result'); const T = window.t || ((k) => k);
    r.textContent = '…';
    try {
      const u = await (await fetch('api/update')).json();
      if (u.updateAvailable) {
        r.innerHTML = '';
        const a = document.createElement('a');
        a.href = u.url || 'https://p33k.nl/baanbrein/'; a.target = '_blank'; a.rel = 'noopener';
        a.className = 'upd-new'; a.textContent = '⬆️ ' + T('update_to') + ' ' + u.latest;
        r.appendChild(a);
      } else if (u.checkedOk) {
        r.textContent = '✓ ' + T('up_to_date');
      } else {
        r.textContent = T('upd_offline');
      }
    } catch (e) { r.textContent = T('upd_offline'); }
  };

  function renderLearn() {
    const st = $('#learn-status'); if (!st) return;
    const L = state.learn || {};
    const T = window.t || ((k) => k);
    const sb = $('#learn-start');
    if (sb) { sb.textContent = L.recording ? T('learn_stop') : T('learn_start'); sb.classList.toggle('on', !!L.recording); }
    const seen = (L.seen || []).join(', ');
    st.innerHTML = (L.recording ? '🔴 ' + T('learn_recording') + ' · ' : '') +
      T('learn_detected') + ': ' + (seen || '—') + (L.steps ? ' · ' + L.steps + ' ' + T('learn_steps') : '');
  }

  function onState() {
    setConn();
    showVersion();
    renderLearn();
    $('#power').setAttribute('aria-pressed', state.power ? 'true' : 'false');
    // alleen de actieve loc + roster-snelheden verversen (geen full rerender van inputs tijdens slepen)
    if (!dragging) { renderRoster(); renderCab(); }
    else { renderRosterSpeedsOnly(); }
    renderTurnouts();
    renderBlocks();
    renderRoutes();
    renderRuns();
    renderClock();
    renderWebcam();
    renderSignals();
    renderTurntables();
    renderShuttles();
    renderStaging();
    renderClub();
    if (window.BBManage && window.BBManage.diag) window.BBManage.diag();
    if (window.BBAnim) window.BBAnim.recStatus();
  }
  function renderStaging() {
    const h = document.getElementById('staging-h'), g = document.getElementById('staging'); if (!g) return;
    const ys = (state && state.staging) || [];
    if (h) h.hidden = !ys.length;
    g.innerHTML = '';
    ys.forEach((y) => {
      const free = (y.tracks || []).filter((t) => !t.busy).length;
      const box = document.createElement('div'); box.style.cssText = 'background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:8px';
      box.innerHTML = '<div style="font-weight:600;margin-bottom:6px">' + (y.name || ('Opstel ' + y.id)) + ' <span class="muted small">· ' + free + '/' + (y.tracks || []).length + ' vrij</span></div>';
      const row = document.createElement('div'); row.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px';
      (y.tracks || []).forEach((t) => { const d = document.createElement('span'); d.className = 'chip'; d.style.cssText = 'padding:5px 10px;border:1px solid var(--line);border-radius:8px;font-size:12px;background:' + (t.busy ? 'rgba(255,93,93,.15)' : 'rgba(62,207,142,.12)'); d.textContent = t.block; row.appendChild(d); });
      box.appendChild(row); g.appendChild(box);
    });
  }
  function renderShuttles() {
    const h = document.getElementById('shuttles-h'), g = document.getElementById('shuttles'); if (!g) return;
    const sh = (state && state.shuttles) || [];
    if (h) h.hidden = !sh.length;
    g.innerHTML = '';
    const T = window.t || ((k) => k);
    sh.forEach((s) => {
      const b = document.createElement('button'); b.className = 'rbtn' + (s.active ? ' active' : '');
      b.innerHTML = '<span></span><span class="rst"></span>';
      b.querySelectorAll('span')[0].textContent = (s.active ? '⏹ ' : '▶ ') + (s.name || ('Pendel ' + s.id));
      b.querySelector('.rst').textContent = (s.from || '?') + ' ⇄ ' + (s.to || '?') + ' · loc ' + s.addr;
      b.onclick = () => send({ type: s.active ? 'shuttle_stop' : 'shuttle_start', id: s.id });
      g.appendChild(b);
    });
  }
  function renderWebcam() {
    const w = document.getElementById('cam-wrap'); if (!w) return;
    const url = state && state.webcamUrl;
    if (!url) { w.hidden = true; camUrl = ''; return; }
    w.hidden = false; camUrl = url;
    const img = document.getElementById('cam-img');
    if (img && !img.src) img.src = url + (url.indexOf('?') > -1 ? '&' : '?') + '_t=' + Date.now();
    if (!window._camTimer) window._camTimer = setInterval(() => {
      const i = document.getElementById('cam-img'); if (camUrl && i && !document.getElementById('cam-wrap').hidden) i.src = camUrl + (camUrl.indexOf('?') > -1 ? '&' : '?') + '_t=' + Date.now();
    }, 2500);
  }
  function renderSignals() {
    const h = document.getElementById('signals-h'), g = document.getElementById('signals'); if (!g) return;
    const sigs = (state && state.signals) || [];
    if (h) h.hidden = !sigs.length;
    g.innerHTML = '';
    const ICON = { red: '🔴', yellow: '🟡', green: '🟢' };
    const SYS = { nl: 'NS', db: 'DB' };
    sigs.forEach((s) => {
      const d = document.createElement('div'); d.className = 'bk' + (s.aspect === 'red' ? ' bz' : '');
      const tag = (s.sys && SYS[s.sys]) ? ' <small class="muted">' + SYS[s.sys] + '</small>' : '';
      d.innerHTML = '<span class="dot"></span><span>' + (s.name || s.id) + tag + '</span><span class="bs">' + (ICON[s.aspect] || '🟢') + '</span>';
      g.appendChild(d);
    });
  }
  function renderTurntables() {
    const w = document.getElementById('tt-wrap'), g = document.getElementById('turntables'); if (!g) return;
    const tts = (state && state.turntables) || [];
    if (w) w.hidden = !tts.length;
    g.innerHTML = '';
    tts.forEach((tt) => {
      const box = document.createElement('div'); box.style.cssText = 'background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px;margin-bottom:8px';
      const n = document.createElement('div'); n.style.cssText = 'font-weight:600;margin-bottom:8px'; n.textContent = tt.name || ('Draaischijf ' + tt.id); box.appendChild(n);
      const row = document.createElement('div'); row.style.cssText = 'display:flex;flex-wrap:wrap;gap:6px';
      for (let p = 0; p < (tt.positions || 1); p++) {
        const b = document.createElement('button'); b.textContent = p;
        b.style.cssText = 'min-width:38px;padding:8px;border-radius:8px;border:1px solid var(--line);cursor:pointer;font-weight:700;' + ((tt.pos || 0) === p ? 'background:var(--accent);color:#06122b' : 'background:var(--panel2);color:var(--ink)');
        b.onclick = () => send({ type: 'tt_goto', id: tt.id, pos: p });
        row.appendChild(b);
      }
      box.appendChild(row); g.appendChild(box);
    });
  }
  function tmins(s) { const m = /^(\d{1,2}):(\d{2})$/.exec(String(s || '')); return m ? parseInt(m[1], 10) * 60 + parseInt(m[2], 10) : 0; }
  function renderClock() {
    const box = document.getElementById('clockbox'); if (!box) return;
    const c = state && state.clock;
    if (!c) { box.hidden = true; return; }
    box.hidden = false;
    const T = window.t || ((k) => k);
    const te = document.getElementById('clk-time');
    if (te) te.textContent = String(Math.floor(c.model / 60)).padStart(2, '0') + ':' + String(c.model % 60).padStart(2, '0');
    const tog = document.getElementById('clk-toggle'); if (tog) tog.textContent = c.running ? '⏸' : '▶';
    const fs = document.getElementById('clk-factor');
    if (fs) {
      if (!fs.options.length) [1, 2, 4, 8, 12, 16, 24, 30, 60].forEach((v) => { const o = document.createElement('option'); o.value = v; o.textContent = v; fs.appendChild(o); });
      if (String(c.factor) !== fs.value) fs.value = String(c.factor);
    }
    const next = document.getElementById('clk-next');
    if (next) {
      const tt = (state.timetable || []).filter((e) => e.time && e.run).slice().sort((a, b) => tmins(a.time) - tmins(b.time));
      const up = tt.filter((e) => tmins(e.time) >= c.model).concat(tt.filter((e) => tmins(e.time) < c.model)).slice(0, 3);
      next.textContent = up.length ? (T('clk_next') + ': ' + up.map((e) => e.time + ' → ' + e.run).join(' · ')) : '';
    }
    if (!box._bound) {
      box._bound = true;
      tog && tog.addEventListener('click', () => send({ type: (state.clock && state.clock.running) ? 'clock_stop' : 'clock_start' }));
      const rst = document.getElementById('clk-reset'); rst && rst.addEventListener('click', () => send({ type: 'clock_reset' }));
      fs && fs.addEventListener('change', (e) => send({ type: 'clock_set', factor: parseInt(e.target.value, 10) }));
    }
  }
  function renderRosterSpeedsOnly() {
    document.querySelectorAll('.lococard').forEach((c, i) => {
      const l = roster.locos[i]; if (!l) return;
      const st = state.locos[l.addr] || { speed: 0, dir: 1 };
      const ls = c.querySelector('.ls'); if (ls) ls.innerHTML = (st.dir ? '▶' : '◀') + ' <b>' + (st.speed || 0) + '</b>';
    });
  }

  // ---- bediening -------------------------------------------------------
  let dragging = false;
  const thr = $('#throttle');
  thr.addEventListener('pointerdown', () => { dragging = true; });
  thr.addEventListener('pointerup', () => { dragging = false; });
  thr.addEventListener('input', () => {
    const v = parseInt(thr.value, 10);
    $('#speedval').textContent = v;
    const pct = (v / 126) * 100;
    thr.style.background = 'linear-gradient(90deg,var(--accent) ' + pct + '%,var(--panel2) ' + pct + '%)';
    const dir = $('#dir-fwd').classList.contains('on') ? 1 : 0;
    if (sel !== null) tx({ type: 'throttle', addr: sel, speed: v, dir: dir });
  });

  $('#stop').onclick = () => { if (sel === null) return; setSpeedUI(0); const dir = $('#dir-fwd').classList.contains('on') ? 1 : 0; tx({ type: 'throttle', addr: sel, speed: 0, dir: dir }); };
  function setDir(dir) {
    if (sel === null) return;
    setDirUI(dir); setSpeedUI(0);                 // veilig: eerst stoppen bij richtingswissel
    tx({ type: 'throttle', addr: sel, speed: 0, dir: dir });
  }
  $('#dir-fwd').onclick = () => setDir(1);
  $('#dir-rev').onclick = () => setDir(0);

  $('#power').onclick = () => send({ type: 'power', on: !state.power });
  $('#estop').onclick = () => send({ type: 'estop' });

  // ---- log -------------------------------------------------------------
  const logEl = $('#log'); const logLines = [];
  function addLog(line) { logLines.push(line); if (logLines.length > 200) logLines.shift(); logEl.textContent = logLines.join('\n'); logEl.scrollTop = logEl.scrollHeight; }

  // publieke API voor losse modules (bv. de schema-editor)
  const subs = [];
  window.BB = {
    send: send,
    roster: () => roster,
    state: () => state,
    onState: (cb) => { subs.push(cb); cb(state, roster); },
  };
  const _onState = onState;
  onState = function () { _onState(); subs.forEach((cb) => { try { cb(state, roster); } catch (e) {} }); };

  // ---- hulp: de baan koppelen ------------------------------------------
  const CENTRALS = {
    sim:        { label: 'Simulator', kind: 'none' },
    dccex:      { label: 'DCC-EX', kind: 'net', port: 2560 },
    z21:        { label: 'Roco/Fleischmann Z21', kind: 'net', port: 21105 },
    dr5000:     { label: 'Digikeijs DR5000', kind: 'net', port: 21105 },
    ecos:       { label: 'ESU ECoS', kind: 'net', port: 15471 },
    marklin:    { label: 'Märklin CS2/CS3', kind: 'net', port: 15731 },
    twincenter: { label: 'Fleischmann Twin Center', kind: 'serial' },
    xpressnet:  { label: 'Lenz/multiMaus (XpressNet)', kind: 'serial' },
    loconet:    { label: 'Uhlenbrock/LoDi (LocoNet)', kind: 'serial' },
  };
  function renderHelp() {
    const T = window.t || ((k) => k);
    const mode = (state && state.mode) || 'sim';
    const c = CENTRALS[mode] || { label: mode, kind: 'net' };
    const connected = state && state.connected;
    // statusregel
    const st = $('#help-status');
    const ok = connected && mode !== 'sim';
    st.className = 'help-status ' + (mode === 'sim' ? 'sim' : (connected ? 'ok' : 'wait'));
    st.innerHTML = '<b>' + T('help_current') + ':</b> ' + c.label +
      ' — ' + (mode === 'sim' ? T('help_sim_on') : (connected ? T('help_connected') : T('help_notconn')));

    const body = $('#help-body');
    let steps = [];
    if (mode === 'sim') {
      steps = [T('help_sim_1'), T('help_sim_2')];
    } else if (c.kind === 'net') {
      steps = [
        T('help_net_1'),
        T('help_net_2').replace('{mode}', mode),
        T('help_net_3') + (c.port ? ' (poort ' + c.port + ')' : ''),
        T('help_net_4'),
        T('help_check'),
      ];
    } else {
      steps = [
        T('help_ser_1'),
        T('help_ser_2'),
        T('help_ser_3').replace('{mode}', mode),
        T('help_ser_4'),
        T('help_check'),
      ];
    }
    body.innerHTML = '<ol>' + steps.map((s) => '<li>' + s + '</li>').join('') + '</ol>' +
      '<p class="muted small">' + T('help_more') + ' <a href="https://p33k.nl/baanbrein/#download" target="_blank" rel="noopener">p33k.nl/baanbrein</a> · HARDWARE.md</p>';
  }
  $('#help-btn').onclick = () => { renderHelp(); $('#help-dialog').showModal(); };
  $('#help-close').onclick = () => $('#help-dialog').close();

  // ---- leer-modus ------------------------------------------------------
  const _ls = $('#learn-start');
  if (_ls) _ls.onclick = () => send({ type: (state.learn && state.learn.recording) ? 'learn_stop' : 'learn_start' });
  const _la = $('#learn-apply'); if (_la) _la.onclick = () => send({ type: 'learn_apply' });
  const _lc = $('#learn-clear'); if (_lc) _lc.onclick = () => send({ type: 'learn_clear' });
  const _las = $('#learn-autoschema'); if (_las) _las.onclick = () => send({ type: 'learn_autoschema' });
  const _ms = $('#m-start'); if (_ms) _ms.onclick = () => send({ type: 'measure_start', addr: parseInt(($('#m-addr') || {}).value, 10) || 0, from: ($('#m-from') || {}).value || '', to: ($('#m-to') || {}).value || '', dist: parseFloat(($('#m-dist') || {}).value) || 0, scale: parseInt(($('#m-scale') || {}).value, 10) || 87 });
  const _mst = $('#m-stop'); if (_mst) _mst.onclick = () => send({ type: 'measure_stop' });

  // ---- telefoon koppelen (QR) ------------------------------------------
  function renderQR(url) {
    const box = $('#qr-canvas'); if (!box || !window.QR) return;
    box.innerHTML = '';
    const m = window.QR.make(url, 'M'); const n = m.count, scale = 6, quiet = 4;
    const dim = (n + quiet * 2) * scale;
    const cv = document.createElement('canvas'); cv.width = cv.height = dim;
    const ctx = cv.getContext('2d');
    ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, dim, dim);
    ctx.fillStyle = '#000';
    for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (m.isDark(r, c)) ctx.fillRect((c + quiet) * scale, (r + quiet) * scale, scale, scale);
    box.appendChild(cv);
  }
  $('#qr-btn').onclick = () => {
    const url = (state && state.serverUrl) || location.origin;
    $('#qr-url').textContent = url;
    renderQR(url);
    $('#qr-dialog').showModal();
  };
  $('#qr-close').onclick = () => $('#qr-dialog').close();

  // ---- update-melding --------------------------------------------------
  function showUpdate(u) {
    markUpdateChip(u);
    const b = $('#update-banner');
    b.hidden = false;
    b.innerHTML = (window.t?window.t('update_avail',{latest:u.latest||'',current:u.current||''}):('Nieuwe versie '+(u.latest||''))) + '<a href="' + (u.url || 'https://p33k.nl/baanbrein/') + '" target="_blank" rel="noopener">' + (window.t?window.t('download'):'Downloaden') + '</a>';
  }

  // ---- licentie --------------------------------------------------------
  function renderLicense() {
    const btn = $('#lic-btn'); const banner = $('#lic-banner');
    btn.className = 'pwr' + (lic.locked ? ' locked' : (lic.tier === 'pro' ? ' pro' : ''));
    btn.textContent = lic.tier === 'pro' ? (window.t?window.t('lic_pro'):'🔑 Pro') : (lic.locked ? (window.t?window.t('lic_locked'):'🔒 Vergrendeld') : (window.t?window.t('lic_free'):'🔓 Gratis'));
    $('#lic-status').textContent = lic.message || '';
    if (lic.locked) {
      banner.hidden = false;
      banner.innerHTML = (window.t?window.t('lic_locked_banner'):'🔒 Besturen is vergrendeld') + ' <a id="lic-open">' + (window.t?window.t('lic_enter'):'Licentie invoeren') + '</a>';
      const o = $('#lic-open'); if (o) o.onclick = openLic;
    } else { banner.hidden = true; }
  }
  function openLic() { $('#lic-msg').textContent = ''; $('#lic-dialog').showModal(); }
  $('#lic-btn').onclick = openLic;
  $('#lic-cancel').onclick = () => $('#lic-dialog').close();
  $('#lic-activate').onclick = async () => {
    const key = $('#lic-input').value.trim(); const msg = $('#lic-msg');
    if (!key) { msg.className = 'lic-msg err'; msg.textContent = 'Plak eerst een sleutel.'; return; }
    try {
      const r = await fetch('api/license', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key }) });
      const j = await r.json();
      if (j.ok) { msg.className = 'lic-msg ok'; msg.textContent = '✓ Geactiveerd: ' + (j.status.message || ''); lic = j.status; renderLicense(); setTimeout(() => $('#lic-dialog').close(), 1200); }
      else { msg.className = 'lic-msg err'; msg.textContent = '✗ ' + (j.error || 'ongeldige sleutel'); }
    } catch (e) { msg.className = 'lic-msg err'; msg.textContent = '✗ kon niet activeren'; }
  };

  // In relay-modus: Beheer + Schema-bewerken vereisen het lokale agentje → verberg ze.
  // (Rijden + Baan werken volledig via de relay.)
  if (RELAY_MODE) {
    document.querySelectorAll('.tab[data-tab="beheer"], .tab[data-tab="schema"]').forEach((b) => b.remove());
  }
  // tabs (generiek over alle tabviews)
  document.querySelectorAll('.tab').forEach((t) => {
    t.onclick = () => {
      const which = t.dataset.tab;
      document.querySelectorAll('.tab').forEach((x) => x.classList.toggle('on', x === t));
      document.querySelectorAll('.tabview').forEach((v) => { v.hidden = (v.id !== 'tab-' + which); });
      if (which === 'baan' && window.BBSchema) window.BBSchema.renderLive();
      if (which === 'beheer' && window.BBManage) window.BBManage.refresh();
    };
  });

  // ---- taalkeuze -------------------------------------------------------
  (function initLang() {
    const sel = $('#lang');
    if (!sel || !window.i18nLangs) return;
    window.i18nLangs().forEach((l) => { const o = document.createElement('option'); o.value = l.code; o.textContent = l.code.toUpperCase(); sel.appendChild(o); });
    sel.value = window.getLang();
    sel.onchange = () => window.setLang(sel.value);
    window.applyI18n();
  })();
  // bij taalwissel: alle dynamisch gerenderde delen opnieuw tekenen
  document.addEventListener('langchange', () => {
    renderRoster(); renderCab(); renderTurnouts(); renderBlocks(); renderRoutes(); renderRuns();
    renderLicense(); setConn();
    const h = $('#baan-hint'); if (h && window.t) h.textContent = window.t('baan_hint');
  });

  // ---- thema (donker / licht / nacht) + grote-knoppen-modus -------------
  (function initTheme() {
    const ORDER = ['dark', 'light', 'night'];
    const ICON = { dark: '☀️', light: '🌙', night: '🌑' };
    let th; try { th = localStorage.getItem('bb-theme'); } catch (e) {}
    if (!th) th = (window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches) ? 'light' : 'dark';
    document.documentElement.dataset.theme = th;
    const btn = $('#theme-btn');
    if (btn) {
      const paint = () => { btn.textContent = ICON[document.documentElement.dataset.theme] || '☀️'; };
      paint();
      btn.onclick = () => { const cur = document.documentElement.dataset.theme; const n = ORDER[(ORDER.indexOf(cur) + 1) % ORDER.length]; document.documentElement.dataset.theme = n; try { localStorage.setItem('bb-theme', n); } catch (e) {} paint(); };
    }
    // grote-knoppen-modus (toegankelijkheid)
    let big = '0'; try { big = localStorage.getItem('bb-big') || '0'; } catch (e) {}
    document.documentElement.dataset.big = big;
    const bb = $('#big-btn');
    if (bb) bb.onclick = () => { const n = document.documentElement.dataset.big === '1' ? '0' : '1'; document.documentElement.dataset.big = n; try { localStorage.setItem('bb-big', n); } catch (e) {} };
  })();

  // ---- scherm-aan houden (wake lock) -----------------------------------
  let wakeLock = null;
  async function keepAwake() {
    try { if ('wakeLock' in navigator && !wakeLock) { wakeLock = await navigator.wakeLock.request('screen'); wakeLock.addEventListener('release', () => { wakeLock = null; }); } } catch (e) {}
  }
  document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') keepAwake(); });
  keepAwake();

  // ---- haptisch: trillen bij noodstop ----------------------------------
  const _estopBtn = $('#estop');
  if (_estopBtn) _estopBtn.addEventListener('click', () => { if (navigator.vibrate) navigator.vibrate([40, 30, 40]); });

  // ---- toetsenbord-bediening (pc) --------------------------------------
  document.addEventListener('keydown', (e) => {
    const tag = (e.target && e.target.tagName) || '';
    if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
    if (sel === null) return;
    const st = state.locos[sel] || { speed: 0, dir: 1 };
    const dir = $('#dir-fwd').classList.contains('on') ? 1 : 0;
    if (e.key === 'ArrowUp') { e.preventDefault(); const v = Math.min(126, (st.speed || 0) + 5); setSpeedUI(v); tx({ type: 'throttle', addr: sel, speed: v, dir: dir }); }
    else if (e.key === 'ArrowDown') { e.preventDefault(); const v = Math.max(0, (st.speed || 0) - 5); setSpeedUI(v); tx({ type: 'throttle', addr: sel, speed: v, dir: dir }); }
    else if (e.key === ' ') { e.preventDefault(); setSpeedUI(0); tx({ type: 'throttle', addr: sel, speed: 0, dir: dir }); }
    else if (e.key === 'ArrowLeft') { e.preventDefault(); setDir(0); }
    else if (e.key === 'ArrowRight') { e.preventDefault(); setDir(1); }
    else if (e.key === 'Escape') { e.preventDefault(); send({ type: 'estop' }); if (navigator.vibrate) navigator.vibrate([40, 30, 40]); }
  });

  // ---- Push-meldingen: aanmelden voor anomalie-meldingen ---------------
  // De meldingen lopen via modelrailpro.com (eigen push-systeem). Je meldt je aan met je
  // cloud-code; je thuis-baan stuurt anomalieën naar dezelfde code → push op dit toestel.
  (function initPush() {
    const btn = document.getElementById('push-btn'); if (!btn) return;
    const PUSH = 'https://modelrailpro.com/push';
    function supported() { return ('serviceWorker' in navigator) && ('PushManager' in window) && ('Notification' in window); }
    function b64ToU8(s) { const pad = '='.repeat((4 - s.length % 4) % 4); const b = atob((s + pad).replace(/-/g, '+').replace(/_/g, '/')); const u = new Uint8Array(b.length); for (let i = 0; i < b.length; i++) u[i] = b.charCodeAt(i); return u; }
    function stationKey() {
      let k = null; try { k = localStorage.getItem('bb-relay-code'); } catch (e) {}
      if (!k) { k = (prompt(window.t ? window.t('push_key_prompt') : 'Je cloud-code (zelfde als op de thuis-baan):') || '').trim(); if (k) { try { localStorage.setItem('bb-relay-code', k); } catch (e) {} } }
      return k;
    }
    async function current() { try { const r = await navigator.serviceWorker.ready; return await r.pushManager.getSubscription(); } catch (e) { return null; } }
    function paint(on) { btn.textContent = on ? '🔔' : '🔕'; btn.title = window.t ? window.t(on ? 'push_on' : 'push_off') : 'Meldingen'; }
    async function subscribe() {
      const key = stationKey(); if (!key || key.length < 6) { alert(window.t ? window.t('push_key_short') : 'Cloud-code van minstens 6 tekens nodig.'); return; }
      const perm = await Notification.requestPermission(); if (perm !== 'granted') { alert(window.t ? window.t('push_denied') : 'Meldingen niet toegestaan.'); return; }
      const reg = await navigator.serviceWorker.ready;
      const vk = await (await fetch(PUSH + '/key.php')).json();
      const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToU8(vk.key) });
      const r = await (await fetch(PUSH + '/subscribe.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key, subscription: sub.toJSON() }) })).json();
      if (r.ok) { paint(true); addLog('🔔 ' + (window.t ? window.t('push_subscribed') : 'Aangemeld voor meldingen')); } else alert('✗ ' + (r.error || ''));
    }
    async function unsubscribe() {
      const sub = await current(); const key = stationKey();
      if (sub) { try { await fetch(PUSH + '/subscribe.php', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: key, unsubscribe: true, endpoint: sub.endpoint }) }); } catch (e) {} await sub.unsubscribe(); }
      paint(false); addLog('🔕 ' + (window.t ? window.t('push_unsubscribed') : 'Meldingen uit'));
    }
    if (!supported()) { btn.style.display = 'none'; return; }
    btn.onclick = async () => { const s = await current(); if (s) unsubscribe(); else subscribe(); };
    current().then((s) => paint(!!s));
  })();

  // ---- Live-animatie & sessie-replay -----------------------------------
  (function initAnim() {
    const cv = document.getElementById('anim-canvas'); if (!cv) return;
    const ctx = cv.getContext('2d');
    const recBtn = document.getElementById('rec-btn'), playBtn = document.getElementById('play-btn');
    const scrub = document.getElementById('play-scrub'), statusEl = document.getElementById('rec-status');
    let playing = false, frames = [], playIdx = 0, playT0 = 0;
    function blocks() { return (roster.blocks || []).map((b) => b.id); }
    function fit() { const w = cv.clientWidth || 600; cv.width = w * (window.devicePixelRatio || 1); cv.height = 120 * (window.devicePixelRatio || 1); ctx.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0); return w; }
    function draw(frame, phase) {
      const w = fit(), h = 120; ctx.clearRect(0, 0, w, h);
      const bl = blocks(); if (!bl.length) { ctx.fillStyle = '#5b6573'; ctx.font = '13px system-ui'; ctx.fillText(window.t ? window.t('anim_noblocks') : 'Maak blokken aan om de animatie te zien.', 12, 64); return; }
      const sensors = frame.sensors || {}, locos = frame.locos || {};
      const pad = 14, gap = 10, segW = (w - pad * 2 - gap * (bl.length - 1)) / bl.length, y = 38, segH = 30;
      bl.forEach((id, i) => {
        const x = pad + i * (segW + gap), busy = !!sensors[id];
        ctx.fillStyle = busy ? '#1c2a3a' : '#161b22'; ctx.strokeStyle = busy ? '#e2902b' : '#2a313d'; ctx.lineWidth = 2;
        ctx.beginPath(); ctx.roundRect(x, y, segW, segH, 7); ctx.fill(); ctx.stroke();
        if (busy) {  // bewegende streep = trein rijdt
          const ph = (phase * 0.06) % 1; const tx = x + 6 + ph * (segW - 24);
          ctx.fillStyle = '#4c8bf5'; ctx.beginPath(); ctx.roundRect(tx, y + 7, 18, segH - 14, 4); ctx.fill();
          ctx.fillStyle = '#cfe0ff'; ctx.font = '11px system-ui'; ctx.fillText('🚂', tx + 1, y + segH / 2 + 4);
        }
        ctx.fillStyle = busy ? '#e8edf4' : '#5b6573'; ctx.font = '11px system-ui'; ctx.textAlign = 'center';
        const bn = (roster.blocks.find((b) => String(b.id) === String(id)) || {}).name || id;
        ctx.fillText(String(bn).slice(0, 10), x + segW / 2, y + segH + 16); ctx.textAlign = 'left';
      });
      const n = Object.keys(locos).filter((a) => (locos[a].speed || locos[a].s || 0) > 0).length;
      ctx.fillStyle = '#5b6573'; ctx.font = '11px system-ui';
      ctx.fillText((window.t ? window.t('anim_moving') : 'rijdend') + ': ' + n, pad, 18);
    }
    let raf = 0, t = 0;
    function tick() {
      t++;
      if (playing && frames.length) {
        const now = Date.now() - playT0;
        while (playIdx < frames.length - 1 && frames[playIdx + 1].t <= now) playIdx++;
        scrub.value = Math.round((playIdx / Math.max(1, frames.length - 1)) * 100);
        draw(frames[playIdx], t);
        if (playIdx >= frames.length - 1) { playing = false; playBtn.textContent = '▶ ' + (window.t ? window.t('rec_play') : 'Replay'); }
      } else if (!playing) {
        const tabBaan = document.getElementById('tab-baan');
        if (tabBaan && !tabBaan.hidden) draw({ sensors: state.sensors || {}, locos: state.locos || {} }, t);
      }
      raf = requestAnimationFrame(tick);
    }
    if (recBtn) recBtn.onclick = () => {
      const rec = state.replay && state.replay.recording;
      send({ type: rec ? 'replay_stop' : 'replay_start' });
    };
    if (playBtn) playBtn.onclick = async () => {
      if (playing) { playing = false; playBtn.textContent = '▶ ' + (window.t ? window.t('rec_play') : 'Replay'); return; }
      try {
        const d = await (await fetch('api/replay')).json(); frames = d.frames || [];
        if (!frames.length) { statusEl.textContent = window.t ? window.t('rec_none') : 'Nog niets opgenomen.'; return; }
        playIdx = 0; playT0 = Date.now() - frames[0].t; playing = true; playBtn.textContent = '⏸';
      } catch (e) {}
    };
    if (scrub) scrub.oninput = () => { if (frames.length) { playing = false; playBtn.textContent = '▶ ' + (window.t ? window.t('rec_play') : 'Replay'); playIdx = Math.round((scrub.value / 100) * (frames.length - 1)); draw(frames[playIdx], t); } };
    window.BBAnim = { recStatus: () => { if (!statusEl) return; const r = state.replay || {}; if (recBtn) recBtn.textContent = r.recording ? ('⏺ ' + (window.t ? window.t('rec_stop') : 'Stop') ) : ('⏺ ' + (window.t ? window.t('rec_start') : 'Opnemen')); statusEl.textContent = r.recording ? ('🔴 ' + r.count + ' frames') : (r.count ? (r.count + ' frames') : ''); } };
    tick();
  })();

  // ---- Gamepad / USB-handregelaar --------------------------------------
  // Werkt met elke standaard USB/Bluetooth-gamepad (Gamepad API). Mapping:
  //   linker stick omhoog/omlaag of triggers = snelheid van de gekozen loc
  //   knop 0 (A/×) = stop (snelheid 0)   ·   knop 1 (B/○) = noodstop
  //   D-pad links/rechts = rijrichting   ·   knoppen 2-7 = functietoetsen F0-F5
  (function initGamepad() {
    let active = false, raf = 0;
    let prevBtn = [], lastSpeed = -1, lastDir = -1;
    const DEAD = 0.15;
    function loop() {
      const pads = navigator.getGamepads ? navigator.getGamepads() : [];
      const gp = [].find.call(pads, (p) => p && p.connected);
      if (gp && sel !== null) {
        // snelheid: trigger-as (axes[3] of axes[1]) — omhoog = sneller
        let axis = gp.axes && gp.axes.length > 1 ? -gp.axes[1] : 0;
        if (Math.abs(axis) < DEAD) axis = 0;
        if (axis !== 0) {
          const cur = (state.locos[sel] || {}).speed || 0;
          let v = Math.max(0, Math.min(126, Math.round(cur + axis * 6)));
          const dir = $('#dir-fwd').classList.contains('on') ? 1 : 0;
          if (v !== lastSpeed) { lastSpeed = v; setSpeedUI(v); tx({ type: 'throttle', addr: sel, speed: v, dir: dir }); }
        }
        const b = (gp.buttons || []).map((x) => x.pressed);
        const pressed = (i) => b[i] && !prevBtn[i];
        if (pressed(0)) { setSpeedUI(0); tx({ type: 'throttle', addr: sel, speed: 0, dir: $('#dir-fwd').classList.contains('on') ? 1 : 0 }); }
        if (pressed(1)) { send({ type: 'estop' }); if (navigator.vibrate) navigator.vibrate([40, 30, 40]); }
        if (pressed(14)) setDir(0);   // D-pad links = achteruit
        if (pressed(15)) setDir(1);   // D-pad rechts = vooruit
        for (let f = 0; f <= 5; f++) if (pressed(f + 2)) tx({ type: 'func', addr: sel, fn: f, on: true });
        prevBtn = b;
      }
      raf = requestAnimationFrame(loop);
    }
    window.addEventListener('gamepadconnected', () => { if (!active) { active = true; addLog('🎮 ' + (window.t ? window.t('gamepad_connected') : 'Handregelaar verbonden')); loop(); } });
    window.addEventListener('gamepaddisconnected', () => { addLog('🎮 ' + (window.t ? window.t('gamepad_disconnected') : 'Handregelaar losgekoppeld')); });
  })();

  // ---- hamburgermenu (mobiel) ------------------------------------------
  (function initMenu() {
    const btn = $('#menu-btn'), menu = $('#menu');
    if (!btn || !menu) return;
    const close = () => { menu.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
    btn.onclick = (e) => { e.stopPropagation(); const open = menu.classList.toggle('open'); btn.setAttribute('aria-expanded', open ? 'true' : 'false'); };
    menu.addEventListener('click', (e) => { if (e.target.closest('button, a')) close(); });
    document.addEventListener('click', (e) => { if (!menu.contains(e.target) && e.target !== btn) close(); });
  })();

  // service worker (PWA)
  if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {});

  // ---- Setup-wizard "baan in 5 stappen" --------------------------------
  (function initWizard() {
    const dlg = document.getElementById('wiz-dialog'); if (!dlg) return;
    const T = (k) => (window.t ? window.t(k) : k);
    const MODES = [['sim', 'Simulator'], ['dccex', 'DCC-EX (WiFi)'], ['z21', 'Roco/Fleischmann Z21'], ['dr5000', 'Digikeijs DR5000'], ['ecos', 'ESU ECoS'], ['marklin', 'Märklin CS2/CS3'], ['twincenter', 'Fleischmann Twin Center']];
    let step = 0; const body = document.getElementById('wiz-body'); const stepEl = document.getElementById('wiz-step');
    const back = document.getElementById('wiz-back'), next = document.getElementById('wiz-next'), skip = document.getElementById('wiz-skip');
    const steps = [
      () => '<h4>' + T('wiz_w1_h') + '</h4><p class="muted small">' + T('wiz_w1_p') + '</p><div class="learn-ctrls"><button data-lang="nl" class="pwr">NL</button><button data-lang="en" class="pwr">EN</button><button data-lang="de" class="pwr">DE</button></div>'
        + '<p class="muted small" style="margin-top:14px">' + T('wiz_first_p') + '</p><div class="learn-ctrls"><button id="wiz-first" class="pwr on">🚂 ' + T('wiz_first') + '</button></div><div id="wiz-first-msg" class="muted small"></div>',
      () => '<h4>' + T('wiz_w2_h') + '</h4><p class="muted small">' + T('wiz_w2_p') + '</p>'
        + '<div class="learn-ctrls" style="flex-wrap:wrap"><select id="wiz-mode" class="langsel">' + MODES.map(([v, l]) => '<option value="' + v + '">' + l + '</option>').join('') + '</select>'
        + '<input id="wiz-host" placeholder="IP (bv. 192.168.0.111)" style="flex:1;min-width:160px"><button id="wiz-scan" class="pwr">🔍 ' + T('wiz_scan') + '</button></div>'
        + '<div id="wiz-scanres" class="muted small"></div><div id="wiz-savemsg" class="muted small"></div>',
      () => '<h4>' + T('wiz_w3_h') + '</h4><p class="muted small">' + T('wiz_w3_p') + '</p><div class="learn-ctrls"><button id="wiz-power" class="pwr on">⏻ ' + T('wiz_power') + '</button></div>',
      () => '<h4>' + T('wiz_w4_h') + '</h4><p class="muted small">' + T('wiz_w4_p') + '</p><div class="learn-ctrls"><button id="wiz-phone" class="pwr">📱 ' + T('wiz_phone') + '</button></div>',
    ];
    function render() {
      stepEl.textContent = (step + 1) + ' / ' + steps.length;
      body.innerHTML = steps[step]();
      back.style.visibility = step === 0 ? 'hidden' : 'visible';
      next.textContent = step === steps.length - 1 ? T('wiz_finish') : T('wiz_next');
      if (step === 0) {
        body.querySelectorAll('[data-lang]').forEach((b) => b.onclick = () => { if (window.setLang) window.setLang(b.dataset.lang); render(); });
        const fb = document.getElementById('wiz-first');
        if (fb) fb.onclick = async () => {
          fb.disabled = true; const msg = document.getElementById('wiz-first-msg'); if (msg) msg.textContent = '⏳';
          try { await window.BBFirst(); if (msg) msg.textContent = '✓ ' + T('wiz_first_done'); } catch (e) { if (msg) msg.textContent = '✗'; }
          fb.disabled = false;
        };
      }
      if (step === 1) {
        const mode = document.getElementById('wiz-mode'), host = document.getElementById('wiz-host');
        fetch('api/config').then((r) => r.json()).then((c) => { if (c) { mode.value = c.mode || 'sim'; host.value = c.host || ''; } }).catch(() => {});
        document.getElementById('wiz-scan').onclick = () => {
          const res = document.getElementById('wiz-scanres'); res.textContent = '⏳ ' + T('wiz_scanning');
          fetch('api/scan').then((r) => r.json()).then((d) => {
            const f = (d && d.found) || [];
            if (!f.length) { res.textContent = T('wiz_scan_none'); return; }
            res.innerHTML = '';
            f.forEach((x) => { const b = document.createElement('button'); b.className = 'pwr'; b.style.margin = '3px'; b.textContent = x.ip + ' (' + x.type + ')'; b.onclick = () => { mode.value = (x.type === 'dcc-ex' ? 'dccex' : x.type); host.value = x.ip; }; res.appendChild(b); });
          }).catch(() => { res.textContent = T('wiz_scan_none'); });
        };
      }
      if (step === 2) { const p = document.getElementById('wiz-power'); if (p) p.onclick = () => { send({ type: 'power', on: true }); p.textContent = '✓'; }; }
      if (step === 3) { const ph = document.getElementById('wiz-phone'); if (ph) ph.onclick = () => { const q = document.getElementById('qr-btn'); if (q) q.click(); }; }
    }
    function saveStep1() {
      const mode = document.getElementById('wiz-mode'), host = document.getElementById('wiz-host');
      if (!mode) return Promise.resolve();
      return fetch('api/config').then((r) => r.json()).then((c) => {
        const payload = Object.assign({}, c || {}, { mode: mode.value, host: (host.value || '').trim() });
        return fetch('api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
      }).catch(() => {});
    }
    function close(done) { try { dlg.close(); } catch (e) {} try { localStorage.setItem('bb-setup-done', '1'); } catch (e) {} }
    back.onclick = () => { if (step > 0) { step--; render(); } };
    next.onclick = () => { const p = step === 1 ? saveStep1() : Promise.resolve(); p.then(() => { if (step < steps.length - 1) { step++; render(); } else close(true); }); };
    skip.onclick = () => close(false);
    // "Mijn eerste baan": een complete voorbeeldbaan in de simulator zetten zodat een beginner meteen iets ziet rijden.
    window.BBFirst = async function () {
      const FIRST = {
        locos: [{ addr: 3, name: T('first_loco'), color: '#4c8bf5', functions: [{ fn: 0, label: 'Licht' }, { fn: 1, label: 'Geluid' }], accel: 9, decel: 9 }],
        turnouts: [{ id: '1', name: T('first_sw') + ' 1', addr: 1 }, { id: '2', name: T('first_sw') + ' 2', addr: 2 }],
        blocks: [{ id: '1', name: T('first_station'), cm: 100 }, { id: '2', name: T('first_track') + ' 1', cm: 80 }, { id: '3', name: T('first_track') + ' 2', cm: 80 }],
        routes: [], runs: [], consists: [], timetable: [], clockFactor: 8,
        signals: [{ id: '1', name: 'Sein A', watch: '2', next: '3', sys: 'nl', addr: 0 }],
        turntables: [], webcamUrl: '', shuttles: [], staging: [],
      };
      await fetch('api/roster', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(FIRST) });
      try { const c = await (await fetch('api/config')).json(); await fetch('api/config', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(Object.assign({}, c, { mode: 'sim' })) }); } catch (e) {}
      setTimeout(() => { send({ type: 'power', on: true }); }, 800);
    };
    window.openWizard = () => { step = 0; render(); try { dlg.showModal(); } catch (e) {} };
    let doneFlag = '1'; try { doneFlag = localStorage.getItem('bb-setup-done'); } catch (e) {}
    if (!doneFlag && !RELAY_MODE) setTimeout(() => window.openWizard(), 800);
  })();

  // ---- Geluidsscènes: sfeer-/aankondigingsgeluiden op het bedienapparaat ----
  (function initSound() {
    let on = '0'; try { on = localStorage.getItem('bb-sound') || '0'; } catch (e) {}
    let ac = null;
    const langMap = { nl: 'nl-NL', en: 'en-GB', de: 'de-DE' };
    function curLang() { try { return localStorage.getItem('bb-lang') || 'nl'; } catch (e) { return 'nl'; } }
    function ctx() { try { if (!ac) ac = new (window.AudioContext || window.webkitAudioContext)(); if (ac.state === 'suspended') ac.resume(); } catch (e) { ac = null; } return ac; }
    // korte synth-toon (sinus met fade) — geen audiobestanden nodig
    function tone(freq, dur, type, gain) {
      const c = ctx(); if (!c) return;
      const o = c.createOscillator(), g = c.createGain();
      o.type = type || 'sine'; o.frequency.value = freq;
      const t = c.currentTime, vol = gain == null ? 0.18 : gain;
      g.gain.setValueAtTime(0, t); g.gain.linearRampToValueAtTime(vol, t + 0.02);
      g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
      o.connect(g); g.connect(c.destination); o.start(t); o.stop(t + dur + 0.02);
    }
    function chime() { tone(880, 0.18, 'sine', 0.16); setTimeout(() => tone(1175, 0.28, 'sine', 0.14), 130); }   // ding-dong (aankomst)
    function whistle() { tone(1568, 0.5, 'triangle', 0.12); }   // stoomfluit-achtig
    function clack() { tone(220, 0.06, 'square', 0.08); }       // wissel/relais
    const SCENES = { chime: chime, whistle: whistle, clack: clack, depart: () => { tone(330, 0.25, 'sawtooth', 0.12); setTimeout(() => tone(440, 0.3, 'sawtooth', 0.12), 200); } };
    function speak(text) {
      try {
        if (!('speechSynthesis' in window)) return;
        const u = new SpeechSynthesisUtterance(text); u.lang = langMap[curLang()] || 'nl-NL'; u.rate = 1; u.volume = 0.9;
        window.speechSynthesis.speak(u);
      } catch (e) {}
    }
    function announce(blockName) {
      const l = curLang();
      const tpl = l === 'de' ? ('Zug erreicht ' + blockName) : l === 'en' ? ('Train arriving at ' + blockName) : ('Trein arriveert op ' + blockName);
      speak(tpl);
    }
    let lastPower = null;
    function onState(cur, prev) {
      if (on !== '1' || !cur) return;
      // power-transitie
      if (lastPower !== null && cur.power !== lastPower) tone(cur.power ? 523 : 392, 0.2, 'sine', 0.14);
      lastPower = cur.power;
      // blok-bezetting: vrij→bezet = aankomst (ding-dong + gesproken melding)
      const ps = (prev && prev.sensors) || {}, cs = cur.sensors || {};
      for (const id of Object.keys(cs)) {
        if (cs[id] && !ps[id]) {
          chime();
          const bl = (roster.blocks || []).find((b) => String(b.id) === String(id));
          if (bl && bl.name) setTimeout(() => announce(bl.name), 420);
        }
      }
    }
    function paint(btn) { btn.textContent = on === '1' ? '🔊' : '🔇'; btn.title = window.t ? window.t(on === '1' ? 'sound_on' : 'sound_off') : 'Geluid'; }
    const btn = document.getElementById('sound-btn');
    if (btn) {
      paint(btn);
      btn.onclick = () => { on = on === '1' ? '0' : '1'; try { localStorage.setItem('bb-sound', on); } catch (e) {} if (on === '1') { ctx(); chime(); } paint(btn); };
    }
    window.BBSound = { onState: onState, play: (k) => { if (on === '1' && SCENES[k]) SCENES[k](); }, scenes: Object.keys(SCENES) };
  })();

  connect();
  helloOperator();
  setInterval(() => helloOperator(), 60000);   // periodiek "ik ben er nog" (operator-presence)
})();
