// KueSync — Bitfocus Companion module entry point
// © 2026 Krometis Analytics. All rights reserved.
//
// MULTI-MACHINE BY DESIGN: one connection drives many presentation Macs. Enter one agent IP per
// line on the config page; the module opens a WebSocket to each and BROADCASTS every command to
// all of them, using absolute slide targets so the whole fleet stays in sync. Slide 1 of the list
// is the "primary" used for slide-number feedback.

import { InstanceBase, InstanceStatus, runEntrypoint } from '@companion-module/base'
import WebSocket from 'ws'
import http from 'http'
import { getActions } from './actions.js'
import { getFeedbacks } from './feedbacks.js'
import { getVariableDefinitions, updateVariables } from './variables.js'
import { getPresets } from './presets.js'
import { LicenseManager } from './license.js'
import { shouldRefresh, acceptRefreshedKey, requestRefresh, decodeKeyPayload, DEFAULT_REFRESH_ENDPOINT } from './license-refresh.js'
import { resolveTrialStart, persistTrialStart, loadLastSeen, persistLastSeen } from './trialstore.js'
import { buildCue, dwellFor, nextCueSlide, parseCueSheet } from './cuesheet.js'
import { UpgradeScripts } from './upgrades.js'
import { fmtClock, stopClock, startClock, pauseClock, resumeClock, finishClock, clockReadout, sourceLabel } from './mediaclock.js'

const DEFAULT_PORT = 37100

// Parse "host", "host:port", or bracketed IPv6 "[fe80::1]:port" / "fe80::1". Splitting on the LAST
// colon (only when there's exactly one, or brackets are used) keeps IPv6 link-local addresses intact.
function splitHostPort(s, def = DEFAULT_PORT) {
  s = String(s || '').trim()
  if (!s) return { host: '', port: def }
  if (s[0] === '[') {                                  // [ipv6]:port
    const e = s.indexOf(']')
    if (e > 0) { const h = s.slice(1, e); const rest = s.slice(e + 1); const p = rest.startsWith(':') ? Number(rest.slice(1)) : 0; return { host: h, port: p || def } }
  }
  if ((s.match(/:/g) || []).length === 1) {            // exactly one colon → host:port (IPv4 / name)
    const [h, p] = s.split(':'); return { host: h.trim(), port: Number(p) || def }
  }
  return { host: s, port: def }                        // bare IPv6 or hostname, no port
}

class KueSyncInstance extends InstanceBase {
  async init(config) {
    this.config = config
    this.cmdId = 0
    this.targets = [] // [{ host, port, ws, connected, state, reconnectTimer }]
    this.cue = { running: false, paused: false, ticker: null, dwellRemaining: 0, targetSlide: 0, loop: false }
    // Video clock — TRT/remaining/elapsed for the current slide's media. trtMs/source/trigger come from
    // the leader agent (measured on Windows/PowerPoint) or the cue sheet (planned, on macOS/Keynote).
    this.video = { trtMs: 0, source: 'none', trigger: 'none', playClick: -1, armed: false, lastSlide: -1, clock: stopClock() }
    this.intendedSlide = null      // optimistic slide counter (fast-click race fix)
    // Cue light (hardware-style): mirrors the fleet's advances — GREEN on a forward move, RED on
    // a backward move — by watching the primary's slide+build position. Output-only (a feedback paints
    // the button); drop the cue_light feedback on one key or tile it across many to build a big lamp.
    this.cueLight = { state: 'idle', lastPos: null, timer: null, lastFireAt: 0 }
    this._lastAdvFireAt = 0   // 0.10.29: last time an advance actually fired — drives the rapid-tap debounce
    this.lastCmdAt = 0
    this.IDLE_RECONCILE_MS = 800    // after this idle gap, trust agent feedback over the counter
    this.SYNC_SETTLE_MS = 1500      // grace after a command before drift is flagged (agents catching up)
    this.healthTimer = null         // 1Hz health/staleness + auto-resync evaluator
    this._driftSince = 0            // when the fleet first went out of sync (for auto-resync hold)

    // Trial start is resolved from a persistent file (survives module reinstall) AND the config,
    // taking the EARLIEST so re-adding the connection / reinstalling cannot restart the 7 days.
    const effectiveTrialStart = resolveTrialStart(config.trialStart)
    config.trialStart = effectiveTrialStart
    this.saveConfig(config)
    // Monotonic clock: never let "now" go backward, so rolling the system clock back can't extend
    // the trial. maxSeen is a high-water mark persisted to disk.
    this.maxSeen = Math.max(loadLastSeen(), Date.now())
    persistLastSeen(this.maxSeen)
    const monotonicNow = () => { this.maxSeen = Math.max(this.maxSeen, Date.now()); return this.maxSeen }
    this._seenTimer = setInterval(() => persistLastSeen(this.maxSeen), 5 * 60 * 1000)
    this._seenTimer.unref?.()
    this.license = new LicenseManager({
      key: config.licenseKey || '',
      trialStart: effectiveTrialStart,
      now: monotonicNow,
      saveTrialStart: (ts) => { persistTrialStart(ts); this.config.trialStart = ts; this.saveConfig(this.config) },
    })
    // 0.10.0 seamless renewal (Companion-driven topology): throttle + in-flight guard + injectable transport.
    this._licRefreshAt = 0
    this._licRefreshing = false
    this._fetchRefresh = requestRefresh

    this.setActionDefinitions(getActions(this))
    this.setFeedbackDefinitions(getFeedbacks(this))
    this.setVariableDefinitions(getVariableDefinitions())
    this.setPresetDefinitions(getPresets(this))
    // 0.12.0 CUE-LIGHT RE-BIND: on a module RELOAD (new version, or Companion quit/reopen) Companion loads the
    // existing buttons but does NOT always re-bind their advanced feedbacks to this fresh instance — so the live
    // checkFeedbacks('cue_light') from a cue never reached them and the lamp stayed dead until the operator
    // manually removed + re-added the button (a re-add re-runs `subscribe`). subscribeFeedbacks() re-runs every
    // feedback's `subscribe` against this instance — the programmatic equivalent of re-adding ALL buttons — so
    // cue lights (and every other feedback) come up live after a reload with no manual re-add. Deferred a tick so
    // Companion has finished loading the button instances first; guarded so a missing API can never throw.
    setTimeout(() => { try { this.subscribeFeedbacks() } catch {} try { this.checkFeedbacks() } catch {} }, 0)
    // NOTE (cue light after a Companion restart): Companion 4.3.4 does not re-render a PLACED feedback (advanced
    // OR boolean) after the app restarts until the button is manually re-added — confirmed across many approaches
    // (subscribe-on-add, subscribeFeedbacks, typed/bare checkFeedbacks, advanced→boolean, setFeedbackDefinitions
    // re-registration). The module fires every cue correctly and the preset previews update live; it is a
    // Companion-core reload bug, reported upstream. No module workaround reaches placed instances, so we don't
    // ship one. Lamps work for the whole session once placed; a restart needs a one-time re-add.
    this.refreshLicenseUi()
    this.connectAll()
    // Health monitor: a connected socket isn't the same as a healthy machine. Re-evaluate staleness
    // (an agent that stopped heart-beating), refresh the UI, and run optional auto-resync once a
    // second — independent of incoming messages, so a machine that goes silent actually turns red.
    this.healthTimer = setInterval(() => {
      // CRASH-PROOF (v7.0.1): a throw inside the health tick is an instance crash. Never.
      try { this.updateStatusAggregate() } catch {}
      try { this.updateUi() } catch {}
      try { this.maybeAutoResync() } catch {}
      try { this._videoTick() } catch {}
      try { this._maybeRefreshLicense() } catch {}
      try { this._updateTimerVars() } catch {}
      try { if ((++this._licPushTick % 30) === 0) this.pushLiveLicense() } catch {}   // 0.12.2: keep the fleet's live licence fresh (~30s)
    }, 1000)
    this.healthTimer.unref?.()
  }

