// Service worker
// 1) Wykonuje JS w kontekście strony (MAIN world) - omija strict CSP
// 2) Trzyma pozycje pływającego przycisku/panelu per okno przeglądarki
// 3) Menu kontekstowe (prawy klik na stronie)
// 4) Tle: poller sync (pull + push) co 60s gdy user zalogowany

importScripts('lib/i18n.js', 'lib/sync.js');

// Inicjalizacja i18n (czytanie userLang ze storage). Service worker może być wybudzony
// w dowolnym momencie — czekamy na init przed pierwszym użyciem JZ_I18N.t().
const _i18nReady = JZ_I18N.init();

const SESSION_KEY = 'positionsByWindow';
const SYNC_ALARM_NAME = 'jz-sync-poll';
const SYNC_ALARM_PERIOD_MIN = 1; // co 60s

// Od v2.13.3: positions trzymane w chrome.storage.LOCAL (nie .session) — durability.
// Session storage może być wyczyszczone gdy Chrome ubije service workera pod presją pamięci,
// gubiąc bar connections / floating btn pos. Local jest persistent na dysku, przeżywa restart
// przeglądarki. NIE jest objęty syncem (sync.js applyEntities operuje na konkretnych encjach,
// nie wszystkich kluczach storage).
async function loadPositions() {
  try {
    const data = await chrome.storage.local.get([SESSION_KEY]);
    if (data && data[SESSION_KEY]) return data[SESSION_KEY];
    // Migracja jednorazowa: jeśli w local pusto a session ma stare dane — przenieś.
    const sess = await chrome.storage.session.get([SESSION_KEY]);
    if (sess && sess[SESSION_KEY]) {
      await chrome.storage.local.set({ [SESSION_KEY]: sess[SESSION_KEY] });
      return sess[SESSION_KEY];
    }
    return {};
  } catch (e) {
    return {};
  }
}

async function savePositions(map) {
  try {
    await chrome.storage.local.set({ [SESSION_KEY]: map });
  } catch (e) {
    console.warn('[JustZix] Nie zapisano pozycji:', e);
  }
}

// Serializacja set-position handlers (od v2.13.3) — bez tego równoległe set-position
// messages (np. group drag z 3 barami = 3 wiadomości) robiły read-modify-write race:
// loadA → modifyA → saveA + loadB(stale) → modifyB → saveB nadpisuje A. Gubiło to
// connections i pozycje. Promise chain serializuje wszystkie set-position writes.
let _setPositionChain = Promise.resolve();

// === Wykonywanie kodu użytkownika w świecie MAIN strony ====================
// MV3: new Function/eval wywołane w świecie MAIN podlega CSP strony — przy
// script-src bez 'unsafe-eval' (np. facebook.com) rzuca EvalError. Strategia
// (od v3.2.0), od najpewniejszej:
//   1. chrome.userScripts.execute — kod jako string, CSP-immune, działa na
//      każdej stronie. Wymaga uprawnienia "userScripts" + jednorazowego
//      włączenia przez użytkownika ("Zezwól na skrypty użytkownika").
//   2. new Function w świecie MAIN — gdy CSP strony dopuszcza 'unsafe-eval'.
//   3. <script src="blob:…"> — gdy CSP strony dopuszcza blob: w script-src
//      (większość dużych serwisów, m.in. facebook.com).
// Gdy żadna nie zadziała → { ok:false, cspBlocked:true }.

function jzUserScriptsReady() {
  try { return !!(chrome.userScripts && typeof chrome.userScripts.execute === 'function'); }
  catch (_) { return false; }
}

