// /AIWK_RELAY/side_panel.js
// 용도: Relay side panel에서 WSS client map, route dedupe, chat/ping UI를 관리한다.
// AIWK Relay v157-heart-title-sticky-common
(function () {
  "use strict";
  const VERSION = "v157-heart-title-sticky-common";
  let lastState = null;
  let timer = null;
  let clientRefreshTimer = null;
  let clientMapItems = [];
  let chatRows = [];
  const chatSeen = new Set();
  const lastClientStatus = {};
  const BINDING_STORE_KEY = "AIWK_RELAY_BINDINGS_V096";

  function bindingRoomKey(it) {
    it = it || activeProfile();
    return [it.group || "home", it.project || "IG_CRM", it.room_id || it.roomId || "RE5ay2jRmY"].join(".");
  }
  function tabIdOfItem(it) { return String((it && (it.tab_id || (it.tab && it.tab.tab_id))) || "").trim(); }
  function routeKeyOfItem(it) { return String((it && (it.route_key || it.url_key || (it.tab && (it.tab.route_key || it.tab.url_key)))) || "").trim(); }
  function bindingMemberKey(it) {
    it = it || {};
    return [String(it.client_id || it.clientId || "").toUpperCase(), routeKeyOfItem(it) || tabIdOfItem(it) || "route-unknown"].join(":");
  }
  function bindingMemberAliases(it) {
    it = it || {};
    const cid = String(it.client_id || it.clientId || "").toUpperCase();
    const out = [];
    if (!cid) return out;
    const rk = routeKeyOfItem(it);
    const tid = tabIdOfItem(it);
    if (rk) out.push(cid + ":" + rk);
    if (tid) out.push(cid + ":" + tid);
    out.push(cid);
    return Array.from(new Set(out));
  }
  function readBindings() { try { return JSON.parse(localStorage.getItem(BINDING_STORE_KEY) || "{}"); } catch(e) { return {}; } }
  function bindingFailHelp(reason) {
    reason = String(reason || "");
    if (/CLIENT_ID_MISSING/i.test(reason)) return "연결 지도에서 client_id가 있는 대상 행을 선택하십시오.";
    if (/ROUTE_OR_TAB_MISSING/i.test(reason)) return "url5/route_key 또는 tab_id가 없는 대상입니다. 연결 지도 전체 불러오기 후 해당 탭을 새로고침하십시오.";
    if (/TARGET_LIMITED/i.test(reason)) return "대상이 token/권한 부족 상태입니다. 대상 접속창에서 client/token을 확인하고 WSS 인증등록 후 다시 묶으십시오.";
    if (/STORAGE_WRITE_FAIL|STORAGE_VERIFY_FAIL/i.test(reason)) return "브라우저 저장소(localStorage) 쓰기 또는 읽기 검증에 실패했습니다. Relay 창 새로고침, 확장 새로고침, 저장소 용량/권한을 확인하십시오.";
    return "연결 지도 전체 불러오기 후 대상 행을 다시 선택하고, 그래도 실패하면 대상 접속창의 token/client_id/WSS 상태를 확인하십시오.";
  }
  function writeBindings(data) {
    try {
      localStorage.setItem(BINDING_STORE_KEY, JSON.stringify(data || {}));
      return { ok:1, data:data || {} };
    } catch (e) {
      const msg = e && e.message ? e.message : String(e);
      try { console.warn("[AIWK] binding storage skipped", msg); } catch (_) {}
      return { ok:0, code:"STORAGE_WRITE_FAIL", error:msg, solution:bindingFailHelp("STORAGE_WRITE_FAIL") };
    }
  }
  function showDanger(text) {
    text = String(text || "");
    if (!text) return;
    const status = $("sendStatus");
    if (status) status.textContent = text;
    addChat({ kind:"system", text:text, statusEmoji:"🔴" });
    let toast = $("relayDangerToast");
    if (!toast) {
      toast = document.createElement("div");
      toast.id = "relayDangerToast";
      toast.className = "relay-danger-toast";
      toast.innerHTML = "<button type='button' class='relay-toast-close' title='닫기'>×</button><div class='relay-toast-text'></div>";
      document.body.appendChild(toast);
      const btn = toast.querySelector(".relay-toast-close");
      if (btn) btn.addEventListener("click", function () { toast.classList.remove("show"); });
    }
    const body = toast.querySelector(".relay-toast-text");
    if (body) body.textContent = text;
    toast.classList.add("show");
  }
  function bindingFailureText(action, it, code, error) {
    const cid = String((it && (it.client_id || it.clientId)) || "?").toUpperCase();
    const routeKey = routeKeyOfItem(it) || "-";
    const tabId = tabIdOfItem(it) || "-";
    const reason = String(code || error || "BINDING_FAIL");
    return (action || "묶기") + " 실패: " + cid + " / url5:" + routeKey + " / tab:" + tabId + "\n오류 원인: " + reason + (error && error !== reason ? " / " + error : "") + "\n해결책: " + bindingFailHelp(reason);
  }
  function isApprovedClient(it) {
    it = it || {};
    const ap = activeProfile();
    const cid = String(it.client_id || it.clientId || "").toUpperCase();
    const tid = tabIdOfItem(it);
    const rk = routeKeyOfItem(it);
    const activeRoute = lastState && lastState.active_tab && lastState.active_tab.comm && lastState.active_tab.comm.route ? String(lastState.active_tab.comm.route.route_key || lastState.active_tab.comm.route.url_key || "") : "";
    if (cid && cid === String(ap.clientId || "").toUpperCase() && rk && activeRoute && rk === activeRoute) return true;
    if (cid && cid === String(ap.clientId || "").toUpperCase() && tid && lastState && lastState.active_tab && String(lastState.active_tab.tab_id || "") === tid) return true;
    const all = readBindings();
    const room = all[bindingRoomKey(it)] || {};
    return bindingMemberAliases(it).some(function (key) { return !!room[key]; });
  }
  function setApprovedClient(it, flag) {
    it = it || {};
    const cid = String(it.client_id || it.clientId || "").toUpperCase();
    const rk = routeKeyOfItem(it);
    const tid = tabIdOfItem(it);
    if (!cid) return { ok:0, code:"CLIENT_ID_MISSING", error:"client_id가 없는 대상은 묶기/승인할 수 없습니다." };
    if (!rk && !tid) return { ok:0, code:"ROUTE_OR_TAB_MISSING", error:"url5/route_key와 tab_id가 모두 없습니다." };
    if (flag && it.can_speak === false) return { ok:0, code:"TARGET_LIMITED", error:"token/권한 부족 상태의 대상은 채팅 승인 묶기를 할 수 없습니다." };
    const all = readBindings();
    const room = bindingRoomKey(it);
    all[room] = all[room] || {};
    const keys = bindingMemberAliases(it);
    const mainKey = bindingMemberKey(it);
    if (flag) {
      keys.forEach(function (key) {
        all[room][key] = { client_id:String(it.client_id||it.clientId||"").toUpperCase(), route_key:routeKeyOfItem(it), tab_id:tabIdOfItem(it), approved_at:Date.now(), title:(it.tab&&it.tab.title)||"" };
      });
    } else {
      keys.forEach(function (key) { delete all[room][key]; });
      delete all[room][mainKey];
    }
    const wr = writeBindings(all);
    if (!wr.ok) return wr;
    const check = readBindings();
    const savedRoom = check[room] || {};
    const verified = flag ? keys.some(function (key) { return !!savedRoom[key]; })
                          : !keys.some(function (key) { return !!savedRoom[key]; });
    if (!verified) return { ok:0, code:"STORAGE_VERIFY_FAIL", error:"묶기 저장 후 검증에 실패했습니다.", solution:bindingFailHelp("STORAGE_VERIFY_FAIL") };
    return { ok:1, room:room, key:mainKey, approved:!!flag };
  }


  function aiwkIsBenignRuntimeMessageError(text) {
    text = String(text || "");
    return /message channel closed|message port closed|Receiving end does not exist|Could not establish connection|Extension context invalidated/i.test(text);
  }
  window.addEventListener("unhandledrejection", function (ev) {
    const msg = ev && ev.reason ? (ev.reason.message || String(ev.reason)) : "";
    if (aiwkIsBenignRuntimeMessageError(msg)) {
      try { ev.preventDefault(); } catch (e) {}
      try { addChat({ kind: "system", text: "WARN RUNTIME_MESSAGE_IGNORED / " + msg.slice(0, 160) }); } catch (e) {}
    }
  });
  window.addEventListener("error", function (ev) {
    const msg = ev && ev.message ? ev.message : "";
    if (aiwkIsBenignRuntimeMessageError(msg)) {
      try { ev.preventDefault(); } catch (e) {}
      return false;
    }
  });

  function $(id) { return document.getElementById(id); }
  function pad4(n) { return String(Math.max(0, parseInt(n || 0, 10) || 0)).padStart(4, "0"); }
  function shortUrl(url) { return String(url || "").replace(/^https?:\/\//, "").slice(0, 160); }
  function htmlEscape(s) { return String(s === undefined || s === null ? "" : s).replace(/[&<>"]/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c])); }
  function rt(c) { c = c || {}; return "R:" + pad4(c.rx) + " T:" + pad4(c.tx); }
  function mode() { return new URLSearchParams(location.search).get("mode") || "popup"; }
  function nowText(ts) { return new Date(ts || Date.now()).toLocaleTimeString(); }

  function statusInfoFromComm(comm) {
    comm = comm || {};
    const wss = comm.wss || {};
    const bus = comm.tabbus || {};
    const nr = comm.nodered || {};
    const duplicate = !!wss.duplicate_route || /same_route_client_reconnected|replaced/i.test(String(wss.last_error || wss.last_close_reason || ""));
    const e1 = duplicate ? "♻️" : (wss.open ? "🟢" : (wss.connecting || wss.auto_reconnect ? "🟡" : "🔴"));
    const e2 = bus.registered ? "🟢" : (bus.ok ? "🟡" : "🔴");
    const e3 = nr.ok ? "🟢" : (nr.auto_keep ? "🟡" : "🔴");
    const triple = e1 + e2 + e3;
    if (duplicate) return { key: "replaced", emoji: triple, text: "WSS/BUS/NR", cls: "replaced", title: "WSS/BUS/NR 상태: " + triple + " / 같은 route/client가 새 접속으로 교체되었습니다." };
    if (wss.open && bus.registered && nr.ok) return { key: "online", emoji: triple, text: "WSS/BUS/NR", cls: "online", title: "WSS/BUS/NR 모두 online" };
    if (wss.open || wss.connecting || wss.auto_reconnect || bus.registered || bus.ok || nr.ok || nr.auto_keep) return { key: "reconnecting", emoji: triple, text: "WSS/BUS/NR", cls: "reconnecting", title: "WSS/BUS/NR 상태: " + triple };
    return { key: "offline", emoji: triple, text: "WSS/BUS/NR", cls: "offline", title: "WSS/BUS/NR offline" };
  }
  function statusInfoFromItem(it) {
    it = it || {};
    const tabId = tabIdOfItem(it);
    const st = String(it.status || it.aiwk_status || "").toUpperCase();
    if (it.ended || st === "END" || st === "USER_CLOSED" || it.user_closed) return { key:"user_closed", emoji:"⏹", text:"종료", cls:"closed", title:"사용자가 정상 종료했습니다. 오류가 아닙니다." };
    if (it.status === "replaced" || it.replaced) return { key:"replaced", emoji:"♻️", text:"교체", cls:"replaced", title:"최근 같은 client/route 교체 이벤트" };
    if (it.online === false || it.status === "offline") return { key:"offline", emoji:"🔴", text:"끊김", cls:"offline", title:"offline / ping 불가" };
    if (!it.online && !tabId) return { key:"unknown", emoji:"⚫", text:"미확인", cls:"unknown", title:"상태 수집 전 또는 tab_id 없음" };
    if (!it.can_speak && it.can_speak !== undefined) return { key:"limited", emoji:"🔴", text:"불가", cls:"limited", title:"연결은 보이나 token/권한이 부족해 채팅 불가" };
    if (it.status === "reconnecting" || it.connecting) return { key:"reconnecting", emoji:"🟡", text:"확인중", cls:"reconnecting", title:"재접속/상태 확인 중" };
    if (isApprovedClient(it)) return { key:"approved", emoji:"🟢", text:"승인", cls:"online", title:"binding 승인됨 / Ping+Chat 가능" };
    return { key:"unapproved", emoji:"🟡", text:"미승인", cls:"unapproved", title:"연결됨 / Ping 가능 / Chat 승인 필요" };
  }
  function statusInfoForClient(cid) {
    cid = String(cid || "").toUpperCase();
    const list = clientMapItems || [];
    for (const it of list) {
      if (String(it.client_id || "").toUpperCase() === cid) return statusInfoFromItem(it);
    }
    const t = lastState && lastState.active_tab ? lastState.active_tab : null;
    if (t && displayClient(t) === cid) return statusInfoFromComm(t.comm || {});
    return { key:"unknown", emoji:"⚫", text:"unknown", cls:"unknown", title:"상태 미확인" };
  }
  function setRelayHeader(cid, info) {
    cid = String(cid || "?").toUpperCase();
    info = info || { emoji:"🔴", text:"offline", cls:"offline" };
    const c = $("relayClientBadge");
    const b = $("relayConnBadge");
    const tb = $("relayTabBadge");
    const active = lastState && lastState.active_tab ? lastState.active_tab : {};
    const tid = active ? String(active.tab_id || "-") : "-";
    const rk = active ? String(active.route_key || active.url_key || (active.comm && active.comm.route && (active.comm.route.route_key || active.comm.route.url_key)) || "") : "";
    if (tb) {
      tb.textContent = "🔑url5:" + (rk || "-") + " 🧷tab:" + tid;
      tb.title = "현재 Relay 기준: url5/route_key가 상위 identity, tab_id는 현재 실행 탭 인스턴스";
    }
    if (c) c.textContent = cid;
    if (b) {
      b.className = "relay-conn-badge " + (info.cls || "offline");
      b.textContent = (info.emoji || "🔴");
      b.title = info.title || "";
    }
  }
  function rememberClientStatus(cid, info, source) {
    if (source !== "client-map") return;
    cid = String(cid || "").toUpperCase();
    if (!cid || cid === "?") return;
    info = info || {};
    const key = info.key || info.text || "unknown";
    const prev = lastClientStatus[cid];
    if (prev && prev !== key) {
      addChat({ kind:"system", text:(info.emoji || "") + " " + cid + " 상태 변경: " + prev + " → " + key + " / client-map", statusEmoji:info.emoji || "" });
    }
    lastClientStatus[cid] = key;
  }

  const AIWK_HEART_TOKEN_RE = /^(?:❤\ufe0f?|♥\ufe0f?|♡\ufe0f?|❣\ufe0f?|💛|💚|💙|💜|🧡|🖤|🤍|🤎|💖|💗|💓|💞|💕|💘|💝|💟)/u;
  const AIWK_STATUS_TOKEN_GROUP = "(?:🟢|🟡|🔴|⚫|♻\ufe0f?|⚠\ufe0f?|⏹\ufe0f?)";
  const AIWK_TITLE_META_RE = new RegExp("^\\s*(?:" + AIWK_STATUS_TOKEN_GROUP + "\\s*){1,6}([A-Z]+[A-Z0-9_-]{0,31})\\s+R:([0-9]{1,8})\\s+T:([0-9]{1,8})\\s*-\\s*(.*)$", "iu");
  function readLeadingHeartTriple(title) {
    const src = String(title || "");
    const leading = (src.match(/^\s*/) || [""])[0];
    let idx = leading.length;
    let prefix = leading;
    for (let i = 0; i < 3; i++) {
      const m = src.slice(idx).match(AIWK_HEART_TOKEN_RE);
      if (!m) return { prefix: "", rest: src };
      prefix += m[0];
      idx += m[0].length;
    }
    return { prefix: prefix.trim(), rest: src.slice(idx).replace(/^\s+/, "") };
  }
  function joinHeartPrefix(prefix, title) {
    const body = String(title || "").trim();
    const head = String(prefix || "").trim();
    if (!head) return body;
    return body ? head + " " + body : head;
  }
  function parseTitleMeta(title) {
    if (window.AIWK_TITLE && typeof window.AIWK_TITLE.parse === "function") {
      const p = window.AIWK_TITLE.parse(title);
      return { clientId: p.clientId || "", counters: p.counters || null, title: p.title || "" };
    }
    const heart = readLeadingHeartTriple(title);
    let raw = heart.prefix ? heart.rest : String(title || "");
    let clientId = "";
    let counters = null;
    for (let i = 0; i < 16; i++) {
      const before = raw;
      const m = raw.match(AIWK_TITLE_META_RE);
      if (m) {
        clientId = String(m[1] || clientId || "").toUpperCase();
        counters = { rx: parseInt(m[2] || "0", 10) || 0, tx: parseInt(m[3] || "0", 10) || 0 };
        raw = String(m[4] || "").trim();
      }
      const nestedHeart = readLeadingHeartTriple(raw);
      if (nestedHeart.prefix) {
        const rest = String(nestedHeart.rest || "").trim();
        if (heart.prefix || AIWK_TITLE_META_RE.test(rest)) raw = rest;
      }
      if (raw === before) break;
    }
    return { clientId: clientId, counters: counters, title: joinHeartPrefix(heart.prefix, raw) };
  }
  function displayClient(t) {
    t = t || {};
    const meta = parseTitleMeta(t.title || "");
    const cid = String(t.client_id || "").trim();
    if (cid && cid !== "?") return cid.toUpperCase();
    if (meta.clientId) return meta.clientId;
    return "?";
  }
  function displayCounters(t) {
    t = t || {};
    const c = t.counters || {};
    if ((parseInt(c.rx || 0, 10) || 0) || (parseInt(c.tx || 0, 10) || 0)) return c;
    const meta = parseTitleMeta(t.title || "");
    return meta.counters || c;
  }
  function displayTitle(t) {
    t = t || {};
    const meta = parseTitleMeta(t.title || "");
    return meta.title || t.title || "";
  }
  function cleanTitleFromItem(it) {
    const raw = String((it && (it.tab_title || (it.tab && it.tab.title))) || "");
    const meta = parseTitleMeta(raw);
    return meta.title || raw.replace(AIWK_TITLE_META_RE, "$4");
  }
  function shortCleanTitle(it) { return String(cleanTitleFromItem(it) || "").slice(0, 160); }
  function clientBadgeHtml(cid) {
    cid = String(cid || "?").toUpperCase();
    const cls = cid === "?" ? "client-badge unknown" : "client-badge";
    return "<span class='" + cls + "'>" + htmlEscape(cid) + "</span>";
  }
  function statusPillHtml(status) {
    const info = typeof status === "object" ? status : (status ? { emoji:"🟢", text:"online", cls:"online" } : { emoji:"🔴", text:"offline", cls:"offline" });
    return "<span class='status-pill " + (info.cls || "offline") + "' title='" + htmlEscape(info.title || "") + "'><span class='status-dot'></span><span class='status-emoji'>" + htmlEscape(info.emoji || "") + "</span>" + htmlEscape(info.text || "offline") + "</span>";
  }
  function bindingInfoFromItem(it) {
    const approved = isApprovedClient(it);
    if (approved) return { key:"bound", emoji:"🔗", text:"묶임", cls:"bound", title:"이 client_id/tab_id가 현재 ROOM binding에 묶여 있습니다. 버튼을 누르면 묶임/승인을 해제합니다." };
    return { key:"unbound", emoji:"○", text:"미묶임", cls:"unbound", title:"아직 ROOM binding에 묶이지 않았습니다. 버튼을 누르면 묶기/승인합니다." };
  }
  function bindingBadgeHtml(info) {
    info = info || {};
    return "<span class='binding-badge " + htmlEscape(info.cls || "unbound") + "' title='" + htmlEscape(info.title || "") + "'><span class='binding-dot'></span><span>" + htmlEscape((info.emoji || "○") + " " + (info.text || "미묶임")) + "</span></span>";
  }
  function approveButtonLabel(approved) {
    return approved ? "🟢 묶임 · 해제" : "○ 미묶임 · 묶기/승인";
  }
  function approveButtonTitle(approved) {
    return approved ? "현재 묶임/승인 상태입니다. 클릭하면 해제합니다." : "현재 미묶임 상태입니다. 클릭하면 묶기/승인합니다.";
  }
  function aiwkMessageErrorCode(message) {
    message = String(message || "");
    if (message.indexOf("message channel closed") >= 0 || message.indexOf("asynchronous response") >= 0) return "RUNTIME_MESSAGE_CHANNEL_CLOSED";
    if (message.indexOf("Receiving end does not exist") >= 0 || message.indexOf("Could not establish connection") >= 0) return "RUNTIME_RECEIVER_MISSING";
    if (message.indexOf("Extension context invalidated") >= 0) return "EXTENSION_CONTEXT_INVALIDATED";
    return "RUNTIME_MESSAGE_FAIL";
  }
  function send(msg) {
    return new Promise(function (resolve) {
      let done = false;
      const timer = setTimeout(function () {
        if (done) return;
        done = true;
        resolve({ ok: 0, code: "RUNTIME_MESSAGE_TIMEOUT", error: "chrome.runtime.sendMessage 응답 시간 초과", solution: "확장 새로고침 또는 대상 탭 새로고침 후 다시 실행하십시오.", request_type: msg && msg.type || "" });
      }, 8000);
      try {
        chrome.runtime.sendMessage(msg, function (res) {
          if (done) return;
          done = true;
          clearTimeout(timer);
          const le = chrome.runtime.lastError;
          if (le) {
            const em = le.message || String(le);
            resolve({ ok: 0, code: aiwkMessageErrorCode(em), error: em, solution: "service_worker/content script 응답 채널이 닫혔습니다. 대상 탭이 로딩/새로고침 중인지, content.js가 주입됐는지 확인하십시오.", request_type: msg && msg.type || "" });
            return;
          }
          resolve(res || { ok: 1 });
        });
      } catch (err) {
        if (done) return;
        done = true;
        clearTimeout(timer);
        const em = err && err.message ? err.message : String(err);
        resolve({ ok: 0, code: aiwkMessageErrorCode(em), error: em, solution: "확장 컨텍스트 또는 runtime 연결 상태를 확인하십시오.", request_type: msg && msg.type || "" });
      }
    });
  }

  function renderActive(state) {
    const box = $("activeTabBox");
    const now = $("relayNow");
    if (now) now.textContent = new Date().toLocaleTimeString() + " · " + VERSION + " · " + mode();
    if (!box) return;
    const t = state && state.active_tab ? state.active_tab : null;
    if (!t) { box.textContent = "활성 탭 없음"; return; }
    const c = displayCounters(t) || (t.comm && t.comm.counters) || {};
    const cid = displayClient(t);
    const title = displayTitle(t);
    const comm = t.comm || {};
    const info = statusInfoFromComm(comm);
    setRelayHeader(cid, info);
    rememberClientStatus(cid, info, "active tab");
    const wss = comm.wss || {};
    const bus = comm.tabbus || {};
    const nr = comm.nodered || {};
    box.innerHTML = [
      "<div class='active-client-row'>" + clientBadgeHtml(cid) + " <span class='rt-text'>" + htmlEscape(rt(c)) + "</span></div>",
      "<div>tab:" + htmlEscape(t.tab_id || "-") + "</div>",
      "<div>url:" + htmlEscape(shortUrl(t.url)) + "</div>",
      "<div>title:" + htmlEscape(title) + "</div>"
    ].join("");
  }

  function renderTabs(state) {
    const box = $("tabsBox");
    if (!box) return;
    const tabs = (state && state.tabs) || [];
    if (!tabs.length) { box.textContent = "탭 없음"; return; }
    box.innerHTML = "";
    tabs.forEach(function (t) {
      const div = document.createElement("div");
      div.className = "tab-line" + (t.active ? " active" : "");
      const c = displayCounters(t);
      const cid = displayClient(t);
      const title = displayTitle(t);
      const info = statusInfoFromComm(t.comm || {});
      rememberClientStatus(cid, info, "tab");
      div.dataset.tabId = String(t.tab_id || "");
      div.title = "클릭하면 이 탭으로 전환합니다: tab " + String(t.tab_id || "-");
      div.innerHTML = "<div class='tab-top'><span class='status-emoji-inline'>" + htmlEscape(info.emoji || "") + "</span>" + clientBadgeHtml(cid) + " <span class='rt-text'>" + htmlEscape(rt(c)) + "</span> · <span class='tab-id-text'>tab:" + htmlEscape(t.tab_id || "") + "</span>" + (t.active ? " <span class='ok active-mark'>ACTIVE</span>" : "") + "</div><div class='small tab-url'>" + htmlEscape(shortUrl(t.url)) + "</div><div class='small tab-title'>" + htmlEscape(title) + "</div>";
      div.addEventListener("click", async function () {
        await activateTab(String(t.tab_id || ""));
      });
      box.appendChild(div);
    });
  }

  async function activateTab(tabId) {
    tabId = String(tabId || "").trim();
    if (!tabId) {
      $("sendStatus").textContent = "탭 전환 실패: tab_id 없음";
      return;
    }
    const res = await send({ type: "AIWK_RELAY_ACTIVATE_TAB", tab_id: tabId });
    $("sendStatus").textContent = res && res.ok ? ("탭 전환: tab " + tabId) : ("탭 전환 실패: " + ((res && (res.error || res.code)) || "unknown"));
    setTimeout(refresh, 250);
  }

  function addChat(row) {
    row = row || {};
    const key = String(row.key || [row.kind, row.from, row.to, row.text, row.ts].join("|"));
    if (chatSeen.has(key)) return;
    chatSeen.add(key);
    chatRows.push(Object.assign({ ts: Date.now() }, row));
    while (chatRows.length > 150) chatRows.shift();
    renderChat();
  }

  function renderChat() {
    const box = $("chatBox");
    if (!box) return;
    if (!chatRows.length) { box.textContent = "채팅 로그 대기"; return; }
    box.innerHTML = "";
    chatRows.slice(-80).forEach(function (r) {
      const div = document.createElement("div");
      const kind = r.kind || "peer";
      div.className = "chat-msg " + (kind === "me" ? "me" : (kind === "system" ? "system" : "peer"));
      const label = kind === "me" ? "나" : (kind === "system" ? "시스템" : (r.from || "상대"));
      const statusEmoji = r.statusEmoji || (kind === "me" ? "🟢" : (kind === "system" ? "ℹ️" : statusInfoForClient(label).emoji));
      div.innerHTML = "<div class='chat-meta'><span class='status-emoji-inline'>" + htmlEscape(statusEmoji || "") + "</span>" + clientBadgeHtml(label) + "<span>" + htmlEscape(nowText(r.ts)) + "</span>" + (r.to ? "<span>→ " + htmlEscape(r.to) + "</span>" : "") + "</div><div class='chat-text'>" + htmlEscape(r.text || "") + "</div>";
      box.appendChild(div);
    });
    box.scrollTop = box.scrollHeight;
  }

  function renderFlow(state) {
    const box = $("flowBox");
    if (!box) return;
    const rows = ((state && state.log) || []).slice(-120).reverse();
    if (!rows.length) { box.textContent = "패킷 로그 대기"; return; }
    box.innerHTML = "";
    rows.forEach(function (r) {
      const div = document.createElement("div");
      const dir = r.dir || "";
      div.className = "flow-line " + (dir === "rx" ? "rx" : (dir === "tx" ? "tx" : (dir === "status" ? "status" : "")));
      const p = r.packet || {};
      const action = r.action || p.action || p.aiwk_action || p.type || "";
      const from = p.from || r.from || "";
      const to = p.to || r.to || "";
      const err = p.error || r.error || "";
      div.textContent = "[" + new Date(r.ts || Date.now()).toLocaleTimeString() + "] " + (dir || r.action || "event") + " " + action + (from || to ? " · " + from + " → " + to : "") + (err ? " · ERR:" + err : "");
      box.appendChild(div);
    });
  }

  function extractClientItemsFromState(state) {
    const out = [];
    const logs = ((state && state.log) || []).slice(-200);
    logs.forEach(function (r) {
      const p = r.packet || {};
      const payload = p.payload || p.result || p;
      const items = payload && payload.items ? payload.items : (payload && payload.result && payload.result.items ? payload.result.items : null);
      if (Array.isArray(items)) items.forEach(x => out.push(Object.assign({ _source:"wss" }, x)));
    });
    const recent = state && state.active_tab && state.active_tab.comm && state.active_tab.comm.recent_messages ? state.active_tab.comm.recent_messages : [];
    (recent || []).forEach(function (m) {
      const items = m && m.items ? m.items : null;
      if (Array.isArray(items)) items.forEach(x => out.push(Object.assign({ _source:"comm_recent" }, x)));
    });
    return out;
  }

  function fallbackClientsFromTabs(state) {
    const tabs = (state && state.tabs) || [];
    return tabs.map(function (t) {
      const cid = displayClient(t);
      const c = t.comm || {};
      const routeKey = String(t.route_key || t.url_key || (c.route && (c.route.route_key || c.route.url_key)) || "").trim();
      return { _source:"service_worker", client_id: cid, group: t.group || "local", project: t.project || "tabs", room_id: t.room_id || "", endpoint: cid === "N1" ? "node-red" : "ext4", online: cid !== "?", addr: "", route_key: routeKey, url_key: routeKey, tab: { tab_id: t.tab_id, route_key: routeKey, url_key: routeKey, title: t.title || "", url: t.url || "" }, can_speak: !!(c.wss && c.wss.open), can_receive: true, can_execute: false };
    }).filter(x => x.client_id !== "?");
  }

  function itemScore(x, cid) {
    x = x || {};
    const title = String((x.tab && x.tab.title) || x.tab_title || x.title || "");
    const meta = parseTitleMeta(title);
    let score = 0;
    if (String(meta.clientId || "").toUpperCase() === String(cid || "").toUpperCase()) score += 1000;
    if (x.online !== false) score += 100;
    if (x.can_speak) score += 80;
    if (isApprovedClient(x)) score += 60;
    if (x.status === "online") score += 40;
    const c = x.counters || (x.comm && x.comm.counters) || {};
    score += Math.min(30, (parseInt(c.rx || x.rx || 0, 10) || 0) + (parseInt(c.tx || x.tx || 0, 10) || 0));
    score += Math.min(20, Math.floor((Number(x.last_seen || x.updated_at || 0) || 0) / 100000000000));
    return score;
  }
  function normalizeOneClientItem(x) {
    if (!x) return null;
    const titleMeta = parseTitleMeta((x.tab && x.tab.title) || x.tab_title || x.title || "");
    const cid = String(x.client_id || x.clientId || x.cid || titleMeta.clientId || "").toUpperCase();
    if (!cid) return null;
    const tabId = String(x.tab_id || (x.tab && x.tab.tab_id) || "");
    const out = Object.assign({}, x, {
      client_id: cid,
      tab_id: tabId,
      online: x.online !== false,
      status: x.status || (x.online === false ? "offline" : "online"),
      _source: x._source || x.source || "wss"
    });
    if (!out.tab && (x.tab_title || x.title || x.url)) out.tab = { tab_id:tabId, title:x.tab_title || x.title || "", url:x.url || "" };
    return out;
  }
  function normalizeClientItems(items, state) {
    const primary = new Map();
    const conflict = {};
    function add(x) {
      const it = normalizeOneClientItem(x);
      if (!it) return;
      const tabId = tabIdOfItem(it);
      const rk = routeKeyOfItem(it);
      const cid = String(it.client_id || "").toUpperCase();
      const key = rk ? [it.group || "", it.project || "", it.room_id || it.roomId || "", it.endpoint || "ext4", cid, rk].join("/")
                        : tabId ? [it.group || "", it.project || "", it.room_id || it.roomId || "", it.endpoint || "ext4", tabId].join("/")
                        : [it.group || "", it.project || "", it.room_id || it.roomId || "", it.endpoint || "ext4", cid].join("/");
      const old = primary.get(key);
      if (old && String(old.client_id || "").toUpperCase() !== cid) {
        conflict[key] = Array.from(new Set([].concat(conflict[key] || [], [old.client_id, cid]).filter(Boolean).map(v => String(v).toUpperCase())));
      }
      if (!old || itemScore(it, cid) >= itemScore(old, old.client_id)) primary.set(key, it);
    }
    (items || []).forEach(add);
    fallbackClientsFromTabs(state).forEach(function (x) {
      const it = normalizeOneClientItem(x);
      if (!it) return;
      const tabId = tabIdOfItem(it);
      const rk = routeKeyOfItem(it);
      const key = rk ? [it.group || "", it.project || "", it.room_id || it.roomId || "", it.endpoint || "ext4", it.client_id || "", rk].join("/")
                        : tabId ? [it.group || "", it.project || "", it.room_id || it.roomId || "", it.endpoint || "ext4", tabId].join("/")
                        : [it.group || "", it.project || "", it.room_id || it.roomId || "", it.endpoint || "ext4", it.client_id || ""].join("/");
      if (!primary.has(key)) primary.set(key, it);
    });
    Object.keys(conflict).forEach(function (key) {
      const it = primary.get(key);
      if (it) it.conflict_clients = conflict[key].filter(c => c !== String(it.client_id || "").toUpperCase());
    });
    const values = Array.from(primary.values());
    const routeByClientTab = new Set();
    values.forEach(function (it) {
      const rk = routeKeyOfItem(it);
      const tid = tabIdOfItem(it);
      const cid = String(it.client_id || "").toUpperCase();
      if (rk && tid && cid) routeByClientTab.add([it.endpoint || "ext4", cid, tid].join("/"));
    });
    const noRouteBest = new Map();
    const filtered = [];
    values.forEach(function (it) {
      const rk = routeKeyOfItem(it);
      const tid = tabIdOfItem(it);
      const cid = String(it.client_id || "").toUpperCase();
      const tabKey = [it.endpoint || "ext4", cid, tid].join("/");
      if (!rk && tid && cid && routeByClientTab.has(tabKey)) return;
      if (!rk && tid && cid) {
        const old = noRouteBest.get(tabKey);
        const oldScore = old ? itemScore(old, old.client_id) + (String(old.group || "") === "local" ? -30 : 0) : -999999;
        const newScore = itemScore(it, cid) + (String(it.group || "") === "local" ? -30 : 0);
        if (!old || newScore >= oldScore) noRouteBest.set(tabKey, it);
        return;
      }
      filtered.push(it);
    });
    noRouteBest.forEach(function (it) { filtered.push(it); });
    return filtered.sort(function (a,b) {
      return String(a.group||"").localeCompare(String(b.group||"")) || String(a.project||"").localeCompare(String(b.project||"")) || String(a.room_id||"").localeCompare(String(b.room_id||"")) || String(a.client_id||"").localeCompare(String(b.client_id||""));
    });
  }

  function renderClientHeaderBadges(list) {
    const box = $("clientMapHeaderBadges");
    if (!box) return;
    const ranked = { online: 50, bound: 45, reconnecting: 40, unapproved: 30, unknown: 20, limited: 10, offline: 0 };
    const byClient = new Map();
    (list || []).forEach(function (it) {
      const cid = String(it.client_id || it.clientId || "?").toUpperCase();
      if (!cid || cid === "?") return;
      const info = statusInfoFromItem(it) || { key:"unknown", emoji:"⚫", cls:"unknown", text:"unknown" };
      const approved = isApprovedClient(it);
      const score = (approved ? 100 : 0) + (it.can_speak ? 20 : 0) + (ranked[info.key] !== undefined ? ranked[info.key] : (ranked[info.cls] || 0));
      const old = byClient.get(cid);
      if (!old || score > old.score || (score === old.score && String(routeKeyOfItem(it) || "").localeCompare(String(routeKeyOfItem(old.item) || "")) < 0)) {
        byClient.set(cid, { item: it, info: info, score: score, count: old ? old.count + 1 : 1 });
      } else {
        old.count += 1;
      }
    });
    box.innerHTML = "";
    Array.from(byClient.keys()).sort().forEach(function (cid) {
      const row = byClient.get(cid) || {};
      const it = row.item || {};
      const info = row.info || statusInfoFromItem(it) || { emoji:"⚫", cls:"unknown", title:"" };
      const sp = document.createElement("span");
      sp.className = "client-map-mini-badge " + (info.cls || "unknown");
      sp.textContent = (info.emoji || "⚫") + cid + (row.count > 1 ? "×" + row.count : "");
      const routePart = routeKeyOfItem(it) ? (":" + routeKeyOfItem(it)) : "";
      const addr = it.addr || (it.room_id ? ("aiwk://" + it.room_id + "@" + (it.group || "home") + "." + (it.project || "IG_CRM") + "." + (it.client_id || cid) + routePart + "#" + (it.endpoint || "ext4")) : "");
      sp.title = cid + " 대표 상태 / " + (info.text || "") + " / 항목:" + (row.count || 1) + "개 / route:" + (routeKeyOfItem(it) || "-") + " / tab:" + (tabIdOfItem(it) || "-") + " / room:" + (it.room_id || it.roomId || "");
      sp.setAttribute("role", "button");
      sp.dataset.addr = addr;
      sp.addEventListener("click", function () {
        if (addr && $("toInput")) $("toInput").value = addr;
        addChat({ kind:"system", text:"대상 선택: " + cid + " / 대표 " + (info.text || "") + " / 항목:" + (row.count || 1) + "개 / url5:" + (routeKeyOfItem(it) || "-") + " / tab:" + (tabIdOfItem(it) || "-"), to:cid });
      });
      box.appendChild(sp);
    });
  }

  function pcInfoOfItem(it) {
    it = it || {};
    const pc = it.pc_info || it.pc || it.AIWK_PC || it.aiwk_pc || it.device || {};
    const bind = it.pc_binding || it.AIWK_PC_binding || it.room_pc_binding || {};
    const name = String(pc.pc_name || pc.hostname || pc.name || it.pc_name || it.hostname || it.device_label || it.device_name || it.system_hostname || bind.pc_name || bind.current_pc_name || "").trim();
    const hash = String(pc.pc_hash || pc.fingerprint_hash || it.pc_hash || it.fingerprint_hash || bind.pc_hash || bind.current_pc_hash || "").trim();
    const sess = String(pc.session_id || it.session_id || it.pc_session_id || bind.session_id || "").trim();
    const mode = String(bind.mode || pc.mode || it.pc_mode || "single").trim() || "single";
    const maxRaw = bind.max_devices || pc.max_devices || it.max_devices || it.pc_max_devices || 1;
    const maxDevices = Math.max(1, parseInt(maxRaw, 10) || 1);
    const activeRaw = bind.active_count || bind.bound_count || pc.active_count || it.pc_active_count || "";
    const activeCount = Math.max(0, parseInt(activeRaw, 10) || 0);
    const state = String(bind.state || pc.state || it.pc_state || "").trim();
    return {
      pc_name: name || "device-unknown",
      pc_hash: hash,
      pc_hash_short: hash ? hash.replace(/^sha256:/i, "").slice(0, 8) : "",
      session_id: sess,
      mode: mode,
      max_devices: maxDevices,
      active_count: activeCount,
      state: state || "unknown",
      replace_history: bind.replace_history || pc.replace_history || it.replace_history || []
    };
  }

  function pcTreeKeyOfItem(it) {
    const pc = pcInfoOfItem(it);
    return (pc.pc_hash || pc.pc_name || "device-unknown").toLowerCase();
  }

  function sourceBadgeHtml(it) {
    const src = String((it && (it._source || it.source)) || "wss").trim() || "wss";
    const label = src === "service_worker" ? "source:service_worker" : (src === "comm_recent" ? "source:comm_recent" : (src === "chrome.storage" ? "source:chrome.storage" : (src === "stale" ? "source:stale" : "source:wss")));
    return "<span class='muted source-badge' title='이 항목의 표시 출처'>" + htmlEscape(label) + "</span>";
  }

  function pcStatusIcon(pc) {
    if (!pc) return "🔴";
    if (/lock|blocked|mismatch|replaced/i.test(String(pc.state || ""))) return "🔴";
    if (pc.pc_name === "device-unknown" || !pc.pc_hash) return "🟡";
    return "🟢";
  }

  function clientAddrOfItem(it) {
    it = it || {};
    const routePart = routeKeyOfItem(it) ? (":" + routeKeyOfItem(it)) : "";
    return it.addr || (it.room_id ? ("aiwk://" + it.room_id + "@" + (it.group || "home") + "." + (it.project || "IG_CRM") + "." + it.client_id + routePart + "#" + (it.endpoint || "ext4")) : "");
  }

  function makeTreeNode(cls, title, open) {
    const wrap = document.createElement("div");
    wrap.className = "aiwk-tree-node " + cls;
    const head = document.createElement("div");
    head.className = "aiwk-tree-head";
    head.innerHTML = title;
    const body = document.createElement("div");
    body.className = "aiwk-tree-body";
    body.style.display = open === false ? "none" : "block";
    head.addEventListener("click", function (ev) {
      if (ev.target && ev.target.closest && ev.target.closest("button,input,textarea,a")) return;
      body.style.display = body.style.display === "none" ? "block" : "none";
      wrap.classList.toggle("closed", body.style.display === "none");
    });
    wrap.appendChild(head);
    wrap.appendChild(body);
    return { wrap:wrap, head:head, body:body };
  }

  function buildPcTree(list) {
    const pcMap = new Map();
    (list || []).forEach(function (it) {
      const pc = pcInfoOfItem(it);
      const pcKey = pcTreeKeyOfItem(it);
      if (!pcMap.has(pcKey)) pcMap.set(pcKey, { pc:pc, groups:new Map(), clients:0, hashes:new Set() });
      const pcNode = pcMap.get(pcKey);
      pcNode.clients += 1;
      if (pc.pc_hash) pcNode.hashes.add(pc.pc_hash);
      if (pc.pc_hash && !pcNode.pc.pc_hash) pcNode.pc.pc_hash = pc.pc_hash;
      if (pc.pc_hash_short && !pcNode.pc.pc_hash_short) pcNode.pc.pc_hash_short = pc.pc_hash_short;
      if (pc.pc_name && pcNode.pc.pc_name === "device-unknown") pcNode.pc.pc_name = pc.pc_name;
      const gpKey = [it.group || "-", it.project || "-"].join("/");
      if (!pcNode.groups.has(gpKey)) pcNode.groups.set(gpKey, { group:it.group || "-", project:it.project || "-", rooms:new Map() });
      const gp = pcNode.groups.get(gpKey);
      const roomId = String(it.room_id || it.roomId || "room-unknown");
      if (!gp.rooms.has(roomId)) gp.rooms.set(roomId, { room_id:roomId, clients:[] });
      gp.rooms.get(roomId).clients.push(it);
    });
    return Array.from(pcMap.values()).map(function (pcNode) {
      const active = pcNode.pc.active_count || Math.min(pcNode.pc.max_devices || 1, pcNode.hashes.size || (pcNode.clients ? 1 : 0));
      pcNode.pc.active_count = active;
      pcNode.groups = Array.from(pcNode.groups.values()).map(function (gp) {
        gp.rooms = Array.from(gp.rooms.values());
        return gp;
      });
      return pcNode;
    });
  }

  function setPcSummary(tree) {
    const now = $("relayNow");
    if (!now || !tree || !tree.length) return;
    const first = tree[0].pc || {};
    const room = tree[0].groups && tree[0].groups[0] && tree[0].groups[0].rooms && tree[0].groups[0].rooms[0] ? tree[0].groups[0].rooms[0].room_id : "-";
    const gp = tree[0].groups && tree[0].groups[0] ? (tree[0].groups[0].group + "." + tree[0].groups[0].project) : "-";
    now.textContent = new Date().toLocaleTimeString() + " · " + VERSION + " · " + mode() + " · 💻" + (first.pc_name || "device-unknown") + " PC:" + (first.active_count || 0) + "/" + (first.max_devices || 1) + "개 · " + gp + " · " + room;
  }

  function renderClientLeaf(it) {
    const div = document.createElement("div");
    div.className = "aiwk-tree-leaf client-line";
    const addr = clientAddrOfItem(it);
    div.dataset.addr = addr;
    div.dataset.clientId = it.client_id || "";
    div.dataset.tabId = tabIdOfItem(it);
    const info = statusInfoFromItem(it);
    rememberClientStatus(it.client_id || "?", info, "client-map");
    const tabId = tabIdOfItem(it) || "-";
    const routeKey = routeKeyOfItem(it) || "-";
    const cleanTitle = (shortCleanTitle(it) || "-").slice(0, 110);
    const approved = isApprovedClient(it);
    const binding = bindingInfoFromItem(it);
    const pc = pcInfoOfItem(it);
    const pcHint = pc.pc_hash_short ? ("pc:" + pc.pc_hash_short) : "pc:-";
    const sess = pc.session_id ? ("sess:" + pc.session_id.slice(0, 8)) : "";
    const conflictNote = it.conflict_clients && it.conflict_clients.length ? "<div class='small client-conflict'>충돌: 같은 tab_id에 " + htmlEscape(it.conflict_clients.join(",")) + " 잔여</div>" : "";
    div.innerHTML = "<div class='client-top'>" + statusPillHtml(info) + bindingBadgeHtml(binding) + clientBadgeHtml(it.client_id || "?") + "<span class='tab-id-text'>url5:" + htmlEscape(routeKey) + "</span><span class='tab-id-text'>tab:" + htmlEscape(tabId) + "</span>" + sourceBadgeHtml(it) + "<span>" + htmlEscape(it.endpoint || "ext4") + "</span><span class='muted'>" + htmlEscape(pcHint) + "</span>" + (sess ? "<span class='muted'>" + htmlEscape(sess) + "</span>" : "") + (it.can_speak ? "<span class='ok'>token</span>" : "<span class='muted'>limited</span>") + (it.can_execute ? "<span class='warn'>exec</span>" : "") + "<button type='button' class='client-approve-btn " + (approved ? "bound" : "unbound") + "' title='" + htmlEscape(approveButtonTitle(approved)) + "' data-action='toggle-approve'>" + htmlEscape(approveButtonLabel(approved)) + "</button></div>" + conflictNote + "<div class='small client-clean-title'>" + htmlEscape(cleanTitle) + "</div><div class='small client-addr-line'>" + htmlEscape(addr || "") + "</div>";
    div.addEventListener("click", function (ev) {
      if (ev.target && ev.target.getAttribute && ev.target.getAttribute("data-action") === "toggle-approve") return;
      if (addr && $("toInput")) $("toInput").value = addr;
      addChat({ kind: "system", text: "대상 선택: " + (it.client_id || "?") + " / url5:" + routeKey + " / tab:" + tabId + (approved ? " / 승인됨" : " / 미승인·Ping 가능"), to: it.client_id });
    });
    const approveBtn = div.querySelector(".client-approve-btn");
    if (approveBtn) approveBtn.addEventListener("click", function (ev) {
      ev.stopPropagation();
      const next = !isApprovedClient(it);
      const res = setApprovedClient(it, next);
      if (!res || !res.ok) {
        showDanger(bindingFailureText(next ? "묶기/승인" : "묶임 해제", it, res && (res.code || res.reason), res && res.error));
        return;
      }
      addChat({ kind:"system", text:(next ? "묶임 적용: " : "묶임 해제: ") + (it.client_id || "?") + " / url5:" + routeKey + " / tab:" + tabId, statusEmoji: next ? "🔗" : "○" });
      renderClientMap(clientMapItems);
    });
    return div;
  }

  function renderClientMap(items) {
    const box = $("clientMapBox");
    if (!box) return;
    const list = normalizeClientItems(items && items.length ? items : clientMapItems, lastState);
    clientMapItems = list;
    renderClientHeaderBadges(list);
    if (!list.length) { box.textContent = "연결 지도 대기"; return; }
    const tree = buildPcTree(list);
    setPcSummary(tree);
    box.innerHTML = "";
    tree.forEach(function (pcNode) {
      const pc = pcNode.pc || {};
      const pcTitle = htmlEscape(pcStatusIcon(pc) + " 💻 " + (pc.pc_name || "device-unknown") + " · PC:" + (pc.active_count || 0) + "/" + (pc.max_devices || 1) + "개" + (pc.pc_hash_short ? " · hash:" + pc.pc_hash_short : "") + (pc.state && pc.state !== "unknown" ? " · " + pc.state : ""));
      const pcEl = makeTreeNode("level-pc", pcTitle, true);
      (pcNode.groups || []).forEach(function (gp) {
        const gpEl = makeTreeNode("level-group", htmlEscape("🏠 " + (gp.group || "-") + " / " + (gp.project || "-")), true);
        (gp.rooms || []).forEach(function (room) {
          const roomEl = makeTreeNode("level-room", htmlEscape("🔐 " + (room.room_id || "room-unknown")), true);
          (room.clients || []).forEach(function (it) { roomEl.body.appendChild(renderClientLeaf(it)); });
          gpEl.body.appendChild(roomEl.wrap);
        });
        pcEl.body.appendChild(gpEl.wrap);
      });
      box.appendChild(pcEl.wrap);
    });
  }

  async function refresh() {
    try {
      const state = await send({ type: "AIWK_RELAY_GET_STATE" });
      lastState = state;
      renderActive(state);
      renderTabs(state);
      renderFlow(state);
      const fromState = extractClientItemsFromState(state);
      renderClientMap(fromState.length ? fromState : clientMapItems);
      const recent = state && state.active_tab && state.active_tab.comm && state.active_tab.comm.recent_messages ? state.active_tab.comm.recent_messages : [];
      (recent || []).forEach(function (m) {
        const text = m.text || (m.action ? m.action : "");
        if (!text) return;
        addChat({ key: "recent:" + (m.at || "") + ":" + m.from + ":" + text, kind: "peer", from: m.from || "HUB", to: m.to || "", text: text, ts: m.at || Date.now() });
      });
    } catch (err) {
      const box = $("activeTabBox");
      if (box) box.textContent = "Relay 상태 확인 실패: " + (err && err.message ? err.message : String(err));
    }
  }

  function activeProfile() {
    const t = lastState && lastState.active_tab ? lastState.active_tab : {};
    const route = t.comm && t.comm.route ? t.comm.route : {};
    return { group: t.group || "home", project: t.project || "IG_CRM", roomId: t.room_id || "RE5ay2jRmY", clientId: t.client_id || displayClient(t) || "C1", tabId: String(t.tab_id || ""), routeKey: String(route.route_key || route.url_key || "") };
  }
  async function sendActiveComm(action, extra) {
    const p = activeProfile();
    const msg = { type: "AIWK_POPUP_TO_ACTIVE_TAB", target_tab_id: p.tabId || "", message: { type: "AIWK_EXT4_COMM_CONTROL", action: action, skip_config: true, target_tab_id: p.tabId || "", extra: extra || {} } };
    return await send(msg);
  }
  async function requestClientMap() {
    const res = await sendActiveComm("list_clients_send", { scope: "all" });
    if (res && res.ok) {
      addChat({ kind: "system", text: "연결 지도 전체 불러오기 요청: OK" });
    } else {
      const why = ((res && (res.code || res.error)) || "unknown");
      const unsupported = /TARGET_TAB_NOT_USABLE|chrome:\/\/newtab|chrome:\/\//i.test(String(why));
      addChat({ kind: "system", text: (unsupported ? "INFO ACTIVE_TAB_UNSUPPORTED / " : "ERR CLIENT_MAP_REQUEST_FAIL / ") + why + (unsupported ? " / chrome://newtab 등 지원불가 페이지에서는 content.js 메시지를 보내지 않습니다." : " / 해결: 활성 탭 새로고침, WSS 연결, content.js 주입 상태 확인") });
    }
    setTimeout(refresh, 700);
    return res;
  }
  function findClientByTarget(target) {
    target = String(target || "");
    const cidMatch = target.match(/\.([A-Za-z]+[0-9]{1,6})(?::|#|$)/) || target.match(/\b([A-Za-z]+[0-9]{1,6})\b/);
    const cid = cidMatch ? String(cidMatch[1] || "").toUpperCase() : "";
    for (const it of clientMapItems || []) {
      const routePart = routeKeyOfItem(it) ? (":" + routeKeyOfItem(it)) : "";
      const addr = it.addr || (it.room_id ? ("aiwk://" + it.room_id + "@" + (it.group || "home") + "." + (it.project || "IG_CRM") + "." + it.client_id + routePart + "#" + (it.endpoint || "ext4")) : "");
      if (addr && target === addr) return it;
      if (cid && String(it.client_id || "").toUpperCase() === cid) return it;
    }
    return null;
  }
  function canChatTarget(target) {
    const it = findClientByTarget(target);
    if (!it) return { ok:false, reason:"대상이 연결 지도에 없습니다. 먼저 전체 불러오기 후 묶기/승인하십시오." };
    if (!isApprovedClient(it)) return { ok:false, reason:"채팅 승인 필요: " + (it.client_id || "?") + " / url5:" + (routeKeyOfItem(it) || "-") + " / tab:" + (tabIdOfItem(it) || "-") + " 는 Ping만 가능합니다." };
    return { ok:true, item:it };
  }
  async function sendChat(kind) {
    const target = String($("toInput") && $("toInput").value || "").trim();
    const text = String($("msgInput") && $("msgInput").value || "");
    if (!target) { $("sendStatus").textContent = "to가 비었습니다."; return; }
    if (kind === "chat" && !text.trim()) { $("sendStatus").textContent = "메시지가 비었습니다."; return; }
    if (kind === "chat") {
      const gate = canChatTarget(target);
      if (!gate.ok) {
        $("sendStatus").textContent = gate.reason;
        addChat({ kind:"system", text:gate.reason, statusEmoji:"🟡" });
        return;
      }
    }
    addChat({ kind: kind === "ping" ? "system" : "me", to: target, text: kind === "ping" ? "Ping 요청" : text });
    const res = await sendActiveComm(kind === "ping" ? "ping_send" : "chat_send", { to: target, text: text });
    $("sendStatus").textContent = (kind === "ping" ? "Ping" : "전송") + " 요청: " + (res && res.ok ? "OK" : "확인 필요") + " / " + new Date().toLocaleTimeString();
    if (kind === "chat" && $("msgInput")) $("msgInput").value = "";
    setTimeout(refresh, 300);
  }
  async function stopAll() {
    const res = await send({ type: "AIWK_RELAY_STOP_ALL", reason: "relay-panel" });
    $("sendStatus").textContent = "전체 루프 중단 요청: " + ((res && res.count) || 0) + " tabs";
    setTimeout(refresh, 500);
  }
  async function resetCounters() {
    const res = await send({ type: "AIWK_RELAY_RESET_COUNTERS", reason: "relay-panel" });
    $("sendStatus").textContent = "R/T 리셋 요청: " + ((res && res.count) || 0) + " tabs";
    setTimeout(refresh, 500);
  }
  async function copyLog() {
    const text = JSON.stringify(lastState || {}, null, 2);
    try { await navigator.clipboard.writeText(text); $("sendStatus").textContent = "로그 복사 완료 len=" + text.length; }
    catch (err) { $("sendStatus").textContent = "로그 복사 실패: " + (err && err.message ? err.message : String(err)); }
  }
  function toggleSection(id) {
    const el = $(id);
    if (!el) return;
    el.classList.toggle("collapsed");
  }
  function bind() {
    $("refreshBtn").addEventListener("click", refresh);
    $("stopAllBtn").addEventListener("click", stopAll);
    $("resetCountersBtn").addEventListener("click", resetCounters);
    $("copyLogBtn").addEventListener("click", copyLog);
    $("sendChatBtn").addEventListener("click", () => sendChat("chat"));
    $("sendPingBtn").addEventListener("click", () => sendChat("ping"));
    $("refreshClientsBtn").addEventListener("click", requestClientMap);
    $("toN1Btn").addEventListener("click", () => { const p = activeProfile(); $("toInput").value = "aiwk://" + p.roomId + "@" + p.group + "." + p.project + ".N1:nodrd#node-red"; });
    $("toActiveBtn").addEventListener("click", () => { const p = activeProfile(); const r = lastState && lastState.active_tab && lastState.active_tab.comm && lastState.active_tab.comm.route ? lastState.active_tab.comm.route : {}; const rk = String(r.route_key || r.url_key || ""); $("toInput").value = "aiwk://" + p.roomId + "@" + p.group + "." + p.project + "." + p.clientId + (rk ? ":" + rk : "") + "#ext4"; });
    $("msgInput").addEventListener("keydown", function (ev) {
      if (ev.key === "Enter" && !ev.shiftKey) {
        ev.preventDefault();
        sendChat("chat");
      }
    });
    document.querySelectorAll(".section-toggle").forEach(function (btn) {
      btn.addEventListener("click", function () { toggleSection(btn.getAttribute("data-target")); });
    });
    const attach = $("attachSideBtn");
    const detach = $("detachPopupBtn");
    const close = $("closeRelayBtn");
    if (mode() === "sidepanel") {
      attach.style.display = "none";
      detach.style.display = "inline-flex";
    } else {
      attach.style.display = "inline-flex";
      detach.style.display = "none";
    }
    attach.addEventListener("click", async function () { await send({ type: "AIWK_RELAY_OPEN_SIDEPANEL" }); });
    detach.addEventListener("click", async function () { await send({ type: "AIWK_RELAY_DETACH_POPUP" }); });
    close.addEventListener("click", function () { window.close(); });
    chrome.runtime.onMessage.addListener(function (msg) { if (msg && msg.type === "AIWK_RELAY_STATE_CHANGED") refresh(); });
  }
  document.addEventListener("DOMContentLoaded", function () {
    bind();
    refresh();
    setTimeout(function () { requestClientMap().catch(function (err) { addChat({ kind:"system", text:"ERR CLIENT_MAP_TIMER_FAIL / " + (err && err.message ? err.message : String(err)) }); }); }, 900);
    timer = setInterval(refresh, 2000);
    clientRefreshTimer = setInterval(function () { requestClientMap().catch(function (err) { addChat({ kind:"system", text:"ERR CLIENT_MAP_INTERVAL_FAIL / " + (err && err.message ? err.message : String(err)) }); }); }, 15000);
  });
  window.addEventListener("unload", function () { if (timer) clearInterval(timer); if (clientRefreshTimer) clearInterval(clientRefreshTimer); });
}());