  // 0.10.0: SEAMLESS RENEWAL for the COMPANION-DRIVEN topology (Companion + Followers, no agent Leader).
  // When THIS connection holds the paid key (entered in the module's Licensing field) and it is within its
  // renewal window / just expired, quietly fetch the NEWEST key issued to the same account, verify it locally
  // against the bundled public key, and install it ONLY IF strictly newer. The module gates command-sending on
  // entitlement (it doesn't push keys to agents), so installing locally is all that's needed. If a primary
  // AGENT holds the key instead (its own field empty here), this no-ops and the agent handles its own renewal.
  // Best-effort, throttled, never throws; trial / keyless setups never call out.
  _maybeRefreshLicense() {
    const key = this.config.licenseKey || ''
    if (!key || this._licRefreshing) return
    const now = this.license.now()
    const cur = decodeKeyPayload(key)
    const decision = shouldRefresh({ haveKey: true, expiry: cur && cur.expiry, now, lastTryAt: this._licRefreshAt || 0 })
    if (!decision.go) return
    this._licRefreshAt = now
    this._licRefreshing = true
    const endpoint = (this.config.licenseRefreshUrl && /^https:\/\//i.test(this.config.licenseRefreshUrl)) ? this.config.licenseRefreshUrl : DEFAULT_REFRESH_ENDPOINT
    Promise.resolve(this._fetchRefresh({ endpoint, currentKey: key, email: (cur && cur.email) || '' }))
      .then((newKey) => {
        this._licRefreshing = false
        if (!newKey) return
        const verdict = acceptRefreshedKey(newKey, cur, this.license.now())
        if (!verdict.ok) { this.log('info', `license refresh: keeping current key (${verdict.reason})`); return }
        this.license.setKey(newKey)
        this.config.licenseKey = newKey
        try { this.saveConfig(this.config) } catch {}
        try { this.refreshLicenseUi() } catch {}
        const thru = verdict.payload && verdict.payload.expiry ? new Date(verdict.payload.expiry).toISOString().slice(0, 10) : 'perpetual'
        this.log('info', `license auto-renewed (valid through ${thru})`)
      })
      .catch(() => { this._licRefreshing = false })
  }

  async destroy() {
    this.cancelCue()
    this.closeAll()
    if (this._seenTimer) clearInterval(this._seenTimer)
    if (this.healthTimer) clearInterval(this.healthTimer)
    if (this.maxSeen) persistLastSeen(this.maxSeen)
  }

  async configUpdated(config) {
    this.config = config
    this.license.setKey(config.licenseKey || '')
    this.refreshLicenseUi()
    this.connectAll()
  }

  // 0.9.8: choices for the Follower Slot dropdowns on the config page. Lists every machine this
  // connection already knows about — LEADER-canonical (its #1, #2… with device names) when a Leader is
  // present, otherwise the machines this connection is talking to — so you can ASSIGN/SHIFT which machine
  // sits in each slot from a dropdown instead of retyping IPs. allowCustom (set on the field) still lets
  // you type any IP / host:port on first setup or a Companion-only rig where nothing is known yet.
  configSlotChoices() {
    // 0.12.0: only offer machines actually reachable on the CURRENT network (a connected target, or one the
    // live Leader lists). Configured-but-unreachable IPs left over from a PRIOR network are dropped — they were
    // cluttering the dropdown with stale addresses. Agent LEADERS are surfaced explicitly and listed first.
    const out = [{ id: '', label: '— empty —' }]
    const seen = new Set()
    const add = (ip, label) => { const k = String(ip).toLowerCase(); if (!ip || seen.has(k)) return; seen.add(k); out.push({ id: String(ip), label }) }
    const lead = this.leaderTarget && this.leaderTarget()
    // 1) the active Leader(s) this connection currently sees — pickable as a slot, listed first.
    for (const t of (this.targets || [])) {
      if (!t.connected) continue
      if (t.state && t.state.role === 'leader') {
        const ip = String(t.baseHost || t.host)
        add(ip, `Leader${t.state.label ? ' · ' + t.state.label : ''} · ${ip}`)
      }
    }
    // 2) the Leader's canonical follower fleet (current network, authoritative numbering).
    if (lead && Array.isArray(lead.state.fleetFollowers)) {
      for (const f of lead.state.fleetFollowers) {
        const ip = String(f.ip)
        const t = (this.targets || []).find((x) => this.normHost(x.baseHost || x.host) === this.normHost(ip))
        const name = t && t.state && t.state.label ? ' · ' + t.state.label : ''
        add(ip, `Follower ${f.n}${name} · ${ip}`)
      }
    }
    // 3) any other machine actually CONNECTED here (stale prior-network IPs never connect → excluded).
    for (const t of (this.targets || [])) {
      if (!t.connected) continue
      const ip = String(t.baseHost || t.host)
      const name = t.state && t.state.label ? ' · ' + t.state.label : ''
      add(ip, `${ip}${name}`)
    }
    return out
  }

  // A live, human-readable snapshot of what THIS connection currently sees on the network, for the config
  // panel. Companion only calls getConfigFields() when the panel opens (it doesn't live-refresh form fields),
  // so the Slot dropdowns can look stale on first open before the fleet has reported. This text reflects the
  // module's state at the moment the panel is (re)opened — re-open Settings, or hit any field, to refresh it.
  seenMachinesHtml() {
    const lead = this.leaderTarget && this.leaderTarget()
    // Map normalized IP → stable follower number from the Leader's canonical list (entries are { n, ip }).
    const numByIp = new Map()
    if (lead && Array.isArray(lead.state.fleetFollowers)) {
      for (const f of lead.state.fleetFollowers) numByIp.set(this.normHost(String(f.ip)), f.n)
    }
    const rows = (this.targets || []).map((t) => {
      const host = t.baseHost || t.host
      const name = (t.state && t.state.label) ? t.state.label : ''
      const dot = this.healthy(t) ? '🟢' : (t.connected ? '🟠' : '⚪')
      const n = numByIp.get(this.normHost(host))
      const fnum = n ? `#${n} · ` : ''
      return `${dot} ${fnum}${name ? '<b>' + name + '</b> · ' : ''}${host}`
    })
    if (lead && Array.isArray(lead.state.fleetFollowers)) {
      // The Leader knows the canonical fleet — surface any member this connection hasn't dialled yet so the
      // operator sees the real machine numbers/IPs without guessing. (Names arrive once it's connected here.)
      const known = new Set((this.targets || []).map((t) => this.normHost(t.baseHost || t.host)))
      for (const f of lead.state.fleetFollowers) {
        if (known.has(this.normHost(String(f.ip)))) continue
        rows.push(`⚪ #${f.n} · ${f.ip} <i>(seen by Leader, not yet connected here)</i>`)
      }
    }
    return rows.length ? rows.join('<br/>') : '<i>No machines seen yet. Open KueSync on a presentation machine and enter its IP in a slot below (it will fill in its name once connected).</i>'
  }

  getConfigFields() {
    // 0.9.8: each machine slot is a dropdown of known machines (+ allowCustom to type any IP), so a
    // Follower Slot can be re-assigned/shifted by name/IP and stays consistent with the Leader's numbering.
    const slotChoices = this.configSlotChoices()
    const slot = (i) => ({
      type: 'dropdown', id: 'ip' + i, width: 6,
      label: `Follower Slot ${i}${i === 1 ? ' — machine / IP' : ''}`,
      default: i === 1 ? '127.0.0.1' : '',
      choices: slotChoices, allowCustom: true,
      tooltip: 'Pick a machine KueSync already sees, or type any IP (host or host:port). This is Follower ' + i + ' — the same position used on the Companion follower buttons, so assignments stay consistent with the Leader’s numbering.',
    })
    return [
      { type: 'static-text', id: 'about', width: 12, label: 'About', value: '<b>KueSync</b> &mdash; multi-machine slide sync. © 2026 Krometis Analytics. All rights reserved.<br/><a href="https://kuesync.com" target="_blank">kuesync.com</a> · support@krometis.com' },
      { type: 'static-text', id: 'info', width: 12, label: 'KueSync — multi-machine slide sync', value: 'Assign one presentation machine to each <b>Follower Slot</b> below (Mac or Windows PC). Pick a machine KueSync already sees from the dropdown, or type its IP — find a machine’s IP in its KueSync menu-bar / tray menu. Leave unused slots empty. The slot number is the Follower position used on the Companion buttons, so it matches the Leader’s numbering. Every button drives all connected machines in sync.' },
      { type: 'static-text', id: 'seen', width: 12, label: 'Currently seen on the network (live snapshot — re-open Settings to refresh)', value: '🟢 healthy · 🟠 connecting · ⚪ known but not connected here<br/>' + this.seenMachinesHtml() },
      slot(1), slot(2), slot(3), slot(4), slot(5), slot(6), slot(7), slot(8),
      { type: 'textinput', id: 'prefSubnet', width: 12, label: 'Preferred network for traffic (optional) — e.g. 172.26.32', default: '', tooltip: 'On a multi-network rig (a dedicated control LAN + house Wi-Fi), enter the first three octets of the subnet you want KueSync traffic to ride. The module asks each agent for all its addresses and connects over the one on this subnet — so you can type any reachable IP above and still keep the show on your control network. Agents keep listening & discovering on every interface. Blank = use the IPs exactly as typed.' },
      { type: 'number', id: 'port', width: 4, label: 'Default port', default: DEFAULT_PORT, min: 1, max: 65535 },
      { type: 'number', id: 'reconnect', width: 4, label: 'Reconnect (ms)', default: 3000, min: 500, max: 60000 },
      { type: 'number', id: 'syncWindow', width: 4, label: 'Sync tightening (ms)', default: 120, min: 0, max: 1000, tooltip: 'A small buffer so every screen flips together. The default is dialed in for rock-solid, snappy sync — leave it unless you have a reason to change it. 0 = single machine.' },
      { type: 'dropdown', id: 'syncMode', width: 8, label: 'Sync timing', default: 'tod', choices: [
        { id: 'tod', label: 'Standard (tightest — per-machine clock correction, default)' },
        { id: 'relative', label: 'Relative delay (fallback — each machine fires a fixed delay after receipt)' },
      ], tooltip: 'Leave on Standard. KueSync continuously measures each machine’s clock and corrects every cue per machine, so the fleet fires together even on closed networks. Relative is a compatibility fallback.' },
      { type: 'checkbox', id: 'resyncOnReconnect', width: 4, label: 'Resync a machine when it reconnects', default: true, tooltip: 'If a machine drops and rejoins mid-show it has missed advances; snap just that machine to the fleet’s current slide so it’s realigned (skips its in-slide builds).' },
      { type: 'number', id: 'staleSeconds', width: 4, label: 'Health timeout (s)', default: 6, min: 3, max: 60, tooltip: 'A machine is shown HEALTHY only if it reported within this many seconds. Agents heartbeat every ~2s, so 6 tolerates a couple of misses. A connected-but-silent (wedged) machine turns red.' },
      { type: 'checkbox', id: 'autoResync', width: 4, label: 'Auto-resync drift (skips builds!)', default: false, tooltip: 'If the fleet stays out of sync past the settle window, snap everyone to the primary’s slide. OFF by default because snapping skips in-slide builds — best for video walls / static decks.' },
      { type: 'number', id: 'autoResyncSeconds', width: 4, label: 'Auto-resync after (s)', default: 4, min: 1, max: 60, tooltip: 'How long drift must persist (beyond the settle window) before an auto-resync snap.' },
      { type: 'textinput', id: 'token', width: 12, label: 'Control password (optional — must match the one set on the agents)', default: '', tooltip: 'On a shared or house network, set the same control password on every agent and here, so only your control surfaces can drive the show. Leave blank on an isolated show network.' },
      { type: 'textinput', id: 'cueSheet', width: 12, label: 'Fallback cue sheet (optional; buttons can set their own timing)', default: 'default=10' },
      { type: 'dropdown', id: 'cueMode', width: 6, label: 'Cue light mode', default: 'flash', choices: [
        { id: 'flash', label: 'Flash (pulse on each move — like a hardware cue light)' },
        { id: 'latch', label: 'Latch (hold the last direction until the next move)' },
      ], tooltip: 'Drives the Cue Light presets: GREEN flashes on a forward advance, RED on a backward one, mirroring the presenter’s clicker. Tile the lamp across several keys for a big cue light.' },
      { type: 'number', id: 'cueHoldMs', width: 6, label: 'Cue flash hold (ms)', default: 800, min: 120, max: 5000, tooltip: 'How long the green/red lamp stays lit after each move in Flash mode.' },
      { type: 'static-text', id: 'videoHelp', width: 12, label: 'Video time (TRT / remaining)', value: 'On <b>Windows + PowerPoint</b> KueSync reads each clip’s real length automatically (the variable shows <b>measured</b>). On <b>macOS / Keynote the OS does not expose video length</b>, so it can’t be measured — enter the clip length per slide in the cue sheet above (e.g. <code>7=120</code> = a 2:00 video on slide 7) and the variable shows <b>planned (cue sheet)</b>. With nothing entered it shows <b>not available</b>. Add the “Video — …” buttons from the presets.' },
      { type: 'static-text', id: 'lic', width: 12, label: 'Licensing', value: '7-day free trial. Enter a license key here <b>or</b> on a leader agent (KueSync ▸ Settings) — either licenses the whole fleet. Existing keys keep working.' },
      { type: 'textinput', id: 'licenseKey', width: 12, label: 'License key (optional if a leader agent is licensed)', default: '' },
    ]
  }

  // ----------------------------- targets / connections -----------------------------
  parseAgents() {
    const def = Number(this.config.port) || DEFAULT_PORT
    // Collect from the per-machine IP fields (ip1..ip8), plus any legacy multi-line "agents" field.
    const entries = []
    for (let i = 1; i <= 8; i++) {
      const v = this.config['ip' + i]
      if (v && String(v).trim()) entries.push(v)
    }
    if (this.config.agents) entries.push(this.config.agents)

    const out = []
    const seen = new Set()
    for (const entry of entries) {
      for (const raw of String(entry).split(/[\n,;]+/)) {
        const s = raw.trim()
        if (!s) continue
        const { host, port } = splitHostPort(s, def)
        if (!host) continue
        const target = { host, port }
        const key = `${target.host}:${target.port}`
        if (seen.has(key)) continue
        seen.add(key)
        out.push(target)
      }
    }
    if (!out.length) out.push({ host: '127.0.0.1', port: def })
    return out
  }

  connectAll() {
    this.closeAll()
    this.targets = this.parseAgents().map((a) => ({
      ...a, baseHost: a.host, ws: null, connected: false, lastStateAt: 0,
      clockOffset: 0, pongOffset: null, bestRtt: null,   // per-agent clock sounding (see _soundClocks)
      state: { app: '', playing: false, slide: 0, total: 0, slideShown: 0, totalShown: 0, buildIndex: -1, buildCount: -1, role: '', leaderHost: '', mediaTrtMs: -1, mediaTrigger: '', mediaPlayClick: -1, followerActive: true, fleetFollowers: [] }, reconnectTimer: null,
    }))
    this.targets.forEach((t) => this.routeAndConnect(t))
    // Security heads-up (C1): without a shared token, control is open to anyone who can reach the
    // agents. Loopback / link-local rigs are inherently scoped; warn when targets are routable.
    const routable = this.targets.some((t) => !/^(127\.|169\.254\.|localhost$)/i.test(String(t.baseHost)))
    if (routable && !String(this.config.token || '').trim()) {
      this.log('warn', 'No control password set while agents are on a routable network — anyone on that LAN can drive the show. Set a matching control password on the agents and in this connection to lock control down.')
    }
    this.updateStatusAggregate()
    this.updateUi()
  }

  // Preferred-subnet routing: if a preferred network is set, ask the agent (at its typed address) for
  // ALL its addresses and connect over the one on that subnet — so traffic rides the chosen control LAN
  // even if a different IP was typed. Agents still listen/discover on every interface. Blank = as typed.
  routeAndConnect(t) {
    const pref = String(this.config.prefSubnet || '').trim().replace(/\.$/, '')
    if (!pref) { t.host = t.baseHost; return this.connectTarget(t) }
    // Match on an OCTET BOUNDARY — "10.1.1" must match 10.1.1.x but NOT 10.1.10.x / 10.1.100.x
    // (a bare prefix match could pin show traffic onto the wrong subnet).
    this.resolveAddresses(t.baseHost, t.port, (ips) => {
      const match = (ips || []).find((a) => String(a) === pref || String(a).startsWith(pref + '.'))
      t.host = (match && match !== t.baseHost) ? match : t.baseHost
      if (match && match !== t.baseHost) this.log('info', `route ${t.baseHost} → ${t.host} (preferred subnet ${pref})`)
      this.connectTarget(t)
    })
  }

  // Fetch the agent's /health and return its advertised LAN addresses (best-first). Short timeout; any
  // failure → null so we fall back to the typed address.
  resolveAddresses(host, port, cb) {
    let done = false
    const finish = (ips) => { if (!done) { done = true; cb(ips) } }
    try {
      const req = http.get({ host, port, path: '/health', timeout: 1500 }, (res) => {
        let body = ''
        res.on('data', (d) => { body += d })
        res.on('end', () => { try { const j = JSON.parse(body); finish((j.ips || []).map((x) => (x && x.address) ? x.address : x)) } catch { finish(null) } })
      })
      req.on('error', () => finish(null))
      req.on('timeout', () => { try { req.destroy() } catch {} ; finish(null) })
    } catch { finish(null) }
  }

  connectTarget(t) {
    const url = `ws://${t.host}:${t.port}/`
    // Token travels in a header, not the query string (keeps it out of URLs / logs).
    const opts = this.config.token ? { headers: { 'x-kuesync-token': String(this.config.token) } } : {}
    let ws
    opts.maxPayload = 256 * 1024   // cap inbound frames from an agent (defense-in-depth, W-S4)
    try { ws = new WebSocket(url, opts) } catch (e) { this.log('error', `WS init ${t.host}: ${e.message}`); return this.scheduleReconnect(t) }
    t.ws = ws
    ws.on('open', () => { try {
      try { ws._socket.setNoDelay(true) } catch {}   // commands must not sit in Nagle's buffer
      const wasConnectedBefore = t.everConnected
      t.everConnected = true
      t.connected = true; t.lastStateAt = Date.now(); t.authRejected = false   // fresh connection → clear any prior password-mismatch flag
      this.log('info', `connected ${t.host}:${t.port}`)
      try { this.pushLiveLicense() } catch {}   // 0.12.2: if THIS Companion is licensed, license this agent for the session
      // Reconnect realignment: a machine that dropped and rejoined mid-show missed advances. Snap
      // ONLY it to the fleet's current slide (not the whole fleet), so it catches up without
      // disturbing the others. Skips its builds — a slide-boundary snap is the honest recovery.
      if (wasConnectedBefore && this.config.resyncOnReconnect !== false) {
        const n = this.intendedSlide || this.primarySlide()
        if (n > 0) {
          this.log('info', `resync ${t.host} after reconnect → slide ${n}`)
          setTimeout(() => this.sendToTarget(t, { action: 'goto', slide: n }), 150)
        }
      }
      this.updateStatusAggregate(); this.updateUi()
      // 0.10.29: re-evaluate ALL feedbacks once the agent connection is live. Companion may not track a feedback
      // added before the instance was Ok, which is the remaining cause of "the cue light needs a remove + re-add."
      // A bare checkFeedbacks() forces a fresh evaluation of every button (cue light included) on (re)connect.
      // 0.12.0: also re-SUBSCRIBE all feedbacks ONCE after the first live connect — the durable fix for the lamp
      // going dead after a module reload (it re-binds existing button instances to this instance, like a re-add).
      if (!this._didResubscribe) { this._didResubscribe = true; try { this.subscribeFeedbacks() } catch {} }
      try { this.checkFeedbacks() } catch {}
    } catch (e) { this.log('debug', `open handling error ${t.host}: ${e.message}`) } })
    ws.on('message', (data) => this.onTargetMessage(t, data))
    // CRASH-PROOF (v7.0.2): close fires every ~3s for an ABSENT machine — a throw here crash-looped
    // the whole instance (red, config dead, lamps dead). Reconnect is scheduled NO MATTER WHAT.
    ws.on('close', () => {
      try { t.connected = false; this._stopSounding(t); this.updateStatusAggregate(); this.updateUi() }
      catch (e) { this.log('debug', `close handling error ${t.host}: ${e.message}`) }
      finally { this.scheduleReconnect(t) }
    })
    ws.on('error', (e) => this.log('debug', `WS error ${t.host}: ${e.message}`))
    this._startSounding(t)
  }

  // ---- per-agent clock sounding (round trip — same math as the Leader link) ----
  // Each machine's clock offset is measured continuously (offset = tf − (t0+t1)/2, unbiased under
  // symmetric latency) and every cue's fire time is translated into THAT machine's clock, so the
  // fleet fires at the same physical instant even on closed networks with no NTP. Agents that
  // predate the sounding simply never answer — they get uncorrected times, exactly as before.
  _startSounding(t) {
    this._stopSounding(t)
    const ping = () => { if (t.ws && t.ws.readyState === WebSocket.OPEN) { try { t.ws.send(JSON.stringify({ type: 'ks.tping', t0: Date.now() })) } catch {} } }
    t.pingTimer = setInterval(ping, 2000)
    setTimeout(ping, 200)   // first sample right after connect (after the agent wires its handlers)
  }
  _stopSounding(t) { if (t.pingTimer) { clearInterval(t.pingTimer); t.pingTimer = null } }

  scheduleReconnect(t) {
    if (t.reconnectTimer) return
    // Re-resolve the preferred-subnet address on each reconnect so a DHCP/address change recovers.
    t.reconnectTimer = setTimeout(() => {
      t.reconnectTimer = null
      try { this.routeAndConnect(t) } catch (e) { this.log('debug', `reconnect error ${t.host}: ${e.message}`); this.scheduleReconnect(t) }
    }, this.config.reconnect || 3000)
  }

  closeAll() {
    for (const t of this.targets) {
      if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null }
      this._stopSounding(t)
      this._closeSocket(t)
    }
    this.targets = []
  }