// Instalator helperów window.JZ — prawdziwa funkcja (czytelność/lint),
// serializowana przez .toString() do payloadu wykonywanego w MAIN world.
function JZ_HELPERS_INSTALL() {
  if (window.JZ && typeof window.JZ._jzVersion !== 'undefined' && window.JZ._jzVersion >= 21333) return;
  var cssEsc = function (s) { return (window.CSS && CSS.escape ? CSS.escape(s) : String(s).replace(/"/g, '\\"')); };
  window.JZ = Object.assign(window.JZ || {}, {
    _jzVersion: 21333,
    actions: function () {
      return Array.from(document.querySelectorAll('[data-jz-action-id], #justzix-actions-panel .jz-action-btn'));
    },
    actionById: function (id) {
      if (id == null) return null;
      try { return document.querySelector('[data-jz-action-id="' + cssEsc(String(id)) + '"]'); }
      catch (_) { return null; }
    },
    action: function (labelOrId) {
      if (labelOrId == null) return null;
      var byId = this.actionById(labelOrId);
      if (byId) return byId;
      var key = String(labelOrId).toUpperCase();
      try {
        return document.querySelector('.jz-actions-bar [data-jz-key="' + cssEsc(key) + '"]')
          || document.querySelector('#justzix-actions-panel [data-jz-key="' + cssEsc(key) + '"]');
      } catch (_) { return null; }
    },
    click: function (labelOrId) {
      var el = this.action(labelOrId);
      if (!el) return false;
      if (el.tagName === 'SELECT') {
        var firstReal = Array.from(el.options).find(function (o) { return !o.disabled && !o.hidden; });
        if (firstReal) { el.value = firstReal.value; el.dispatchEvent(new Event('change', { bubbles: true })); return true; }
        return false;
      }
      el.click();
      return true;
    },
    value: function (labelOrId) {
      var el = this.action(labelOrId);
      if (!el) return null;
      var type = el.dataset && el.dataset.jzActionType;
      if (type === 'input' || type === 'textarea') return el.value;
      if (type === 'select' && el.tagName === 'SELECT') return el.value;
      if (el.dataset && 'jzValue' in el.dataset) return el.dataset.jzValue;
      return null;
    },
    setValue: function (labelOrId, value) {
      var el = this.action(labelOrId);
      if (!el) return false;
      var type = el.dataset && el.dataset.jzActionType;
      if (type === 'input' || type === 'textarea') {
        el.value = String(value);
        el.dispatchEvent(new Event('input', { bubbles: true }));
        el.dispatchEvent(new Event('change', { bubbles: true }));
        return true;
      }
      if (type === 'slider') {
        var slider = el.querySelector('.jz-action-slider');
        if (!slider) return false;
        slider.value = String(value);
        slider.dispatchEvent(new Event('input', { bubbles: true }));
        slider.dispatchEvent(new Event('change', { bubbles: true }));
        return true;
      }
      if (type === 'select' && el.tagName === 'SELECT') {
        el.value = String(value);
        el.dispatchEvent(new Event('change', { bubbles: true }));
        return true;
      }
      if (type === 'select' || type === 'toggle3') {
        el.dispatchEvent(new CustomEvent('jz-set-value', { detail: { value: value }, bubbles: false }));
        return true;
      }
      return false;
    },
    labels: function () { return this.actions().map(function (b) { return b.dataset.jzLabel || ''; }); },
    bars: function () { return Array.from(document.querySelectorAll('.jz-actions-bar')); },
    barById: function (id) {
      if (id == null) return null;
      try { return document.querySelector('.jz-actions-bar[data-jz-bar-id="' + cssEsc(String(id)) + '"]'); }
      catch (_) { return null; }
    },
    floatingBtn: function () { return document.getElementById('justzix-floating-btn'); },
  });
}

// Buduje kompletny payload dla reguły/akcji: instalator JZ + prelude (extraVars,
// $el, $action) + kod użytkownika. Wykonywany jako ciało funkcji / IIFE.
function jzBuildActionPayload(jsCode, contextId, extras) {
  var prelude = '';
  try {
    if (extras && typeof extras === 'object') {
      for (var k of Object.keys(extras)) {
        if (k === '_jsVariant') continue;
        prelude += 'const ' + k + ' = ' + JSON.stringify(extras[k]) + ';\n';
      }
    }
    if (contextId) {
      prelude += 'const $el = window.JZ.actionById(' + JSON.stringify(contextId) + ');\n';
      prelude += 'const $action = $el ? { id: ' + JSON.stringify(contextId) +
                 ', label: $el.dataset.jzLabel || "", type: $el.dataset.jzActionType || "button", el: $el } : null;\n';
    }
  } catch (_) {}
  return '(' + JZ_HELPERS_INSTALL.toString() + ')();\n' + prelude + (jsCode || '');
}

// Funkcja wstrzykiwana do MAIN world (executeScript serializuje TYLKO ją —
// musi być samowystarczalna). Tier 2: new Function; Tier 3: <script src=blob:>.
function jzMainInjector(payload) {
  var evalOk = false;
  try { evalOk = (new Function('return 1'))() === 1; } catch (_) { evalOk = false; }
  if (evalOk) {
    try { (new Function(payload))(); return { ok: true, via: 'function' }; }
    catch (e) { return { ok: false, error: (e && e.message) || String(e), stack: (e && e.stack) || '', via: 'function' }; }
  }
  return new Promise(function (resolve) {
    var beacon = 'jz' + Date.now() + '_' + Math.random().toString(36).slice(2);
    var settled = false;
    function onMsg(ev) {
      if (ev.source !== window) return;
      var d = ev.data;
      if (!d || d.__jzBeacon !== beacon) return;
      settled = true;
      window.removeEventListener('message', onMsg);
      resolve(d.err ? { ok: false, error: d.err, stack: d.stack || '', via: 'blob' }
                    : { ok: true, via: 'blob' });
    }
    window.addEventListener('message', onMsg);
    var bid = JSON.stringify(beacon);
    var blobCode =
      'try{(function(){' + payload + '\n})();' +
      'window.postMessage({__jzBeacon:' + bid + '},"*");}' +
      'catch(e){window.postMessage({__jzBeacon:' + bid +
      ',err:String(e&&e.message||e),stack:String(e&&e.stack||"")},"*");}';
    try {
      var url = URL.createObjectURL(new Blob([blobCode], { type: 'text/javascript' }));
      var s = document.createElement('script');
      s.src = url;
      s.addEventListener('load', function () { try { URL.revokeObjectURL(url); } catch (_) {} if (s.parentNode) s.remove(); });
      s.addEventListener('error', function () { try { URL.revokeObjectURL(url); } catch (_) {} if (s.parentNode) s.remove(); });
      (document.head || document.documentElement).appendChild(s);
    } catch (e2) {
      window.removeEventListener('message', onMsg);
      resolve({ ok: false, cspBlocked: true, error: (e2 && e2.message) || 'blob failed', via: 'blob' });
      return;
    }
    setTimeout(function () {
      if (settled) return;
      window.removeEventListener('message', onMsg);
      resolve({ ok: false, cspBlocked: true, via: 'blob' });
    }, 1800);
  });
}

// Uruchamia payload w MAIN world. Promise<{ok,error,stack,via,cspBlocked}>.
function jzRunInMain(tabId, frameId, payload) {
  var target = { tabId: tabId, frameIds: [frameId || 0] };
  if (jzUserScriptsReady()) {
    var wrapped =
      '(function(){var __r={ok:true};try{(function(){' + payload +
      '\n})();}catch(e){__r={ok:false,error:String(e&&e.message||e),stack:String(e&&e.stack||"")};}return __r;})()';
    return chrome.userScripts.execute({
      target: target, world: 'MAIN', injectImmediately: true, js: [{ code: wrapped }]
    }).then(function (res) {
      var r = (res && res[0] && res[0].result) || { ok: true };
      r.via = 'userScripts';
      return r;
    }).catch(function () { return jzRunViaExecuteScript(target, payload); });
  }
  return jzRunViaExecuteScript(target, payload);
}

function jzRunViaExecuteScript(target, payload) {
  return chrome.scripting.executeScript({
    target: target, world: 'MAIN', func: jzMainInjector, args: [payload]
  }).then(function (results) {
    return (results && results[0] && results[0].result) || { ok: false, error: 'no-result' };
  }).catch(function (err) {
    return { ok: false, error: err.message || 'executeScript failed', via: 'executeScript' };
  });
}

// Serializowalny stringifier dla JS Console (REPL). Nazwana funkcja — rekurencja
// po własnej nazwie działa też po .toString() (named function expression).
function JZ_CONSOLE_STRINGIFY(val) {
  if (val === undefined) return 'undefined';
  if (val === null) return 'null';
  var t = typeof val;
  if (t === 'string') return JSON.stringify(val);
  if (t === 'number' || t === 'boolean') return String(val);
  if (t === 'function') return '[Function' + (val.name ? ': ' + val.name : '') + ']';
  if (t === 'symbol') return val.toString();
  if (t === 'bigint') return String(val) + 'n';
  if (val instanceof Error) return val.name + ': ' + val.message;
  if (val instanceof Date) return val.toISOString();
  if (val instanceof RegExp) return val.toString();
  if (typeof Element !== 'undefined' && val instanceof Element) {
    return '<' + val.tagName.toLowerCase()
      + (val.id ? ' id="' + val.id + '"' : '')
      + (val.className && typeof val.className === 'string' ? ' class="' + val.className + '"' : '')
      + '>';
  }
  if (typeof NodeList !== 'undefined' && (val instanceof NodeList
      || (typeof HTMLCollection !== 'undefined' && val instanceof HTMLCollection))) {
    return Array.from(val).map(function (n) { return '<' + (n.tagName ? n.tagName.toLowerCase() : '?') + '>'; }).join(', ');
  }
  if (Array.isArray(val)) {
    try {
      return '[' + val.slice(0, 50).map(JZ_CONSOLE_STRINGIFY).join(', ') + (val.length > 50 ? ', …' : '') + ']';
    } catch (_) { return '[Array(' + val.length + ')]'; }
  }
  try {
    var seen = new WeakSet();
    return JSON.stringify(val, function (k, v) {
      if (typeof v === 'object' && v !== null) {
        if (seen.has(v)) return '[Circular]';
        seen.add(v);
      }
      if (typeof v === 'function') return '[Function]';
      return v;
    }, 2);
  } catch (e) { return '[Object: ' + (e && e.message) + ']'; }
}

// Buduje wrapper JS Console dla userScripts (kod jako string, bez eval).
// asExpr=true → próbuje wyrażenia (wartość REPL); false → instrukcje.
function jzConsoleWrapper(code, asExpr) {
  var body = asExpr
    ? 'try{__res=(' + code + '\n);}catch(e){__err=e;}'
    : 'try{' + code + '\n;__res=undefined;}catch(e){__err=e;}';
  return '(function(){' +
    'var __cap=[];' +
    'var __o={log:console.log,warn:console.warn,error:console.error,info:console.info};' +
    'var __strf=(' + JZ_CONSOLE_STRINGIFY.toString() + ');' +
    'function __w(k){return function(){' +
      'try{__cap.push({type:k,text:Array.prototype.slice.call(arguments).map(function(x){' +
        'return typeof x==="string"?x:__strf(x);}).join(" ")});}catch(_){}' +
      'try{__o[k].apply(console,arguments);}catch(_){}' +
    '};}' +
    'console.log=__w("log");console.warn=__w("warn");console.error=__w("error");console.info=__w("info");' +
    'var __res,__err;' +
    body +
    'console.log=__o.log;console.warn=__o.warn;console.error=__o.error;console.info=__o.info;' +
    'return {ok:!__err,result:__err?undefined:__strf(__res),' +
      'error:__err?String((__err&&__err.message)||__err):"",captured:__cap};' +
  '})()';
}

// JS Console przez userScripts — najpierw wyrażenie, przy SyntaxError instrukcje.
function jzConsoleUserScripts(target, code) {
  var run = function (codeStr) {
    return chrome.userScripts.execute({
      target: target, world: 'MAIN', injectImmediately: true, js: [{ code: codeStr }]
    }).then(function (res) { return (res && res[0]) ? res[0].result : undefined; })
      .catch(function () { return undefined; });
  };
  return run(jzConsoleWrapper(code, true)).then(function (r) {
    if (r && typeof r === 'object' && 'ok' in r) return r;
    return run(jzConsoleWrapper(code, false)).then(function (r2) {
      if (r2 && typeof r2 === 'object' && 'ok' in r2) return r2;
      return { ok: false, cspBlocked: true, error: '', captured: [] };
    });
  });
}

// Funkcja wstrzykiwana do MAIN world dla JS Console (gdy userScripts niedostępne).
// Samowystarczalna. Przy CSP blokującym eval → { cspBlocked:true }.
function jzConsoleInjector(jsCode) {
  var evalOk = false;
  try { evalOk = (new Function('return 1'))() === 1; } catch (_) { evalOk = false; }
  if (!evalOk) return { ok: false, cspBlocked: true, error: '', captured: [] };
  var stringify = (val) => {
    if (val === undefined) return 'undefined';
    if (val === null) return 'null';
    const t = typeof val;
    if (t === 'string') return JSON.stringify(val);
    if (t === 'number' || t === 'boolean') return String(val);
    if (t === 'function') return '[Function' + (val.name ? ': ' + val.name : '') + ']';
    if (t === 'symbol') return val.toString();
    if (t === 'bigint') return String(val) + 'n';
    if (val instanceof Error) return val.name + ': ' + val.message;
    if (val instanceof Date) return val.toISOString();
    if (val instanceof RegExp) return val.toString();
    if (val instanceof Element) return '<' + val.tagName.toLowerCase()
      + (val.id ? ' id="' + val.id + '"' : '')
      + (val.className && typeof val.className === 'string' ? ' class="' + val.className + '"' : '')
      + '>';
    if (val instanceof NodeList || val instanceof HTMLCollection) {
      return Array.from(val).map(n => '<' + (n.tagName ? n.tagName.toLowerCase() : '?') + '>').join(', ');
    }
    if (Array.isArray(val)) {
      try {
        return '[' + val.slice(0, 50).map(stringify).join(', ') + (val.length > 50 ? ', …' : '') + ']';
      } catch (_) { return '[Array(' + val.length + ')]'; }
    }
    try {
      const seen = new WeakSet();
      return JSON.stringify(val, (k, v) => {
        if (typeof v === 'object' && v !== null) {
          if (seen.has(v)) return '[Circular]';
          seen.add(v);
        }
        if (typeof v === 'function') return '[Function]';
        return v;
      }, 2);
    } catch (e) { return '[Object: ' + (e && e.message) + ']'; }
  };
  const captured = [];
  const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
  const wrap = (kind) => function (...args) {
    try {
      const text = args.map(a => {
        const s = stringify(a);
        return (typeof a === 'string') ? a : s;
      }).join(' ');
      captured.push({ type: kind, text });
    } catch (_) {}
    try { orig[kind].apply(console, args); } catch (_) {}
  };
  console.log = wrap('log');
  console.warn = wrap('warn');
  console.error = wrap('error');
  console.info = wrap('info');
  let response;
  try {
    try {
      const fn = new Function('return (' + jsCode + ');');
      response = { ok: true, result: stringify(fn()) };
    } catch (eExpr) {
      try {
        (new Function(jsCode))();
        response = { ok: true, result: 'undefined' };
      } catch (eStmt) {
        response = { ok: false, error: (eStmt && eStmt.message) || String(eStmt) };
      }
    }
  } finally {
    console.log = orig.log;
    console.warn = orig.warn;
    console.error = orig.error;
    console.info = orig.info;
  }
  response.captured = captured;
  return response;
}

// JS Console — REPL: wartość ostatniego wyrażenia + przechwycone logi.
function jzRunConsole(tabId, frameId, code) {
  var target = { tabId: tabId, frameIds: [frameId || 0] };
  if (jzUserScriptsReady()) {
    return jzConsoleUserScripts(target, code);
  }
  return chrome.scripting.executeScript({
    target: target, world: 'MAIN', func: jzConsoleInjector, args: [code]
  }).then(function (results) {
    return (results && results[0] && results[0].result) || { ok: false, error: 'no-result', captured: [] };
  }).catch(function (err) {
    return { ok: false, error: err.message || 'executeScript failed', captured: [] };
  });
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  // Wykonanie JS reguły / akcji w świecie MAIN — patrz helpery jzRunInMain wyżej.
  if ((msg.type === 'execute-js' || msg.type === 'execute-action') && sender.tab) {
    const ctxId = msg.ruleId || msg.actionId;
    const payload = jzBuildActionPayload(msg.code, ctxId, msg.extraVars || null);
    jzRunInMain(sender.tab.id, sender.frameId || 0, payload).then((r) => {
      if (!r || !r.ok) {
        if (r && r.cspBlocked) {
          console.warn('[JustZix] Ta strona blokuje wykonywanie skryptów (CSP). ' +
            'Włącz „Skrypty użytkownika" dla JustZix w ustawieniach przeglądarki, ' +
            'aby JS reguł i akcji działał na tej stronie.');
        } else {
          console.error('[JustZix] JS error in rule set/action "' + ctxId + '":\n',
            (r && r.error) || 'unknown', '\nStack:', (r && r.stack) || '');
        }
      }
      try {
        sendResponse(r && r.ok
          ? { ok: true }
          : { ok: false, error: (r && r.error) || 'failed', cspBlocked: !!(r && r.cspBlocked) });
      } catch (_) {}
    });
    return true;
  }

  // JS pane — live evaluation. Analog do execute-js, BEZ memoizacji i bez
  // helperów window.JZ (pane to czysty scratch-pad). Sync errors → sendResponse.
  if (msg.type === 'execute-js-pane' && sender.tab) {
    jzRunInMain(sender.tab.id, sender.frameId || 0, msg.code || '').then((r) => {
      try {
        sendResponse(r && r.ok
          ? { ok: true }
          : { ok: false, error: (r && r.error) || 'failed', cspBlocked: !!(r && r.cspBlocked) });
      } catch (_) {}
    });
    return true;
  }

  // JS Console (od v2.13.66) — wykonuje EXPRESSION i zwraca serializowany result.
  // Różnica vs JS pane: tu chcemy WARTOŚĆ ostatniego expression (REPL behavior)
  // oraz przechwycone console.log/warn/error/info.
  if (msg.type === 'execute-js-console' && sender.tab) {
    jzRunConsole(sender.tab.id, sender.frameId || 0, msg.code || '').then((r) => {
      r = r || { ok: false, error: 'no-result', captured: [] };
      try {
        if (r.ok) sendResponse({ ok: true, result: r.result, captured: r.captured || [] });
        else sendResponse({ ok: false, error: r.error || '', cspBlocked: !!r.cspBlocked, captured: r.captured || [] });
      } catch (_) {}
    });
    return true;
  }

  // Output Console page hook (od v2.13.74) — wcześniej content script wstrzykiwał inline
  // <script> z textContent. Zawodziło przy stricter CSP strony (script-src 'self' bez 'unsafe-inline'
  // blokuje nasz inline script, chociaż dodanie elementu do DOM samo w sobie nie rzuca błędu —
  // skrypt po prostu się nie wykonuje). chrome.scripting.executeScript z `world: 'MAIN'` jest
  // CSP-immune (extension privileges przeważają), tak samo jak w execute-js-console.
  if (msg.type === 'inject-output-hook' && sender.tab) {
    chrome.scripting.executeScript({
      target: { tabId: sender.tab.id, frameIds: [sender.frameId || 0] },
      world: 'MAIN',
      func: () => {
        try {
          if (window.__jzOutputHookInstalled) return;
          window.__jzOutputHookInstalled = true;
          const orig = {
            log:   console.log,
            warn:  console.warn,
            error: console.error,
            info:  console.info,
            debug: console.debug,
          };
          const stringifyArg = (a) => {
            if (a === undefined) return 'undefined';
            if (a === null) return 'null';
            const t = typeof a;
            if (t === 'string') return a;
            if (t === 'number' || t === 'boolean') return String(a);
            if (t === 'function') return '[Function' + (a.name ? ' ' + a.name : '') + ']';
            if (a instanceof Error) return (a.stack || (a.name + ': ' + a.message));
            try {
              const seen = new WeakSet();
              return JSON.stringify(a, (k, v) => {
                if (typeof v === 'object' && v !== null) {
                  if (seen.has(v)) return '[Circular]';
                  seen.add(v);
                }
                if (typeof v === 'function') return '[Function]';
                if (typeof v === 'undefined') return 'undefined';
                return v;
              }, 2);
            } catch (e) { return String(a); }
          };
          const joinArgs = (args) => {
            const parts = [];
            for (let i = 0; i < args.length; i++) parts.push(stringifyArg(args[i]));
            return parts.join(' ');
          };
          // Podgląd (wpis zwinięty) — obiekty jako kompaktowy jednolinijkowy JSON, przycięty.
          const previewArg = (a) => {
            if (a === undefined) return 'undefined';
            if (a === null) return 'null';
            const t = typeof a;
            if (t === 'string') return a;
            if (t === 'number' || t === 'boolean') return String(a);
            if (t === 'function') return '[Function' + (a.name ? ' ' + a.name : '') + ']';
            if (a instanceof Error) return (a.name + ': ' + a.message);
            try {
              const seen = new WeakSet();
              let s = JSON.stringify(a, (k, v) => {
                if (typeof v === 'object' && v !== null) {
                  if (seen.has(v)) return '[Circular]';
                  seen.add(v);
                }
                if (typeof v === 'function') return '[Function]';
                return v;
              });
              if (s === undefined) s = String(a);
              return s.length > 240 ? s.slice(0, 240) + '…' : s;
            } catch (e) { return String(a); }
          };
          const post = (data) => {
            try { window.postMessage(Object.assign({ source: 'jz-output-console' }, data), '*'); } catch (e) {}
          };
          const postOut = (kind, level, args) => {
            let hasObj = false;
            const previews = [];
            for (let i = 0; i < args.length; i++) {
              previews.push(previewArg(args[i]));
              if (args[i] && typeof args[i] === 'object') hasObj = true;
            }
            // detail — pełna, czytelna postać (z wcięciami); pokazywana po rozwinięciu wpisu.
            post({
              kind: kind, level: level,
              text: previews.join(' '),
              detail: hasObj ? joinArgs(args) : '',
              t: Date.now(),
            });
          };
          const wrap = (level) => function() {
            postOut('console', level, arguments);
            try { return orig[level].apply(console, arguments); } catch (e) {}
          };
          console.log   = wrap('log');
          console.warn  = wrap('warn');
          console.error = wrap('error');
          console.info  = wrap('info');
          console.debug = wrap('debug');
          // Pozostałe metody console produkujące wyjście — przechwytywane jako poziom 'log'.
          ['trace', 'dir', 'dirxml', 'table', 'group', 'groupCollapsed'].forEach((m) => {
            if (typeof console[m] !== 'function') return;
            const o = console[m].bind(console);
            console[m] = function () {
              postOut('console', 'log', arguments);
              try { return o.apply(console, arguments); } catch (e) {}
            };
          });
          // Uncaught errors + unhandled promise rejections → kind='error' (osobna zakładka
          // Errors w Output Console). DevTools pokazuje je natywnie — NIE wywołują
          // console.error, więc wrap by je pominął. Capture phase, przed page handlers.
          window.addEventListener('error', (ev) => {
            try {
              // Błędy ładowania zasobów (img/script/link 404 itp.) też odpalają 'error'
              // w fazie capture — ich target to element, nie window. To NIE są błędy JS
              // (awarie sieciowe widać w zakładce Network), więc je pomijamy — inaczej
              // każdy nieudany zasób tworzył pusty wpis "Uncaught error".
              if (ev && ev.target && ev.target !== window && ev.target.nodeType) return;
              let message = '', stack = '';
              if (ev && ev.error instanceof Error) {
                message = ev.error.message || String(ev.error);
                stack = ev.error.stack || '';
              } else if (ev && ev.message) {
                message = String(ev.message);
              }
              if (!message) return; // brak treści — nic sensownego do pokazania
              const loc = (ev && ev.filename)
                ? (ev.filename + ':' + (ev.lineno || '?') + ':' + (ev.colno || '?'))
                : '';
              post({ kind: 'error', errType: 'error', message: message, loc: loc, stack: stack, t: Date.now() });
            } catch (_) {}
          }, true);
          window.addEventListener('unhandledrejection', (ev) => {
            try {
              const reason = ev && ev.reason;
              let message, stack = '';
              if (reason instanceof Error) {
                message = reason.message || String(reason);
                stack = reason.stack || '';
              } else if (typeof reason === 'string') {
                message = reason;
              } else {
                message = stringifyArg(reason);
              }
              post({ kind: 'error', errType: 'promise', message: message, loc: '', stack: stack, t: Date.now() });
            } catch (_) {}
          }, true);
          // Network — przechwytywane przez chrome.webRequest w service workerze
          // (background.js), nie tutaj. Page hook łapie tylko console/jz/error/datalayer.
          // DataLayer (GTM / GA4) — wrap push + odtworzenie historii. Best-effort: gdy
          // dataLayer pojawia się później, retry przez ~30 s.
          const dlEventName = (a) => {
            if (a && typeof a === 'object' && !Array.isArray(a) && a.event != null) return String(a.event);
            if (Array.isArray(a)) return String(a[0]);
            return '(no event)';
          };
          // Tłumienie szumu inspektora: kliknięcia we własne UI JustZix (okna,
          // paski akcji, pływający przycisk) bąbelkują do strony i bywają łapane
          // przez tracker (GTM auto-event), który robi dataLayer.push. To
          // zalewało zakładkę DataLayer i resetowało drzewo przy każdym kliknięciu.
          // NIE blokujemy propagacji (paski akcji jej potrzebują) — zamiast tego
          // pushy powstałe z kliknięcia w element JustZix nie raportujemy do Output.
          // Marker: id zaczyna się od "justzix-", klasa zaczyna się od "jz-".
          const JZ_UI_SEL = '[id^="justzix-"],[class^="jz-"]';
          let jzDlSuppressUntil = 0;
          const jzInJzUi = (el) => {
            try { return !!(el && el.closest && el.closest(JZ_UI_SEL)); }
            catch (_) { return false; }
          };
          const jzMarkClick = (ev) => {
            // Listener w fazie PRZECHWYTYWANIA — biegnie przed delegowanym
            // listenerem trackera (bubble), więc flaga jest ustawiona, gdy
            // tracker synchronicznie pushuje. Okno 60 ms łapie też lekko
            // opóźnione pushe.
            if (ev && jzInJzUi(ev.target)) jzDlSuppressUntil = Date.now() + 60;
          };
          try {
            document.addEventListener('click', jzMarkClick, true);
            document.addEventListener('pointerdown', jzMarkClick, true);
            document.addEventListener('mousedown', jzMarkClick, true);
          } catch (_) {}
          // Drugi sygnał: GTM auto-event niesie referencję klikniętego elementu
          // w 'gtm.element' — sprawdzamy ją wprost, niezależnie od kolejności listenerów.
          const jzPushFromJzUi = (a) => {
            try {
              if (a && typeof a === 'object') {
                const el = a['gtm.element'] || a.element;
                if (jzInJzUi(el)) return true;
              }
            } catch (_) {}
            return false;
          };
          // Migawka całego obiektu window.dataLayer (do prawej kolumny zakładki DataLayer).
          // Serializacja circular-safe, funkcje → '[Function]'. Debounce, by nie zalewać.
          let dlSnapTimer = null;
          const sendDlSnapshot = () => {
            try {
              const seen = new WeakSet();
              const json = JSON.stringify(window.dataLayer, (k, v) => {
                if (typeof v === 'object' && v !== null) {
                  if (seen.has(v)) return '[Circular]';
                  seen.add(v);
                }
                if (typeof v === 'function') return '[Function]';
                return v;
              });
              post({ kind: 'datalayer-state', json: json || '[]', t: Date.now() });
            } catch (e) {}
          };
          const scheduleDlSnapshot = () => {
            if (dlSnapTimer) return;
            dlSnapTimer = setTimeout(() => { dlSnapTimer = null; sendDlSnapshot(); }, 300);
          };
          let dlWrapped = false;
          const wrapDataLayer = () => {
            if (dlWrapped || !Array.isArray(window.dataLayer)) return;
            dlWrapped = true;
            try {
              window.dataLayer.forEach((e) => {
                post({ kind: 'datalayer', event: dlEventName(e), data: stringifyArg(e), history: true, t: Date.now() });
              });
            } catch (_) {}
            const origPush = window.dataLayer.push;
            window.dataLayer.push = function () {
              // Push z kliknięcia w UI JustZix — pomijamy w Output (ani wpisu,
              // ani migawki), żeby drzewo DataLayer się nie resetowało. Realny
              // window.dataLayer i tak go dostaje (origPush niżej).
              let suppress = Date.now() < jzDlSuppressUntil;
              if (!suppress) {
                for (let i = 0; i < arguments.length; i++) {
                  if (jzPushFromJzUi(arguments[i])) { suppress = true; break; }
                }
              }
              if (!suppress) {
                try {
                  for (let i = 0; i < arguments.length; i++) {
                    post({ kind: 'datalayer', event: dlEventName(arguments[i]), data: stringifyArg(arguments[i]), t: Date.now() });
                  }
                } catch (_) {}
              }
              const r = origPush.apply(window.dataLayer, arguments);
              if (!suppress) scheduleDlSnapshot();
              return r;
            };
            sendDlSnapshot();
          };
          wrapDataLayer();
          if (!dlWrapped) {
            let dlTries = 0;
            const dlTimer = setInterval(() => {
              wrapDataLayer();
              if (dlWrapped || ++dlTries > 60) clearInterval(dlTimer);
            }, 500);
          }
          // window.JZ.* — własny logger user code; NIE passthrough do oryginalnej console
          // (w odróżnieniu od console.* wrap). Per spec sekcja 6 + 12 q.7 fallback __JUSTZIX__.
          const jzWrap = (level) => function() { postOut('jz', level, arguments); };
          const jzApi = {
            log:   jzWrap('log'),
            warn:  jzWrap('warn'),
            error: jzWrap('error'),
            info:  jzWrap('info'),
            debug: jzWrap('debug'),
          };
          // Primary: window.JUSTZIX (od v2.13.76 — brand name, very low collision risk).
          // Aliasy: window.__JUSTZIX__ (zawsze ustawiony dla backwards-compat z v2.13.72-75)
          // i window.JZ (tylko jeśli nie zajęte przez stronę — krótszy skrót).
          const jzCollision = (typeof window.JZ !== 'undefined');
          window.JUSTZIX     = jzApi;
          window.__JUSTZIX__ = jzApi;
          if (!jzCollision) window.JZ = jzApi;
          // Welcome — kind='jz' żeby było widać w mode jz/both. Pokazuje wszystkie dostępne
          // aliasy żeby user wybrał wygodniejszy.
          const apiHint = jzCollision
            ? 'window.JUSTZIX (lub __JUSTZIX__; window.JZ zajęte przez stronę)'
            : 'window.JUSTZIX (lub JZ / __JUSTZIX__)';
          jzApi.info('[JustZix] Output Console hook gotowy. API: ' + apiHint + '.log/.warn/.error/.info/.debug');
        } catch (e) {
          try { console.warn('[JustZix] hook init error:', e); } catch (_) {}
        }
      },
    }).then(() => {
      try { sendResponse({ ok: true }); } catch (_) {}
    }).catch((err) => {
      try { sendResponse({ ok: false, error: err && err.message }); } catch (_) {}
    });
    return true;
  }

  if (msg.type === 'get-positions' && sender.tab) {
    (async () => {
      const windowId = sender.tab.windowId;
      const all = await loadPositions();
      const winPos = all[windowId] || {};
      sendResponse({
        floatingPos: winPos.floatingPos || { x: 20, y: 20 },
        actionsPos: winPos.actionsPos || { x: 80, y: 20 },
        actionsHidden: winPos.actionsHidden || false,
        // Faza 3 — per-bar pozycje i hide w obrębie okna.
        barPositions: winPos.barPositions || {},
        barsHidden: winPos.barsHidden || {},
        // Faza 4 — sklejanie pasków: tablica { aId, aSide, bId, bSide }.
        barConnections: winPos.barConnections || [],
      });
    })();
    return true;
  }

  if (msg.type === 'set-position' && sender.tab) {
    // Serializacja przez _setPositionChain (od v2.13.3) — zapobiega read-modify-write race
    // gdy parallel messages (np. group drag) chciałyby modyfikować stan jednocześnie.
    _setPositionChain = _setPositionChain.then(async () => {
      try {
        const windowId = sender.tab.windowId;
        const all = await loadPositions();
        if (!all[windowId]) all[windowId] = {};
        if (msg.floatingPos) all[windowId].floatingPos = msg.floatingPos;
        if (msg.actionsPos) all[windowId].actionsPos = msg.actionsPos;
        if (typeof msg.actionsHidden === 'boolean') all[windowId].actionsHidden = msg.actionsHidden;
        // Faza 3 — partial update mapy pozycji/hide per bar.
        if (msg.barPosition && msg.barPosition.barId) {
          if (!all[windowId].barPositions) all[windowId].barPositions = {};
          all[windowId].barPositions[msg.barPosition.barId] = { x: msg.barPosition.x, y: msg.barPosition.y };
        }
        // Batch update wielu pozycji jednocześnie (group drag z fazy 4).
        if (msg.barPositions && typeof msg.barPositions === 'object') {
          if (!all[windowId].barPositions) all[windowId].barPositions = {};
          for (const [bid, pos] of Object.entries(msg.barPositions)) {
            all[windowId].barPositions[bid] = { x: pos.x, y: pos.y };
          }
        }
        if (msg.barHidden && msg.barHidden.barId) {
          if (!all[windowId].barsHidden) all[windowId].barsHidden = {};
          all[windowId].barsHidden[msg.barHidden.barId] = !!msg.barHidden.hidden;
        }
        // Faza 4 — dodanie / usunięcie połączenia między dwoma barami.
        if (msg.addBarConnection) {
          if (!Array.isArray(all[windowId].barConnections)) all[windowId].barConnections = [];
          const c = msg.addBarConnection;
          // Idempotent: nie duplikuj. Para (aId,aSide)↔(bId,bSide) jest taka sama jak odwrócona.
          const exists = all[windowId].barConnections.some(x =>
            (x.aId === c.aId && x.aSide === c.aSide && x.bId === c.bId && x.bSide === c.bSide) ||
            (x.aId === c.bId && x.aSide === c.bSide && x.bId === c.aId && x.bSide === c.aSide)
          );
          if (!exists) all[windowId].barConnections.push({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide });
        }
        if (msg.removeBarConnection) {
          const c = msg.removeBarConnection;
          if (Array.isArray(all[windowId].barConnections)) {
            all[windowId].barConnections = all[windowId].barConnections.filter(x => !(
              (x.aId === c.aId && x.aSide === c.aSide && x.bId === c.bId && x.bSide === c.bSide) ||
              (x.aId === c.bId && x.aSide === c.bSide && x.bId === c.aId && x.bSide === c.aSide)
            ));
          }
        }
        await savePositions(all);
        sendResponse({ ok: true });
      } catch (e) {
        try { sendResponse({ ok: false, error: e.message }); } catch (_) {}
      }
    });
    return true;
  }

  // Otwieranie panelu edycji w osobnym oknie (od v3.2.4) — wywoływane skrótem
  // Ctrl+Alt+{I,O,P} z content scripta. target=null gdy żaden zestaw nie pasuje
  // do bieżącej strony → panel otwiera się bez wstępnej nawigacji.
  if (msg.type === 'open-edit-panel') {
    const tab = (msg.tab === 'js' || msg.tab === 'actions') ? msg.tab : 'css';
    const target = (msg.target && typeof msg.target === 'object') ? msg.target : null;
    const pending = target
      ? { folderId: target.folderId, groupId: target.groupId, ruleId: target.ruleId, tab: tab, ts: Date.now() }
      : null;
    chrome.storage.local.set({ jzPendingEditNav: pending });
    const optionsUrl = chrome.runtime.getURL('options.html');
    chrome.tabs.query({}, (tabs) => {
      const existing = (tabs || []).find(t => t && typeof t.url === 'string' && t.url.startsWith(optionsUrl));
      if (existing) {
        // Panel już otwarty w swoim oknie — aktywujemy je, options.js dostanie
        // cel przez storage.onChanged (jzPendingEditNav) i sam przejdzie do zestawu.
        try { chrome.windows.update(existing.windowId, { focused: true }); } catch (_) {}
        try { chrome.tabs.update(existing.id, { active: true }); } catch (_) {}
        try { sendResponse({ ok: true, reused: true }); } catch (_) {}
      } else {
        // Nowe okno, typ 'normal' — pełna przeglądarka z paskiem kart.
        chrome.windows.create({ url: optionsUrl, type: 'normal', width: 1200, height: 850 }, () => {
          try { sendResponse({ ok: true, reused: false }); } catch (_) {}
        });
      }
    });
    return true;
  }
});

// Sprzątanie pozycji okien
chrome.windows.onRemoved.addListener(async (windowId) => {
  const all = await loadPositions();
  if (all[windowId]) {
    delete all[windowId];
    await savePositions(all);
  }
});

// === Menu kontekstowe ===
//
// Od v2.13.22: zamiast legacy `ci-toggle-actions-panel` (toggle starego single panelu akcji)
// dodajemy dynamiczne submenu `ci-action-bars` z checkbox per relevant bar. Lista budowana
// na podstawie odpowiedzi `get-relevant-bars` od content scripta aktywnej karty —
// przebudowa na tabs.onActivated, tabs.onUpdated, oraz storage.onChanged (folders/groups/
// rules/actionBars). Checkbox `checked=true` → bar jest widoczny.
const BAR_MENU_PREFIX = 'ci-bar-';
const CSS_PANE_MENU_PREFIX = 'ci-csspane-';
const JS_PANE_MENU_PREFIX = 'ci-jspane-';
const JS_CONSOLE_MENU_PREFIX = 'ci-jsconsole-';
const OUTPUT_CONSOLE_MENU_PREFIX = 'ci-outputconsole-'; // od v2.13.72

// Serializacja buildContextMenus — module top-level call + onInstalled + onStartup + zmiana
// języka mogą wszystkie wystartować równolegle. Bez chain'a: dwa removeAll'e + dwa create({id:'ci-root'})
// → "duplicate id ci-root". Promise chain gwarantuje że tylko jeden build w danej chwili leci.
let _buildMenuChain = Promise.resolve();

function buildContextMenus() {
  _buildMenuChain = _buildMenuChain.then(() => doBuildContextMenus().catch(e => {
    console.warn('[JustZix] buildContextMenus error:', e);
  }));
  return _buildMenuChain;
}

// safeCreateContextMenu — pochłania lastError (np. duplicate id przy race), żeby nie
// spamować console'a Chromem "Unchecked runtime.lastError". Defense in depth — _buildMenuChain
// powinien wystarczyć, ale gdyby user-language-change albo restart wpadły w bardzo nieszczęsny moment.
function safeCreateContextMenu(props) {
  try {
    chrome.contextMenus.create(props, () => { void chrome.runtime.lastError; });
  } catch (_) {}
}

async function doBuildContextMenus() {
  // removeAll PRZED czekaniem na i18n — po restarcie SW Chrome wciąż trzyma
  // pozycje menu z poprzedniej inkarnacji SW; czyścimy je natychmiast, nie po
  // ewentualnie długim ładowaniu tłumaczeń (inaczej osierocone pozycje wisiały
  // w menu kontekstowym przez całe okno _i18nReady).
  await new Promise(res => chrome.contextMenus.removeAll(res));
  await _i18nReady;
  safeCreateContextMenu({
    id: 'ci-root',
    title: 'JustZix',
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-toggle-button',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.toggleFloat'),
    contexts: ['page', 'frame']
  });
  // Tryb edycji paska akcji (od v2.13.97) — toggle on/off.
  safeCreateContextMenu({
    id: 'ci-edit-mode',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.editMode'),
    contexts: ['page', 'frame']
  });
  // AI Helper (od v2.13.127) — toggle okna asystenta AI.
  safeCreateContextMenu({
    id: 'ci-ai-helper',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.aiHelper'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-action-bars',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.actionBars'),
    contexts: ['page', 'frame']
  });
  // Placeholder gdy lista pusta — wyłączony item informujący że nie ma barów.
  safeCreateContextMenu({
    id: 'ci-action-bars-empty',
    parentId: 'ci-action-bars',
    title: JZ_I18N.t('background.contextMenu.actionBarsEmpty'),
    enabled: false,
    contexts: ['page', 'frame']
  });
  // CSS panes submenu (od v2.13.42, Faza 2 "Okna na froncie") — analog do action-bars.
  safeCreateContextMenu({
    id: 'ci-css-panes',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.cssPanes'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-css-panes-empty',
    parentId: 'ci-css-panes',
    title: JZ_I18N.t('background.contextMenu.cssPanesEmpty'),
    enabled: false,
    contexts: ['page', 'frame']
  });
  // JS panes submenu (od v2.13.56) — analog do CSS panes.
  safeCreateContextMenu({
    id: 'ci-js-panes',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.jsPanes'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-js-panes-empty',
    parentId: 'ci-js-panes',
    title: JZ_I18N.t('background.contextMenu.jsPanesEmpty'),
    enabled: false,
    contexts: ['page', 'frame']
  });
  // JS Consoles submenu (od v2.13.68) — analog do JS panes.
  safeCreateContextMenu({
    id: 'ci-js-consoles',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.jsConsoles'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-js-consoles-empty',
    parentId: 'ci-js-consoles',
    title: JZ_I18N.t('background.contextMenu.jsConsolesEmpty'),
    enabled: false,
    contexts: ['page', 'frame']
  });
  // Output Consoles submenu (od v2.13.72) — analog.
  safeCreateContextMenu({
    id: 'ci-output-consoles',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.outputConsoles'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-output-consoles-empty',
    parentId: 'ci-output-consoles',
    title: JZ_I18N.t('background.contextMenu.outputConsolesEmpty'),
    enabled: false,
    contexts: ['page', 'frame']
  });
  // TEMP panes (od v2.13.81) — submenu "Tymczasowe okno" z 4 typami. Działa na każdej
  // stronie, niezależnie od folder match.
  safeCreateContextMenu({
    id: 'ci-temp-panes',
    parentId: 'ci-root',
    title: JZ_I18N.t('background.contextMenu.tempPanes'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-temp-css',
    parentId: 'ci-temp-panes',
    title: JZ_I18N.t('background.contextMenu.tempCss'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-temp-js',
    parentId: 'ci-temp-panes',
    title: JZ_I18N.t('background.contextMenu.tempJs'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-temp-jsconsole',
    parentId: 'ci-temp-panes',
    title: JZ_I18N.t('background.contextMenu.tempJsConsole'),
    contexts: ['page', 'frame']
  });
  safeCreateContextMenu({
    id: 'ci-temp-output',
    parentId: 'ci-temp-panes',
    title: JZ_I18N.t('background.contextMenu.tempOutput'),
    contexts: ['page', 'frame']
  });
  // Reset trackerów po removeAll — Chrome state jest pusty.
  _currentBarMenuIds = [];
  _currentCssPaneMenuIds = [];
  _currentJsPaneMenuIds = [];
  _currentJsConsoleMenuIds = [];
  _currentOutputConsoleMenuIds = [];

  // Po utworzeniu szkieletu — natychmiast zrekonstruuj listę dla aktywnej karty.
  try {
    const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
    if (activeTab) {
      rebuildBarsSubmenu(activeTab.id);
      rebuildCssPanesSubmenu(activeTab.id);
      rebuildJsPanesSubmenu(activeTab.id);
      rebuildJsConsolesSubmenu(activeTab.id);
      rebuildOutputConsolesSubmenu(activeTab.id);
    }
  } catch (_) {}
}

// Aktualnie utworzone IDs barów w submenu — żeby selektywnie usuwać przy następnej przebudowie.
let _currentBarMenuIds = [];
let _currentCssPaneMenuIds = [];
let _currentJsPaneMenuIds = [];
let _currentJsConsoleMenuIds = []; // od v2.13.68
let _currentOutputConsoleMenuIds = []; // od v2.13.72
// Serializacja rebuildów — równoległe wywołania (np. onActivated + storage onChanged) potrafiły
// stworzyć duplikaty / race przy update/remove.
let _rebuildChain = Promise.resolve();
let _rebuildCssPanesChain = Promise.resolve();
let _rebuildJsPanesChain = Promise.resolve();
let _rebuildJsConsolesChain = Promise.resolve(); // od v2.13.68
let _rebuildOutputConsolesChain = Promise.resolve(); // od v2.13.72

function rebuildBarsSubmenu(tabId) {
  // Chain za _buildMenuChain żeby parent ci-action-bars na pewno istniał przed create children'ów.
  _rebuildChain = _rebuildChain.then(() => _buildMenuChain).then(() => doRebuildBarsSubmenu(tabId).catch(e => {
    console.warn('[JustZix] rebuildBarsSubmenu error:', e);
  }));
  return _rebuildChain;
}

function rebuildCssPanesSubmenu(tabId) {
  _rebuildCssPanesChain = _rebuildCssPanesChain.then(() => _buildMenuChain).then(() => doRebuildCssPanesSubmenu(tabId).catch(e => {
    console.warn('[JustZix] rebuildCssPanesSubmenu error:', e);
  }));
  return _rebuildCssPanesChain;
}

function rebuildJsPanesSubmenu(tabId) {
  _rebuildJsPanesChain = _rebuildJsPanesChain.then(() => _buildMenuChain).then(() => doRebuildJsPanesSubmenu(tabId).catch(e => {
    console.warn('[JustZix] rebuildJsPanesSubmenu error:', e);
  }));
  return _rebuildJsPanesChain;
}

function rebuildJsConsolesSubmenu(tabId) {
  _rebuildJsConsolesChain = _rebuildJsConsolesChain.then(() => _buildMenuChain).then(() => doRebuildJsConsolesSubmenu(tabId).catch(e => {
    console.warn('[JustZix] rebuildJsConsolesSubmenu error:', e);
  }));
  return _rebuildJsConsolesChain;
}

function rebuildOutputConsolesSubmenu(tabId) {
  _rebuildOutputConsolesChain = _rebuildOutputConsolesChain.then(() => _buildMenuChain).then(() => doRebuildOutputConsolesSubmenu(tabId).catch(e => {
    console.warn('[JustZix] rebuildOutputConsolesSubmenu error:', e);
  }));
  return _rebuildOutputConsolesChain;
}

// Defensive remove — ignore "not found" lastError. Wrapper żeby uniknąć duplikatu
// `chrome.runtime.lastError` clearing logic. Używane przed create() w doRebuildBarsSubmenu,
// żeby chronić przed duplicate-id errorem gdy `_currentBarMenuIds` jest stale po SW restart
// (zmienne modułowe gubią się gdy SW idle-timeout, ale Chrome's contextMenu state — przeżywa).
function safeRemoveContextMenu(id) {
  return new Promise(res => {
    try {
      chrome.contextMenus.remove(id, () => { void chrome.runtime.lastError; res(); });
    } catch (_) { res(); }
  });
}

// Rebuild submenu kontekstowego per typ encji (bary, CSS/JS pane, JS/Output Console).
// Wszystkie 5 typów ma identyczną procedurę: pobierz relevantne itemy z content.js;
// usuń stare items (z trackera + defensywnie też te, które chcemy stworzyć — handluje
// SW-restart, gdy tracker jest stale po wybudzeniu, a Chrome wciąż ma items z poprzedniego
// incarnationa); pokaż/ukryj placeholder pusty; utwórz checkbox per item. Konfiguracja
// per typ w `cfg`; tracker przekazywany jako `prevIds`, zaktualizowana lista zwracana —
// caller przypisuje ją do swojego module-level `let`.
async function doRebuildSubmenu(cfg, tabId, prevIds) {
  let items = [];
  try {
    items = await new Promise((resolve) => {
      chrome.tabs.sendMessage(tabId, { type: cfg.msgType }, (resp) => {
        if (chrome.runtime.lastError || !resp || !Array.isArray(resp[cfg.respField])) resolve([]);
        else resolve(resp[cfg.respField]);
      });
    });
  } catch (_) {
    items = [];
  }

  const toRemove = new Set(prevIds);
  for (const it of items) toRemove.add(cfg.idPrefix + it[cfg.itemIdField]);
  for (const id of toRemove) {
    await safeRemoveContextMenu(id);
  }

  // Pokaż/ukryj placeholder pusty.
  try {
    chrome.contextMenus.update(cfg.emptyMenuId, { visible: items.length === 0 });
  } catch (_) {}

  // Stwórz nowe items per encja. Checkbox reflectuje persistent enabled state.
  const newIds = [];
  for (const it of items) {
    const id = cfg.idPrefix + it[cfg.itemIdField];
    try {
      chrome.contextMenus.create({
        id,
        parentId: cfg.parentMenuId,
        type: 'checkbox',
        checked: !!it.enabled,
        title: it.name || JZ_I18N.t(cfg.fallbackKey),
        contexts: ['page', 'frame'],
      }, () => {
        // Pochłaniaj lastError zamiast pozwalać Chrome'i logować jako "Unchecked runtime.lastError".
        // Jeśli mimo defensive remove'a i tak duplicate-id zaszedł (równoległe rebuilds),
        // nie crashujemy — następny rebuild i tak go ureguluje.
        void chrome.runtime.lastError;
      });
      newIds.push(id);
    } catch (_) {}
  }
  return newIds;
}

async function doRebuildBarsSubmenu(tabId) {
  _currentBarMenuIds = await doRebuildSubmenu({
    msgType: 'get-relevant-bars', respField: 'bars',
    idPrefix: BAR_MENU_PREFIX, itemIdField: 'barId',
    emptyMenuId: 'ci-action-bars-empty', parentMenuId: 'ci-action-bars',
    fallbackKey: 'background.contextMenu.actionBarFallback',
  }, tabId, _currentBarMenuIds);
}

async function doRebuildCssPanesSubmenu(tabId) {
  _currentCssPaneMenuIds = await doRebuildSubmenu({
    msgType: 'get-relevant-css-panes', respField: 'panes',
    idPrefix: CSS_PANE_MENU_PREFIX, itemIdField: 'paneId',
    emptyMenuId: 'ci-css-panes-empty', parentMenuId: 'ci-css-panes',
    fallbackKey: 'background.contextMenu.cssPaneFallback',
  }, tabId, _currentCssPaneMenuIds);
}

async function doRebuildJsPanesSubmenu(tabId) {
  _currentJsPaneMenuIds = await doRebuildSubmenu({
    msgType: 'get-relevant-js-panes', respField: 'panes',
    idPrefix: JS_PANE_MENU_PREFIX, itemIdField: 'paneId',
    emptyMenuId: 'ci-js-panes-empty', parentMenuId: 'ci-js-panes',
    fallbackKey: 'background.contextMenu.jsPaneFallback',
  }, tabId, _currentJsPaneMenuIds);
}

async function doRebuildJsConsolesSubmenu(tabId) {
  _currentJsConsoleMenuIds = await doRebuildSubmenu({
    msgType: 'get-relevant-js-consoles', respField: 'consoles',
    idPrefix: JS_CONSOLE_MENU_PREFIX, itemIdField: 'consoleId',
    emptyMenuId: 'ci-js-consoles-empty', parentMenuId: 'ci-js-consoles',
    fallbackKey: 'background.contextMenu.jsConsoleFallback',
  }, tabId, _currentJsConsoleMenuIds);
}

async function doRebuildOutputConsolesSubmenu(tabId) {
  _currentOutputConsoleMenuIds = await doRebuildSubmenu({
    msgType: 'get-relevant-output-consoles', respField: 'consoles',
    idPrefix: OUTPUT_CONSOLE_MENU_PREFIX, itemIdField: 'consoleId',
    emptyMenuId: 'ci-output-consoles-empty', parentMenuId: 'ci-output-consoles',
    fallbackKey: 'background.contextMenu.outputConsoleFallback',
  }, tabId, _currentOutputConsoleMenuIds);
}

chrome.runtime.onInstalled.addListener(() => { buildContextMenus(); ensureSyncAlarm(); });
chrome.runtime.onStartup.addListener(() => { buildContextMenus(); ensureSyncAlarm(); });

// Module top-level — handluje MV3 SW idle-wakeup (np. tab activated wybudza SW, ale
// ani onInstalled, ani onStartup nie odpalają). Bez tego `_currentBarMenuIds` jest empty
// po wake-up, a Chrome wciąż ma items z poprzedniego SW incarnationa → duplicate-id error
// przy pierwszym rebuildBarsSubmenu. buildContextMenus() robi removeAll() na start, więc
// reset state jest spójny z świeżą instancją SW.
buildContextMenus();
ensureSyncAlarm();

// Przebuduj context menu gdy user zmieni język w opcjach.
chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== 'local') return;
  if (changes.userLang) {
    buildContextMenus();
    return;
  }
  // Lista barów + checked-state (od v2.13.24 = persistent enabled, nie per-window hidden)
  // zależą od folders/groups/rules/actionBars/floatingHidden — przebuduj submenu na zmianę.
  if (changes.folders || changes.groups || changes.rules || changes.actionBars
      || changes.floatingHidden) {
    (async () => {
      try {
        const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (activeTab) rebuildBarsSubmenu(activeTab.id);
      } catch (_) {}
    })();
  }
  // CSS panes submenu rebuild — folders/groups/rules wpływają na discovery (URL match),
  // cssPanes na samą listę + enabled.
  if (changes.folders || changes.groups || changes.rules || changes.cssPanes) {
    (async () => {
      try {
        const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (activeTab) rebuildCssPanesSubmenu(activeTab.id);
      } catch (_) {}
    })();
  }
  // JS panes submenu rebuild — analog (od v2.13.56).
  if (changes.folders || changes.groups || changes.rules || changes.jsPanes) {
    (async () => {
      try {
        const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (activeTab) rebuildJsPanesSubmenu(activeTab.id);
      } catch (_) {}
    })();
  }
  // JS Consoles submenu rebuild — analog (od v2.13.68).
  if (changes.folders || changes.groups || changes.rules || changes.jsConsoles) {
    (async () => {
      try {
        const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (activeTab) rebuildJsConsolesSubmenu(activeTab.id);
      } catch (_) {}
    })();
  }
  // Output Consoles submenu rebuild — analog (od v2.13.72).
  if (changes.folders || changes.groups || changes.rules || changes.outputConsoles) {
    (async () => {
      try {
        const [activeTab] = await chrome.tabs.query({ active: true, currentWindow: true });
        if (activeTab) rebuildOutputConsolesSubmenu(activeTab.id);
      } catch (_) {}
    })();
  }
});

// Aktywacja karty → przebuduj submenu dla nowej aktywnej.
chrome.tabs.onActivated.addListener(({ tabId }) => {
  rebuildBarsSubmenu(tabId);
  rebuildCssPanesSubmenu(tabId);
  rebuildJsPanesSubmenu(tabId);
  rebuildJsConsolesSubmenu(tabId);
  rebuildOutputConsolesSubmenu(tabId);
});

// URL change w aktywnej karcie → przebuduj submenu.
chrome.tabs.onUpdated.addListener((tabId, info, tab) => {
  if (!tab || !tab.active) return;
  if (info.status === 'complete' || info.url) {
    rebuildBarsSubmenu(tabId);
    rebuildCssPanesSubmenu(tabId);
    rebuildJsPanesSubmenu(tabId);
    rebuildJsConsolesSubmenu(tabId);
    rebuildOutputConsolesSubmenu(tabId);
  }
});

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (!tab) return;
  if (info.menuItemId === 'ci-toggle-button') {
    chrome.tabs.sendMessage(tab.id, { type: 'toggle-floating-button' }).catch(() => {});
    return;
  }
  if (info.menuItemId === 'ci-edit-mode') {
    chrome.tabs.sendMessage(tab.id, { type: 'toggle-edit-mode' }).catch(() => {});
    return;
  }
  if (info.menuItemId === 'ci-ai-helper') {
    chrome.tabs.sendMessage(tab.id, { type: 'toggle-ai-helper' }).catch(() => {});
    return;
  }
  if (typeof info.menuItemId === 'string' && info.menuItemId.startsWith(BAR_MENU_PREFIX)) {
    const barId = info.menuItemId.slice(BAR_MENU_PREFIX.length);
    // info.checked = NOWY stan checkbox po kliknięciu. visible = checked.
    const visible = info.checked === true;
    chrome.tabs.sendMessage(tab.id, {
      type: 'set-bar-visibility',
      barId,
      visible,
    }).catch(() => {});
    return;
  }
  if (typeof info.menuItemId === 'string' && info.menuItemId.startsWith(CSS_PANE_MENU_PREFIX)) {
    const paneId = info.menuItemId.slice(CSS_PANE_MENU_PREFIX.length);
    const enabled = info.checked === true;
    chrome.tabs.sendMessage(tab.id, {
      type: 'set-css-pane-enabled',
      paneId,
      enabled,
    }).catch(() => {});
    return;
  }
  if (typeof info.menuItemId === 'string' && info.menuItemId.startsWith(JS_PANE_MENU_PREFIX)) {
    const paneId = info.menuItemId.slice(JS_PANE_MENU_PREFIX.length);
    const enabled = info.checked === true;
    chrome.tabs.sendMessage(tab.id, {
      type: 'set-js-pane-enabled',
      paneId,
      enabled,
    }).catch(() => {});
    return;
  }
  if (typeof info.menuItemId === 'string' && info.menuItemId.startsWith(JS_CONSOLE_MENU_PREFIX)) {
    const consoleId = info.menuItemId.slice(JS_CONSOLE_MENU_PREFIX.length);
    const enabled = info.checked === true;
    chrome.tabs.sendMessage(tab.id, {
      type: 'set-js-console-enabled',
      consoleId,
      enabled,
    }).catch(() => {});
    return;
  }
  if (typeof info.menuItemId === 'string' && info.menuItemId.startsWith(OUTPUT_CONSOLE_MENU_PREFIX)) {
    const consoleId = info.menuItemId.slice(OUTPUT_CONSOLE_MENU_PREFIX.length);
    const enabled = info.checked === true;
    chrome.tabs.sendMessage(tab.id, {
      type: 'set-output-console-enabled',
      consoleId,
      enabled,
    }).catch(() => {});
    return;
  }
  // TEMP panes (od v2.13.81) — chrome contextMenu items.
  const TEMP_MENU_TO_TYPE = {
    'ci-temp-css': 'css',
    'ci-temp-js': 'js',
    'ci-temp-jsconsole': 'jsconsole',
    'ci-temp-output': 'output',
  };
  if (TEMP_MENU_TO_TYPE[info.menuItemId]) {
    chrome.tabs.sendMessage(tab.id, {
      type: 'create-temp-pane',
      tempType: TEMP_MENU_TO_TYPE[info.menuItemId],
    }).catch(() => {});
  }
});

