(function(){
  "use strict";

  var mounts = window.MQTTP_MOUNTS || [];
  var cfg = {};
  var debug = !!cfg.debug;

  function nowIso(){ return new Date().toISOString(); }

  function safeText(el, txt){
    if (!el) return;
    el.textContent = String(txt == null ? "" : txt);
  }

  function escHtml(s){
    return String(s == null ? '' : s)
      .replace(/&/g,'&amp;')
      .replace(/</g,'&lt;')
      .replace(/>/g,'&gt;')
      .replace(/\"/g,'&quot;')
      .replace(/'/g,'&#039;');
  }

  function clamp(n, a, b){
    n = Number(n);
    if (!isFinite(n)) return a;
    return Math.max(a, Math.min(b, n));
  }

  function appendLog(c, msg){
    if (!c.log) return;
    c.log.textContent += "[" + nowIso() + "] " + msg + "\n";
    c.log.scrollTop = c.log.scrollHeight;
    if (debug) { console.log("[MQTT-Plug]", msg); }
  }

  function formatPayload(payload, wantJson){
    var s = String(payload == null ? "" : payload);
    if (!wantJson) return s;
    var t = s.trim();
    if (!t) return s;
    if ((t[0] === '{' && t[t.length-1] === '}') || (t[0] === '[' && t[t.length-1] === ']')) {
      try { return JSON.stringify(JSON.parse(t), null, 2); } catch(e){ return s; }
    }
    return s;
  }


function tryParseJson(str){
  try { return JSON.parse(str); } catch(e){ return null; }
}

function extractMetrics(payload){
  var obj = tryParseJson(String(payload == null ? "" : payload));
  if (!obj || typeof obj !== 'object') return [];
  var out = [];
  Object.keys(obj).forEach(function(k){
    var v = obj[k];
    if (typeof v === 'number' || typeof v === 'string' || typeof v === 'boolean') {
      out.push([k, v]);
    }
  });
  var preferred = ['temp_c','temp_f','hum','humidity','rssi','uptime_s','battery','voltage','ok'];
  out.sort(function(a,b){
    var ai = preferred.indexOf(a[0]); if (ai < 0) ai = 999;
    var bi = preferred.indexOf(b[0]); if (bi < 0) bi = 999;
    if (ai !== bi) return ai - bi;
    return String(a[0]).localeCompare(String(b[0]));
  });
  return out.slice(0, 6);
}

function topicLeaf(topic){
  var p = String(topic||'').split('/').filter(Boolean);
  return p.length ? p[p.length-1] : String(topic||'');
}

  function shortPayload(payload, max){
    max = clamp(max || 220, 40, 2000);
    var s = String(payload == null ? "" : payload);
    if (s.length <= max) return s;
    return s.slice(0, max) + "…";
  }

  /* --------------------------
     MQTT Mini Client (from Mesh-Plug; trimmed only where needed)
  --------------------------- */

  function u8cat(a,b){
    var out = new Uint8Array(a.length + b.length);
    out.set(a,0);
    out.set(b,a.length);
    return out;
  }

  function encodeStr(s){
    var te = new TextEncoder();
    var b = te.encode(String(s));
    var out = new Uint8Array(2 + b.length);
    out[0] = (b.length >> 8) & 0xff;
    out[1] = b.length & 0xff;
    out.set(b, 2);
    return out;
  }

  function decodeStr(buf, off){
    if (off + 2 > buf.length) return null;
    var len = (buf[off] << 8) | buf[off+1];
    var start = off + 2;
    var end = start + len;
    if (end > buf.length) return null;
    var b = buf.slice(start, end);
    try {
      var td = new TextDecoder('utf-8', { fatal: true });
      return { value: td.decode(b), next: end };
    } catch(e){
      // best effort
      try {
        return { value: String.fromCharCode.apply(null, Array.from(b)), next: end };
      } catch(_e){
        return { value: '', next: end };
      }
    }
  }

  function encodeRemainingLength(len){
    var out = [];
    do {
      var digit = len % 128;
      len = Math.floor(len / 128);
      if (len > 0) digit = digit | 0x80;
      out.push(digit);
    } while (len > 0);
    return new Uint8Array(out);
  }

  function parseRemainingLength(buf, off){
    var mul = 1;
    var val = 0;
    var i = off;
    for (var c = 0; c < 4; c++){
      if (i >= buf.length) return null;
      var digit = buf[i++];
      val += (digit & 127) * mul;
      if ((digit & 128) === 0) return { value: val, next: i };
      mul *= 128;
    }
    return null;
  }

  function buildConnect(clientId, username, password, keepalive){
    // Fixed header: CONNECT (1)
    var proto = encodeStr("MQTT");
    var vh = new Uint8Array(10);
    // Protocol name length+bytes (handled by proto)
    // Protocol level
    vh[0] = 0; // placeholder; we will concatenate proto + [level, flags, keepalive]
    // We'll construct properly below.

    var level = new Uint8Array([0x04]); // MQTT 3.1.1
    var flags = 0;
    if (username) flags |= 0x80;
    if (password) flags |= 0x40;
    flags |= 0x02; // clean session

    var ka = new Uint8Array(2);
    var k = clamp(keepalive || 20, 5, 300);
    ka[0] = (k >> 8) & 0xff;
    ka[1] = k & 0xff;

    var payload = encodeStr(clientId || ("mqtt-plug-" + Math.random().toString(16).slice(2)));
    if (username) payload = u8cat(payload, encodeStr(username));
    if (password) payload = u8cat(payload, encodeStr(password));

    var varHeader = u8cat(proto, u8cat(level, u8cat(new Uint8Array([flags]), ka)));
    var body = u8cat(varHeader, payload);

    var rl = encodeRemainingLength(body.length);
    var fh = new Uint8Array(1);
    fh[0] = 0x10; // CONNECT

    return u8cat(u8cat(fh, rl), body);
  }

  function buildSubscribe(packetId, topic){
    var pid = new Uint8Array(2);
    pid[0] = (packetId >> 8) & 0xff;
    pid[1] = packetId & 0xff;

    var t = encodeStr(topic || "#");
    var qos = new Uint8Array([0x00]); // QoS0
    var payload = u8cat(t, qos);

    var body = u8cat(pid, payload);

    var fh = new Uint8Array([0x82]); // SUBSCRIBE, QoS1 required
    var rl = encodeRemainingLength(body.length);
    return u8cat(u8cat(fh, rl), body);
  }

  function buildPing(){
    return new Uint8Array([0xC0, 0x00]);
  }

  function MqttMini(url, opts){
    this.url = url;
    this.opts = opts || {};
    this.ws = null;
    this.connected = false;
    this.packetId = 1;
    this.keepTimer = null;
    this.buf = new Uint8Array(0);
    this.onMessage = null;
    this.onStatus = null;
    this.onError = null;
  }

  MqttMini.prototype._emitStatus = function(type, detail){
    if (typeof this.onStatus === "function") this.onStatus(type, detail);
  };

  MqttMini.prototype.connect = function(){
    var self = this;
    self._emitStatus("connecting");

    try {
      self.ws = new WebSocket(self.url);
      self.ws.binaryType = "arraybuffer";
    } catch(e){
      if (typeof self.onError === "function") self.onError(e);
      self._emitStatus("error", e && e.message ? e.message : "WebSocket error");
      return;
    }

    self.ws.onopen = function(){
      var cid = (self.opts.clientId || (self.opts.clientPrefix || "mqtt-plug-") + Math.random().toString(16).slice(2));
      var pkt = buildConnect(cid, self.opts.username || "", self.opts.password || "", self.opts.keepalive || 20);
      try { self.ws.send(pkt); } catch(e){}
    };

    self.ws.onclose = function(){
      self.connected = false;
      self._stopKeepalive();
      self._emitStatus("disconnected");
    };

    self.ws.onerror = function(){
      self._emitStatus("error");
    };

    self.ws.onmessage = function(ev){
      try {
        var u8 = new Uint8Array(ev.data);
        self.buf = u8cat(self.buf, u8);
        self._drain();
      } catch(e){
        if (typeof self.onError === "function") self.onError(e);
      }
    };
  };

  MqttMini.prototype.disconnect = function(){
    try {
      if (this.ws) this.ws.close();
    } catch(e){}
    this.connected = false;
    this._stopKeepalive();
  };

  MqttMini.prototype._startKeepalive = function(){
    var self = this;
    var ka = clamp(self.opts.keepalive || 20, 5, 300);
    if (self.keepTimer) { clearInterval(self.keepTimer); }
    self.keepTimer = setInterval(function(){
      try {
        if (self.ws && self.ws.readyState === 1) self.ws.send(buildPing());
      } catch(e){}
    }, Math.floor((ka * 1000) / 2));
  };

  MqttMini.prototype._stopKeepalive = function(){
    if (this.keepTimer) { clearInterval(this.keepTimer); this.keepTimer = null; }
  };

  MqttMini.prototype.subscribe = function(topic){
    if (!this.ws || this.ws.readyState !== 1) return;
    var pid = this.packetId++ & 0xffff;
    if (pid === 0) pid = 1;
    try { this.ws.send(buildSubscribe(pid, topic)); } catch(e){}
  };

  MqttMini.prototype._drain = function(){
    var buf = this.buf;
    var i = 0;

    while (i + 2 <= buf.length){
      var type = buf[i] >> 4;

      var rlInfo = parseRemainingLength(buf, i+1);
      if (!rlInfo) break;

      var rl = rlInfo.value;
      var hdrEnd = rlInfo.next;
      var pktEnd = hdrEnd + rl;
      if (pktEnd > buf.length) break;

      var body = buf.slice(hdrEnd, pktEnd);

      // CONNACK
      if (type === 2 && body.length >= 2){
        var rc = body[1];
        if (rc === 0){
          this.connected = true;
          this._emitStatus("connected");
          this._startKeepalive();
        } else {
          this._emitStatus("connack", rc);
          if (typeof this.onError === "function") this.onError(new Error("CONNACK error code " + rc));
          this.disconnect();
        }
      }

      // PUBLISH QoS0
      if (type === 3){
        var d = decodeStr(body, 0);
        if (d){
          var topic = d.value;
          var payload = body.slice(d.next);
          var txt = '';
          var isBinary = false;
          try {
            txt = new TextDecoder('utf-8', { fatal: true }).decode(payload);
          } catch(e){
            isBinary = true;
            txt = '[binary payload; ' + payload.length + ' bytes]';
          }
          if (typeof this.onMessage === "function") {
            this.onMessage(topic, txt, isBinary ? payload.length : null);
          }
        }
      }

      i = pktEnd;
    }

    this.buf = buf.slice(i);
  };

  /* --------------------------
     UI Mount (MQTT-Plug)
  --------------------------- */

  function mount(rootId){
    var root = document.getElementById(rootId);
    if (!root) return;

    // Refresh settings at mount-time; avoids race if inline settings print after the JS file.
    cfg = window.MQTTP_SETTINGS || cfg || {};

    var el = {
      root: root,
      tabs: Array.prototype.slice.call(root.querySelectorAll('.mqtt-plug-tab')),
      panes: Array.prototype.slice.call(root.querySelectorAll('.mqtt-plug-pane')),
      latestBody: root.querySelector('[data-role="latest-body"]'),
      log: root.querySelector('[data-role="log"]'),
      statusDot: root.querySelector('[data-role="status-dot"]'),
      statusText: root.querySelector('[data-role="status-text"]'),
      modeBadge: root.querySelector('[data-role="mode-badge"]'),
      topicBadge: root.querySelector('[data-role="topic-badge"]'),
      btnPause: root.querySelector('[data-action="pause"]'),
      btnClear: root.querySelector('[data-action="clear"]'),
      cards: root.querySelector('[data-role="cards"]'),
      historyBody: root.querySelector('[data-role="history-body"]'),
      historyRange: root.querySelector('[data-role="history-range"]'),
      historyLimit: root.querySelector('[data-role="history-limit"]'),
      btnHistoryRefresh: root.querySelector('[data-action="history-refresh"]')
    };

    var wantJson = Number(root.getAttribute('data-json') || '1') === 1;
    var maxItems = parseInt(root.getAttribute('data-max') || '200', 10);
    if (!isFinite(maxItems) || maxItems <= 0) maxItems = 200;
    maxItems = clamp(maxItems, 50, 2000);

    var mode = (root.getAttribute('data-mode') || 'observer').toLowerCase();
    if (mode !== 'live' && mode !== 'observer') mode = 'observer';

    var topicFilter = String(root.getAttribute('data-topic') || '#').trim();
    if (!topicFilter) topicFilter = '#';

    safeText(el.modeBadge, mode.toUpperCase());
    safeText(el.topicBadge, topicFilter);

    function setConnected(isConn){
      if (el.statusDot) {
        el.statusDot.classList.toggle('is-online', !!isConn);
        el.statusDot.classList.toggle('is-offline', !isConn);
      }
      safeText(el.statusText, isConn ? "Connected" : "Disconnected");
    }

    function setTab(tab){
      el.tabs.forEach(function(b){
        b.classList.toggle('is-active', b.getAttribute('data-tab') === tab);
      });
      el.panes.forEach(function(p){
        p.style.display = (p.getAttribute('data-pane') === tab) ? '' : 'none';
      });
    }

    // Default tab: prefer Latest if present.
    if (root.querySelector('[data-pane="latest"]')) setTab('latest');
    else setTab('log');

    el.tabs.forEach(function(b){
      b.addEventListener('click', function(){
        setTab(b.getAttribute('data-tab'));
      });
    });

    var paused = false;
    if (el.btnPause) {
      el.btnPause.addEventListener('click', function(){
        paused = !paused;
        el.btnPause.textContent = paused ? "Resume" : "Pause";
      });
    }
    if (el.btnClear) {
      el.btnClear.addEventListener('click', function(){
        if (el.log) el.log.textContent = '';
        if (el.latestBody) {
          el.latestBody.innerHTML = '<tr><td colspan="3" class="mqtt-plug-muted">No messages yet.</td></tr>';
        }
      });
    }


if (el.btnHistoryRefresh) {
  el.btnHistoryRefresh.addEventListener('click', function(){
    // Force observer refresh; in live mode, we'll fetch snapshot once.
    if (mode === 'observer') {
      // no-op; next poll will update; call tick once if available via closure in observer.
    } else {
      fetchSnapshotOnce();
    }
  });
}
if (el.historyRange) el.historyRange.addEventListener('change', renderHistory);
if (el.historyLimit) el.historyLimit.addEventListener('change', renderHistory);

    // Latest table rendering.
    var latestRows = []; // [{ts,topic,payload}]
    function renderLatest(){
      if (!el.latestBody) return;
      if (!latestRows.length) {
        el.latestBody.innerHTML = '<tr><td colspan="3" class="mqtt-plug-muted">No messages yet.</td></tr>';
        return;
      }
      var rows = latestRows.slice().sort(function(a,b){ return (b.ts||0) - (a.ts||0); });
      rows = rows.slice(0, 1000); // hard guard
      var html = '';
      for (var i=0; i<rows.length; i++){
        var r = rows[i];
        html += '<tr>'
          + '<td class="mqtt-plug-tdtime">' + escHtml(new Date((r.ts||0)*1000).toLocaleString()) + '</td>'
          + '<td class="mqtt-plug-tdtopic"><code>' + escHtml(r.topic||'') + '</code></td>'
          + '<td class="mqtt-plug-tdpayload"><pre class="mqtt-plug-payload">' + escHtml(shortPayload(formatPayload(r.payload||'', wantJson), 420)) + '</pre></td>'
          + '</tr>';
      }
      el.latestBody.innerHTML = html;
      renderCards();
    }

    
function renderCards(){
  if (!el.cards) return;
  if (!latestRows.length){
    el.cards.innerHTML = '<div class="mqtt-plug-muted">No messages yet.</div>';
    return;
  }
  var rows = latestRows.slice().sort(function(a,b){ return (b.ts||0) - (a.ts||0); }).slice(0, 12);
  var html = '';
  for (var i=0; i<rows.length; i++){
    var r = rows[i];
    var when = new Date((r.ts||0)*1000).toLocaleString();
    var metrics = extractMetrics(r.payload||'');
    html += '<div class="mqtt-plug-card">'
      + '<div class="mqtt-plug-cardtop">'
      + '<div class="mqtt-plug-cardtitle">' + escHtml(topicLeaf(r.topic||'')) + '</div>'
      + '<div class="mqtt-plug-cardtime">' + escHtml(when) + '</div>'
      + '</div>'
      + '<div class="mqtt-plug-cardmeta"><code>' + escHtml(r.topic||'') + '</code></div>';
    if (metrics.length){
      html += '<div class="mqtt-plug-metrics">';
      for (var m=0; m<metrics.length; m++){
        html += '<div class="mqtt-plug-metric">'
          + '<div class="mqtt-plug-mkey">' + escHtml(metrics[m][0]) + '</div>'
          + '<div class="mqtt-plug-mval">' + escHtml(String(metrics[m][1])) + '</div>'
          + '</div>';
      }
      html += '</div>';
    } else {
      html += '<div class="mqtt-plug-cardpreview">' + escHtml(shortPayload(r.payload||'', 140)) + '</div>';
    }
    html += '</div>';
  }
  el.cards.innerHTML = html;
}

var lastSnapshot = { latest: [], log: [] };

function renderHistory(){
  if (!el.historyBody) return;
  var hours = el.historyRange ? parseInt(el.historyRange.value||'24',10) : 24;
  if (!isFinite(hours) || hours <= 0) hours = 24;
  var limit = el.historyLimit ? parseInt(el.historyLimit.value||'200',10) : 200;
  if (!isFinite(limit) || limit <= 0) limit = 200;
  limit = clamp(limit, 50, 2000);

  var since = Math.floor(Date.now()/1000) - (hours * 3600);
  var rows = Array.isArray(lastSnapshot.log) ? lastSnapshot.log.slice() : [];
  rows = rows.filter(function(it){ return Number(it.ts||0) >= since; });
  rows.sort(function(a,b){ return Number(b.ts||0) - Number(a.ts||0); });
  rows = rows.slice(0, limit);

  if (!rows.length){
    el.historyBody.innerHTML = '<tr><td colspan="3" class="mqtt-plug-muted">No history yet.</td></tr>';
    return;
  }

  var html = '';
  for (var i=0; i<rows.length; i++){
    var r = rows[i];
    html += '<tr>'
      + '<td class="mqtt-plug-tdtime">' + escHtml(new Date((Number(r.ts||0))*1000).toLocaleString()) + '</td>'
      + '<td class="mqtt-plug-tdtopic"><code>' + escHtml(String(r.topic||'')) + '</code></td>'
      + '<td class="mqtt-plug-tdpayload"><pre class="mqtt-plug-payload">' + escHtml(shortPayload(formatPayload(String(r.payload||''), wantJson), 420)) + '</pre></td>'
      + '</tr>';
  }
  el.historyBody.innerHTML = html;
}

// Append to log.
    function pushLogLine(ts, topic, payload){
      if (paused) return;
      var t = ts ? new Date(ts*1000) : new Date();
      var line = topic + " " + payload;
      appendLog(el, line);
    }

    function pushLatest(ts, topic, payload){
      // keep one row per message; simple; bounded.
      latestRows.push({ ts: ts || Math.floor(Date.now()/1000), topic: topic, payload: payload });
      if (latestRows.length > 500) latestRows = latestRows.slice(-500);
      renderLatest();
    }

    
function fetchSnapshotOnce(){
  var restBase = (cfg.restUrl || root.dataset.restUrl || root.getAttribute('data-rest-url') || root.getAttribute('data-rest') || '').replace(/\/\/+$/, '');
  if (!restBase) return;
  var url = restBase + "/snapshot?filter=" + encodeURIComponent(topicFilter) + "&limit=" + encodeURIComponent(String(maxItems));
  return fetch(url, { credentials: "same-origin" })
    .then(function(r){ return r.json(); })
    .then(function(data){
      if (!data || data.ok !== true) return;
      lastSnapshot.latest = Array.isArray(data.latest) ? data.latest : [];
      lastSnapshot.log = Array.isArray(data.log) ? data.log : [];
      renderHistory();
    })
    .catch(function(){});
}

// Observer mode: poll REST snapshot.
    function startObserver(){
      // Refresh settings in case they became available after initial mount.
      cfg = window.MQTTP_SETTINGS || cfg || {};
      setConnected(false);
      var pollMs = parseInt(root.getAttribute('data-poll-ms') || '', 10);
      if (!isFinite(pollMs) || pollMs <= 0) pollMs = (cfg.observerPollMs || 5000);
      pollMs = clamp(pollMs, 1000, 600000);

      var restBase = (cfg.restUrl || root.dataset.restUrl || root.getAttribute('data-rest-url') || root.getAttribute('data-rest') || '').replace(/\/+$/, '');
      if (!restBase) {
        pushLogLine(null, "observer", "REST base URL missing; cannot poll snapshot.");
        return;
      }

      var lastSeen = 0;

      function tick(){
        var url = restBase + "/snapshot?filter=" + encodeURIComponent(topicFilter) + "&limit=" + encodeURIComponent(String(maxItems));
        fetch(url, { credentials: "same-origin" })
          .then(function(r){ return r.json(); })
          .then(function(data){
            if (!data || data.ok !== true) {
              pushLogLine(null, "observer", "Snapshot error.");
              return;
            }
            // Consider the snapshot "online" only if it is recent.
            var offlineAfter = Number(data.offlineAfterSec || cfg.offlineAfterSec || 300);
            if (!isFinite(offlineAfter) || offlineAfter <= 0) offlineAfter = 300;
            var newest = 0;
            if (Array.isArray(data.latest) && data.latest.length){
              for (var n=0; n<data.latest.length; n++){
                newest = Math.max(newest, Number(data.latest[n].ts||0));
              }
            }
            if (!newest && Array.isArray(data.log) && data.log.length){
              for (var m=0; m<data.log.length; m++){
                newest = Math.max(newest, Number(data.log[m].ts||0));
              }
            }
            var age = newest ? (Math.floor(Date.now()/1000) - newest) : 999999;
            setConnected(age <= offlineAfter);
            if (age > offlineAfter) {
              pushLogLine(null, "observer", "Snapshot is stale (" + age + "s old).");
            }

            // Latest
            if (Array.isArray(data.latest)) {
              latestRows = data.latest.map(function(it){
                return { ts: Number(it.ts||0), topic: String(it.topic||''), payload: String(it.payload||'') };
              });
              renderLatest();
            }

            // Keep a copy for History pane.
            lastSnapshot.latest = Array.isArray(data.latest) ? data.latest : [];
            lastSnapshot.log = Array.isArray(data.log) ? data.log : [];
            renderHistory();

            // Log (append only new)
            if (Array.isArray(data.log) && el.log) {
              for (var i=0; i<data.log.length; i++){
                var it = data.log[i];
                var ts = Number(it.ts || 0);
                if (ts && ts <= lastSeen) continue;
                lastSeen = Math.max(lastSeen, ts || lastSeen);
                pushLogLine(ts || null, String(it.topic||''), String(it.payload||''));
              }
            }
          })
          .catch(function(){
            setConnected(false);
          })
          .finally(function(){
            setTimeout(tick, pollMs);
          });
      }

      tick();
    }

    
var pushQueue = [];
var pushTimer = null;

function schedulePush(){
  if (!cfg.restUrl) return;
  if (!cfg.restNonce) return;
  if (!cfg.canPush) return;
  if (!pushQueue.length) return;
  if (pushTimer) return;
  pushTimer = setTimeout(flushPush, 1200);
}

function flushPush(){
  pushTimer = null;
  if (!pushQueue.length) return;
  var batch = pushQueue.splice(0, 50);
  var base = (cfg.restUrl || '').replace(/\/\/+$/, '');
  if (!base) return;
  fetch(base + "/push", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-WP-Nonce": cfg.restNonce
    },
    credentials: "same-origin",
    body: JSON.stringify({ items: batch })
  }).catch(function(){});
  if (pushQueue.length) schedulePush();
}

// Live mode: browser connects to broker.
    function startLive(){
      var wsUrl = String(cfg.wsUrl || '').trim();
      if (!wsUrl) {
        pushLogLine(null, "live", "WebSocket URL is not configured in MQTT-Plug settings.");
        setConnected(false);
        return;
      }

      var hasCreds = !!(cfg.username || cfg.password);
      var canLive = Number(cfg.canLive || 0) === 1;

      if (!canLive) {
        pushLogLine(null, "live", "Live mode disabled for this user; falling back to observer.");
        startObserver();
        return;
      }
      if (!hasCreds) {
        pushLogLine(null, "live", "No credentials configured; falling back to observer.");
        startObserver();
        return;
      }

      var client = new MqttMini(wsUrl, {
        username: cfg.username || "",
        password: cfg.password || "",
        keepalive: cfg.keepalive || 20,
        clientPrefix: cfg.clientPrefix || "mqtt-plug-",
        clientId: (cfg.clientPrefix || "mqtt-plug-") + Math.random().toString(16).slice(2)
      });

      var reconnectMs = clamp(cfg.reconnectMs || 5000, 250, 600000);
      var didSub = false;

      client.onStatus = function(type, detail){
        if (type === "connecting") {
          setConnected(false);
          pushLogLine(null, "status", "Connecting to broker…");
        } else if (type === "connected") {
          setConnected(true);
          pushLogLine(null, "status", "Connected; subscribing to " + topicFilter);
          fetchSnapshotOnce();
          didSub = false;
          // Subscribe shortly after connect.
          setTimeout(function(){
            if (!didSub) {
              client.subscribe(topicFilter);
              didSub = true;
            }
          }, 50);
        } else if (type === "disconnected") {
          setConnected(false);
          pushLogLine(null, "status", "Disconnected; reconnecting in " + reconnectMs + "ms");
          setTimeout(function(){
            try { client.connect(); } catch(e){}
          }, reconnectMs);
        } else if (type === "connack") {
          pushLogLine(null, "status", "CONNACK error: " + String(detail));
        } else if (type === "error") {
          setConnected(false);
          pushLogLine(null, "status", "Connection error.");
        }
      };

      client.onMessage = function(topic, txt, binaryLen){
        var ts = Math.floor(Date.now()/1000);
        var payload = txt;
        if (binaryLen != null) payload = "[binary payload; " + binaryLen + " bytes]";
        pushLogLine(ts, topic, payload);
        pushLatest(ts, topic, payload);
        pushQueue.push({ ts: ts, topic: topic, payload: payload });
        schedulePush();
        // refresh snapshot-driven history occasionally
        if ((pushQueue.length % 20) === 0) { fetchSnapshotOnce(); }
      };

      client.onError = function(err){
        pushLogLine(null, "error", (err && err.message) ? err.message : "Unknown error");
      };

      client.connect();
    }

    // Mode selection.
    if (mode === "live") startLive();
    else startObserver();
  }

  function boot(){
    for (var i=0; i<mounts.length; i++){
      mount(mounts[i]);
    }
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", boot);
  } else {
    boot();
  }
})();