  // CRASH-PROOF teardown (0.12.0): closing a WebSocket that is still CONNECTING makes the `ws` library emit
  // an ASYNCHRONOUS 'error' event ("WebSocket was closed before the connection was established"). The old
  // code did removeAllListeners() THEN close(), so that async error had NO listener → an unhandled 'error'
  // event crashed the WHOLE module process, and Companion restarted it in a loop (it never connected). The
  // try/catch did nothing because the throw happens on a later tick, not inside close(). Fix: strip the real
  // handlers, attach a NO-OP 'error' sink so the async error is swallowed, then terminate() (immediate, safe
  // whether the socket is CONNECTING or OPEN). Belt-and-suspenders: never let teardown throw.
  _closeSocket(t) {
    if (!t || !t.ws) return
    const ws = t.ws
    t.ws = null
    try { ws.removeAllListeners() } catch {}
    try { ws.on('error', () => {}) } catch {}   // swallow the connecting-close 'error' so it can't go unhandled
    try { ws.terminate ? ws.terminate() : ws.close() } catch {}
  }

  onTargetMessage(t, data) {
    // CRASH-PROOF (v7.0.1): an exception anywhere in message handling used to take down the ENTIRE
    // Companion connection (instance crash -> red, config unusable) when one agent died mid-stream.
    // Losing one machine must only ever cost that machine: yellow status, everything else drives on.
    try { this._onTargetMessage(t, data) } catch (e) { this.log('debug', `message handling error from ${t.host}: ${e.message}`) }
  }
  _onTargetMessage(t, data) {
    let msg
    try { msg = JSON.parse(data.toString()) } catch { return }
    t.lastStateAt = Date.now()   // any message = proof of life (used for staleness / health)
    if (msg.type === 'ks.tpong') {
      // Round-trip clock sample: offset = tf − (t0+t1)/2. Only accept clean (near-best-RTT) samples
      // so a congested moment can't poison the estimate; smooth with an EMA.
      const t1 = Date.now()
      if (typeof msg.t0 === 'number' && typeof msg.tf === 'number') {
        const rtt = t1 - msg.t0
        if (rtt >= 0 && rtt < 2000) {
          if (t.bestRtt == null || rtt < t.bestRtt) t.bestRtt = rtt
          if (rtt <= (t.bestRtt + 25)) {
            const raw = msg.tf - (msg.t0 + t1) / 2
            t.pongOffset = (t.pongOffset == null) ? raw : t.pongOffset * 0.7 + raw * 0.3
            t.clockOffset = Math.round(t.pongOffset)
          }
        }
      }
      return
    }
    if (msg.type === 'cue') {
      // Agent-pushed INSTANT cue (v6.4.7/8): every agent broadcasts on every executed advance —
      // the lamp fires on the FIRST event and re-lights on duplicates (never missed, never double-
      // counted; the state-poll observer is suppressed for 700ms after).
      this.cueLight.instAt = Date.now()
      this.log('debug', `cue ${msg.dir === 'back' ? 'back' : 'go'} (agent event)`)
      this._fireCue(msg.dir === 'back' ? 'back' : 'go')
      return
    }
    if (msg.type === 'timer') {
      // 0.10.2 KueSync Timer state pushed by an agent (the leader is authoritative; followers rebroadcast copies).
      // Store per-target with the receive time so kuetimer() can rebase remaining/elapsed onto NOW for display.
      t.timer = { mode: msg.mode || 'down', running: !!msg.running, remainingMs: +msg.remainingMs || 0, elapsedMs: +msg.elapsedMs || 0,
        warnMs: +msg.warnMs || 0, endAction: msg.endAction || 'blink', durationMs: +msg.durationMs || 0, rev: +msg.rev || 0, at: Date.now() }
      if (t === this.primary()) this._updateTimerVars()
      return
    }
    if (msg.type === 'state') {
      t.state = {
        app: msg.app || t.state.app, playing: !!msg.playing, slide: msg.slide || 0, total: msg.total || 0,
        slideShown: msg.slideShown != null ? msg.slideShown : (msg.slide || 0),
        totalShown: msg.totalShown != null ? msg.totalShown : (msg.total || 0),
        buildIndex: msg.buildIndex != null ? msg.buildIndex : -1,   // real build/click pos (PowerPoint), -1 = unknown
        buildCount: msg.buildCount != null ? msg.buildCount : -1,
        // v6 Leader/Follower: agents may report their own role and the IP of the Leader they track.
        // Pre-v6 agents send neither, so these stay '' and "current Leader" reads as none (additive).
        role: msg.role != null ? String(msg.role) : (t.state.role || ''),
        leaderHost: (msg.leaderHost != null || msg.leader != null)
          ? String(msg.leaderHost != null ? msg.leaderHost : msg.leader)
          : (t.state.leaderHost || ''),
        // v6.0.8 video clock — clip length (ms) of the current slide's media + how it's triggered.
        // Measured on Windows/PowerPoint; -1/'' on macOS where the OS doesn't expose it (cue-sheet fallback).
        mediaTrtMs: msg.mediaTrtMs != null ? Number(msg.mediaTrtMs) : (t.state.mediaTrtMs ?? -1),
        mediaTrigger: msg.mediaTrigger != null ? String(msg.mediaTrigger) : (t.state.mediaTrigger || ''),
        mediaPlayClick: msg.mediaPlayClick != null ? Number(msg.mediaPlayClick) : (t.state.mediaPlayClick ?? -1),
        // v6.3 additive: friendly name, this agent's version, and a published-update notice.
        label: msg.label != null ? String(msg.label) : (t.state.label || ''),
        leaderName: msg.leaderName != null ? String(msg.leaderName) : (t.state.leaderName || ''),
        agentVersion: msg.agentVersion != null ? String(msg.agentVersion) : (t.state.agentVersion || ''),
        updateVersion: (msg.updateAvailable && msg.updateAvailable.version) ? String(msg.updateAvailable.version) : (msg.updateVersion ? String(msg.updateVersion) : (t.state.updateVersion || '')),
        firedLateMs: msg.firedLateMs != null ? Number(msg.firedLateMs) : (t.state.firedLateMs || 0),
        clockSource: msg.clockSource ? String(msg.clockSource) : (t.state.clockSource || ''),
        // 0.9.6: this machine's follower-Active state (default true if an older agent omits it), so the
        // Activate/Deactivate buttons can show Active/Inactive feedback per machine.
        followerActive: msg.followerActive != null ? !!msg.followerActive : (t.state.followerActive !== false),
        // 0.9.8: the Leader's canonical ordered followers ([{n, ip}]) — used to label the Follower picker.
        fleetFollowers: Array.isArray(msg.fleetFollowers) ? msg.fleetFollowers : (t.state.fleetFollowers || []),
        // 0.10.0: the numbered launchable decks in KueSync Link ([{num,n,title,rel}]) — used by the Launch action/presets.
        shows: Array.isArray(msg.shows) ? msg.shows : (t.state.shows || []),
        // 0.10.29: the agent's MEASURED double-tap min gap (ms) — Companion uses the slowest machine's value to
        // de-dup its own rapid taps at the same rate, so mashing never stacks/keeps advancing after release.
        advanceMinGapMs: msg.advanceMinGapMs != null ? Number(msg.advanceMinGapMs) : (t.state.advanceMinGapMs || 0),
      }
      // Per-agent license report — the leader agent that holds the key authorizes the whole fleet
      // (and Companion), so the operator only enters the key once, on that machine.
      if (msg.licenseState != null) {
        t.license = { licensed: !!msg.licensed, state: msg.licenseState, licensedTo: msg.licensedTo || '', trialDaysLeft: msg.trialDaysLeft || 0 }
      }
      if (msg.presentationName != null || msg.slideName != null || msg.notes != null) {
        t.meta = { presentationName: msg.presentationName || '', slideName: msg.slideName || '', notes: msg.notes || '' }
      }
      t.connected = true
      // Reconcile the optimistic intended slide with reality ONLY when the operator is idle — so a
      // podium change or drift gets picked up, but a rapid burst of presses isn't clobbered mid-burst.
      if (t === this.primary() && t.state.slide > 0 && Date.now() - this.lastCmdAt > this.IDLE_RECONCILE_MS) {
        this.intendedSlide = t.state.slide
      }
      this._observeCue()
      // build_step resets on every primary slide change (manual tracking; the cue engine sets it itself).
      const ps = this.primarySlide()
      if (ps !== this._lastStepSlide) { this._lastStepSlide = ps; if (!this.cue.running) { this._manualStep = 0; this.setVariableValues({ build_step: 0 }) } }
      this.updateUi()
    } else if (msg.type === 'ack' && msg.ok === false) {
      this.log('warn', `agent ${t.host} rejected: ${msg.error || 'unknown'}`)
      // 0.12.0: surface a control-password mismatch as a VISIBLE connection warning (operators shouldn't have
      // to read the log to learn why nothing drives). Flag the target; updateStatusAggregate() turns it amber
      // with a one-line fix. Cleared on the next accepted command or on (re)connect.
      if (/unauthor|password|token/i.test(String(msg.error || ''))) { t.authRejected = true; try { this.updateStatusAggregate() } catch {} }
    } else if (msg.type === 'ack' && msg.ok === true && t.authRejected) {
      t.authRejected = false; try { this.updateStatusAggregate() } catch {}   // a command got through → password now matches
    }
  }