// === Sync poller (chrome.alarms) ============================================
// Co 60s — gdy user jest zalogowany — robi push pendingChanges/Deletes + pull
// nowych encji od backendu. UI (options.js) odbiera storage onChanged i
// aktualizuje listę katalogów.

async function ensureSyncAlarm() {
  try {
    const existing = await chrome.alarms.get(SYNC_ALARM_NAME);
    if (!existing) {
      chrome.alarms.create(SYNC_ALARM_NAME, {
        delayInMinutes: SYNC_ALARM_PERIOD_MIN,
        periodInMinutes: SYNC_ALARM_PERIOD_MIN,
      });
    }
  } catch (e) {
    console.warn('[JustZix sync] ensureSyncAlarm error:', e);
  }
}

chrome.alarms.onAlarm.addListener(async (alarm) => {
  if (alarm.name !== SYNC_ALARM_NAME) return;
  await runSyncTick({ trigger: 'alarm' });
});

// Wywoływane też ręcznie (z UI: po zalogowaniu, po manualnym "Synchronizuj").
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg && msg.type === 'sync-now') {
    (async () => {
      const r = await runSyncTick({ trigger: msg.trigger || 'manual' });
      sendResponse(r);
    })();
    return true;
  }
  if (msg && msg.type === 'sync-full-resync') {
    (async () => {
      const r = await runFullResync();
      sendResponse(r);
    })();
    return true;
  }
});

