// /AIWK_COMMON/core/aiwk_comm.js
// 용도: EXT4 content context의 WSS/BUS/Node-RED 통신 상태 관리, 인증 등록, NR Ping 분리 처리, compact 진단 상태 제공.
// AIWK EXT4 v157-heart-title-sticky-common
(function () {
    "use strict";

    const AIWK = window.AIWK;
    const KEY_WSS_AUTO = "AIWK_COMM_WSS_AUTO_V1";
    const KEY_WSS_RECONNECT_MS = "AIWK_COMM_WSS_RECONNECT_MS_V1";
    const KEY_BUS_AUTO = "AIWK_COMM_BUS_AUTO_V1";
    const KEY_BUS_KEEP_MS = "AIWK_COMM_BUS_KEEP_MS_V1";
    const KEY_NR_AUTO = "AIWK_COMM_NR_AUTO_V1";
    const KEY_LOG_ENABLED = "AIWK_COMM_LOG_ENABLED_V1";
    const DEFAULT_WS_URL = "wss://n.yjm.kr/aiwk/ws";
    const DEFAULT_CLIENT_ID = "C1";
    const MAX_LOG_LINES = 500;
    const PROFILE_INSTANCE_KEY = "AIWK_PROFILE_INSTANCE_ID";

    function nowIso() { return new Date().toISOString(); }
    function uid(prefix) { return String(prefix || "aiwk") + "_" + Date.now() + "_" + Math.floor(Math.random() * 100000); }
    function uidWithTab(prefix) {
        const tid = safeString(state && state.tabId || "");
        return String(prefix || "wss") + (tid ? "_tab" + tid : "_tabMISSING") + "_" + Date.now() + "_" + Math.floor(Math.random() * 100000);
    }
    function safeString(v) { return String(v === undefined || v === null ? "" : v); }
    function safeJson(v) { try { return JSON.stringify(v || {}, null, 2); } catch (e) { return String(v); } }
    function trimLines(text, maxLines) {
        const lines = String(text || "").split(/\r\n|\r|\n/);
        const max = parseInt(maxLines || MAX_LOG_LINES, 10) || MAX_LOG_LINES;
        if (lines.length <= max) return String(text || "");
        return lines.slice(lines.length - max).join("\n");
    }
    function roomKey(profile) {
        profile = profile || getProfile();
        return [profile.group || "home", profile.project || "IG_CRM", profile.roomId || profile.room || ""].join(".");
    }
    function endpoint(endpointName) {
        const p = getProfile();
        const ep = safeString(endpointName || "main").replace(/^#/, "") || "main";
        const rk = getUrlRouteIdentity().route_key;
        return [p.group || "home", p.project || "IG_CRM", p.clientId || DEFAULT_CLIENT_ID].join(".") + (rk ? ":" + rk : "") + "#" + ep;
    }
    function fallbackHash5(text) {
        let h = 0;
        text = safeString(text || "");
        for (let i = 0; i < text.length; i += 1) h = ((h << 5) - h + text.charCodeAt(i)) | 0;
        return ("00000" + (h >>> 0).toString(16)).slice(-5);
    }
    function getUrlRouteIdentity() {
        const href = safeString((typeof location !== "undefined" && location.href) || "");
        let ctx = null;
        try {
            if (window.AIWK && AIWK.urlContext && typeof AIWK.urlContext.makeContext === "function") ctx = AIWK.urlContext.makeContext(href);
        } catch (err) { ctx = null; }
        const urlMd5 = safeString(ctx && ctx.url_md5 || "");
        const urlKey = safeString(ctx && ctx.url_key || (urlMd5 ? urlMd5.slice(0, 5) : "") || fallbackHash5(href)).slice(0, 5);
        return {
            url: href,
            url_key: urlKey,
            route_key: safeString(ctx && ctx.route_key || urlKey).slice(0, 32),
            domain_route_key: safeString(ctx && ctx.domain_route_key || "")
        };
    }
    function getProfile() {
        let p = {};
        try { p = (typeof state !== "undefined" && state.profileOverride) ? state.profileOverride : {}; } catch (err) { p = {}; }
        return {
            wsUrl: safeString(p.wsUrl || DEFAULT_WS_URL) || DEFAULT_WS_URL,
            clientId: safeString(p.clientId || DEFAULT_CLIENT_ID) || DEFAULT_CLIENT_ID,
            role: safeString(p.role || p.authRole || (AIWK.auth && AIWK.auth.role) || "guest") || "guest",
            group: safeString(p.group || "home") || "home",
            project: safeString(p.project || "IG_CRM") || "IG_CRM",
            roomId: safeString(p.roomId || p.room || "") || "",
            authToken: safeString(p.authToken || p.token || "") || "",
            authRole: safeString(p.authRole || "") || "",
            authUserType: safeString(p.authUserType || "") || "",
            profileInstanceId: safeString(p.profileInstanceId || p.profile_instance_id || state.profileInstanceId || "") || ""
        };
    }
    function redactedProfile() {
        const p = getProfile();
        if (p.authToken) p.authToken = "***";
        return p;
    }
    function profileIdentity(p) {
        p = p || getProfile();
        return [p.group || "home", p.project || "IG_CRM", p.roomId || "", p.clientId || DEFAULT_CLIENT_ID].join("|");
    }
    function registerIdentity(reg) {
        reg = reg || {};
        return [reg.group || "home", reg.project || "IG_CRM", reg.room_id || "", reg.client_id || DEFAULT_CLIENT_ID].join("|");
    }
    function isPermanentAuthBlock(reason) {
        reason = safeString(reason || "").toLowerCase();
        return /auth_required|token_missing|token_format_invalid|token_signature_invalid|token_expired|token_not_yet_valid|token_revoked|connect_not_allowed|target_not_allowed|resolved_target_not_allowed|sender_muted/.test(reason);
    }
    function authBlockHelp(reason) {
        reason = safeString(reason || "");
        if (/token_missing|auth_required/i.test(reason)) return "WSS 등록 자체의 token 또는 현재 client 권한 문제입니다. 접속창에서 client_id/token을 확인한 뒤 [프로필 저장] -> [WSS 인증등록]을 다시 실행하십시오.";
        if (/token_/i.test(reason)) return "token 형식/서명/만료 상태를 확인한 뒤 새 token으로 저장하고 WSS 인증등록을 다시 실행하십시오.";
        if (/connect_not_allowed|target_not_allowed|resolved_target_not_allowed/i.test(reason)) return "token의 접속/전송 허용 대상에 현재 client 또는 대상이 없습니다. allow_targets/connect_targets를 확인하십시오.";
        if (/sender_muted/i.test(reason)) return "현재 client가 speak 권한이 없습니다. 권한 설정을 확인하십시오.";
        return "재시도로 해결되지 않는 인증/권한 오류입니다. 설정을 수정한 뒤 수동으로 다시 연결하십시오.";
    }

    function commandBlockHelp(reason, msg) {
        reason = safeString(reason || "auth_required");
        msg = msg || {};
        const from = safeString(msg.from || endpoint("ext4"));
        const to = safeString(msg.to || "");
        const act = safeString(msg.act || msg.action || msg.aiwk_action || "");
        if (/auth_required|token_missing/i.test(reason)) return "CMD 송신 권한 문제입니다. WSS 소켓 자체는 유지하고, 보낸 쪽 client의 [프로필 저장] -> [WSS 인증등록]을 다시 실행하십시오. from=" + from + (to ? " / to=" + to : "") + (act ? " / act=" + act : "");
        return "CMD 전송이 서버에서 차단되었습니다. from/to/act/reason을 확인하십시오. from=" + from + (to ? " / to=" + to : "") + (act ? " / act=" + act : "");
    }
    function isNodeRedCommand(msg) {
        msg = msg || {};
        const to = safeString(msg.to || msg.target || "");
        const act = safeString(msg.act || msg.action || msg.aiwk_action || "");
        return /node-red|\.N1|N1:nodrd|#node-red/i.test(to) || /aiwk_ping|nodered|node-red/i.test(act);
    }
    function markCommandBlock(reason, msg) {
        reason = safeString(reason || "auth_required");
        msg = msg || {};
        state.cmdBlock = {
            reason: reason,
            source: safeString(msg.type || msg.ev || "command_ack"),
            from: safeString(msg.from || endpoint("ext4")),
            to: safeString(msg.to || ""),
            act: safeString(msg.act || msg.action || msg.aiwk_action || ""),
            at: Date.now(),
            help: commandBlockHelp(reason, msg),
            nr_related: isNodeRedCommand(msg) ? 1 : 0
        };
        if (state.cmdBlock.nr_related) {
            stopNrKeepTimer();
            state.nr.auto_keep = false;
            state.nr.status = "CMD차단";
            state.nr.last_ok = false;
            state.nr.last_error = reason;
            try { AIWK.store.set(KEY_NR_AUTO, false); } catch (err) {}
        }
        state.lastError = state.cmdBlock.nr_related ? "" : (reason + " / " + commandBlockHelp(reason, msg));
        appendLog(state.cmdBlock.nr_related ? "NR CMD 차단" : "WSS CMD 차단", state.cmdBlock);
        emitStatus();
        return { ok: 0, blocked: 1, cmd_block: 1, reason: reason, help: state.cmdBlock.help };
    }

    function blockAutoRetry(reason, source) {
        reason = safeString(reason || "auth_required");
        clearReconnect();
        stopNrKeepTimer();
        state.wsAutoReconnect = false;
        state.nr.auto_keep = false;
        state.nr.status = "중단";
        state.nr.last_ok = false;
        state.nr.last_error = reason;
        state.lastError = reason + " / " + authBlockHelp(reason);
        try {
            AIWK.store.set(KEY_WSS_AUTO, false);
            AIWK.store.set(KEY_NR_AUTO, false);
        } catch (err) {}
        appendLog("WSS 인증 실패 · 자동 재시도 중단", { reason: reason, source: source || "", help: authBlockHelp(reason), profile: redactedProfile() });
        emitStatus();
        return { ok: 1, blocked: 1, reason: reason, help: authBlockHelp(reason) };
    }
    function clearAuthBlock(reason) {
        if (isPermanentAuthBlock(state.lastError || "") || isPermanentAuthBlock(state.wssAuth.auth_reason || "")) {
            state.lastError = "";
            state.wssAuth.auth_reason = "";
            state.nr.last_error = "";
            state.cmdBlock = null;
            state.nr.status = "대기";
            appendLog("WSS 인증 차단 상태 초기화", { reason: reason || "manual_retry" });
            emitStatus();
        }
    }
    function setProfile(profile, reason) {
        profile = profile || {};
        if (!/off|stop|closed/i.test(String(reason || ""))) state.hardStopped = false;
        state.profileOverride = {
            wsUrl: profile.wsUrl || DEFAULT_WS_URL,
            clientId: profile.clientId || profile.client_id || "",
            role: profile.role || "",
            group: profile.group || "home",
            project: profile.project || "IG_CRM",
            roomId: profile.roomId || profile.room || "",
            authToken: profile.authToken || profile.token || "",
            token: profile.authToken || profile.token || "",
            authRole: profile.authRole || "",
            authMode: profile.authMode || "",
            authUserType: profile.authUserType || "",
            profileInstanceId: profile.profileInstanceId || profile.profile_instance_id || state.profileInstanceId || ""
        };
        appendLog("profile 적용", { reason: reason || "setProfile", clientId: state.profileOverride.clientId || "", group: state.profileOverride.group || "", project: state.profileOverride.project || "", roomId: state.profileOverride.roomId || "", profile_store:"runtime_only_v152" });
        clearAuthBlock(reason || "setProfile");
        updateWindowTitle();
        emitStatus();
        return { ok: 1, profile: redactedProfile() };
    }


    function readStoredHeartPrefix() {
        try { return sessionStorage.getItem("AIWK_HEART_PREFIX_STICKY_V157") || ""; } catch (err) { return ""; }
    }
    function writeStoredHeartPrefix(prefix) {
        try {
            const v = safeString(prefix || "").trim();
            if (v) sessionStorage.setItem("AIWK_HEART_PREFIX_STICKY_V157", v);
        } catch (err) {}
    }

    const state = {
        tabId: "",
        profileInstanceId: "",
        tabInbox: [],
        wssInbox: [],
        ws: null,
        wsOpen: false,
        wsConnecting: false,
        wsCloseExpected: false,
        wsAutoReconnect: false,
        reconnectMs: 3000,
        reconnectTimer: null,
        reconnectAttempts: 0,
        reconnectMaxAttempts: 3,
        lastError: "",
        lastCloseCode: 0,
        lastCloseReason: "",
        duplicateRoute: false,
        lastRegister: null,
        lastReceivedAt: 0,
        lastSentAt: 0,
        lastRoundtripAt: 0,
        lastRoundtripAction: "",
        wssVerifiedOk: false,
        wssAuth: {
            auth_token_ok: false,
            auth_level: "",
            auth_reason: "",
            can_speak: false,
            can_receive: false,
            can_execute: false,
            client_count: 0
        },
        bus: {
            registered: false,
            last_ok: false,
            last_error: "",
            client_count: 0,
            last_seen: 0,
            auto_keep: false,
            keep_timer: null,
            keep_ms: 10000
        },
        nr: {
            status: "끊김",
            last_ok: false,
            last_error: "",
            last_sent_at: 0,
            last_received_at: 0,
            auto_keep: false,
            keep_timer: null
        },
        logEnabled: true,
        counters: {
            rx: 0,
            tx: 0,
            wss_rx: 0,
            wss_tx: 0,
            bus_rx: 0,
            bus_tx: 0,
            nr_rx: 0,
            nr_tx: 0
        },
        titleOriginal: "",
        heartPrefixSticky: readStoredHeartPrefix(),
        cmdBlock: null,
        titleLast: "",
        tabProfileActive: false,
        tabProfileReason: "boot",
        profileOverride: null,
        hardStopped: false
    };

    function makeProfileInstanceId() {
        return "pf_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 10);
    }
    async function getProfileInstanceId() {
        if (state.profileInstanceId) return state.profileInstanceId;
        if (!chrome || !chrome.storage || !chrome.storage.local) {
            throw new Error("AIWK_PROFILE_INSTANCE_STORAGE_MISSING: chrome.storage.local 없음");
        }
        const obj = await chrome.storage.local.get([PROFILE_INSTANCE_KEY]);
        let id = safeString(obj && obj[PROFILE_INSTANCE_KEY] || "").trim();
        if (!id) {
            id = makeProfileInstanceId();
            await chrome.storage.local.set({ [PROFILE_INSTANCE_KEY]: id });
        }
        state.profileInstanceId = id;
        return id;
    }
    function requirePacketIdentity(p, stage) {
        p = p || getProfile();
        const tabId = safeString(state.tabId || "").trim();
        const pf = safeString(state.profileInstanceId || p.profileInstanceId || "").trim();
        const client = safeString(p.clientId || "").trim();
        if (!tabId) throw new Error("AIWK_TAB_ID_MISSING: " + (stage || "wss") + " / WSS packet에는 tab_id가 필수입니다.");
        if (!pf) throw new Error("AIWK_PROFILE_INSTANCE_ID_MISSING: " + (stage || "wss") + " / WSS packet에는 profile_instance_id가 필수입니다.");
        if (!client) throw new Error("AIWK_CLIENT_ID_MISSING: " + (stage || "wss") + " / WSS packet에는 client_id가 필수입니다.");
        return { tab_id: tabId, profile_instance_id: pf, client_id: client };
    }
    function enrichWssPacket(packet, fromEndpoint) {
        packet = packet || {};
        const p = getProfile();
        const idn = requirePacketIdentity(p, packet.type || "wss_packet");
        const epName = safeString(fromEndpoint || packet.endpoint || "ext4").replace(/^#/, "") || "ext4";
        const rid = getUrlRouteIdentity();
        packet.group = packet.group || p.group || "home";
        packet.project = packet.project || p.project || "IG_CRM";
        packet.room_id = packet.room_id || p.roomId || "";
        packet.client_id = packet.client_id || idn.client_id;
        packet.from_client = packet.from_client || idn.client_id;
        packet.route_key = packet.route_key || rid.route_key;
        packet.url_key = packet.url_key || rid.url_key;
        packet.tab_id = packet.tab_id || idn.tab_id;
        packet.from_tab_id = packet.from_tab_id || idn.tab_id;
        packet.profile_instance_id = packet.profile_instance_id || idn.profile_instance_id;
        packet.endpoint = packet.endpoint || epName;
        packet.from_endpoint = packet.from_endpoint || epName;
        packet.debug_title = packet.debug_title || document.title || "";
        packet.debug_url = packet.debug_url || location.href || "";
        if (!packet.from || packet.from === "ext4") packet.from = endpoint(epName);
        return packet;
    }
    function getLogEnabled() { return !!state.logEnabled; }
    function setLogEnabled(flag) {
        state.logEnabled = !!flag;
        try { AIWK.store.set(KEY_LOG_ENABLED, state.logEnabled); } catch (err) {}
        emitStatus();
        return { ok: 1, log_enabled: state.logEnabled };
    }

    function pad4(n) { return String(Math.max(0, parseInt(n || 0, 10) || 0)).padStart(4, "0"); }
    const AIWK_HEART_TOKEN_RE = /^(?:❤\ufe0f?|♥\ufe0f?|♡\ufe0f?|❣\ufe0f?|💛|💚|💙|💜|🧡|🖤|🤍|🤎|💖|💗|💓|💞|💕|💘|💝|💟)/u;
    const AIWK_STATUS_TOKEN_GROUP = "(?:🟢|🟡|🔴|⚫|♻\ufe0f?|⚠\ufe0f?|⏹\ufe0f?)";
    const AIWK_STATUS_PREFIX_PATTERN = "(?:" + AIWK_STATUS_TOKEN_GROUP + "\\s*){1,8}";
    const AIWK_TITLE_PREFIX_WITH_COUNTERS_RE = new RegExp("^\\s*" + AIWK_STATUS_PREFIX_PATTERN + "[A-Za-z][A-Za-z0-9_-]{0,31}\\s+R:\\d{1,8}\\s+T:\\d{1,8}\\s*[-–]\\s*", "u");
    const AIWK_TITLE_PREFIX_SIMPLE_RE = new RegExp("^\\s*" + AIWK_STATUS_PREFIX_PATTERN + "[A-Za-z][A-Za-z0-9_-]{0,31}\\s*[-–]\\s*", "u");
    const AIWK_TITLE_TAIL_WITH_COUNTERS_RE = new RegExp("\\s*[-–]\\s*" + AIWK_STATUS_PREFIX_PATTERN + "[A-Za-z][A-Za-z0-9_-]{0,31}\\s+R:\\d{1,8}\\s+T:\\d{1,8}.*$", "u");
    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 cleanAiwkTitleBody(title, hasPreservedHeart) {
        let out = String(title || "").trim();
        for (let i = 0; i < 12; i++) {
            const before = out;
            out = out
                .replace(AIWK_TITLE_PREFIX_WITH_COUNTERS_RE, "")
                .replace(AIWK_TITLE_PREFIX_SIMPLE_RE, "")
                .replace(AIWK_TITLE_TAIL_WITH_COUNTERS_RE, "")
                .trim();

            const nestedHeart = readLeadingHeartTriple(out);
            if (nestedHeart.prefix) {
                const rest = String(nestedHeart.rest || "").trim();
                // v156: 이미 보존할 Heart prefix가 있거나, Heart 뒤에 AIWK prefix가 붙은 중복 title이면
                // 내부 Heart 3개는 제거하고 다음 루프에서 AIWK prefix를 다시 제거한다.
                if (hasPreservedHeart || AIWK_TITLE_PREFIX_WITH_COUNTERS_RE.test(rest) || AIWK_TITLE_PREFIX_SIMPLE_RE.test(rest)) {
                    out = rest;
                }
            }
            if (out === before) break;
        }
        return out.trim();
    }
    function titleApi() { return window.AIWK_TITLE || (AIWK && AIWK.title) || null; }
    function rememberHeartPrefix(title) {
        const T = titleApi();
        const heart = T ? T.readLeadingHeartTriple(title) : readLeadingHeartTriple(title);
        if (heart && heart.prefix) { state.heartPrefixSticky = heart.prefix; writeStoredHeartPrefix(heart.prefix); }
        return state.heartPrefixSticky || "";
    }
    function parsedTitle(title) {
        const T = titleApi();
        if (T && typeof T.parse === "function") return T.parse(title);
        const heart = readLeadingHeartTriple(title);
        const out = cleanAiwkTitleBody(heart.prefix ? heart.rest : String(title || ""), !!heart.prefix);
        return { heartPrefix: heart.prefix, cleanTitle: out, title: joinHeartPrefix(heart.prefix, out), clientId: "", counters: null };
    }
    function joinStickyHeart(title) {
        const T = titleApi();
        const clean = String(title || "").trim();
        const sticky = state.heartPrefixSticky || "";
        if (!sticky) return clean;
        return T ? T.joinHeartPrefix(sticky, clean) : joinHeartPrefix(sticky, clean);
    }
    function stripAiwkTitlePrefix(title) {
        const p = parsedTitle(title);
        if (p.heartPrefix) { state.heartPrefixSticky = p.heartPrefix; writeStoredHeartPrefix(p.heartPrefix); }
        const T = titleApi();
        const clean = String(p.cleanTitle || "").trim();
        const hp = p.heartPrefix || "";
        return T ? T.joinHeartPrefix(hp, clean) : joinHeartPrefix(hp, clean);
    }
    function isTabClientActive() {
        return !!state.tabProfileActive;
    }
    function setTabClientActive(flag, reason) {
        state.tabProfileActive = !!flag;
        state.tabProfileReason = safeString(reason || (flag ? "active" : "inactive"));
        updateWindowTitle();
        emitStatus();
        if (state.tabProfileActive) {
            getTabId().then(function () {
                try { if (state.bus.auto_keep) startBusKeepTimer(); } catch (err) {}
                try { if (state.wsAutoReconnect && !state.wsOpen && !state.wsConnecting) scheduleReconnect(state.reconnectMs); } catch (err) {}
            }).catch(function () {});
        } else {
            stopBusKeepTimer();
            stopNrKeepTimer();
        }
        return { ok: 1, active: state.tabProfileActive, reason: state.tabProfileReason };
    }
    function formatAiwkBrowserTitle(baseTitle) {
        const p = getProfile();
        const cid = safeString(p.clientId || "") || "AIWK";
        const parsed = parsedTitle(String(baseTitle || "AIWK"));
        if (parsed.heartPrefix) { state.heartPrefixSticky = parsed.heartPrefix; writeStoredHeartPrefix(parsed.heartPrefix); }
        const heartPrefix = parsed.heartPrefix || state.heartPrefixSticky || "";
        const cleanBase = String(parsed.cleanTitle || "AIWK").trim() || "AIWK";
        const T = titleApi();
        if (T && typeof T.format === "function") {
            return T.format({ heartPrefix: heartPrefix, statusTriple: statusEmojiTriple(), clientId: cid, rx: state.counters.rx, tx: state.counters.tx, baseTitle: cleanBase });
        }
        const aiwkTitle = statusEmojiTriple() + " " + cid + " R:" + pad4(state.counters.rx) + " T:" + pad4(state.counters.tx) + " - " + cleanBase;
        return joinHeartPrefix(heartPrefix, aiwkTitle);
    }
    function statusEmojiByLevel(level) {
        if (level === "ok") return "🟢";
        if (level === "warn" || level === "off") return "🟡";
        if (level === "replaced") return "♻️";
        if (level === "unknown") return "⚫";
        return "🔴";
    }
    function wssEmoji() {
        const duplicate = !!state.duplicateRoute || /same_route_client_reconnected|replaced/i.test(String(state.lastError || state.lastCloseReason || ""));
        if (duplicate) return "♻️";
        if (state.wsOpen) return "🟢";
        if (state.wsConnecting || state.wsAutoReconnect) return "🟡";
        return "🔴";
    }
    function busEmoji() {
        if (state.bus && state.bus.registered) return "🟢";
        if (state.bus && state.bus.last_ok) return "🟡";
        return "🔴";
    }
    function nrEmoji() {
        if (state.nr && state.nr.last_ok) return "🟢";
        if (state.nr && state.nr.auto_keep) return "🟡";
        return "🔴";
    }
    function statusEmojiTriple() {
        return wssEmoji() + busEmoji() + nrEmoji();
    }
    function updateWindowTitle() {
        const current = String(document.title || "");
        const currentParsed = parsedTitle(current);
        if (currentParsed.heartPrefix) { state.heartPrefixSticky = currentParsed.heartPrefix; writeStoredHeartPrefix(currentParsed.heartPrefix); }

        const originalParsed = parsedTitle(state.titleOriginal || "");
        if (originalParsed.heartPrefix) { state.heartPrefixSticky = originalParsed.heartPrefix; writeStoredHeartPrefix(originalParsed.heartPrefix); }

        const currentCleanBody = String(currentParsed.cleanTitle || "").trim();
        const originalCleanBody = String(originalParsed.cleanTitle || "").trim();
        const cleanCurrent = currentCleanBody ? joinStickyHeart(currentCleanBody) : "";
        const cleanOriginal = originalCleanBody ? joinStickyHeart(originalCleanBody) : "";

        if (state.hardStopped || !state.tabProfileActive) {
            const base0 = cleanCurrent || cleanOriginal || joinStickyHeart(current || "AIWK");
            state.titleLast = base0;
            if (document.title !== base0) document.title = base0;
            return;
        }

        const base = cleanCurrent || cleanOriginal || joinStickyHeart("AIWK");
        // v157: 현재 title이 AIWK prefix만 남은 순간에는 원본 title을 덮어쓰지 않는다.
        // cleanCurrent가 있을 때만 state.titleOriginal을 갱신하고, 하트 3개는 sticky로 유지한다.
        if (currentCleanBody && (!state.titleOriginal || state.titleLast !== current || stripAiwkTitlePrefix(state.titleOriginal) !== state.titleOriginal)) {
            state.titleOriginal = base || state.titleOriginal || joinStickyHeart("AIWK");
        }

        const next = formatAiwkBrowserTitle(base || state.titleOriginal || current || "AIWK");
        state.titleLast = next;
        if (document.title !== next) document.title = next;
    }
    function bumpCounter(kind, subkind) {
        if (kind === "rx") state.counters.rx += 1;
        if (kind === "tx") state.counters.tx += 1;
        if (subkind && state.counters[subkind + "_" + kind] !== undefined) state.counters[subkind + "_" + kind] += 1;
        updateWindowTitle();
    }
    function resetCounters() {
        Object.keys(state.counters).forEach(function(k){ state.counters[k] = 0; });
        updateWindowTitle();
        emitStatus();
        appendLog("송수신 카운트 리셋", { counters: state.counters });
        return { ok: 1, counters: Object.assign({}, state.counters) };
    }
    function emitStatus() {
        try { updateWindowTitle(); } catch (err) {}
        try { AIWK.events.emit("comm:status", status()); } catch (err) {}
    }

    function appendLog(name, obj) {
        const text = "[" + nowIso() + "] " + name + "\n" + (typeof obj === "string" ? obj : safeJson(obj || {}));
        if (!state.logEnabled) return text;
        try {
            if (window.AIWK_LOG && typeof window.AIWK_LOG.appendResult === "function") {
                window.AIWK_LOG.appendResult("COMM", text);
            }
        } catch (err) {}
        return text;
    }

    function appendToBox(box, header, obj, enabled, maxLines) {
        if (!box || enabled === false) return;
        const block = "[" + nowIso() + "] " + header + "\n" + (typeof obj === "string" ? obj : safeJson(obj || {}));
        const old = String(box.textContent || "");
        box.textContent = trimLines(block + "\n\n" + old, maxLines || MAX_LOG_LINES);
    }

    function levelText(level) {
        if (level === "ok") return "연결";
        if (level === "warn") return "주의";
        return "끊김";
    }
    function wsStateText() {
        if (state.wsOpen) return "연결됨";
        if (state.wsConnecting) return "연결중";
        return "끊김";
    }
    function commIndicators() {
        let wssLevel = "bad";
        let wssText = "끊김";
        let wssTip = "WSS가 연결되지 않았습니다.";
        if (state.wsOpen) {
            if (state.wssAuth.auth_token_ok || state.wssAuth.can_speak || state.wssAuth.can_receive) {
                wssLevel = state.wssAuth.auth_token_ok || state.wssAuth.can_speak ? "ok" : "warn";
                wssText = state.wssAuth.auth_token_ok ? "연결" : "주의";
                wssTip = "WSS 연결됨 / auth=" + (state.wssAuth.auth_token_ok ? "OK" : "NO") + " / level=" + (state.wssAuth.auth_level || "") + " / speak=" + (state.wssAuth.can_speak ? "Y" : "N") + " / receive=" + (state.wssAuth.can_receive ? "Y" : "N") + " / clients=" + (state.wssAuth.client_count || 0);
            } else {
                wssLevel = "warn";
                wssText = "주의";
                wssTip = "WSS 연결됨. 아직 registered/status 응답을 확인하지 못했습니다.";
            }
        } else if (state.wsConnecting) {
            wssLevel = "warn";
            wssText = "연결중";
            wssTip = "WSS 연결 시도 중입니다.";
        } else if (state.lastError) {
            wssTip = "WSS 끊김 / " + state.lastError;
        }

        let busLevel = "bad";
        let busText = "끊김";
        let busTip = "Tab BUS 등록 전입니다.";
        if (state.bus.registered || state.tabId) {
            busLevel = state.bus.last_error ? "warn" : "ok";
            busText = state.bus.last_error ? "주의" : "연결";
            busTip = "Tab BUS tab_id=" + (state.tabId || "없음") + " / registered=" + (state.bus.registered ? "Y" : "N") + " / clients=" + (state.bus.client_count || 0) + (state.bus.last_error ? " / error=" + state.bus.last_error : "");
        }

        let nrLevel = "bad";
        let nrText = "끊김";
        let nrTip = "Node-RED 전송 전입니다.";
        if (state.nr.last_ok) {
            nrLevel = "ok";
            nrText = "연결";
            nrTip = "Node-RED 전송 성공 / last_sent=" + (state.nr.last_sent_at ? new Date(state.nr.last_sent_at).toLocaleString() : "");
        } else if (state.nr.last_error || state.wsOpen) {
            nrLevel = "warn";
            nrText = "주의";
            nrTip = state.nr.last_error ? ("Node-RED 주의 / " + state.nr.last_error) : "WSS는 연결됨. Node-RED 전송 테스트 필요.";
        }
        return {
            wss: { key: "WSS", level: wssLevel, text: wssText, title: wssTip },
            bus: { key: "BUS", level: busLevel, text: busText, title: busTip },
            nr: { key: "NR", level: nrLevel, text: nrText, title: nrTip }
        };
    }

    function indicatorHtml() {
        const s = commIndicators();
        return [s.nr, s.bus, s.wss].map(function (v) {
            return v.key + ":" + v.text;
        }).join(" | ");
    }

    function clearReconnect() {
        if (state.reconnectTimer) {
            clearTimeout(state.reconnectTimer);
            state.reconnectTimer = null;
        }
    }
    function scheduleReconnect(ms) {
        clearReconnect();
        if (!state.wsAutoReconnect) { emitStatus(); return; }
        if (isPermanentAuthBlock(state.lastError || state.wssAuth.auth_reason || "")) {
            blockAutoRetry(state.wssAuth.auth_reason || state.lastError || "auth_required", "schedule_reconnect");
            return;
        }
        const delay = Math.max(1000, parseInt(ms || state.reconnectMs || 3000, 10) || 3000);
        if (state.reconnectAttempts >= state.reconnectMaxAttempts) {
            clearReconnect();
            state.wsAutoReconnect = false;
            try { AIWK.store.set(KEY_WSS_AUTO, false); } catch (err) {}
            state.lastError = "WSS_RECONNECT_LIMIT: 연속 재접속 " + state.reconnectAttempts + "회 실패. 서버 차단 방지를 위해 자동 재시도를 중단했습니다. 설정과 token을 확인한 뒤 수동으로 WSS 인증등록하십시오.";
            appendLog("WSS 자동 재시도 횟수 초과", { attempts: state.reconnectAttempts, max: state.reconnectMaxAttempts, help: state.lastError });
            emitStatus();
            return;
        }
        state.reconnectTimer = setTimeout(function () {
            state.reconnectTimer = null;
            state.reconnectAttempts += 1;
            if (state.wsAutoReconnect && !state.wsOpen) AIWK.comm.wssConnect({ force: false });
        }, delay);
        emitStatus();
    }
    async function getTabId() {
        if (state.tabId) return state.tabId;
        if (!chrome || !chrome.runtime || !chrome.runtime.sendMessage) return "";
        try {
            const res = await chrome.runtime.sendMessage({ type: "AIWK_GET_TAB_ID" });
            state.tabId = res && res.tabId ? String(res.tabId) : "";
            if (state.tabId) state.bus.last_ok = true;
        } catch (err) {
            state.tabId = "";
            state.bus.last_error = err && err.message ? err.message : String(err);
        }
        emitStatus();
        return state.tabId;
    }
    function tabRegister(endpointName) {
        return new Promise(function (resolve) {
            if (!chrome || !chrome.runtime || !chrome.runtime.sendMessage) {
                state.bus.last_error = "CHROME_RUNTIME_MISSING";
                emitStatus();
                resolve({ ok: 0, error: "CHROME_RUNTIME_MISSING" });
                return;
            }
            const p = getProfile();
            const ep = endpointName || "tabbus";
            chrome.runtime.sendMessage({
                type: "AIWK_TAB_BUS_REGISTER",
                profile: p,
                endpoint: ep,
                address: endpoint(ep),
                page: { title: document.title, url: location.href }
            }, function (res) {
                res = res || { ok: 0, error: chrome.runtime.lastError && chrome.runtime.lastError.message };
                state.bus.registered = !!res.ok;
                state.bus.last_ok = !!res.ok;
                state.bus.last_error = res.ok ? "" : (res.error || "TAB_REGISTER_FAIL");
                state.bus.last_seen = Date.now();
                if (res.tabId) state.tabId = String(res.tabId);
                emitStatus();
                resolve(res);
            });
        });
    }
    function tabUnregister(endpointName) {
        return new Promise(function (resolve) {
            if (!chrome || !chrome.runtime || !chrome.runtime.sendMessage) {
                state.bus.last_error = "CHROME_RUNTIME_MISSING";
                state.bus.registered = false;
                emitStatus();
                resolve({ ok: 0, error: "CHROME_RUNTIME_MISSING" });
                return;
            }
            const ep = endpointName || "tabbus";
            chrome.runtime.sendMessage({ type: "AIWK_TAB_BUS_UNREGISTER", profile: getProfile(), endpoint: ep, address: endpoint(ep) }, function (res) {
                res = res || { ok: 0, error: chrome.runtime.lastError && chrome.runtime.lastError.message };
                state.bus.registered = false;
                state.bus.last_ok = !!res.ok;
                state.bus.last_error = res.ok ? "" : (res.error || "TAB_UNREGISTER_FAIL");
                state.bus.last_seen = Date.now();
                emitStatus();
                resolve(res);
            });
        });
    }

    function busKeepRegister() {
        if (!state.bus.auto_keep) return { ok: 0, auto: 0 };
        if (!state.tabProfileActive) {
            state.bus.last_error = "TAB_PROFILE_INACTIVE";
            emitStatus();
            return { ok: 0, error: "TAB_PROFILE_INACTIVE" };
        }
        return tabRegister("tabbus").catch(function (err) {
            state.bus.registered = false;
            state.bus.last_ok = false;
            state.bus.last_error = err && err.message ? err.message : String(err);
            emitStatus();
            return { ok: 0, error: state.bus.last_error };
        });
    }
    function startBusKeepTimer(immediate) {
        stopBusKeepTimer();
        if (!state.bus.auto_keep) return;
        state.bus.keep_timer = setInterval(function(){ try { busKeepRegister(); } catch(err) {} }, Math.max(2000, state.bus.keep_ms || state.reconnectMs || 3000));
        if (immediate !== false) setTimeout(function(){ try { busKeepRegister(); } catch(err) {} }, 100);
    }
    function stopBusKeepTimer() {
        if (state.bus.keep_timer) { clearInterval(state.bus.keep_timer); state.bus.keep_timer = null; }
    }

    function tabList() {
        return new Promise(function (resolve) {
            if (!chrome || !chrome.runtime || !chrome.runtime.sendMessage) {
                state.bus.last_error = "CHROME_RUNTIME_MISSING";
                emitStatus();
                resolve({ ok: 0, error: "CHROME_RUNTIME_MISSING" });
                return;
            }
            chrome.runtime.sendMessage({ type: "AIWK_TAB_BUS_LIST", profile: getProfile() }, function (res) {
                res = res || { ok: 0, error: chrome.runtime.lastError && chrome.runtime.lastError.message };
                state.bus.last_ok = !!res.ok;
                state.bus.last_error = res.ok ? "" : (res.error || "TAB_LIST_FAIL");
                if (res.clients && Array.isArray(res.clients)) state.bus.client_count = res.clients.length;
                if (res.list && Array.isArray(res.list)) state.bus.client_count = res.list.length;
                emitStatus();
                resolve(res);
            });
        });
    }
    function tabSend(to, action, payload, endpointName) {
        return new Promise(function (resolve) {
            if (!chrome || !chrome.runtime || !chrome.runtime.sendMessage) {
                state.bus.last_error = "CHROME_RUNTIME_MISSING";
                emitStatus();
                resolve({ ok: 0, error: "CHROME_RUNTIME_MISSING" });
                return;
            }
            const packet = {
                msg_id: uid("tab"),
                type: "tabbus.message",
                from: endpoint(endpointName || "tabbus"),
                to: safeString(to || "A*#tabbus"),
                action: action || "tabbus.test",
                payload: payload || {},
                group: getProfile().group,
                project: getProfile().project,
                room_id: getProfile().roomId,
                ts: Date.now()
            };
            chrome.runtime.sendMessage({ type: "AIWK_TAB_BUS_SEND", profile: getProfile(), packet: packet }, function (res) {
                res = Object.assign({ packet: packet }, res || { ok: 0, error: chrome.runtime.lastError && chrome.runtime.lastError.message });
                state.bus.last_ok = !!res.ok;
                state.bus.last_error = res.ok ? "" : (res.error || "TAB_SEND_FAIL");
                if (res.ok) bumpCounter("tx", "bus");
                try { AIWK.events.emit("comm:tab:send", Object.assign({ ok: !!res.ok }, packet)); } catch (err) {}
                emitStatus();
                resolve(res);
            });
        });
    }

    function wssDisconnect(reason) {
        stopNrKeepTimer();
        clearReconnect();
        state.wsCloseExpected = true;
        state.wsConnecting = false;
        state.wsOpen = false;
        const ws = state.ws;
        state.ws = null;
        if (ws) {
            try { ws.__aiwkExpectedClose = true; } catch (err) {}
            try { ws.close(1000, reason || "manual"); } catch (err) {}
        }
        appendLog("WSS 끊기", { reason: reason || "manual" });
        emitStatus();
        return { ok: 1, status: "closed" };
    }
    function wssConnect(opt) {
        opt = opt || {};
        clearReconnect();
        if (opt.force) clearAuthBlock("force_connect");
        const p = getProfile();
        const wsUrl = safeString(p.wsUrl || DEFAULT_WS_URL) || DEFAULT_WS_URL;
        if (state.ws && (state.ws.readyState === WebSocket.OPEN || state.ws.readyState === WebSocket.CONNECTING)) {
            const sameIdentity = state.lastRegister && registerIdentity(state.lastRegister) === profileIdentity(p);
            if (sameIdentity && !opt.force && state.ws.readyState === WebSocket.OPEN && state.wssAuth.can_speak) {
                return { ok: 1, status: wsStateText(), reused: 1, already_registered: !!state.wssAuth.auth_token_ok, identity: profileIdentity(p) };
            }
            // 다른 client_id/tab 설정으로 전환할 때만 기존 소켓을 정상 종료하고 새 register를 보낸다.
            try { state.ws.__aiwkExpectedClose = true; state.ws.close(1000, "profile_switch"); } catch (err) {}
            state.ws = null;
            state.wsOpen = false;
            state.wsConnecting = false;
        }
        let ws;
        try {
            state.wsConnecting = true;
            state.wsOpen = false;
            state.wsCloseExpected = false;
            state.lastError = "";
            emitStatus();
            ws = new WebSocket(wsUrl);
            ws.__aiwkExpectedClose = false;
            state.ws = ws;
        } catch (err) {
            state.wsConnecting = false;
            state.lastError = err && err.message ? err.message : String(err);
            appendLog("WSS 생성 오류", { error: state.lastError });
            scheduleReconnect(state.reconnectMs);
            emitStatus();
            return { ok: 0, error: state.lastError };
        }
        ws.onopen = async function () {
            if (state.ws !== ws) return;
            try {
                await getTabId();
                state.profileInstanceId = await getProfileInstanceId();
                const rp = getProfile();
                const idn = requirePacketIdentity(rp, "register");
                const rid = getUrlRouteIdentity();
                state.wsConnecting = false;
                state.wsOpen = true;
                state.reconnectAttempts = 0;
                const reg = {
                    type: "register",
                    role: rp.role || "guest",
                    group: rp.group || "home",
                    project: rp.project || "IG_CRM",
                    room_id: rp.roomId || "",
                    client_id: rp.clientId || DEFAULT_CLIENT_ID,
                    from_client: rp.clientId || DEFAULT_CLIENT_ID,
                    token: rp.authToken || "",
                    route_key: rid.route_key,
                    url_key: rid.url_key,
                    tab_id: idn.tab_id,
                    from_tab_id: idn.tab_id,
                    profile_instance_id: idn.profile_instance_id,
                    tab_kind: "browser",
                    endpoint: "ext4",
                    from_endpoint: "ext4",
                    address: endpoint("ext4"),
                    endpoints: [endpoint("ext4"), endpoint("tabbus")],
                    tab: { tab_id: idn.tab_id, route_key: rid.route_key, url_key: rid.url_key, profile_instance_id: idn.profile_instance_id, tab_kind: "browser", title: document.title, url: location.href },
                    debug_title: document.title,
                    debug_url: location.href,
                    ts: Date.now()
                };
                state.lastRegister = Object.assign({}, reg, { token: reg.token ? "***" : "" });
                wssSendRaw(reg);
                appendLog("WSS 연결/등록", state.lastRegister);
                emitStatus();
            } catch (err) {
                state.wsConnecting = false;
                state.wsOpen = false;
                state.lastError = err && err.message ? err.message : String(err);
                appendLog("WSS 등록 중단", { error: state.lastError, tab_id: state.tabId || "", profile_instance_id: state.profileInstanceId || "" });
                try { ws.close(4008, state.lastError.slice(0, 120)); } catch(e) {}
                emitStatus();
            }
        };
        ws.onmessage = function (ev) {
            if (state.ws !== ws) return;
            let msg;
            try { msg = JSON.parse(ev.data); } catch (err) { msg = { raw: ev.data, parse_error: err.message }; }
            msg._received_at = Date.now();
            state.lastReceivedAt = msg._received_at;
            bumpCounter("rx", "wss");
            state.wssInbox.push(msg);
            if (state.wssInbox.length > 300) state.wssInbox.shift();
            if (msg.type === "registered") {
                state.wssAuth.auth_token_ok = !!msg.auth_token_ok;
                state.wssAuth.auth_level = safeString(msg.auth_level || "");
                state.wssAuth.auth_reason = safeString(msg.auth_reason || msg.reason || "");
                state.wssAuth.can_speak = !!msg.can_speak;
                state.wssAuth.can_receive = !!msg.can_receive;
                state.wssAuth.can_execute = !!msg.can_execute;
                if (msg.ok === 0 || isPermanentAuthBlock(state.wssAuth.auth_reason) || (state.wssAuth.auth_level === "anonymous_limited" && !state.wssAuth.can_speak)) {
                    blockAutoRetry(state.wssAuth.auth_reason || "auth_required", "registered");
                } else if (state.nr.auto_keep && state.wssAuth.can_speak) {
                    startNrKeepTimer();
                }
            }
            if (msg.type === "status") {
                state.wssAuth.client_count = parseInt(msg.client_count || 0, 10) || 0;
                if (msg.clients && msg.clients[0]) {
                    const cur = msg.clients[0];
                    state.wssAuth.auth_token_ok = !!cur.auth_token_ok;
                    state.wssAuth.auth_level = safeString(cur.auth_level || "");
                    state.wssAuth.auth_reason = safeString(cur.auth_reason || "");
                    state.wssAuth.can_speak = !!cur.can_speak;
                    state.wssAuth.can_receive = !!cur.can_receive;
                    state.wssAuth.can_execute = !!cur.can_execute;
                }
            }
            if (msg.ev === "cmd_block" && isPermanentAuthBlock(msg.reason || "")) {
                markCommandBlock(msg.reason || "auth_required", msg);
            } else if ((msg.type === "command_ack" || msg.type === "chat_ack" || msg.type === "result_ack") && msg.ok === 0 && isPermanentAuthBlock(msg.reason || "")) {
                if (msg.type === "command_ack" || isNodeRedCommand(msg)) markCommandBlock(msg.reason || "auth_required", msg);
                else blockAutoRetry(msg.reason || "auth_required", msg.type);
            }
            const actionName = String(msg.action || msg.aiwk_action || "");
            const isClientResult = msg.type === "result" || actionName === "aiwk_pong" || actionName === "aiwk_chat_ack" || /_result$/.test(actionName);
            if (isClientResult) {
                state.wssVerifiedOk = true;
                state.lastRoundtripAt = Date.now();
                state.lastRoundtripAction = actionName || msg.type || "result";
            }
            if (msg.type === "nodered" || msg.type === "node-red" || msg.endpoint === "node-red" || actionName === "aiwk_pong" || actionName === "aiwk_chat_ack" || String(msg.from || "").indexOf(".N1") >= 0 || String(msg.from || "").indexOf("node-red") >= 0) {
                state.nr.last_ok = true;
                state.nr.status = "송수신";
                state.nr.last_received_at = Date.now();
                state.nr.last_error = "";
                bumpCounter("rx", "nr");
            }
            try { AIWK.events.emit("comm:wss:message", msg); } catch (err) {}
            appendLog("WSS 수신", msg);
            emitStatus();
        };
        ws.onerror = function () {
            if (state.ws !== ws) return;
            state.lastError = "WS 오류";
            appendLog("WSS 오류", { error: state.lastError });
            emitStatus();
        };
        ws.onclose = function (ev) {
            if (state.ws !== ws) return;
            const expected = state.wsCloseExpected || ws.__aiwkExpectedClose === true;
            const code = ev && ev.code || 0;
            const reason = safeString(ev && ev.reason || "");
            const sameRoute = code === 4002 || reason.indexOf("same_route_client_reconnected") >= 0 || reason.indexOf("same_client_reconnected") >= 0;
            state.ws = null;
            state.wsOpen = false;
            state.wsConnecting = false;
            state.wsCloseExpected = false;
            state.lastCloseCode = code;
            state.lastCloseReason = reason;
            state.duplicateRoute = !!sameRoute;
            if (expected) {
                state.lastError = "";
            } else if (sameRoute) {
                state.lastError = "동일 route/client 재접속으로 이전 세션이 교체됨 code=" + code + " reason=" + reason;
            } else {
                state.lastError = "WS 끊김 code=" + code + " reason=" + reason;
            }
            appendLog("WSS 끊김", { expected: expected, auto: state.wsAutoReconnect, code: code, reason: reason, duplicate_route: sameRoute ? 1 : 0 });
            emitStatus();
            /* v055: 접속유지 체크는 사용자가 끄기 전까지 절대 자동 해제하지 않는다.
               단, same_route_client_reconnected는 다른 탭/동일 route가 이미 새 세션을 잡은 상황일 수 있으므로
               즉시 자동 재접속 루프를 만들지 않고 상태만 보존한다. 일반 끊김만 자동 재접속한다. */
            if (state.wsAutoReconnect && !expected && !sameRoute) scheduleReconnect(state.reconnectMs);
        };
        return { ok: 1, status: "connecting", wsUrl: wsUrl };
    }
    function wssSendRaw(packet) {
        if (isPermanentAuthBlock(state.lastError || state.wssAuth.auth_reason || state.nr.last_error || "")) {
            appendLog("WSS 전송 차단", { reason: state.wssAuth.auth_reason || state.nr.last_error || state.lastError, help: authBlockHelp(state.wssAuth.auth_reason || state.nr.last_error || state.lastError) });
            emitStatus();
            return false;
        }
        if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
            return false;
        }
        state.ws.send(JSON.stringify(packet));
        state.lastSentAt = Date.now();
        bumpCounter("tx", "wss");
        emitStatus();
        return true;
    }
    function wssSendPacket(packet) {
        packet = packet || {};
        if (!packet.message_id && packet.msg_id) packet.message_id = packet.msg_id;
        if (!packet.msg_id && packet.message_id) packet.msg_id = packet.message_id;
        if (!packet.message_id && !packet.msg_id) {
            packet.message_id = uidWithTab("wss");
            packet.msg_id = packet.message_id;
        }
        if (!packet.type) packet.type = "command";
        packet = enrichWssPacket(packet, packet.from_endpoint || packet.endpoint || "ext4");
        if (!packet.client_ts) packet.client_ts = Date.now();
        if (!packet.sent_at_ms) packet.sent_at_ms = Date.now();
        const sent = wssSendRaw(packet);
        try { AIWK.events.emit("comm:wss:send", Object.assign({ ok: sent ? 1 : 0 }, packet)); } catch (err) {}
        appendLog(sent ? "WSS packet 전송" : "WSS packet 전송 실패", packet);
        return { ok: sent ? 1 : 0, sent: sent, packet: packet, status: wsStateText(), error: sent ? "" : "WSS_NOT_CONNECTED" };
    }
    function wssSend(to, action, payload, fromEndpoint) {
        const p = getProfile();
        let packet = {
            type: "command",
            msg_id: uidWithTab("wss"),
            from: endpoint(fromEndpoint || "ext4"),
            to: safeString(to || "A*"),
            group: p.group || "home",
            project: p.project || "IG_CRM",
            room_id: p.roomId || "",
            action: action || "aiwk.test",
            payload: payload || {},
            need_ack: true,
            ts: Date.now()
        };
        packet = enrichWssPacket(packet, fromEndpoint || "ext4");
        const sent = wssSendRaw(packet);
        try { AIWK.events.emit("comm:wss:send", Object.assign({ ok: sent ? 1 : 0 }, packet)); } catch (err) {}
        appendLog(sent ? "WSS 전송" : "WSS 전송 실패", packet);
        return { ok: sent ? 1 : 0, sent: sent, packet: packet, status: wsStateText(), error: sent ? "" : "WSS_NOT_CONNECTED" };
    }
    function noderedTarget() {
        const p = getProfile();
        const room = p.roomId ? String(p.roomId) + "@" : "";
        return "aiwk://" + room + [p.group || "home", p.project || "IG_CRM", "N1:nodrd"].join(".") + "#node-red";
    }
    function nrKeepPing() {
        if (!state.nr.auto_keep) return { ok: 0, auto: 0 };
        if (isPermanentAuthBlock(state.lastError || state.wssAuth.auth_reason || "")) {
            return blockAutoRetry(state.wssAuth.auth_reason || state.lastError || "auth_required", "nr_keep");
        }
        if (isPermanentAuthBlock(state.nr.last_error || "")) {
            state.nr.auto_keep = false;
            stopNrKeepTimer();
            try { AIWK.store.set(KEY_NR_AUTO, false); } catch (err) {}
            emitStatus();
            return { ok: 0, error: state.nr.last_error, blocked: 1, scope: "nr" };
        }
        if (!state.wsOpen && !state.wsConnecting && state.wsAutoReconnect) {
            try { wssConnect({ force:false }); } catch (err) {}
        }
        if (!state.wsOpen) {
            state.nr.status = "대기";
            state.nr.last_error = "WSS_NOT_CONNECTED";
            emitStatus();
            return { ok: 0, error: "WSS_NOT_CONNECTED" };
        }
        if (!state.wssAuth.can_speak) {
            state.nr.status = "대기";
            state.nr.last_error = state.wssAuth.auth_reason || "WSS_AUTH_PENDING";
            emitStatus();
            return { ok: 0, error: state.nr.last_error, solution: "WSS registered 응답에서 can_speak=true 확인 후 N1 Ping을 보냅니다." };
        }
        return noderedSend(noderedTarget(), "aiwk_ping", { text:"NR keep ping", at:nowIso() });
    }
    function startNrKeepTimer(immediate) {
        stopNrKeepTimer();
        if (!state.nr.auto_keep) return;
        state.nr.keep_timer = setInterval(function(){ try { nrKeepPing(); } catch(err) {} }, Math.max(2000, state.reconnectMs || 3000));
        if (immediate !== false) setTimeout(function(){ try { nrKeepPing(); } catch(err) {} }, 100);
    }
    function stopNrKeepTimer() {
        if (state.nr.keep_timer) { clearInterval(state.nr.keep_timer); state.nr.keep_timer = null; }
    }
    function noderedSend(to, action, payload) {
        const p = getProfile();
        const target = safeString(to || noderedTarget());
        if (!state.wssAuth.can_speak) {
            state.nr.last_sent_at = Date.now();
            state.nr.last_ok = false;
            state.nr.status = "대기";
            state.nr.last_error = state.wssAuth.auth_reason || "WSS_AUTH_PENDING";
            emitStatus();
            return { ok: 0, error: state.nr.last_error, solution: "WSS 연결 후 register/auth 성공(can_speak=true) 상태에서 N1로 전송하십시오." };
        }
        const res = wssSend(target, action || "nodered.test", payload || { text: "AIWK EXT4 → Node-RED test", at: nowIso() }, "ext4");
        state.nr.last_sent_at = Date.now();
        if (res && res.ok) bumpCounter("tx", "nr");
        state.nr.last_ok = false;
        state.nr.status = res.ok ? "확인중" : "주의";
        state.nr.last_error = res.ok ? "응답 대기" : (res.error || "NODE_RED_SEND_FAIL");
        try { AIWK.events.emit("comm:nr:send", res); } catch (err) {}
        emitStatus();
        return res;
    }
    function noderedDisconnect(reason) {
        stopNrKeepTimer();
        state.nr.auto_keep = false;
        try { AIWK.store.set(KEY_NR_AUTO, false); } catch (err) {}
        state.nr.status = "끊김";
        state.nr.last_ok = false;
        state.nr.last_error = reason || "manual";
        appendLog("Node-RED 끊기", { reason: reason || "manual" });
        emitStatus();
        return { ok: 1, status: "closed", reason: reason || "manual" };
    }
    function setBusKeepInterval(seconds) {
        const sec = Math.max(2, Math.min(3600, parseInt(seconds || 0, 10) || Math.round((state.bus.keep_ms || 10000) / 1000)));
        state.bus.keep_ms = sec * 1000;
        try { AIWK.store.set(KEY_BUS_KEEP_MS, state.bus.keep_ms); } catch (err) {}
        if (state.bus.auto_keep) startBusKeepTimer(false);
        appendLog("BUS 유지 간격 변경", { seconds: sec, keep_ms: state.bus.keep_ms });
        emitStatus();
        return { ok: 1, seconds: sec, keep_ms: state.bus.keep_ms };
    }
    function setBusAutoKeep(flag, keepMs) {
        if (keepMs) setBusKeepInterval(Math.round((parseInt(keepMs, 10) || 0) / 1000));
        state.bus.auto_keep = !!flag;
        try { AIWK.store.set(KEY_BUS_AUTO, state.bus.auto_keep); } catch (err) {}
        if (state.bus.auto_keep) startBusKeepTimer();
        else stopBusKeepTimer();
        emitStatus();
        return { ok: 1, auto: state.bus.auto_keep, keep_ms: state.bus.keep_ms };
    }
    function setNrAutoKeep(flag) {
        state.nr.auto_keep = !!flag;
        try { AIWK.store.set(KEY_NR_AUTO, state.nr.auto_keep); } catch (err) {}
        if (state.nr.auto_keep) startNrKeepTimer();
        else stopNrKeepTimer();
        emitStatus();
        return { ok: 1, auto: state.nr.auto_keep, target: noderedTarget(), reconnect_ms: state.reconnectMs };
    }

    function setRetryInterval(seconds) {
        const sec = Math.max(2, Math.min(3600, parseInt(seconds || 0, 10) || Math.round((state.reconnectMs || 3000) / 1000)));
        state.reconnectMs = sec * 1000;
        try { AIWK.store.set(KEY_WSS_RECONNECT_MS, state.reconnectMs); } catch (err) {}
        if (state.bus.auto_keep) startBusKeepTimer(false);
        if (state.nr.auto_keep) startNrKeepTimer(false);
        if (state.wsAutoReconnect && !state.wsOpen && !state.wsConnecting) scheduleReconnect(state.reconnectMs);
        appendLog("자동 재시도 간격 변경", { seconds: sec, reconnect_ms: state.reconnectMs });
        emitStatus();
        return { ok: 1, seconds: sec, reconnect_ms: state.reconnectMs };
    }

    function setAutoReconnect(flag, reconnectMs) {
        state.wsAutoReconnect = !!flag;
        state.reconnectMs = Math.max(2000, parseInt(reconnectMs || state.reconnectMs || 3000, 10) || 3000);
        try {
            AIWK.store.set(KEY_WSS_AUTO, state.wsAutoReconnect);
            AIWK.store.set(KEY_WSS_RECONNECT_MS, state.reconnectMs);
        } catch (err) {}
        if (state.wsAutoReconnect && !state.wsOpen && !state.wsConnecting) scheduleReconnect(state.reconnectMs);
        if (!state.wsAutoReconnect) clearReconnect();
        emitStatus();
        return { ok: 1, auto: state.wsAutoReconnect, reconnect_ms: state.reconnectMs };
    }

    function stopAllRuntime(reason) {
        reason = safeString(reason || "global_use_off");
        state.hardStopped = true;
        state.tabProfileActive = false;
        state.tabProfileReason = reason;
        state.wsAutoReconnect = false;
        state.nr.auto_keep = false;
        state.bus.auto_keep = false;
        stopBusKeepTimer();
        stopNrKeepTimer();
        clearReconnect();
        try { wssDisconnect(reason); } catch (err) {}
        try { AIWK.store.set(KEY_WSS_AUTO, false); AIWK.store.set(KEY_NR_AUTO, false); AIWK.store.set(KEY_BUS_AUTO, false); } catch (err) {}
        updateWindowTitle();
        emitStatus();
        appendLog("AIWK 전역 사용 OFF", { reason: reason });
        return { ok:1, stopped:1, reason:reason };
    }

    function status() {
        const blockReason = isPermanentAuthBlock(state.lastError || "") ? state.lastError : (isPermanentAuthBlock(state.wssAuth.auth_reason || "") ? state.wssAuth.auth_reason : "");
        return {
            ok: 1,
            profile: redactedProfile(),
            address: endpoint("ext4"),
            room_key: roomKey(),
            route: getUrlRouteIdentity(),
            tab_id: state.tabId,
            tab_profile_active: state.tabProfileActive ? 1 : 0,
            tab_profile_reason: state.tabProfileReason || "",
            counters: Object.assign({}, state.counters),
            indicators: commIndicators(),
            indicator_text: indicatorHtml(),
            auth_blocked: blockReason ? 1 : 0,
            auth_block_reason: blockReason || "",
            auth_block_help: blockReason ? authBlockHelp(blockReason) : "",
            cmd_block: state.cmdBlock ? Object.assign({}, state.cmdBlock) : null,
            recent_messages: state.wssInbox.slice(-12).map(function(m){ return { type:m.type||"", action:m.action||m.aiwk_action||"", from:m.from||"", to:m.to||"", text:(m.payload&& (m.payload.text||m.payload.raw_text)) || m.text || "", at:m._received_at||0, reply_to_message_id:m.reply_to_message_id||m.reply_to||"" }; }),
            log_enabled: state.logEnabled ? 1 : 0,
            max_log_lines: MAX_LOG_LINES,
            wss: {
                status: wsStateText(),
                open: state.wsOpen ? 1 : 0,
                connecting: state.wsConnecting ? 1 : 0,
                auto_reconnect: state.wsAutoReconnect ? 1 : 0,
                reconnect_ms: state.reconnectMs,
                last_error: state.lastError || "",
                last_close_code: state.lastCloseCode || 0,
                last_close_reason: state.lastCloseReason || "",
                duplicate_route: state.duplicateRoute ? 1 : 0,
                inbox_count: state.wssInbox.length,
                last_register: state.lastRegister,
                auth: state.wssAuth,
                last_received_at: state.lastReceivedAt,
                last_sent_at: state.lastSentAt,
                last_roundtrip_at: state.lastRoundtripAt || 0,
                last_roundtrip_action: state.lastRoundtripAction || "",
                verified_ok: state.wssVerifiedOk ? 1 : 0,
                rx_count: state.counters.wss_rx,
                tx_count: state.counters.wss_tx
            },
            tabbus: {
                registered: state.bus.registered ? 1 : 0,
                ok: state.bus.last_ok ? 1 : 0,
                auto_keep: state.bus.auto_keep ? 1 : 0,
                keep_ms: state.bus.keep_ms || 10000,
                last_error: state.bus.last_error || "",
                client_count: state.bus.client_count || 0,
                inbox_count: state.tabInbox.length,
                rx_count: state.counters.bus_rx,
                tx_count: state.counters.bus_tx
            },
            nodered: {
                status: state.nr.status,
                ok: state.nr.last_ok ? 1 : 0,
                auto_keep: state.nr.auto_keep ? 1 : 0,
                last_error: state.nr.last_error || "",
                last_sent_at: state.nr.last_sent_at,
                last_received_at: state.nr.last_received_at,
                rx_count: state.counters.nr_rx,
                tx_count: state.counters.nr_tx
            }
        };
    }

    function makeStatusDot(info) {
        const span = document.createElement("span");
        span.className = "aiwk-comm-dot aiwk-comm-" + (info.level || "bad");
        span.textContent = info.key + ":" + (info.text || levelText(info.level));
        span.title = info.title || "";
        return span;
    }
    function renderStatusBar(container) {
        if (!container) return;
        const s = commIndicators();
        container.innerHTML = "";
        container.appendChild(makeStatusDot(s.nr));
        container.appendChild(makeStatusDot(s.bus));
        container.appendChild(makeStatusDot(s.wss));
    }
    function bindStatusBar(container) {
        renderStatusBar(container);
        const off = AIWK.events.on("comm:status", function () { renderStatusBar(container); });
        return off;
    }

    AIWK.comm = {
        version: "v152-storage-namespace-profile-lock",
        state: state,
        getProfile: getProfile,
        setProfile: setProfile,
        redactedProfile: redactedProfile,
        isTabClientActive: isTabClientActive,
        setTabClientActive: setTabClientActive,
        endpoint: endpoint,
        getUrlRouteIdentity: getUrlRouteIdentity,
        roomKey: roomKey,
        getTabId: getTabId,
        tabRegister: tabRegister,
        tabUnregister: tabUnregister,
        tabList: tabList,
        tabSend: tabSend,
        wssConnect: wssConnect,
        wssDisconnect: wssDisconnect,
        wssSend: wssSend,
        wssSendPacket: wssSendPacket,
        noderedTarget: noderedTarget,
        noderedSend: noderedSend,
        noderedDisconnect: noderedDisconnect,
        setAutoReconnect: setAutoReconnect,
        setRetryInterval: setRetryInterval,
        setBusKeepInterval: setBusKeepInterval,
        setBusAutoKeep: setBusAutoKeep,
        setNrAutoKeep: setNrAutoKeep,
        stopAllRuntime: stopAllRuntime,
        getLogEnabled: getLogEnabled,
        setLogEnabled: setLogEnabled,
        resetCounters: resetCounters,
        updateWindowTitle: updateWindowTitle,
        statusEmojiTriple: statusEmojiTriple,
        status: status,
        indicators: commIndicators,
        indicatorText: indicatorHtml,
        renderStatusBar: renderStatusBar,
        bindStatusBar: bindStatusBar,
        appendToBox: appendToBox,
        trimLines: trimLines,
        log: appendLog
    };

    try {
        state.wsAutoReconnect = !!AIWK.store.get(KEY_WSS_AUTO, false);
        state.reconnectMs = Math.max(2000, parseInt(AIWK.store.get(KEY_WSS_RECONNECT_MS, 3000), 10) || 3000);
        state.bus.auto_keep = !!AIWK.store.get(KEY_BUS_AUTO, false);
        state.bus.keep_ms = Math.max(2000, parseInt(AIWK.store.get(KEY_BUS_KEEP_MS, 10000), 10) || 10000);
        state.nr.auto_keep = !!AIWK.store.get(KEY_NR_AUTO, false);
        state.logEnabled = AIWK.store.get(KEY_LOG_ENABLED, true) !== false;
    } catch (err) {}

    if (chrome && chrome.runtime && chrome.runtime.onMessage) {
        chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) {
            if (!msg || msg.type !== "AIWK_TAB_BUS_DELIVER") return false;
            const packet = msg.packet || {};
            packet._received_at = Date.now();
            state.tabInbox.push(packet);
            if (state.tabInbox.length > 300) state.tabInbox.shift();
            bumpCounter("rx", "bus");
            state.bus.last_ok = true;
            state.bus.last_error = "";
            try { AIWK.events.emit("comm:tab:message", packet); } catch (err) {}
            appendLog("Tab BUS 수신", packet);
            emitStatus();
            sendResponse({ ok: 1, received: 1 });
            return true;
        });
    }
    try { updateWindowTitle(); } catch (err) {}
    getTabId().catch(function () {});
    setTimeout(function () {
        if (state.tabProfileActive && state.bus.auto_keep) startBusKeepTimer();
        if (state.tabProfileActive && state.wsAutoReconnect && !state.wsOpen && !state.wsConnecting) scheduleReconnect(state.reconnectMs);
        emitStatus();
    }, 50);
}());