  // HEALTH: a connected socket is not proof the machine is actually carrying the show — its agent
  // could be wedged or the link half-open. A target is healthy only if it reported (state OR
  // heartbeat) within the staleness window. This is what the green dots should really mean.
  staleMs() { const s = Number(this.config.staleSeconds); return (Number.isFinite(s) && s > 0 ? s : 6) * 1000 }
  healthy(t) { return !!(t && t.connected && t.lastStateAt && (Date.now() - t.lastStateAt) < this.staleMs()) }
  healthyTargets() { return this.targets.filter((t) => this.healthy(t)) }
  healthyCount() { return this.healthyTargets().length }
  connectedCount() { return this.targets.filter((t) => t.connected).length }

  // ----------------------------- current Leader (v6 Leader/Follower) -----------------------------
  // Strip any :port and lowercase, so leader-IP comparisons are robust ('192.168.1.51:37100' == '192.168.1.51').
  // W-S3: keep IPv6 intact — bracketed [fe80::1]:port → fe80::1, bare IPv6 (multiple colons) → unchanged,
  // host:port (exactly one colon) → host. (The old split(':')[0] mangled every IPv6 address.)
  normHost(h) {
    const s = String(h || '').trim().toLowerCase()
    if (s[0] === '[') { const e = s.indexOf(']'); return e > 0 ? s.slice(1, e) : s.slice(1) }
    const c = s.split(':')
    return c.length === 2 ? c[0] : s
  }

  // Resolve who is driving the fleet right now, identified by BOTH machine number and IP.
  //   machine = the agent's slot in the configured IP list (Machine 1..8), matching slide_mN.
  //   host    = its IP/host as configured.
  // Source of truth, in order:
  //   1) a HEALTHY machine that self-reports state.role === 'leader' (the Leader drives itself);
  //   2) otherwise, the Leader IP that the most healthy Followers say they're tracking (state.leaderHost).
  // An inferred Leader IP that isn't one of our configured machines is reported with machine = null.
  // No leader info (pre-v6 agents, or Companion-only operation) -> { present:false }.
  currentLeader() {
    const targets = this.targets || []

    // 1) Self-reported active Leader (prefer healthy; tolerate a transient unhealthy one during failover).
    let leaderT = targets.find((t) => this.healthy(t) && t.state && t.state.role === 'leader')
                || targets.find((t) => t.state && t.state.role === 'leader')

    // 2) Infer from Followers' reported leaderHost (majority vote among healthy followers).
    let inferredHost = null
    if (!leaderT) {
      const votes = new Map()
      for (const t of targets) {
        if (!this.healthy(t)) continue
        const h = t.state && t.state.leaderHost
        if (!h) continue
        const key = this.normHost(h)
        votes.set(key, (votes.get(key) || 0) + 1)
      }
      let best = 0
      for (const [h, n] of votes) if (n > best) { best = n; inferredHost = h }
      if (inferredHost) leaderT = targets.find((t) => this.normHost(t.host) === inferredHost)
    }

    if (leaderT) {
      const i = targets.indexOf(leaderT)
      return { present: true, machine: i >= 0 ? i + 1 : null, host: leaderT.host, name: (leaderT.state && leaderT.state.label) || '', healthy: this.healthy(leaderT) }
    }
    if (inferredHost) { const it = targets.find((t)=>this.normHost(t.host)===inferredHost); return { present: true, machine: null, host: inferredHost, name: (it && it.state && it.state.leaderName) || (it && it.state && it.state.label) || '', healthy: true } } // off-list / manual Leader
    return { present: false, machine: null, host: null, name: '', healthy: false }
  }

  // Feedback follows the first HEALTHY machine, not always slot 0 — so if the main machine dies the
  // backups keep the readouts alive instead of freezing/zeroing. Falls back to slot 0 if none are
  // healthy (so callers always get a target).
  primary() { return this.healthyTargets()[0] || this.targets[0] }

  // Cross-machine drift detector over HEALTHY machines (a stale machine isn't "in sync", it's gone).
  // If any healthy machine is on a different slide than the rest, the fleet has slipped. We stay
  // quiet for a settling window right after a command, when slides legitimately differ as agents
  // catch up. NOTE: this is slide-number parity only — see the build-step caveat in the README.
  fleetSync() {
    const conn = this.healthyTargets().filter((t) => t.state && t.state.slide > 0)
    if (conn.length <= 1) return { inSync: true, spread: 0, n: conn.length, settling: false }
    if (Date.now() - this.lastCmdAt < this.SYNC_SETTLE_MS) {
      return { inSync: true, spread: 0, n: conn.length, settling: true }
    }
    const slides = conn.map((t) => t.state.slide)
    const max = Math.max(...slides), min = Math.min(...slides)
    let inSync = max === min
    const spread = max - min
    // BUILD-LEVEL drift: when every healthy machine reports a real build index (Windows PowerPoint)
    // and they're all on the same slide, compare build positions too — catches "same slide, one build
    // behind", the blind spot Keynote can't see. (Gracefully off for Keynote/Mac, which report -1.)
    let buildAware = false
    if (inSync) {
      const builds = conn.map((t) => t.state.buildIndex)
      if (builds.every((b) => b != null && b >= 0)) {
        buildAware = true
        if (Math.max(...builds) !== Math.min(...builds)) inSync = false
      }
    }
    return { inSync, spread, n: conn.length, settling: false, buildAware }
  }

  // Optional: if the fleet stays out of sync past the settle window for the configured hold, snap
  // everyone to the primary's slide. OFF by default (the snap skips builds) — for video walls/static.
  maybeAutoResync() {
    if (!this.config.autoResync || this.cue.running) { this._driftSince = 0; return }
    const fs = this.fleetSync()
    const now = Date.now()
    if (fs.inSync || fs.n <= 1 || fs.settling) { this._driftSince = 0; return }
    if (!this._driftSince) { this._driftSince = now; return }
    const holdMs = (Number(this.config.autoResyncSeconds) || 4) * 1000
    if (now - this._driftSince >= holdMs) {
      this._driftSince = 0
      const n = this.primarySlide()
      if (n > 0) { this.log('info', `auto-resync: fleet drifted, snapping all to slide ${n}`); this.gotoSlide(n) }
    }
  }