// Serializacja tików sync — alarm 60s, ręczny sync-now i after-save mogą zlecać
// runSyncTick równolegle. Nakładające się tiki podwójnie pushują, cofają kursor
// i gubią pendingChanges. Łańcuch promes wymusza wykonanie jeden-po-drugim;
// kolejny tik i tak widzi najświeższy stan (każdy robi JZ_SYNC.reload()).
let _syncTickChain = Promise.resolve();
function runSyncTick(opts) {
  const run = () => _runSyncTickImpl(opts || {});
  _syncTickChain = _syncTickChain.then(run, run);
  return _syncTickChain;
}

// Pełny resync (pull od since=0) — używany w rozwiązywaniu konfliktu logowania
// (options.js: strategie „Scal" / „Użyj danych z konta"). Idzie przez ten sam
// _syncTickChain co tiki: bez tego applyEntities resyncu nakładało się z
// applyEntities równoległego tika — równoległy read-modify-write na kolekcjach
// encji w storage gubił zapisy (lost update).
function runFullResync() {
  const run = async () => {
    try {
      await JZ_SYNC.reload();
      if (!JZ_SYNC.isAuthenticated()) return { ok: false, skipped: 'not_authenticated' };
      await JZ_SYNC.fullResyncFromServer();
      return { ok: true };
    } catch (e) {
      console.warn('[JustZix sync] full resync error:', e);
      return { ok: false, error: e && e.message };
    }
  };
  _syncTickChain = _syncTickChain.then(run, run);
  return _syncTickChain;
}