  updateStatusAggregate() {
    const h = this.healthyCount()
    const total = this.targets.length
    // 0.12.0: a control-password mismatch is a config error the operator must SEE, not dig out of the log.
    const cpassBad = (this.targets || []).filter((t) => t.authRejected).map((t) => t.baseHost || t.host)
    if (cpassBad.length) this.updateStatus(InstanceStatus.BadConfig, `Control password mismatch (${cpassBad.join(', ')}) — set the SAME password on the agents and in this connection`)
    else if (this.fleetEntitlement().state === 'expired') this.updateStatus(InstanceStatus.BadConfig, 'Trial expired — license required')
    else if (total === 0) this.updateStatus(InstanceStatus.BadConfig, 'No agent IPs configured')
    else if (h === 0) this.updateStatus(InstanceStatus.Disconnected, `0/${total} healthy`)
    else if (h < total) {
      const down = this.targets.filter((t) => !this.healthy(t)).map((t) => t.baseHost || t.host).join(', ')
      this.updateStatus(InstanceStatus.UnknownWarning, `${h}/${total} healthy — down: ${down}`)
    }
    else this.updateStatus(InstanceStatus.Ok, `${h}/${total} healthy`)
  }

  // Effective entitlement for the whole control setup: Companion's own license/trial OR any connected
  // agent that holds a valid key (a primary). Reports which machine is providing the authorization, so
  // the license button can show e.g. "Licensed · 192.168.1.51". Existing Companion keys still work.
  fleetEntitlement() {
    const own = this.license.evaluate()
    if (own.state === 'licensed') return { state: 'licensed', trialDaysLeft: 0, licensedTo: own.licensedTo, provider: 'Companion' }
    for (const t of this.targets) {
      if (this.healthy(t) && t.license && t.license.licensed) {
        return { state: 'licensed', trialDaysLeft: 0, licensedTo: t.license.licensedTo, provider: t.host }
      }
    }
    if (own.state === 'trial') return { state: 'trial', trialDaysLeft: own.trialDaysLeft, licensedTo: '', provider: 'Companion' }
    let best = null
    for (const t of this.targets) {
      if (this.healthy(t) && t.license && t.license.state === 'trial') {
        if (!best || (t.license.trialDaysLeft || 0) > best.trialDaysLeft) best = { trialDaysLeft: t.license.trialDaysLeft || 0, provider: t.host }
      }
    }
    if (best) return { state: 'trial', trialDaysLeft: best.trialDaysLeft, licensedTo: '', provider: best.provider }
    return { state: 'expired', trialDaysLeft: 0, licensedTo: '', provider: 'Companion' }
  }