async function _runSyncTickImpl({ trigger } = {}) {
  try {
    // KRYTYCZNE: reload, nie init. Każdy kontekst (service worker, options page,
    // popup) ma własną kopię state w pamięci sync.js. Bez reload'u background
    // nigdy nie widzi pending changes wpisanych przez options.js — push pozostaje
    // pusty i nic nie idzie na backend.
    await JZ_SYNC.reload();
    if (!JZ_SYNC.isAuthenticated()) {
      return { ok: false, skipped: 'not_authenticated' };
    }
    // usage_stats — enqueue per-urządzenie liczniki użycia, gdy zmieniły się od
    // ostatniego pushu (id encji = deviceUuid). Pulpit sumuje wszystkie urządzenia.
    // `jzUsageStatsPushed` przesuwamy DOPIERO po potwierdzeniu, że backend
    // zaakceptował op (patrz niżej) — nie przy samym enqueue. Inaczej odrzucony
    // push (np. older_timestamp) i tak oznaczał deltę jako „wysłaną" i nigdy
    // nie wracała na serwer.
    let usageStatsConfirm = null;
    try {
      const ud = await chrome.storage.local.get(['jzUsageStats', 'jzUsageStatsPushed']);
      const cur = ud.jzUsageStats || {};
      const last = ud.jzUsageStatsPushed || {};
      const sig = (o) => `${o.cssApplied || 0}|${o.jsExecuted || 0}|${o.windowsShown || 0}`;
      if (sig(cur) !== sig(last)) {
        const uuid = JZ_SYNC.getState().deviceUuid;
        if (uuid) {
          await JZ_SYNC.enqueueChange({
            type: 'usage_stats', id: uuid, parentId: null,
            payload: {
              cssApplied: cur.cssApplied || 0,
              jsExecuted: cur.jsExecuted || 0,
              windowsShown: cur.windowsShown || 0,
            },
          });
          usageStatsConfirm = { uuid, cur };
        }
      }
    } catch (e) {}
    let pushed = null;
    let pulled = null;
    let applied = null;
    let anySuccess = false;
    try {
      pushed = await JZ_SYNC.flushPending();
      anySuccess = true;
    } catch (e) {
      console.warn('[JustZix sync] push error:', e.code || e.message);
    }
    // Potwierdzenie usage_stats — zapisz jzUsageStatsPushed tylko gdy backend
    // realnie zaakceptował op. Odrzucenie/brak pushu → następny tick ponowi
    // enqueueChange (ze świeższym updatedAt, więc older_timestamp się rozejdzie).
    if (usageStatsConfirm && pushed && Array.isArray(pushed.results)) {
      const ok = pushed.results.some(r =>
        r && r.type === 'usage_stats' && r.id === usageStatsConfirm.uuid && r.accepted === true);
      if (ok) {
        try { await chrome.storage.local.set({ jzUsageStatsPushed: usageStatsConfirm.cur }); }
        catch (e) {}
      }
    }
    // Pull — pomijamy przy 'after-save' bo powstałby spam pulla (saveAll co 500ms
    // przy ciągłym pisaniu w edytorze). Alarm co 60s + visibility/focus i tak
    // łapią zmiany z innych urządzeń.
    const skipPull = trigger === 'after-save';
    if (!skipPull) {
      try {
        pulled = await JZ_SYNC.pullOnce();
        anySuccess = true;
        if (pulled && Array.isArray(pulled.entities) && pulled.entities.length) {
          applied = await JZ_SYNC.applyEntities(pulled.entities);
          chrome.runtime.sendMessage({
            type: 'sync-pulled',
            count: pulled.entities.length,
            applied,
          }).catch(() => {});
        }
        // Kursor przesuwamy DOPIERO po udanym applyEntities (lub gdy strona pusta).
        // Wyjątek w applyEntities pomija tę linię → następny tick re-pull tej samej
        // strony (applyEntities jest idempotentne: upserty replace-by-id, tombstone'y).
        if (pulled) await JZ_SYNC.commitPullCursor(pulled.cursor);
      } catch (e) {
        console.warn('[JustZix sync] pull error:', e.code || e.message);
      }
    }
    if (anySuccess) await JZ_SYNC.markSynced();
    // Historia synchronizacji — log lokalny per urządzenie (zakładka Status).
    try { await _appendSyncHistory({ trigger, pushed, pulled, applied }); }
    catch (e) { console.debug('JustZix: _appendSyncHistory nie powiódł się', e); /* historia nie może wywrócić ticka */ }
    return { ok: true, trigger, pushed, pulled, applied, skippedPull: skipPull };
  } catch (e) {
    console.warn('[JustZix sync] tick error:', e);
    return { ok: false, error: e.message };
  }
}

// Historia synchronizacji — log lokalny TEGO urządzenia (nie synchronizowany,
// inaczej powstałaby pętla sync→wpis→sync). Każdy tick, który coś realnie
// zmienił (push zaakceptowany albo pull zastosowany), dopisuje wpis: czas,
// urządzenia inicjujące, liczby zmian i stan biblioteki po synchronizacji.
const SYNC_HISTORY_KEY = 'jzSyncHistory';
const SYNC_HISTORY_MAX = 100;
async function _appendSyncHistory({ trigger, pushed, pulled, applied }) {
  const pushedN  = (pushed  && pushed.accepted) || 0;
  const appliedN = (applied && applied.applied) || 0;
  const deletedN = (applied && applied.deletes) || 0;
  // Ręczna synchronizacja ("Synchronizuj teraz") ZAWSZE zostawia wpis — nawet
  // gdy 0 zmian — by user widział, że akcja się wykonała. Tiki automatyczne
  // (alarm 60s, after-save) logują się tylko, gdy coś realnie zmieniły.
  const isManual = trigger === 'manual-ui' || trigger === 'manual';
  if (!isManual && pushedN === 0 && appliedN === 0 && deletedN === 0) return;
  const st = JZ_SYNC.getState();
  const ownUuid = st.deviceUuid || null;
  // Urządzenia, z których przyszły pobrane zmiany (bez własnego).
  const fromSet = new Set();
  for (const e of (pulled && Array.isArray(pulled.entities) ? pulled.entities : [])) {
    if (e && e.deviceUuid && e.deviceUuid !== ownUuid) fromSet.add(e.deviceUuid);
  }
  // Stan biblioteki po synchronizacji — liczymy encje OSIĄGALNE w drzewie
  // (spójnie z pulpitem: grupa musi mieć folder, zestaw — grupę).
  const d = await chrome.storage.local.get([
    'folders', 'groups', 'rules', 'actionBars',
    'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles',
  ]);
  const arr = (k) => (Array.isArray(d[k]) ? d[k] : []);
  const folderIds = new Set(arr('folders').map(f => f.id));
  const groupById = new Map(arr('groups').map(g => [g.id, g]));
  const reachGroups = arr('groups').filter(g => folderIds.has(g.folderId));
  const reachRules = arr('rules').filter(r => {
    const g = groupById.get(r.groupId);
    return g && folderIds.has(g.folderId);
  });
  let actions = 0;
  for (const r of reachRules) actions += Array.isArray(r.actions) ? r.actions.length : 0;
  // Paski/okna są osiągalne, gdy ich rodzic (payload.parentType) jest osiągalny —
  // spójnie z pulpitem i z fetchServerCounts po stronie sync.js.
  const reachGroupIds = new Set(reachGroups.map(g => g.id));
  const reachRuleIds = new Set(reachRules.map(r => r.id));
  const reachParent = (pType, pId) => {
    if (!pId) return false;
    if (pType === 'folder') return folderIds.has(pId);
    if (pType === 'group') return reachGroupIds.has(pId);
    if (pType === 'rule') return reachRuleIds.has(pId);
    return false;
  };
  const reachWin = (x) => reachParent(x && x.parentType, x && x.parentId);
  const bars = arr('actionBars').filter(reachWin).length;
  const windows = arr('cssPanes').filter(reachWin).length
    + arr('jsPanes').filter(reachWin).length
    + arr('jsConsoles').filter(reachWin).length
    + arr('outputConsoles').filter(reachWin).length;
  // Konkretne zmiany w tym ticku: wypchnięte (dir 'out', z pushed.pushedDetail)
  // i pobrane/zastosowane (dir 'in', z applied.changes). Limit 80 — historia
  // ma być czytelna, nie pełnym audytem.
  const changes = [];
  for (const c of (pushed && Array.isArray(pushed.pushedDetail) ? pushed.pushedDetail : [])) {
    if (c) changes.push({ dir: 'out', op: c.op || 'change', type: c.type, name: c.name || null });
  }
  for (const c of (applied && Array.isArray(applied.changes) ? applied.changes : [])) {
    if (c) changes.push({ dir: 'in', op: c.op || 'edit', type: c.type, name: c.name || null });
  }
  if (changes.length > 80) changes.length = 80;
  const entry = {
    ts: Date.now(),
    trigger: trigger || 'auto',
    deviceUuid: ownUuid,
    deviceLabel: st.deviceLabel || null,
    pushed: pushedN,
    pulled: appliedN,
    deleted: deletedN,
    fromDevices: [...fromSet],
    changes,
    counts: {
      folder: arr('folders').length,
      group:  reachGroups.length,
      rule:   reachRules.length,
      action: actions,
      bar:    bars,
      window: windows,
    },
  };
  let hist = [];
  try {
    const h = await chrome.storage.local.get([SYNC_HISTORY_KEY]);
    if (Array.isArray(h[SYNC_HISTORY_KEY])) hist = h[SYNC_HISTORY_KEY];
  } catch (_) {}
  hist.push(entry);
  if (hist.length > SYNC_HISTORY_MAX) hist = hist.slice(-SYNC_HISTORY_MAX);
  await chrome.storage.local.set({ [SYNC_HISTORY_KEY]: hist });
}