  // ----------------------------- commands (broadcast to all) -----------------------------
  sendCommand(cmd, only = null) {
    if (this.fleetEntitlement().state === 'expired') {
      this.log('warn', 'KueSync trial expired — command blocked.')
      return false
    }
    const base = this.buildPayloadObj(cmd)
    // TARGETED (v7.0.0 fix): a machines filter means THESE machines only. Stamp it so an agent that
    // is the active Leader does NOT fan the command out to the whole fleet, and an agent OWNED by a
    // Leader executes it directly instead of deferring to the funnel (which would never carry it).
    if (only) base.targeted = true
    // 0.10.30: INDEPENDENT OPERATOR — when this is a full-fleet broadcast (no machine filter) to MORE than
    // one machine, Companion has already reached every Follower itself. Stamp `coversFleet` so a control-only
    // active Leader does NOT re-fan it (which would advance every Follower a SECOND time). With only the Leader
    // configured (length 1, e.g. a control-only Leader you point Companion at) the flag stays off, so the
    // Leader still fans to its Followers — exactly the case the 0.10.9 control-only fan exists for.
    else if ((this.targets || []).length > 1) base.coversFleet = true
    let sent = 0
    for (const t of (only || this.targets)) {
      if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) continue
      // try/catch EACH send: a socket can die in the race after the readyState check, and one dead
      // machine must never stop the rest of the fleet from receiving the command.
      try { t.ws.send(JSON.stringify(this._forTarget(base, t))); sent++ }
      catch (e) { this.log('warn', `send to ${t.host} failed: ${e.message}`) }
    }
    if (!sent) this.log('warn', 'No connected agents; command dropped.')
    return sent > 0
  }

  // 0.10.19: send a command WITHOUT the slide-flip sync gate (no fireAtMs / syncDelayMs). A deck LAUNCH
  // (open + start) must run immediately on each agent — exactly like the Leader's own launch fan and KueSync
  // Control's /leader/launch. Gating these was the bug: the agent scheduled 'open'/'start' on the flip gate,
  // so the deck switched apps but never started (Mac) or did nothing (Windows). Still `direct: true`, so it
  // drives each agent locally and works WITH or WITHOUT a Leader (co-equal). Honors the optional machine filter.
  sendCommandImmediate(cmd, only = null) {
    if (this.fleetEntitlement().state === 'expired') { this.log('warn', 'KueSync trial expired — command blocked.'); return false }
    const base = { type: 'command', id: String(++this.cmdId), direct: true, ...cmd }
    if (only) base.targeted = true
    else if ((this.targets || []).length > 1) base.coversFleet = true   // 0.10.30: independent-operator — no control-only re-fan
    let sent = 0
    for (const t of (only || this.targets)) {
      if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) continue
      try { t.ws.send(JSON.stringify(base)); sent++ }
      catch (e) { this.log('warn', `send to ${t.host} failed: ${e.message}`) }
    }
    if (!sent) this.log('warn', 'No connected agents; launch command dropped.')
    return sent > 0
  }

  // Translate an absolute fire time into THIS machine's clock using its measured offset, so every
  // machine fires at the same physical instant (offsets are 0 until the agent answers a sounding).
  _forTarget(base, t) {
    if (base.fireAtMs > 0 && t.clockOffset) return { ...base, fireAtMs: base.fireAtMs + t.clockOffset }
    return base
  }

  // Resolve a "machines" filter to a subset of targets. Blank / "all" → null (= whole fleet).
  //
  // TWO addressing schemes are accepted, by design, and both keep working:
  //   • STABLE (recommended, what the dropdowns emit): a machine's IP/host. A button saved with an IP always
  //     hits that physical machine, even if the config slot boxes are later reordered. The follower-picker
  //     dropdowns (actions + the follower_active feedback) and the Launch action all emit IPs.
  //   • LEGACY box-order number (1..8): the position in the IP slot boxes, also what the slide_mN / active_mN
  //     variables are keyed to. Older buttons (pre-0.9.8) used these. They still resolve, BUT if you reorder
  //     the slot boxes, a number now points at whatever machine sits in that box — so the physical target can
  //     shift. To pin an old button to a machine for good, re-save it with the dropdown (it locks in the IP).
  // Note the matching tension: actions/feedbacks resolve by EITHER scheme here, while the slide_mN/active_mN
  // VARIABLES are always box-order. They agree as long as you don't reorder the boxes mid-session; if you do,
  // restart the connection (presets regenerate to the new order) — or just use the name·IP dropdowns.
  resolveTargets(spec) {
    const s = String(spec || '').trim().toLowerCase()
    if (!s || s === 'all' || s === '*') return null
    const wanted = new Set(s.split(/[\s,;]+/).filter(Boolean))
    const out = []
    this.targets.forEach((t, i) => {
      const base = String(t.baseHost || t.host).toLowerCase()
      if (wanted.has(String(i + 1)) || wanted.has(t.host.toLowerCase()) || wanted.has(base) ||
          wanted.has(`${t.host}:${t.port}`.toLowerCase()) || wanted.has(`${base}:${t.port}`)) out.push(t)
    })
    return out
  }

  // Build the wire payload, attaching the chosen sync timing:
  //  • Time-of-Day (default): one ABSOLUTE fire time (this Mac's wall clock); all agents fire at it,
  //    absorbing delivery jitter — tightest, assumes NTP-synced clocks (macOS default on a LAN).
  //  • Relative: a delay each agent applies from ITS OWN receipt — clock-independent fallback.
  buildPayloadObj(cmd) {
    const win = Number(this.config.syncWindow)
    const w = Number.isFinite(win) && win > 0 ? win : 0
    const id = String(++this.cmdId)
    const fields = (this.config.syncMode === 'relative')
      ? { syncDelayMs: w }
      : { fireAtMs: Date.now() + w }
    // `direct: true` — Companion fans every command to ALL its configured machines itself, so it is a
    // CO-EQUAL leader: each agent executes the command locally, an active Leader does NOT re-fan it
    // (that would double-advance the fleet), and a Follower does NOT defer it to its Leader. This lets
    // Companion drive a machine that already has a Leader and lead the Leader itself.
    return { type: 'command', id, direct: true, ...cmd, ...fields }
  }

  // Send a command to ONE machine (used to realign a machine that just reconnected without
  // disturbing the rest of the fleet).
  sendToTarget(t, cmd) {
    if (this.fleetEntitlement().state === 'expired') return false
    if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) return false
    try { t.ws.send(JSON.stringify(this._forTarget({ ...this.buildPayloadObj(cmd), targeted: true }, t))) }
    catch (e) { this.log('warn', `send to ${t.host} failed: ${e.message}`); return false }
    return true
  }

  // 0.12.2 LIVE-ONLY FLEET LICENSE: when THIS Companion holds a valid licence, assert it to every connected
  // agent so a fleet whose agents are all unlicensed still functions WHILE this licensed Companion is present.
  // The agent verifies the key's signature and honours it on a short TTL; it lapses when we disconnect. Sent on
  // connect and every ~30s as a keep-alive. No-op unless our own key is licensed (an agent-licensed fleet
  // doesn't need it). Sent raw (not via sendToTarget) so it isn't gated by fleet-expired.
  pushLiveLicense() {
    try {
      const own = this.license && this.license.evaluate()
      if (!own || own.state !== 'licensed') return
      const key = this.config.licenseKey || ''
      if (!key) return
      for (const t of (this.targets || [])) {
        if (t.ws && t.ws.readyState === WebSocket.OPEN) {
          try { t.ws.send(JSON.stringify({ type: 'license-live', key, id: 'lic' + Date.now() })) } catch {}
        }
      }
    } catch {}
  }

  // ----------------------------- KueSync Timer (0.10.2) -----------------------------
  // Send a timer command to the LEADER (Companion's primary). The leader applies it and FANS it fleet-wide,
  // so sending to ONE machine (not broadcasting) avoids each follower becoming a separate timer authority.
  timerCmd(obj) {
    if (this.fleetEntitlement().state === 'expired') { this.log('warn', 'KueSync trial expired — timer blocked.'); return false }
    const p = this.primary()
    if (!(p && p.ws && p.ws.readyState === WebSocket.OPEN)) { this.log('warn', 'No connected leader; timer command dropped.'); return false }
    try { p.ws.send(JSON.stringify({ type: 'timerCmd', id: String(++this.cmdId), ...obj })); return true }
    catch (e) { this.log('warn', `timer send failed: ${e.message}`); return false }
  }
  // The authoritative KueSync Timer (from the primary/leader), remaining/elapsed rebased onto NOW for display.
  kuetimer() {
    const p = this.primary(); const w = p && p.timer
    if (!w) return { present: false, mode: 'down', running: false, remainingMs: 0, elapsedMs: 0, warnMs: 0, endAction: 'blink', state: 'idle' }
    const dt = Date.now() - (w.at || Date.now())
    let remainingMs = w.remainingMs, elapsedMs = w.elapsedMs
    if (w.running) { if (w.mode === 'up') elapsedMs = w.elapsedMs + dt; else remainingMs = w.remainingMs - dt }
    let state
    if (w.mode === 'tod') state = 'tod'
    else if (w.mode === 'up') state = w.running ? 'countup' : 'idle'
    else if (!w.running && (w.rev || 0) === 0) state = 'idle'
    else if (remainingMs <= 0) state = 'overtime'
    else if (w.warnMs > 0 && remainingMs <= w.warnMs) state = 'warning'
    else state = w.running ? 'running' : 'set'
    return { present: true, mode: w.mode, running: !!w.running, remainingMs, elapsedMs, warnMs: w.warnMs, endAction: w.endAction, state }
  }
  _fmtTimer(k) {
    const p = (n) => (n < 10 ? '0' : '') + n
    if (k.mode === 'tod') { const d = new Date(); return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()) }
    const ms = (k.mode === 'up') ? Math.max(0, k.elapsedMs) : k.remainingMs
    const neg = ms < 0; const t = Math.floor(Math.abs(ms) / 1000); const h = Math.floor(t / 3600), m = Math.floor((t % 3600) / 60), s = t % 60
    return (neg ? '+' : '') + (h > 0 ? (h + ':' + p(m) + ':' + p(s)) : (p(m) + ':' + p(s)))
  }
  _updateTimerVars() {
    const k = this.kuetimer()
    const remS = (k.mode === 'up') ? Math.round(k.elapsedMs / 1000) : Math.round(k.remainingMs / 1000)
    this.setVariableValues({
      kuetimer_time: this._fmtTimer(k), kuetimer_state: k.state, kuetimer_mode: k.mode,
      kuetimer_running: String(!!k.running), kuetimer_remaining_s: remS,
    })
    this.checkFeedbacks('kuetimer')
  }

  // ----------------------------- follower Active (0.9.6) -----------------------------
  machineNumberOf(t) { const i = this.targets.indexOf(t); return i >= 0 ? i + 1 : '?' }
  // Resolve a single machine spec (number 1..8, host, or host:port) to one target.
  targetFor(machineSpec) { const only = this.resolveTargets(machineSpec); return (only && only.length) ? only[0] : null }
  // Read a machine's current Active state (default true if unknown) — used by the toggle action + feedback.
  followerActiveOf(machineSpec) { const t = this.targetFor(machineSpec); return t && t.state ? t.state.followerActive !== false : true }

  // Live dropdown of the machines this connection knows about, so the Follower buttons are picked BY NAME/IP
  // instead of a bare position number (the position number is just the order of the IP boxes, which need not
  // match the leader's follower numbering — that mismatch is what caused "2 toggled 3"). The choice VALUE is
  // the machine's IP, so a button always hits the same physical machine regardless of box order. Numbers and
  // IPs still work (allowCustom on the dropdown + resolveTargets), so existing buttons never break.
  // The target we currently consider the active Leader (it publishes the canonical followers list, if any).
  leaderTarget() {
    return (this.targets || []).find((t) => t.state && (t.state.role === 'leader' || t.state.role === 'standby') && Array.isArray(t.state.fleetFollowers) && t.state.fleetFollowers.length) || null
  }
  followerChoices() {
    // Numbering is hybrid:
    //   • If a LEADER is on the fleet, use ITS canonical followers list (the #1, #2… in its Settings) so a
    //     button labelled "Follower 2" means the same machine in Settings and Companion.
    //   • If there's NO leader (Companion + followers, a supported setup), AUTO-determine from THIS
    //     connection's own machine list (the IP boxes = Follower 1..8) — no leader required.
    // Either way each choice shows the follower number, DEVICE NAME (learned live), and IP, and is keyed by
    // IP so a button always hits the right physical machine regardless of box order. (Numbers/IPs still work.)
    const nameForIp = (ip) => {
      const t = (this.targets || []).find((x) => this.normHost(x.baseHost || x.host) === this.normHost(ip))
      return t && t.state && t.state.label ? String(t.state.label) : ''
    }
    const lead = this.leaderTarget()
    if (lead) {
      return lead.state.fleetFollowers.map((f) => {
        const ip = String(f.ip), name = nameForIp(ip)
        return { id: ip, label: `Follower ${f.n}${name ? ' · ' + name : ''} · ${ip}` }
      })
    }
    const ch = (this.targets || []).map((t, i) => {
      const host = String(t.baseHost || t.host)
      const name = t.state && t.state.label ? String(t.state.label) : ''
      return { id: host, label: `Follower ${i + 1}${name ? ' · ' + name : ''} · ${host}` }
    })
    return ch.length ? ch : [{ id: '1', label: 'Follower 1 (add machine IPs in this connection first)' }]
  }
  // 0.10.0: the numbered launchable decks (read from any agent's state — KueSync Link is identical fleet-wide).
  allShows() {
    for (const t of (this.targets || [])) { if (t.state && Array.isArray(t.state.shows) && t.state.shows.length) return t.state.shows }
    return []
  }
  // Dropdown of launchable shows for the Launch action/presets ("Launch 006 · Welcome"), keyed by number.
  showChoices() {
    const ch = this.allShows().map((s) => ({ id: String(s.num), label: `Launch ${s.n}${s.title ? ' · ' + s.title : ''}` }))
    return ch.length ? ch : [{ id: '', label: 'No numbered shows in KueSync Link yet' }]
  }
  // Launch a numbered KueSync Link deck on the chosen machines (blank/"all" = whole fleet): OPEN the deck
  // (resolved against each machine's OWN KueSync Link folder via linkRel), then START the show at slide 1 a
  // beat later. Reuses the module's existing fan-out + machine filter, so it targets exactly like advance.
  launchShow(numSpec, machines) {
    const want = parseInt(String(numSpec).trim(), 10)
    const s = this.allShows().find((x) => x.num === want)
    if (!s) { this.log('warn', `launch: no numbered show "${numSpec}" in KueSync Link`); return false }
    const only = this.resolveTargets(machines)
    if (only && only.length === 0) { this.log('warn', 'launch: no machines matched the target filter'); return false }
    // 0.10.19: UN-GATED open → (1.4s) → start, mirroring the Leader's launch fan exactly. Previously these
    // went through the flip-sync gate (sendCommand) and the deck wouldn't start. linkRel resolves against each
    // agent's OWN KueSync Link folder, so it lands cross-platform with or without a Leader.
    this.sendCommandImmediate({ action: 'open', linkRel: s.rel }, only)
    setTimeout(() => this.sendCommandImmediate({ action: 'start' }, only), 1400)
    this.log('info', `launch ${s.n}${s.title ? ' · ' + s.title : ''} -> ${machines || 'all machines'}`)
    return true
  }
  // Signatures of the picker contents so we only re-publish dropdowns when something actually changes —
  // never on every poll. Split deliberately:
  //   • fleetSig  — the Follower picker contents (fleet membership, device names, Leader's canonical list).
  //                 Drives the follower-scoped FEEDBACK (follower_active) and the follower ACTION dropdowns.
  //   • showSig   — the numbered shows (0.10.0). Drives only the Launch ACTIONS/PRESETS.
  // Why the split: re-calling setFeedbackDefinitions() re-registers the whole feedback set, which can disturb
  // live button instances (operators saw cue-light buttons needing a re-add). The cue_light feedback is fully
  // static, so it must NOT be re-published just because a deck was launched and the shows list changed. We
  // therefore republish feedbacks ONLY on a real fleet/name change, and actions+presets on either change.
  fleetSig() {
    const lead = this.leaderTarget()
    const canon = lead ? lead.state.fleetFollowers.map((f) => `${f.n}:${f.ip}`).join(',') : ''
    return canon + '||' + (this.targets || []).map((t) => `${t.baseHost || t.host}|${(t.state && t.state.label) || ''}`).join(',')
  }
  showSig() {
    return this.allShows().map((s) => `${s.num}:${s.rel}`).join(',')   // 0.10.0: re-publish Launch buttons when shows change
  }
  // Back-compat alias (some callers/tests reference the combined signature).
  followerChoiceSig() { return this.fleetSig() + '||' + this.showSig() }
  republishFollowerUi() {
    const fSig = this.fleetSig()
    const sSig = this.showSig()
    const fleetChanged = fSig !== this._lastFleetSig
    const showsChanged = sSig !== this._lastShowSig
    if (!fleetChanged && !showsChanged) return
    this._lastFleetSig = fSig
    this._lastShowSig = sSig
    try {
      // Actions + presets carry both the follower pickers and the Launch list → refresh on either change.
      if (fleetChanged || showsChanged) { this.setActionDefinitions(getActions(this)); this.setPresetDefinitions(getPresets(this)) }
      // Feedbacks only carry the follower picker (follower_active). Re-publish ONLY when the fleet/names change,
      // so launching decks (shows change) never re-registers — and never disturbs — the static cue_light lamp.
      if (fleetChanged) this.setFeedbackDefinitions(getFeedbacks(this))
    } catch {}
  }

  // Park / un-park a follower BY MACHINE NUMBER (Companion Activate / Deactivate / Toggle buttons). Sends
  // the authenticated `setLocalFlag` over that machine's own control socket — NOT the Settings-PIN-gated
  // HTTP path — so it works with or without a control password. On ACTIVATE we snap that machine back to
  // the fleet's current slide (it ignored cues while parked), unless the caller turns that off.
  setFollowerActive(machineSpec, on, { resync = true } = {}) {
    const t = this.targetFor(machineSpec)
    if (!t) { this.log('warn', `Follower ${on ? 'activate' : 'deactivate'}: no machine "${machineSpec}" configured`); return false }
    if (!(t.ws && t.ws.readyState === WebSocket.OPEN)) { this.log('warn', `Follower ${machineSpec}: not connected — command dropped`); return false }
    try {
      t.ws.send(JSON.stringify({ type: 'setLocalFlag', key: 'followerActive', on: !!on, id: String(++this.cmdId) }))
    } catch (e) { this.log('warn', `Follower set failed for ${t.host}: ${e.message}`); return false }
    t.state.followerActive = !!on   // optimistic so the button flips at once; the agent's state push confirms
    this.log('info', `Follower ${this.machineNumberOf(t)} → ${on ? 'Active' : 'Inactive'}`)
    // A reactivated machine sat out cues, so it's on a stale slide — snap it to the fleet's current slide.
    if (on && resync) { const n = this.primarySlide(); if (n > 0) setTimeout(() => this.sendToTarget(t, { action: 'goto', slide: n }), 250) }
    this.updateUi()
    return true
  }

  // The fleet's current slide. The configured "primary" (first machine) can legitimately report 0 — a
  // control-only Leader that doesn't present, a parked (Inactive) follower, or a machine whose show is
  // stopped. Using that 0 made SYNC NOW snap everyone to slide 1 (0.9.6 fix). Fall back to the first
  // HEALTHY machine actually on a slide so we align to where the show really is.
  primarySlide() {
    const p = this.primary()
    if (p && p.state && p.state.slide > 0) return p.state.slide
    const onShow = this.healthyTargets().find((t) => t.state && t.state.slide > 0)
    return onShow ? onShow.state.slide : 0
  }
  primaryTotal() {
    const p = this.primary()
    if (p && p.state && p.state.total > 0) return p.state.total
    const onShow = this.healthyTargets().find((t) => t.state && t.state.total > 0)
    return onShow ? onShow.state.total : 0
  }
  // Where the next relative move starts from: the optimistic intended slide if we have one (so
  // fast double-taps each advance), else the primary's last reported slide.
  navBase() { return this.intendedSlide != null ? this.intendedSlide : this.primarySlide() }

  // mode 'relative' = true Next (DEFAULT): the agent runs native "show next", so in-slide builds &
  //   animations play; every machine fires at the same gated time so identical decks stay locked.
  // mode 'synced' = absolute goto(current+1): fleet self-corrects to a slide number, but SKIPS builds.
  // 0.10.29: the rapid-tap debounce gap (ms). Companion fans every advance DIRECTLY to all machines (co-equal
  // leader), so it bypasses the agent's leader-side double-tap gate — the de-dup has to happen HERE, once, at
  // the single tap source, so every machine receives the identical de-duped stream and stays in sync. Use the
  // SLOWEST connected machine's MEASURED gap (so the fleet never outruns it), floored/capped to sane bounds.
  _advanceGapMs() {
    let g = 0
    for (const t of (this.targets || [])) { if (t.connected && t.state && t.state.advanceMinGapMs > g) g = t.state.advanceMinGapMs }
    if (!(g > 0)) g = 200
    return Math.max(120, Math.min(700, g))
  }
  _advanceEaten() {   // true = this tap should be dropped (came faster than the measured gap)
    const now = Date.now()
    if (now - (this._lastAdvFireAt || 0) < this._advanceGapMs()) return true
    this._lastAdvFireAt = now
    return false
  }
  next(mode = 'relative', machines = '') {
    const only = this.resolveTargets(machines)
    if (only && only.length === 0) { this.log('warn', 'No machines matched the target filter'); return false }
    if (this._advanceEaten()) return false   // 0.10.29: eat a mashed tap so it never stacks/keeps advancing after release
    this._fireCue('go')   // instant lamp — don't wait for the state poll round-trip
    if (mode === 'relative') { this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null; this._noteManualStep(+1); return this.sendCommand({ action: 'next' }, only) }
    const base = this.navBase()
    if (base > 0) return this.gotoSlide(base + 1, machines)
    this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null
    return this.sendCommand({ action: 'next' }, only)
  }
  prev(mode = 'relative', machines = '') {
    const only = this.resolveTargets(machines)
    if (only && only.length === 0) { this.log('warn', 'No machines matched the target filter'); return false }
    if (this._advanceEaten()) return false   // 0.10.29: eat a mashed tap (shared gate with next, like the agent's)
    this._fireCue('back')   // instant lamp
    if (mode === 'relative') { this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null; this._noteManualStep(-1); return this.sendCommand({ action: 'prev' }, only) }
    const base = this.navBase()
    if (base > 1) return this.gotoSlide(base - 1, machines)
    this.lastCmdAt = Date.now(); if (!only) this.intendedSlide = null
    return this.sendCommand({ action: 'prev' }, only)
  }
  jump(dir, count, machines = '') { this._fireCue(dir === 'back' ? 'back' : 'go'); const base = this.navBase() || 1; this.gotoSlide(dir === 'back' ? base - count : base + count, machines) }
  // build_step: "Nexts since the slide changed" — now tracked for MANUAL next/prev too (it was
  // previously only written by the builds-cue engine). Reset on every primary slide change.
  _noteManualStep(d) {
    if (this.cue.running) return                       // the cue engine owns build_step while rolling
    this._manualStep = Math.max(0, (this._manualStep || 0) + d)
    this.setVariableValues({ build_step: this._manualStep })
  }
  // OSCPoint-parity transport: media control + file load (gated like any command). Targetable subset.
  media(kind, machines = '') {
    // Drive KueSync's own video countdown off the operator's transport action (so it tracks even where
    // the OS won't report a playhead). Play/Pause toggles the clock; Jump-to-end zeroes it.
    if (kind === 'media_playpause') this._videoPlayPause()
    else if (kind === 'media_end' || kind === 'media_stop') this._videoFinish()
    return this.sendCommand({ action: kind }, this.resolveTargets(machines))
  }

  // ----------------------------- video clock (TRT / remaining / elapsed) -----------------------------
  // PLANNED length: a per-slide clip length the operator typed in the connection cue sheet (e.g. "7=120").
  _videoCueMs(slide) {
    const cue = parseCueSheet(this.config.cueSheet || '')
    const v = cue.map && cue.map[slide]
    return v > 0 ? Math.round(v * 1000) : 0
  }
  // Decide the TRT + how it's triggered for a slide: MEASURED (Windows) wins, else PLANNED (cue sheet).
  _resolveVideo(slide, p) {
    const measured = p ? Number(p.state.mediaTrtMs) : -1
    if (measured > 0) {
      let trig = String(p.state.mediaTrigger || '').toLowerCase()
      if (trig !== 'auto' && trig !== 'click') trig = 'manual'   // shape-click / unknown -> Play button
      return { trtMs: measured, source: 'measured', trigger: trig, playClick: Number(p.state.mediaPlayClick) }
    }
    const planned = this._videoCueMs(slide)
    if (planned > 0) return { trtMs: planned, source: 'planned', trigger: 'manual', playClick: -1 }
    return { trtMs: 0, source: 'none', trigger: 'none', playClick: -1 }
  }
  _videoArm(r) {
    const v = this.video
    v.trtMs = r.trtMs; v.source = r.source; v.trigger = r.trigger; v.playClick = r.playClick
    if (r.trtMs > 0 && r.trigger === 'auto') { v.clock = startClock(r.trtMs, Date.now()); v.armed = false }
    else if (r.trtMs > 0) { v.clock = { ...stopClock(), trtMs: r.trtMs }; v.armed = true }   // wait for the click / Play button
    else { v.clock = stopClock(); v.armed = false }
  }
  // ---------------- cue light (hardware-style) ----------------
  // Composite position (slide + build step) of the primary while a show runs; null when stopped. A rise
  // is a forward move (GREEN), a fall is a backward move (RED) — exactly like a DSAN cue light tracking
  // the presenter's clicker. Builds count, so an in-slide advance still flashes the lamp.
  _cuePos() {
    // A cue is a cue whether or not the show is in full-screen play — the lamp must flash when the
    // deck is advanced at a tech table too (state.slide tracks the editor as well as the show).
    const p = this.primary()
    if (!p || !p.state || !(p.state.slide > 0)) return null
    return (p.state.slide || 0) * 10000 + Math.max(Number(p.state.buildIndex) >= 0 ? Number(p.state.buildIndex) : 0, 0)
  }
  _observeCue() {
    const pos = this._cuePos()
    const prev = this.cueLight.lastPos
    // Skip the state-derived flash when an agent-pushed instant cue just fired (avoid doubles).
    const recentInstant = this.cueLight.instAt && (Date.now() - this.cueLight.instAt) < 700
    if (pos != null && prev != null && pos !== prev && !recentInstant) this._fireCue(pos > prev ? 'go' : 'back')
    this.cueLight.lastPos = pos
  }
  _fireCue(dir) {
    this.log('debug', `cue lamp -> ${dir}`)   // lamp activity is never invisible (v6.4.8)
    this.cueLight.state = dir
    this.cueLight.lastFireAt = Date.now()
    this.setVariableValues({ cue_signal: dir, cue_go: String(dir === 'go'), cue_back: String(dir === 'back') })
    // 0.12.0: a BARE checkFeedbacks() (re-evaluate everything) — NOT the typed checkFeedbacks('cue_light').
    // Field finding (log-confirmed): after a Companion relaunch the typed call re-rendered the preset PREVIEWS
    // but NOT the PLACED button instances (they stayed dark until a manual re-add), while a bare check reaches
    // placed buttons (that's why the on-connect re-render already uses bare). So the live lamp uses bare too.
    this.checkFeedbacks()
    if (this.cueLight.timer) { clearTimeout(this.cueLight.timer); this.cueLight.timer = null }
    if ((this.config.cueMode || 'flash') === 'flash') {
      const hold = Math.max(120, Number(this.config.cueHoldMs) || 800)
      this.cueLight.timer = setTimeout(() => {
        this.cueLight.state = 'idle'; this.cueLight.timer = null
        this.setVariableValues({ cue_signal: 'idle', cue_go: 'false', cue_back: 'false' })
        this.checkFeedbacks()
      }, hold)
    }
    // 'latch' mode: hold the colour until the next advance (no timer).
  }
  cueState() { return this.cueLight ? this.cueLight.state : 'idle' }

  _videoTick() {
    const p = this.primary()
    const slide = p ? (p.state.slide || 0) : 0
    const build = p ? Number(p.state.buildIndex) : -1
    const v = this.video
    if (slide !== v.lastSlide) {
      v.lastSlide = slide
      this._videoArm(this._resolveVideo(slide, p))
    } else if (v.trtMs === 0 && p) {
      // a measured length can arrive a tick after the slide change — pick it up once it's there
      const r = this._resolveVideo(slide, p)
      if (r.trtMs > 0) this._videoArm(r)
    } else if (v.armed && v.source === 'measured' && v.trigger === 'click' && v.playClick >= 0 && build >= v.playClick) {
      v.clock = startClock(v.trtMs, Date.now()); v.armed = false   // the firing click happened
    }
    const ro = clockReadout(v.clock, Date.now())
    const has = v.trtMs > 0
    const ticking = has || v.clock.active   // count-up stopwatch: elapsed is live even without a TRT
    this.setVariableValues({
      media_trt: has ? fmtClock(ro.trtMs) : '—',
      media_remaining: has ? fmtClock(ro.remainingMs) : '—',
      media_elapsed: ticking ? fmtClock(ro.elapsedMs) : '—',
      media_remaining_secs: Math.round(ro.remainingMs / 1000),
      media_playing: String(!!v.clock.playing),
      media_has: String(has),
      media_armed: String(!!v.armed),
      media_time_source: has ? sourceLabel(v.source) : '—',
      media_source_short: has ? (v.source === 'measured' ? 'measured' : v.source === 'planned' ? 'planned' : 'n/a') : '—',
    })
    this.checkFeedbacks('media_ending')
  }
  _videoPlayPause() {
    const v = this.video
    if (v.trtMs <= 0) {
      // No known clip length (Mac without a cue-sheet entry): run a COUNT-UP stopwatch anyway, so
      // ELAPSED is always live from the operator's Play click even when TRT can't be measured.
      if (!v.clock.active) { v.clock = startClock(0, Date.now()); v.source = 'stopwatch' }
      else v.clock = v.clock.playing ? pauseClock(v.clock, Date.now()) : resumeClock(v.clock, Date.now())
      return
    }
    if (v.armed || !v.clock.active) { v.clock = startClock(v.trtMs, Date.now()); v.armed = false; return }
    v.clock = v.clock.playing ? pauseClock(v.clock, Date.now()) : resumeClock(v.clock, Date.now())
  }
  _videoFinish() {
    const v = this.video
    if (v.trtMs > 0) { v.clock = finishClock({ ...v.clock, trtMs: v.trtMs }, Date.now()); v.armed = false }
  }
  // For the "video ending soon" feedback: seconds left, and whether a video is active/playing.
  videoRemainingSecs() { return Math.round(clockReadout(this.video.clock, Date.now()).remainingMs / 1000) }
  videoActive() { return this.video.trtMs > 0 && !!this.video.clock.playing }
  openFile(path, machines = '', password = '') { const c = { action: 'open', path: String(path || '') }; if (password) c.password = String(password); return this.sendCommand(c, this.resolveTargets(machines)) }
  // Resync snaps everyone to a slide (or, blank, to the primary's actual reported slide).
  // Resync: an explicit slide jumps the fleet there (RESYNC ALL with a number). Blank / SYNC NOW aligns to
  // the fleet's CURRENT slide. 0.9.6: if nothing is in a show yet, do NOTHING (don't yank the fleet to slide 1).
  resync(slide) {
    if (slide > 0) return this.gotoSlide(slide)
    const n = this.primarySlide()
    if (n > 0) return this.gotoSlide(n)
    this.log('info', 'sync now: no machine is in a show yet — nothing to align to')
  }
  last() { const total = this.primaryTotal(); if (total > 0) return this.gotoSlide(total); this.lastCmdAt = Date.now(); return this.sendCommand({ action: 'last' }) }

  gotoSlide(n, machines = '') {
    const only = this.resolveTargets(machines)
    const total = this.primaryTotal()
    if (total) n = Math.min(n, total)
    n = Math.max(1, n)
    // Instant lamp on goto too — direction is known from where the fleet is heading vs now.
    const base = this.navBase()
    if (base > 0 && n !== base) this._fireCue(n > base ? 'go' : 'back')
    if (!only) this.intendedSlide = n   // only track fleet-wide intent when driving the WHOLE fleet
    this.lastCmdAt = Date.now()
    return this.sendCommand({ action: 'goto', slide: n }, only)
  }

  // ----------------------------- cue engine (drives all machines) -----------------------------
  runCue({ fromStart = false, loop = false, defaultDwell, perSlide, builds = false } = {}) {
    this.cancelCue()
    const cue = buildCue({ defaultDwell, perSlideText: perSlide, fallbackText: this.config.cueSheet })
    if (builds) return this.runCueBuilds({ fromStart, loop, cue })
    const p = this.primary()
    let slide = fromStart ? 1 : (p ? p.state.slide || 1 : 1)
    this.cue.running = true; this.cue.paused = false; this.cue.loop = loop
    this.gotoSlide(slide)
    this.cue.targetSlide = slide
    // TIMESTAMP-driven dwell (A7): the advance fires when the wall-clock deadline passes, not after
    // N accumulated ticks — so long cues don't drift with timer granularity, and pause is exact
    // (pause stamps the moment; resume pushes the deadline forward by the paused span).
    this.cue.deadlineAt = Date.now() + dwellFor(cue, slide) * 1000
    this.cue.dwellRemaining = dwellFor(cue, slide)
    this.updateUi()

    this.cue.ticker = setInterval(() => {
      if (this.cue.paused) return
      const remainMs = this.cue.deadlineAt - Date.now()
      const remainS = Math.max(0, Math.ceil(remainMs / 1000))
      if (remainS !== this.cue.dwellRemaining) { this.cue.dwellRemaining = remainS; this.setVariableValues({ cue_dwell_remaining: remainS }) }
      if (remainMs > 0) return
      const total = this.primary() ? this.primary().state.total : 0
      const nextSlide = nextCueSlide(this.cue.targetSlide || (this.primary()?.state.slide ?? 0), total, this.cue.loop)
      if (nextSlide == null) return this.cancelCue()
      this.gotoSlide(nextSlide)
      this.cue.targetSlide = nextSlide
      this.cue.deadlineAt = Date.now() + dwellFor(cue, nextSlide) * 1000
      this.cue.dwellRemaining = dwellFor(cue, nextSlide)
      this.updateUi()
    }, 250)
  }
  // Bring the deck up FULL SCREEN at slide 1 on every machine and HOLD — do not auto-advance.
  // Uses the agent's robust restart (stop → set current slide 1 → start), gated, so it reliably
  // (re)enters the show at slide 1 even from the editor. GO DECK = "arm / standby"; press LOOP DECK
  // (or RUN CUE) afterwards to begin advancing.
  goDeck() {
    this.cancelCue()
    this.intendedSlide = 1
    this.lastCmdAt = Date.now()
    this.sendCommand({ action: 'restart' })
  }

  // END SHOW: stop any roll/cue AND exit the slideshow on every machine (back to the editor). One
  // button to take the whole fleet down cleanly at the end of a show.
  endShow() {
    this.cancelCue()
    this.intendedSlide = null
    this.lastCmdAt = Date.now()
    this.sendCommand({ action: 'stop' })
  }

  // Build-aware auto-play: sends one SYNCED native Next per step, so in-slide builds & animations
  // play and all machines step together (identical decks).
  //
  // PER-SLIDE TIMING *WITH* BUILDS: the hold before each step is keyed off the slide the fleet is
  // actually displaying — dwellFor(cue, currentSlide) — so a custom time (e.g. slide 7 = 120s for an
  // embedded video) holds that slide while every other slide still advances/builds at the default.
  // Timing is measured from the last step (a timestamp) so it survives pause and is unaffected by the
  // 1s tick granularity.
  //
  // BUILD-STEP TRACKING: the module issues every advance, so it knows the build position by counting
  // Nexts since the slide number last changed (Keynote can't report it; we drive it). Exposed as
  // `build_step` and reset on each slide change.
  //
  // LOOPING uses KEYNOTE'S OWN "Loop slideshow" setting; if it's off we detect the exit (playing→
  // false) and fall back to a gated restart. NON-LOOP plays through once and stops at the end.
  runCueBuilds({ fromStart = false, loop = false, cue } = {}) {
    this.cue.running = true; this.cue.paused = false; this.cue.loop = loop; this.cue.builds = true
    this.cue.cueSheet = cue || { default: 8, map: {} }
    this.intendedSlide = null
    if (fromStart) {
      const playing = !!(this.primary() && this.primary().state && this.primary().state.playing)
      if (playing) this.gotoSlide(1)
      else { this.intendedSlide = 1; this.lastCmdAt = Date.now(); this.sendCommand({ action: 'restart' }) }
    }
    this._buildStarted = false
    this._lastStepAt = Date.now()
    this._buildStep = 0
    this._curStepSlide = this.primarySlide()
    this.updateUi()

    this.cue.ticker = setInterval(() => {
      if (this.cue.paused) { this._lastStepAt += 1000; return }   // freeze the dwell while paused
      const playing = !!(this.primary() && this.primary().state && this.primary().state.playing)
      if (playing) this._buildStarted = true

      // Show no longer playing after it had started.
      if (this._buildStarted && !playing) {
        if (!loop) return this.cancelCue()           // non-loop: deck ended → stop
        this.intendedSlide = 1; this.lastCmdAt = Date.now()
        this.sendCommand({ action: 'restart' })      // loop fallback (deck's own loop is off)
        this._buildStarted = false; this._lastStepAt = Date.now()
        this._buildStep = 0; this.updateUi()
        return
      }

      const slide = this.primarySlide()
      if (slide !== this._curStepSlide) { this._curStepSlide = slide; this._buildStep = 0 } // new slide
      const dwell = Math.max(1, dwellFor(this.cue.cueSheet, slide))   // seconds for the CURRENT slide
      const elapsed = Math.floor((Date.now() - this._lastStepAt) / 1000)
      this.setVariableValues({ cue_dwell_remaining: Math.max(0, dwell - elapsed), build_step: this._buildStep })
      if (Date.now() - this._lastStepAt < dwell * 1000) return

      this.sendCommand({ action: 'next' })   // one synced step (build/animation), Keynote loops at end
      this._buildStep += 1
      this._lastStepAt = Date.now()
      this.updateUi()
    }, 1000)
  }

  pauseCue() { if (this.cue.running) { this.cue.paused = true; this.cue.pausedAt = Date.now(); this.updateUi() } }
  resumeCue() {
    if (!this.cue.running) return
    // Exact resume: the deadline moves forward by exactly how long we sat paused.
    if (this.cue.paused && this.cue.pausedAt && this.cue.deadlineAt) this.cue.deadlineAt += Date.now() - this.cue.pausedAt
    this.cue.paused = false; this.cue.pausedAt = 0; this._lastStepAt = Date.now(); this.updateUi()
  }
  cancelCue() {
    if (this.cue.ticker) clearInterval(this.cue.ticker)
    this.cue = { running: false, paused: false, ticker: null, dwellRemaining: 0, targetSlide: 0, loop: false }
    this.setVariableValues({ build_step: 0 })
    this.updateUi()
  }

  // ----------------------------- UI -----------------------------
  updateUi() {
    updateVariables(this)
    this.republishFollowerUi()   // refresh the Follower picker if the fleet or a device name changed (sig-gated)
    // CUE-LIGHT SELF-CORRECTION (so the lamp never needs a manual reload between uses):
    //  (a) Watchdog — if a flash got stuck lit because its reset timer was lost on a reconnect/blip,
    //      force it back to idle once the hold window has clearly passed. Without this the 1 Hz
    //      re-assert below would keep painting the STUCK colour forever.
    //  (b) Position fallback — re-derive a missed advance from the fleet's current slide/build, so the
    //      lamp still flashes within ~1 s even if BOTH the agent cue event and the state-change tick
    //      were missed. Dedupe is handled by lastPos + the 700 ms instant-cue suppression.
    try {
      if ((this.config.cueMode || 'flash') === 'flash' && this.cueLight.state !== 'idle') {
        const hold = Math.max(120, Number(this.config.cueHoldMs) || 800)
        if (Date.now() - (this.cueLight.lastFireAt || 0) > hold + 600) {
          if (this.cueLight.timer) { clearTimeout(this.cueLight.timer); this.cueLight.timer = null }
          this.cueLight.state = 'idle'
          this.setVariableValues({ cue_signal: 'idle', cue_go: 'false', cue_back: 'false' })
        }
      }
      this._observeCue()
    } catch {}
    // Every feedback in this once-a-second sweep SELF-HEALS: if Companion ever drops a button's paint
    // (a page redraw, a surface reconnect, a blip), the next tick re-asserts it. v0.7.9: `cue_light` and
    // `fleet_version` were the only visible feedbacks NOT in this list, so they alone went stale and had
    // to be reloaded by hand while everything else recovered on its own. The crisp cue FLASH still comes
    // from _fireCue's immediate checkFeedbacks('cue_light'); re-asserting here only keeps the lamp's
    // RESTING state correct (idle, or a latched colour) so it never gets stuck needing a manual reload.
    this.checkFeedbacks('connected', 'all_connected', 'all_healthy', 'fleet_in_sync', 'playing', 'on_slide',
      'cue_running', 'license_state', 'leader_known', 'cue_light', 'fleet_version', 'follower_active')
  }
  refreshLicenseUi() {
    const ent = this.license.evaluate()
    if (ent.state === 'trial') this.log('info', `KueSync trial: ${ent.trialDaysLeft} day(s) left`)
    else if (ent.state === 'licensed') this.log('info', `KueSync licensed to ${ent.licensedTo}`)
    this.updateStatusAggregate()
    this.updateUi()
  }
}

runEntrypoint(KueSyncInstance, UpgradeScripts)