// ============================================================
// Network capture dla Output Console — chrome.webRequest (od v2.13.118)
// ============================================================
// Page hook (fetch/XHR wrap) łapał tylko fetch i XHR. webRequest widzi WSZYSTKIE
// żądania karty + daje typ zasobu, status, remote IP, initiator. Listenery muszą
// być rejestrowane synchronicznie na top-level, by przeżyć restart service workera.
// Mapa _jzNetReq trzyma stan in-flight; przy uśpieniu SW w trakcie żądania wpis
// przepada — akceptowalne dla narzędzia deweloperskiego.
const _jzNetReq = new Map();
// Bufor ostatnich zdarzeń sieciowych per tabId — Output Console montuje się PO starcie
// ładowania strony, więc wczesne żądania (dokument, CSS, JS) potrzebują odtworzenia.
const _jzNetLog = new Map();
const JZ_NET_BUFFER_MAX = 1000;
// Cap mapy in-flight żądań _jzNetReq: po przekroczeniu MAX garbage-collect usuwa
// najstarsze wpisy (najpierw przeterminowane) aż do TARGET.
const JZ_NET_REQ_MAX = 4000;
const JZ_NET_REQ_TARGET = 3000;
function _jzNetBufferPush(tabId, payload) {
  let arr = _jzNetLog.get(tabId);
  if (!arr) { arr = []; _jzNetLog.set(tabId, arr); }
  arr.push(payload);
  if (arr.length > JZ_NET_BUFFER_MAX) arr.splice(0, arr.length - JZ_NET_BUFFER_MAX);
}

function _jzNetCategory(type, url) {
  const u = String(url || '').split(/[?#]/)[0].toLowerCase();
  if (u.endsWith('.wasm')) return 'wasm';
  if (u.endsWith('.webmanifest') || u.endsWith('manifest.json')) return 'manifest';
  switch (type) {
    case 'main_frame':
    case 'sub_frame':      return 'doc';
    case 'stylesheet':     return 'css';
    case 'script':         return 'js';
    case 'image':          return 'img';
    case 'font':           return 'font';
    case 'media':          return 'media';
    case 'websocket':      return 'socket';
    case 'xmlhttprequest': return 'xhr';
    default:               return 'other';
  }
}

function _jzNetSend(tabId, payload) {
  if (typeof tabId !== 'number' || tabId < 0) return;
  _jzNetBufferPush(tabId, payload);
  try {
    chrome.tabs.sendMessage(tabId, Object.assign({ type: 'jz-network-event' }, payload), () => {
      void chrome.runtime.lastError; // brak content scriptu w karcie — ignorujemy
    });
  } catch (_) {}
}

if (chrome.webRequest) {
  const NET_FILTER = { urls: ['<all_urls>'] };

  chrome.webRequest.onBeforeRequest.addListener((d) => {
    if (d.tabId < 0) return;
    // Nowa nawigacja głównego dokumentu — czyścimy bufor poprzedniej strony.
    if (d.type === 'main_frame') _jzNetLog.set(d.tabId, []);
    _jzNetReq.set(d.requestId, {
      tabId: d.tabId,
      start: d.timeStamp,
      ip: '',
      size: null,
      status: 'pending',
    });
    // Bezpiecznik przeciw wyciekowi. Najstarsze wpisy to nie zawsze śmieci — żądanie
    // w locie wstawione wcześnie (slow download, SSE, websocket) też tam jest. Dlatego
    // najpierw usuwamy tylko rekordy starsze niż 5 min: to osierocone wpisy, których
    // onCompleted/onErrorOccurred nigdy nie dotarło (SW zasnął). Żądania w locie typowo
    // trwają sekundy. Ślepe usuwanie najstarszych jest tylko ostatecznością.
    if (_jzNetReq.size > JZ_NET_REQ_MAX) {
      const STALE_MS = 5 * 60 * 1000;
      for (const [k, rec] of _jzNetReq) {
        if (_jzNetReq.size <= JZ_NET_REQ_TARGET) break;
        if (d.timeStamp - rec.start > STALE_MS) _jzNetReq.delete(k);
      }
      if (_jzNetReq.size > JZ_NET_REQ_MAX) {
        const drop = _jzNetReq.size - JZ_NET_REQ_TARGET;
        let i = 0;
        for (const k of _jzNetReq.keys()) {
          if (i++ >= drop) break;
          _jzNetReq.delete(k);
        }
      }
    }
    _jzNetSend(d.tabId, {
      phase: 'start',
      netId: String(d.requestId),
      method: d.method,
      url: d.url,
      netCat: _jzNetCategory(d.type, d.url),
      initiator: d.documentUrl || d.initiator || '',
      t: Date.now(),
    });
  }, NET_FILTER);

  chrome.webRequest.onHeadersReceived.addListener((d) => {
    const rec = _jzNetReq.get(d.requestId);
    if (!rec) return;
    const headers = d.responseHeaders || [];
    for (let i = 0; i < headers.length; i++) {
      if (headers[i].name && headers[i].name.toLowerCase() === 'content-length') {
        const n = parseInt(headers[i].value, 10);
        if (!isNaN(n)) rec.size = n;
        break;
      }
    }
  }, NET_FILTER, ['responseHeaders']);

  chrome.webRequest.onResponseStarted.addListener((d) => {
    const rec = _jzNetReq.get(d.requestId);
    if (!rec) return;
    if (d.ip) rec.ip = d.ip;
    if (typeof d.statusCode === 'number') rec.status = d.statusCode;
  }, NET_FILTER);

  chrome.webRequest.onCompleted.addListener((d) => {
    const rec = _jzNetReq.get(d.requestId);
    if (!rec) return;
    _jzNetReq.delete(d.requestId);
    if (d.ip) rec.ip = d.ip;
    if (typeof d.statusCode === 'number') rec.status = d.statusCode;
    _jzNetSend(rec.tabId, {
      phase: 'end',
      netId: String(d.requestId),
      status: rec.status,
      ip: rec.ip,
      size: rec.size,
      duration: Math.max(0, Math.round(d.timeStamp - rec.start)),
      fromCache: !!d.fromCache,
    });
  }, NET_FILTER);

  chrome.webRequest.onErrorOccurred.addListener((d) => {
    const rec = _jzNetReq.get(d.requestId);
    if (!rec) return;
    _jzNetReq.delete(d.requestId);
    _jzNetSend(rec.tabId, {
      phase: 'end',
      netId: String(d.requestId),
      status: 'ERR',
      error: d.error || 'net error',
      duration: Math.max(0, Math.round(d.timeStamp - rec.start)),
    });
  }, NET_FILTER);
}

// Bufor sieci na żądanie content scriptu (po zamontowaniu Output Console) — pozwala
// odtworzyć żądania sprzed montażu okna, w tym sam dokument strony.
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg && msg.type === 'jz-get-net-buffer' && sender.tab) {
    sendResponse({ events: _jzNetLog.get(sender.tab.id) || [] });
  }
});
chrome.tabs.onRemoved.addListener((tabId) => { _jzNetLog.delete(tabId); });

// ============================================================
// AI Helper — warstwa dostawców (od v2.13.127)
// ============================================================
// Klucze API żyją w chrome.storage.local pod kluczem 'aiHelper'. NIGDY nie są
// synchronizowane: sync ma jawną allow-listę typów encji (folder/group/rule/
// action/bar/pane'y) — 'aiHelper' nie jest do niej dodany, więc nie wychodzi.
// Wywołania API idą przez background (service worker): klucze nie trafiają do
// MAIN world strony, a fetch SW omija CSP connect-src strony.
const AI_PROVIDERS = {
  openai: {
    label: 'OpenAI',
    modelsUrl: 'https://api.openai.com/v1/models',
    chatUrl:   'https://api.openai.com/v1/chat/completions',
    priority:  ['gpt-4.1', 'gpt-4o', 'o4', 'o3', 'o1', 'gpt-4', 'gpt-3.5'],
  },
  anthropic: {
    label: 'Anthropic',
    modelsUrl: 'https://api.anthropic.com/v1/models',
    chatUrl:   'https://api.anthropic.com/v1/messages',
    priority:  ['opus-4', 'sonnet-4', 'opus', 'sonnet', 'haiku'],
  },
  gemini: {
    label: 'Gemini',
    modelsUrl: 'https://generativelanguage.googleapis.com/v1beta/models',
    priority:  ['2.5-pro', '2.0-pro', '1.5-pro', 'pro', 'flash'],
  },
};

async function _aiGetConfig() {
  // Klucze API żyją w chrome.storage.local.aiHelper. Klucze zaznaczone do
  // synchronizacji są dostarczane na to urządzenie przez JZ_SYNC (encja 'ai_keys',
  // patrz applyEntities w lib/sync.js) — wszystko trafia do tego samego miejsca.
  const local = (await chrome.storage.local.get(['aiHelper'])).aiHelper || {};
  return {
    keys: local.keys || {},
    defaultProvider: local.defaultProvider || '',
    defaultModel: local.defaultModel || {},
  };
}

function _aiErr(j, status) {
  if (j && j.error) {
    if (typeof j.error === 'string') return j.error;
    if (j.error.message) return j.error.message;
  }
  return 'HTTP ' + (status || '?');
}

function _aiPickDefault(provider, models) {
  const prio = (AI_PROVIDERS[provider] || {}).priority || [];
  for (const p of prio) {
    const hit = models.find(m => m.toLowerCase().includes(p));
    if (hit) return hit;
  }
  return models[0] || '';
}

// fetch z twardym timeoutem (AbortController). Bez tego zawieszony provider
// trzymałby otwarty kanał odpowiedzi, a MV3 i tak ubiłby service workera po
// ~5 min — AI Helper wisiałby bez błędu. Zwraca jawny błąd po przekroczeniu.
function _aiFetch(url, opts, timeoutMs) {
  const ctrl = new AbortController();
  const t = setTimeout(() => ctrl.abort(), timeoutMs || 120000);
  return fetch(url, Object.assign({}, opts || {}, { signal: ctrl.signal }))
    .catch((err) => {
      if (err && err.name === 'AbortError') throw new Error('timeout — provider nie odpowiedział');
      throw err;
    })
    .finally(() => clearTimeout(t));
}

async function _aiListModels(provider, keyOverride) {
  // keyOverride — klucz wpisany w modalu konfiguracji, jeszcze nie zapisany do
  // storage (modal "Pobierz modele" nie utrwala kluczy przed kliknięciem Zapisz).
  const cfg = await _aiGetConfig();
  const key = keyOverride || (cfg.keys || {})[provider];
  if (!key) throw new Error('no-key');
  if (provider === 'openai') {
    const r = await _aiFetch(AI_PROVIDERS.openai.modelsUrl, { headers: { Authorization: 'Bearer ' + key } }, 45000);
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(_aiErr(j, r.status));
    return (j.data || []).map(m => m.id).filter(id => /^(gpt-|o[0-9]|chatgpt)/i.test(id)).sort();
  }
  if (provider === 'anthropic') {
    const r = await _aiFetch(AI_PROVIDERS.anthropic.modelsUrl, { headers: {
      'x-api-key': key, 'anthropic-version': '2023-06-01',
      'anthropic-dangerous-direct-browser-access': 'true',
    } }, 45000);
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(_aiErr(j, r.status));
    return (j.data || []).map(m => m.id);
  }
  if (provider === 'gemini') {
    const r = await _aiFetch(AI_PROVIDERS.gemini.modelsUrl + '?pageSize=200&key=' + encodeURIComponent(key), {}, 45000);
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(_aiErr(j, r.status));
    return (j.models || [])
      .filter(m => (m.supportedGenerationMethods || []).includes('generateContent'))
      .map(m => String(m.name || '').replace(/^models\//, ''))
      .filter(id => id.includes('gemini'));
  }
  throw new Error('unknown-provider');
}

// od v3.2.28 — multimodal: messages mogą mieć opc. attachments=[{mimeType, base64}].
// Per-provider mapowanie: OpenAI image_url, Anthropic image source.base64, Gemini inline_data.
// v3.2.45 — zwraca { content, usage:{input, output} } zamiast samego content.
// Per-provider parsing usage:
//   OpenAI:    response.usage.{prompt_tokens, completion_tokens}
//   Anthropic: response.usage.{input_tokens, output_tokens}
//   Gemini:    response.usageMetadata.{promptTokenCount, candidatesTokenCount}
async function _aiChat({ provider, model, system, messages }) {
  const cfg = await _aiGetConfig();
  const key = (cfg.keys || {})[provider];
  if (!key) throw new Error('no-key');
  const hist = Array.isArray(messages) ? messages : [];
  if (provider === 'openai') {
    const msgs = [];
    if (system) msgs.push({ role: 'system', content: system });
    hist.forEach(m => {
      if (Array.isArray(m.attachments) && m.attachments.length) {
        const parts = m.attachments.map(a => ({
          type: 'image_url',
          image_url: { url: 'data:' + a.mimeType + ';base64,' + a.base64, detail: 'auto' },
        }));
        if (m.content) parts.push({ type: 'text', text: m.content });
        msgs.push({ role: m.role, content: parts });
      } else {
        msgs.push({ role: m.role, content: m.content });
      }
    });
    // v3.2.52 — no API `stop` param. Hallucination is cut client-side in
    // content.js (trim after first @@END@@), which works for every model
    // including reasoning ones (gpt-5.5/o-series) that reject `stop`.
    const r = await _aiFetch(AI_PROVIDERS.openai.chatUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + key },
      body: JSON.stringify({ model, messages: msgs }),
    }, 120000);
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(_aiErr(j, r.status));
    const content = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) || '';
    const u = j.usage || {};
    // OpenAI: prompt_tokens to CAŁY input; cached_tokens to jego podzbiór.
    return {
      content,
      usage: {
        input: u.prompt_tokens || 0,
        output: u.completion_tokens || 0,
        cached: (u.prompt_tokens_details && u.prompt_tokens_details.cached_tokens) || 0,
      },
    };
  }
  if (provider === 'anthropic') {
    const r = await _aiFetch(AI_PROVIDERS.anthropic.chatUrl, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json', 'x-api-key': key,
        'anthropic-version': '2023-06-01',
        'anthropic-dangerous-direct-browser-access': 'true',
      },
      body: JSON.stringify({
        model, max_tokens: 4096,
        system: system || undefined,
        messages: hist.map(m => {
          if (Array.isArray(m.attachments) && m.attachments.length) {
            const blocks = m.attachments.map(a => ({
              type: 'image',
              source: { type: 'base64', media_type: a.mimeType, data: a.base64 },
            }));
            if (m.content) blocks.push({ type: 'text', text: m.content });
            return { role: m.role, content: blocks };
          }
          return { role: m.role, content: m.content };
        }),
      }),
    }, 120000);
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(_aiErr(j, r.status));
    const content = (j.content || []).filter(c => c.type === 'text').map(c => c.text).join('');
    const u = j.usage || {};
    // Anthropic: input_tokens to TYLKO input niecache'owany; cache_read/creation są
    // osobno. Normalizujemy `input` do CAŁOŚCI (jak OpenAI/Gemini), `cached` = read.
    return {
      content,
      usage: {
        input: (u.input_tokens || 0) + (u.cache_read_input_tokens || 0) + (u.cache_creation_input_tokens || 0),
        output: u.output_tokens || 0,
        cached: u.cache_read_input_tokens || 0,
      },
    };
  }
  if (provider === 'gemini') {
    const url = AI_PROVIDERS.gemini.modelsUrl + '/' + encodeURIComponent(model) +
      ':generateContent?key=' + encodeURIComponent(key);
    const body = { contents: hist.map(m => {
      const parts = [];
      if (Array.isArray(m.attachments) && m.attachments.length) {
        m.attachments.forEach(a => parts.push({ inline_data: { mime_type: a.mimeType, data: a.base64 } }));
      }
      if (m.content) parts.push({ text: m.content });
      else if (!parts.length) parts.push({ text: '' });
      return { role: m.role === 'assistant' ? 'model' : 'user', parts };
    }) };
    if (system) body.systemInstruction = { parts: [{ text: system }] };
    const r = await _aiFetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }, 120000);
    const j = await r.json().catch(() => ({}));
    if (!r.ok) throw new Error(_aiErr(j, r.status));
    const content = ((j.candidates && j.candidates[0] && j.candidates[0].content &&
      j.candidates[0].content.parts) || []).map(p => p.text || '').join('');
    const u = j.usageMetadata || {};
    // Gemini: promptTokenCount to CAŁY input; cachedContentTokenCount to podzbiór.
    return {
      content,
      usage: {
        input: u.promptTokenCount || 0,
        output: u.candidatesTokenCount || 0,
        cached: u.cachedContentTokenCount || 0,
      },
    };
  }
  throw new Error('unknown-provider');
}

// --- AI Helper: tworzenie encji (folder/grupa/zestaw) z poziomu narzędzi modelu ---
// (od v2.13.128). Zapis idzie do chrome.storage.local; options.js wykrywa zewnętrzny
// write i przeładowuje stan. enqueueChange dba o sync cross-device.
function _aiGenId(prefix) {
  return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}
function _aiSyncEnqueue(type, id, parentId, payload) {
  try {
    if (typeof JZ_SYNC !== 'undefined' && JZ_SYNC.enqueueChange) {
      JZ_SYNC.enqueueChange({ type, id, parentId, payload });
    }
  } catch (_) {}
}
async function _aiCreateEntity(entityType, data) {
  data = data || {};
  // Stan sync ma osobną kopię per kontekst (service worker vs options.js). Odśwież
  // z storage przed enqueueChange — inaczej saveState() nadpisałby kolejkę
  // pendingChanges zakolejkowaną w innym kontekście stałą migawką.
  try { if (typeof JZ_SYNC !== 'undefined' && JZ_SYNC.reload) await JZ_SYNC.reload(); } catch (_) {}
  if (entityType === 'folder') {
    const store = await chrome.storage.local.get(['folders', 'groups']);
    const folders = Array.isArray(store.folders) ? store.folders : [];
    const groups = Array.isArray(store.groups) ? store.groups : [];
    const folder = {
      id: _aiGenId('f'),
      name: String(data.name || 'AI Helper'),
      urlPatterns: Array.isArray(data.urlPatterns) ? data.urlPatterns.map(String) : [],
      urlPattern: '',
      active: true,
      enabled: true,
      showActionsPanel: false,
      actionsOrientation: 'vertical',
      color: typeof data.color === 'string' ? data.color : '',
      icon: typeof data.icon === 'string' ? data.icon.slice(0, 3).toUpperCase() : '',
    };
    folder.urlPattern = folder.urlPatterns[0] || '';
    const group = {
      id: _aiGenId('g'), folderId: folder.id, name: 'Domyślna',
      active: true, enabled: true, urlPatterns: [],
    };
    folders.push(folder);
    groups.push(group);
    await chrome.storage.local.set({ folders, groups, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('folder', folder.id, null, folder);
    _aiSyncEnqueue('group', group.id, folder.id, group);
    return { folderId: folder.id, groupId: group.id };
  }
  if (entityType === 'group') {
    const store = await chrome.storage.local.get(['folders', 'groups']);
    const folders = Array.isArray(store.folders) ? store.folders : [];
    const groups = Array.isArray(store.groups) ? store.groups : [];
    if (!folders.some(f => f.id === String(data.folderId))) throw new Error('folder not found: ' + data.folderId);
    const wantName = String(data.name || 'Grupa');
    // Idempotencja po nazwie — folder ma już domyślną grupę „Domyślna" (tworzoną
    // razem z folderem); bez tego model mógłby utworzyć jej duplikat.
    const existingGroup = groups.find(g => g.folderId === String(data.folderId) &&
      (g.name || '').toLowerCase() === wantName.toLowerCase());
    if (existingGroup) return { groupId: existingGroup.id };
    const group = {
      id: _aiGenId('g'), folderId: String(data.folderId), name: wantName,
      active: true, enabled: true,
      urlPatterns: Array.isArray(data.urlPatterns) ? data.urlPatterns.map(String) : [],
    };
    groups.push(group);
    await chrome.storage.local.set({ groups, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('group', group.id, group.folderId, group);
    return { groupId: group.id };
  }
  if (entityType === 'rule') {
    const store = await chrome.storage.local.get(['groups', 'rules']);
    const groups = Array.isArray(store.groups) ? store.groups : [];
    const rules = Array.isArray(store.rules) ? store.rules : [];
    if (!groups.some(g => g.id === String(data.groupId))) throw new Error('group not found: ' + data.groupId);
    const rule = {
      id: _aiGenId('r'), groupId: String(data.groupId), name: String(data.name || 'Zestaw'),
      css: typeof data.css === 'string' ? data.css : '',
      js: typeof data.js === 'string' ? data.js : '',
      active: true, enabled: true, tags: [],
      urlPatterns: Array.isArray(data.urlPatterns) ? data.urlPatterns.map(String) : [],
      history: [], actions: [], updatedAt: Date.now(),
    };
    rules.push(rule);
    await chrome.storage.local.set({ rules, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('rule', rule.id, rule.groupId, rule);
    return { ruleId: rule.id };
  }
  // od v3.2.25 — persistent okna na froncie (cssPane/jsPane/jsConsole/outputConsole).
  // Wspólny shape: id, parentType, parentId, name, enabled, color, position, size, plus
  // headerBg/Text/bodyBg/Text. JS pane / JS console / Output console mają runColor.
  // Output console ma viewMode ('standard'|'jz'|'both'). Kod CSS/JS żyje per-tab w
  // sessionStorage (CSS_PANE_CONTENT_KEY) — encja ma tylko metadata.
  if (entityType === 'cssPane' || entityType === 'jsPane' ||
      entityType === 'jsConsole' || entityType === 'outputConsole') {
    const TABLE = { cssPane: 'cssPanes', jsPane: 'jsPanes', jsConsole: 'jsConsoles', outputConsole: 'outputConsoles' };
    const PREFIX = { cssPane: 'cp', jsPane: 'jp', jsConsole: 'jc', outputConsole: 'oc' };
    const DEFAULT_COLOR = { cssPane: '#2D9D53', jsPane: '#D65D0E', jsConsole: '#7C3AED', outputConsole: '#10B981' };
    const DEFAULT_NAME = { cssPane: 'CSS', jsPane: 'JS', jsConsole: 'Console', outputConsole: 'Output' };
    const DEFAULT_SIZE = { cssPane: {w:360,h:240}, jsPane: {w:360,h:240}, jsConsole: {w:380,h:280}, outputConsole: {w:380,h:240} };
    const DEFAULT_POS = entityType === 'outputConsole'
      ? { x:20, y:60, anchor:'bottom-right' }
      : { x:20, y:60, anchor:'top-right' };
    const store = await chrome.storage.local.get(['folders', 'groups', 'rules', TABLE[entityType]]);
    const folders = Array.isArray(store.folders) ? store.folders : [];
    const groups = Array.isArray(store.groups) ? store.groups : [];
    const rules = Array.isArray(store.rules) ? store.rules : [];
    const pType = String(data.parentType || '');
    const pId = String(data.parentId || '');
    const parentOk =
      (pType === 'folder' && folders.some(f => f.id === pId)) ||
      (pType === 'group' && groups.some(g => g.id === pId)) ||
      (pType === 'rule' && rules.some(r => r.id === pId));
    if (!parentOk) throw new Error('parent not found: ' + pType + ' ' + pId);
    const arr = Array.isArray(store[TABLE[entityType]]) ? store[TABLE[entityType]] : [];
    const obj = {
      id: _aiGenId(PREFIX[entityType]),
      parentType: pType,
      parentId: pId,
      name: String(data.name || DEFAULT_NAME[entityType]).slice(0, 32),
      enabled: data.enabled !== false,
      color: typeof data.color === 'string' ? data.color : DEFAULT_COLOR[entityType],
      position: (data.position && typeof data.position === 'object') ? data.position : DEFAULT_POS,
      size: (data.size && typeof data.size === 'object') ? data.size : DEFAULT_SIZE[entityType],
      createdAt: Date.now(),
      updatedAt: Date.now(),
    };
    if (entityType === 'outputConsole') {
      obj.viewMode = typeof data.viewMode === 'string' ? data.viewMode : 'both';
    }
    arr.push(obj);
    await chrome.storage.local.set({ [TABLE[entityType]]: arr, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue(entityType, obj.id, obj.parentId, obj);
    return { paneId: obj.id };
  }

  if (entityType === 'bar') {
    const store = await chrome.storage.local.get(['folders', 'groups', 'rules', 'actionBars']);
    const folders = Array.isArray(store.folders) ? store.folders : [];
    const groups = Array.isArray(store.groups) ? store.groups : [];
    const rules = Array.isArray(store.rules) ? store.rules : [];
    const actionBars = Array.isArray(store.actionBars) ? store.actionBars : [];
    const pType = String(data.parentType || '');
    const pId = String(data.parentId || '');
    const parentOk =
      (pType === 'folder' && folders.some(f => f.id === pId)) ||
      (pType === 'group' && groups.some(g => g.id === pId)) ||
      (pType === 'rule' && rules.some(r => r.id === pId));
    if (!parentOk) throw new Error('parent not found: ' + pType + ' ' + pId);
    const bar = {
      id: _aiGenId('b'),
      parentType: pType,
      parentId: pId,
      name: String(data.name || 'Pasek').slice(0, 6),
      enabled: true,
      orientation: data.orientation === 'horizontal' ? 'horizontal' : 'vertical',
      color: typeof data.color === 'string' ? data.color : '',
    };
    actionBars.push(bar);
    await chrome.storage.local.set({ actionBars, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('bar', bar.id, bar.parentId, bar);
    return { barId: bar.id };
  }
  if (entityType === 'action') {
    const store = await chrome.storage.local.get(['folders', 'groups', 'rules', 'actionBars']);
    const folders = Array.isArray(store.folders) ? store.folders : [];
    const groups = Array.isArray(store.groups) ? store.groups : [];
    const rules = Array.isArray(store.rules) ? store.rules : [];
    const actionBars = Array.isArray(store.actionBars) ? store.actionBars : [];
    const rule = rules.find(r => r.id === String(data.ruleId));
    if (!rule) throw new Error('rule not found: ' + data.ruleId);
    if (data.barId) {
      const bar = actionBars.find(b => b.id === String(data.barId));
      if (!bar) throw new Error('bar not found: ' + data.barId);
      // Pasek musi należeć do drzewa tego zestawu — do samego zestawu, do jego
      // grupy albo do jego folderu. Bez tej walidacji akcja mogła wskazać pasek
      // obcego zestawu/folderu: nie renderowałaby się albo trafiała na zły pasek.
      const grp = groups.find(g => g.id === rule.groupId);
      const fld = grp ? folders.find(f => f.id === grp.folderId) : null;
      const inScope =
        (bar.parentType === 'rule'   && bar.parentId === rule.id) ||
        (bar.parentType === 'group'  && grp && bar.parentId === grp.id) ||
        (bar.parentType === 'folder' && fld && bar.parentId === fld.id);
      if (!inScope) {
        throw new Error('bar ' + data.barId + ' nie należy do drzewa zestawu ' + rule.id);
      }
    }
    if (!Array.isArray(rule.actions)) rule.actions = [];
    const TYPES = ['button', 'select', 'input', 'slider', 'textarea', 'toggle3'];
    const type = TYPES.includes(data.type) ? data.type : 'button';
    const action = {
      id: _aiGenId('a'),
      type,
      // Etykieta akcji: limit 12 znaków — spójnie z edytorem UI (maxlength=12 od
      // v3.2.81; wcześniej 8 od v2.13.61, 6 od v2.13.60).
      label: String(data.label || 'BTN').slice(0, 12),
      color: typeof data.color === 'string' ? data.color : '#006870',
      code: typeof data.code === 'string' ? data.code : '',
      visible: true,
    };
    if (data.barId) action.barId = String(data.barId);
    // Pola koloru — realne pola modelu akcji (renderer + edytor UI ich używają).
    // Bez tego AI ustawiał tylko `color`, a colorText/colorBg/... ginęły po cichu.
    ['colorText', 'colorBg', 'colorHover', 'colorInactiveText', 'colorLabel', 'colorValue'].forEach(k => {
      if (typeof data[k] === 'string') action[k] = data[k];
    });
    // defaultValue — wartość początkowa dla input/slider/textarea (renderer ją czyta).
    if (data.defaultValue !== undefined && data.defaultValue !== null) {
      action.defaultValue = String(data.defaultValue);
    }
    // Pola zależne od typu — model podaje znane, resztę user dokonfiguruje w edytorze.
    if (type === 'select' && Array.isArray(data.options)) {
      action.options = data.options.map(String);
    }
    if (type === 'slider') {
      action.min = Number.isFinite(+data.min) ? +data.min : 0;
      action.max = Number.isFinite(+data.max) ? +data.max : 100;
      action.step = Number.isFinite(+data.step) && +data.step > 0 ? +data.step : 1;
    }
    if ((type === 'input' || type === 'textarea') && typeof data.placeholder === 'string') {
      action.placeholder = data.placeholder;
    }
    rule.actions.push(action);
    rule.updatedAt = Date.now();
    await chrome.storage.local.set({ rules, _jzPullTag: _aiGenId('pull') });
    // Akcje synchronizują się jako osobne encje 'action' (parentId = ruleId).
    _aiSyncEnqueue('action', action.id, rule.id, action);
    return { actionId: action.id };
  }
  throw new Error('unknown entity type: ' + entityType);
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (msg && msg.type === 'jz-ai-list-models') {
    (async () => {
      try {
        const models = await _aiListModels(msg.provider, msg.key);
        sendResponse({ ok: true, models, suggested: _aiPickDefault(msg.provider, models) });
      } catch (e) { sendResponse({ ok: false, error: String((e && e.message) || e) }); }
    })();
    return true;
  }
  if (msg && msg.type === 'jz-ai-chat') {
    (async () => {
      try {
        // v3.2.45 — _aiChat now returns { content, usage:{input, output} }
        const r = await _aiChat(msg);
        sendResponse({ ok: true, content: r.content, usage: r.usage });
      } catch (e) { sendResponse({ ok: false, error: String((e && e.message) || e) }); }
    })();
    return true;
  }
  if (msg && msg.type === 'jz-ai-create-entity') {
    (async () => {
      try {
        const r = await _aiCreateEntity(msg.entityType, msg.data);
        sendResponse(Object.assign({ ok: true }, r));
      } catch (e) { sendResponse({ ok: false, error: String((e && e.message) || e) }); }
    })();
    return true;
  }
  if (msg && msg.type === 'jz-ai-update-entity') {
    (async () => {
      try {
        const r = await _aiUpdateEntity(msg.entityType, msg.id, msg.patch);
        sendResponse(Object.assign({ ok: true }, r));
      } catch (e) { sendResponse({ ok: false, error: String((e && e.message) || e) }); }
    })();
    return true;
  }
  // od v3.2.28 — screenshot bieżącego viewportu. Wymaga aktywnej karty
  // (manifest ma activeTab — wystarczy gdy karta jest aktywna w momencie wywołania).
  if (msg && msg.type === 'jz-capture-screenshot') {
    (async () => {
      try {
        const opts = { format: msg.format === 'jpeg' ? 'jpeg' : 'png' };
        if (opts.format === 'jpeg') opts.quality = msg.quality || 90;
        const winId = sender.tab && sender.tab.windowId;
        const dataUrl = await chrome.tabs.captureVisibleTab(winId, opts);
        sendResponse({ ok: true, dataUrl });
      } catch (e) {
        sendResponse({ ok: false, error: String((e && e.message) || e) });
      }
    })();
    return true;
  }
  // v3.2.40 scaffolding (prep): bridge content↔MAIN world for AI Helper page tools.
  // First call per tab injects lib/ai-page-tools-installer.js into MAIN world (idempotent
  // via window.__aiPageToolsInstalled). Subsequent calls execute the named tool with args.
  // Returns standard {data, meta, suggestions} envelope (whatever the tool itself returns).
  // NOT YET WIRED into _aiExecTool — activation happens in main session after agent #3
  // delivers `lib/ai-page-tools-installer.js` content. Currently window.__aiPageTools is empty
  // (stub) so any call returns informative error.
  if (msg && msg.type === 'jz-page-tool-call') {
    (async () => {
      try {
        if (!sender.tab || !sender.tab.id) {
          sendResponse({ ok: false, error: 'no tab' });
          return;
        }
        const tabId = sender.tab.id;
        const toolName = String(msg.tool || '');
        const args = (msg.args && typeof msg.args === 'object') ? msg.args : {};
        if (!toolName) {
          sendResponse({ ok: false, error: 'missing tool name' });
          return;
        }
        // Step 1: ensure installer is loaded (idempotent in MAIN world via window.__aiPageToolsInstalled)
        await chrome.scripting.executeScript({
          target: { tabId },
          world: 'MAIN',
          files: ['lib/ai-page-tools-installer.js'],
        });
        // Step 2: execute the named tool with args
        const results = await chrome.scripting.executeScript({
          target: { tabId },
          world: 'MAIN',
          func: (name, a) => {
            try {
              if (!window.__aiPageTools || typeof window.__aiPageTools[name] !== 'function') {
                return {
                  data: null,
                  meta: { error: 'tool_not_in_page_tools' },
                  warnings: [
                    'Tool "' + name + '" not registered in window.__aiPageTools. '
                    + 'Either the tool is misnamed or the page tools installer is still a stub '
                    + '(agent #3 deliverable pending — see extension/justzix-docs/agent-tasks/page-tools-installer-brief.md).',
                  ],
                  suggestions: [],
                };
              }
              return window.__aiPageTools[name](a);
            } catch (e) {
              return {
                data: null,
                meta: { error: 'exception' },
                warnings: [String((e && e.message) || e)],
                suggestions: [],
              };
            }
          },
          args: [toolName, args],
        });
        // executeScript returns array of frame results; we use the top frame (only one — all_frames false)
        const result = (results && results[0] && results[0].result) || {
          data: null,
          meta: { error: 'empty_result' },
          warnings: ['executeScript returned no result'],
          suggestions: [],
        };
        sendResponse({ ok: true, result });
      } catch (e) {
        sendResponse({ ok: false, error: String((e && e.message) || e) });
      }
    })();
    return true;
  }
});

// od v3.2.13 — aktualizacja istniejących encji. Atomiczny merge whitelist-owanych
// pól (żeby model nie nadpisał id/parentId/innych krytycznych pól), zapis do
// chrome.storage.local + enqueue sync (cross-device).
async function _aiUpdateEntity(entityType, id, patch) {
  if (!id) throw new Error('missing id');
  if (!patch || typeof patch !== 'object') throw new Error('missing patch');
  try { if (typeof JZ_SYNC !== 'undefined' && JZ_SYNC.reload) await JZ_SYNC.reload(); } catch (_) {}

  const applyPatch = (obj, allowed) => {
    for (const k of allowed) {
      if (Object.prototype.hasOwnProperty.call(patch, k)) obj[k] = patch[k];
    }
    obj.updatedAt = Date.now();
  };

  if (entityType === 'folder') {
    const store = await chrome.storage.local.get(['folders']);
    const folders = Array.isArray(store.folders) ? store.folders : [];
    const f = folders.find(x => x && x.id === id);
    if (!f) throw new Error('folder not found: ' + id);
    applyPatch(f, ['name', 'urlPatterns', 'color', 'icon', 'enabled', 'active', 'showActionsPanel', 'actionsOrientation']);
    if (Array.isArray(f.urlPatterns)) f.urlPattern = f.urlPatterns[0] || '';
    await chrome.storage.local.set({ folders, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('folder', f.id, null, f);
    return { folderId: f.id };
  }
  if (entityType === 'group') {
    const store = await chrome.storage.local.get(['groups']);
    const groups = Array.isArray(store.groups) ? store.groups : [];
    const g = groups.find(x => x && x.id === id);
    if (!g) throw new Error('group not found: ' + id);
    applyPatch(g, ['name', 'urlPatterns', 'enabled', 'active']);
    await chrome.storage.local.set({ groups, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('group', g.id, g.folderId, g);
    return { groupId: g.id };
  }
  if (entityType === 'rule') {
    const store = await chrome.storage.local.get(['rules']);
    const rules = Array.isArray(store.rules) ? store.rules : [];
    const r = rules.find(x => x && x.id === id);
    if (!r) throw new Error('rule not found: ' + id);
    applyPatch(r, ['name', 'css', 'js', 'urlPatterns', 'enabled', 'active']);
    await chrome.storage.local.set({ rules, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('rule', r.id, r.groupId, r);
    return { ruleId: r.id };
  }
  if (entityType === 'bar') {
    const store = await chrome.storage.local.get(['actionBars']);
    const bars = Array.isArray(store.actionBars) ? store.actionBars : [];
    const b = bars.find(x => x && x.id === id);
    if (!b) throw new Error('bar not found: ' + id);
    // od v3.2.23 — layoutMode/size (układ absolutny + rozmiar płótna).
    // od v3.2.24 — parentType/parentId (przeniesienie paska między folder/group/rule)
    //              + actionOrder (kolejność akcji w flow mode).
    applyPatch(b, ['name', 'orientation', 'color', 'enabled', 'hidden', 'layoutMode', 'size', 'parentType', 'parentId', 'actionOrder']);
    await chrome.storage.local.set({ actionBars: bars, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('bar', b.id, b.parentId, b);
    return { barId: b.id };
  }
  // od v3.2.25 — update dla persistent okien. Wspólne pola, runColor dla js/output,
  // viewMode dla outputConsole.
  if (entityType === 'cssPane' || entityType === 'jsPane' ||
      entityType === 'jsConsole' || entityType === 'outputConsole') {
    const TABLE = { cssPane: 'cssPanes', jsPane: 'jsPanes', jsConsole: 'jsConsoles', outputConsole: 'outputConsoles' };
    const store = await chrome.storage.local.get([TABLE[entityType]]);
    const arr = Array.isArray(store[TABLE[entityType]]) ? store[TABLE[entityType]] : [];
    const p = arr.find(x => x && x.id === id);
    if (!p) throw new Error(entityType + ' not found: ' + id);
    let fields = ['name', 'enabled', 'color', 'parentType', 'parentId', 'position', 'size', 'headerBg', 'headerText', 'bodyBg', 'bodyText'];
    if (entityType === 'jsPane' || entityType === 'jsConsole' || entityType === 'outputConsole') {
      fields.push('runColor');
    }
    if (entityType === 'outputConsole') {
      fields.push('viewMode');
    }
    applyPatch(p, fields);
    await chrome.storage.local.set({ [TABLE[entityType]]: arr, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue(entityType, p.id, p.parentId, p);
    return { paneId: p.id };
  }

  if (entityType === 'action') {
    const store = await chrome.storage.local.get(['rules']);
    const rules = Array.isArray(store.rules) ? store.rules : [];
    let srcRule = null, srcAction = null;
    for (const r of rules) {
      const a = (r.actions || []).find(x => x && x.id === id);
      if (a) { srcRule = r; srcAction = a; break; }
    }
    if (!srcRule || !srcAction) throw new Error('action not found: ' + id);
    // od v3.2.24 — specjalne traktowanie patch.ruleId: przeniesienie akcji
    // do innego zestawu. Relacja akcja→zestaw jest implicit (action żyje w
    // rule.actions[]), więc nie da się ustawić jej jako "pola action".
    const targetRuleId = (patch && typeof patch.ruleId === 'string') ? patch.ruleId : null;
    if (targetRuleId && targetRuleId !== srcRule.id) {
      const dstRule = rules.find(x => x && x.id === targetRuleId);
      if (!dstRule) throw new Error('target rule not found: ' + targetRuleId);
      // 1) usuń z source rule
      srcRule.actions = (srcRule.actions || []).filter(x => x.id !== id);
      srcRule.updatedAt = Date.now();
      // 2) dołącz do dst rule (kopia obiektu zachowuje ID i wszystkie pola)
      if (!Array.isArray(dstRule.actions)) dstRule.actions = [];
      dstRule.actions.push(srcAction);
      // 3) zaaplikuj resztę patcha (bez ruleId) na akcję
      const restPatch = Object.assign({}, patch);
      delete restPatch.ruleId;
      for (const k of ['label', 'code', 'color', 'type', 'options', 'min', 'max', 'step', 'placeholder', 'barId', 'layout', 'size',
        'colorText', 'colorBg', 'colorHover', 'colorInactiveText', 'colorLabel', 'colorValue', 'defaultValue']) {
        if (Object.prototype.hasOwnProperty.call(restPatch, k)) srcAction[k] = restPatch[k];
      }
      srcAction.updatedAt = Date.now();
      dstRule.updatedAt = Date.now();
      await chrome.storage.local.set({ rules, _jzPullTag: _aiGenId('pull') });
      // Sync OBU rules — przeniesienie akcji zmienia stan w dwóch encjach.
      _aiSyncEnqueue('rule', srcRule.id, srcRule.groupId, srcRule);
      _aiSyncEnqueue('rule', dstRule.id, dstRule.groupId, dstRule);
      return { actionId: srcAction.id, ruleId: dstRule.id, movedFrom: srcRule.id };
    }
    // od v3.2.23 — layout/size; w tym samym rule — normalne applyPatch.
    applyPatch(srcAction, ['label', 'code', 'color', 'type', 'options', 'min', 'max', 'step', 'placeholder', 'barId', 'layout', 'size',
      'colorText', 'colorBg', 'colorHover', 'colorInactiveText', 'colorLabel', 'colorValue', 'defaultValue']);
    await chrome.storage.local.set({ rules, _jzPullTag: _aiGenId('pull') });
    _aiSyncEnqueue('rule', srcRule.id, srcRule.groupId, srcRule);
    return { actionId: srcAction.id, ruleId: srcRule.id };
  }
  throw new Error('unknown entityType: ' + entityType);
}
