// Content script - aplikuje CSS, JS, renderuje przycisk pływający i panel akcji
const STYLE_ID_PREFIX = 'justzix-style-';
const FLOAT_ID = 'justzix-floating-btn';
const PANEL_ID = 'justzix-floating-panel';
const ACTIONS_PANEL_ID = 'justzix-actions-panel';
const JS_EXECUTED = new Set();
// Reguły z opcją jsRunOnce, które już się wykonały — NIE są czyszczone przy
// SPA-nawigacji, dzięki czemu nie ruszają drugi raz w tym samym dokumencie.
const JS_RUNONCE_DONE = new Set();

// Po reload rozszerzenia stary content script "wisi" na stronie z unieważnionym kontekstem.
// chrome.runtime.id jest wtedy undefined, a wszelkie wywołania chrome.runtime.* / chrome.storage.*
// rzucają synchronicznie "Extension context invalidated", zanim w ogóle zwrócą Promise.
function isExtensionContextValid() {
  try { return !!(chrome && chrome.runtime && chrome.runtime.id); }
  catch (e) { return false; }
}

// Bezpieczne wysłanie wiadomości — nigdy nie rzuca, zwraca Promise(undefined) gdy kontekst nieważny.
function safeSendMessage(msg, callback) {
  if (!isExtensionContextValid()) {
    if (typeof callback === 'function') { try { callback(undefined); } catch (e) {} }
    return Promise.resolve(undefined);
  }
  try {
    if (typeof callback === 'function') {
      chrome.runtime.sendMessage(msg, callback);
      return Promise.resolve(undefined);
    }
    return Promise.resolve(chrome.runtime.sendMessage(msg)).catch(() => undefined);
  } catch (e) {
    return Promise.resolve(undefined);
  }
}

// Bezpieczny dostęp do storage — zwraca {} przy nieważnym kontekście.
function safeStorageGet(keys) {
  if (!isExtensionContextValid()) return Promise.resolve({});
  try { return chrome.storage.local.get(keys).catch(() => ({})); }
  catch (e) { return Promise.resolve({}); }
}
function safeStorageSet(items) {
  if (!isExtensionContextValid()) return Promise.resolve();
  try { return chrome.storage.local.set(items).catch(() => {}); }
  catch (e) { return Promise.resolve(); }
}

// Lokalny cache pozycji (per okno) - synchronizowany z backgroundem
let positionCache = null;
// Dedup równoległych load calls (od v2.13.3) — bez tego 3 init applyAll'e
// wysyłały 3 osobne get-positions; ostatnia odpowiedź mogła nadpisać wcześniejsze.
let _positionsLoadPromise = null;

async function getPositions() {
  if (positionCache) return positionCache;
  if (_positionsLoadPromise) return _positionsLoadPromise;
  _positionsLoadPromise = new Promise((resolve) => {
    safeSendMessage({ type: 'get-positions' }, (response) => {
      if (!isExtensionContextValid() || chrome.runtime.lastError || !response) {
        positionCache = { floatingPos: { x: 20, y: 20 }, actionsPos: { x: 80, y: 20 }, actionsHidden: false, barPositions: {}, barsHidden: {}, barConnections: [] };
      } else {
        positionCache = response;
        if (!positionCache.barPositions) positionCache.barPositions = {};
        if (!positionCache.barsHidden) positionCache.barsHidden = {};
        if (!Array.isArray(positionCache.barConnections)) positionCache.barConnections = [];
      }
      _positionsLoadPromise = null;
      resolve(positionCache);
    });
  });
  return _positionsLoadPromise;
}

function setFloatingPos(pos) {
  if (!positionCache) positionCache = {};
  positionCache.floatingPos = pos;
  safeSendMessage({ type: 'set-position', floatingPos: pos });
}

function setActionsPos(pos) {
  if (!positionCache) positionCache = {};
  positionCache.actionsPos = pos;
  safeSendMessage({ type: 'set-position', actionsPos: pos });
}

// Per-bar pozycja w obrębie okna (od v2.10, Faza 3).
function setBarPos(barId, pos) {
  if (!positionCache) positionCache = {};
  if (!positionCache.barPositions) positionCache.barPositions = {};
  positionCache.barPositions[barId] = pos;
  safeSendMessage({ type: 'set-position', barPosition: { barId, x: pos.x, y: pos.y } });
}

function setBarHidden(barId, hidden) {
  if (!positionCache) positionCache = {};
  if (!positionCache.barsHidden) positionCache.barsHidden = {};
  positionCache.barsHidden[barId] = hidden;
  safeSendMessage({ type: 'set-position', barHidden: { barId, hidden } });
}

// Batch — przesuń kilka barów naraz (group drag z fazy 4).
function setBarPositions(positions) {
  if (!positionCache) positionCache = {};
  if (!positionCache.barPositions) positionCache.barPositions = {};
  Object.assign(positionCache.barPositions, positions);
  safeSendMessage({ type: 'set-position', barPositions: positions });
}

// Zapis pozycji grupy mieszanej (bars + CSS panes + floating btn) — od v2.13.42, snap
// integration. Klasyfikujemy każdy id w grupie:
//   '__float'    → setFloatingPos (per-window pos)
//   'cp_*'       → write do cssPanes (synced — anchor liczony z left/top)
//   other        → bar → batch setBarPositions
// Każdy element musi mieć aktualne style.left/style.top (drag system tak ustawia).
let _cssPaneGroupSaveChain = Promise.resolve();
// Od v2.13.53: drugi parametr `virtualPositions` pozwala zapisać pozycje także elementów
// NIESBĘDNYCH w DOM (np. hidden bary w grupie snap). Bez tego, gdy user przeciągnie group
// zawierający disabled bar, jego stored pozycja zostawała sprzed drag → po re-enable bar
// pojawiał się w starym miejscu zamiast podążać za grupą.
function saveGroupPositions(groupIds, virtualPositions) {
  const barPositions = {};
  const cssPanePositions = []; // [{ paneId, left, top, w, h }]
  const jsPanePositions = [];  // od v2.13.56
  const jsConsolePositions = []; // od v2.13.66
  const outputConsolePositions = []; // od v2.13.72
  for (const id of groupIds) {
    let left, top, w, h;
    const p = getPanelElementById(id);
    if (p) {
      left = parseInt(p.style.left) || 0;
      top  = parseInt(p.style.top)  || 0;
      w = p.offsetWidth || 360;
      h = p.offsetHeight || 240;
    } else if (virtualPositions && virtualPositions[id]) {
      left = virtualPositions[id].left;
      top = virtualPositions[id].top;
      w = 360; h = 240;
    } else {
      continue;
    }
    // v2.13.57: dla cp_/jp_/jc_ czytamy w/h z cache (pane.size) — applyCssPanePosition ustawia
    // content-box width, offsetWidth dorzuca 2px border → drift przy round-trip anchor.
    if (typeof id === 'string' && id.startsWith('cp_')) {
      const cachedPane = (cssPanesCache || []).find(x => x.id === id);
      const sz = (cachedPane && cachedPane.size) || {};
      w = Math.max(180, Math.min(1200, sz.w || 360));
      h = Math.max(120, Math.min(900, sz.h || 240));
    } else if (typeof id === 'string' && id.startsWith('jp_')) {
      const cachedPane = (jsPanesCache || []).find(x => x.id === id);
      const sz = (cachedPane && cachedPane.size) || {};
      w = Math.max(180, Math.min(1200, sz.w || 360));
      h = Math.max(120, Math.min(900, sz.h || 240));
    } else if (typeof id === 'string' && id.startsWith('jc_')) {
      const cachedCon = (jsConsolesCache || []).find(x => x.id === id);
      const sz = (cachedCon && cachedCon.size) || {};
      w = Math.max(180, Math.min(1200, sz.w || 380));
      h = Math.max(120, Math.min(900, sz.h || 280));
    } else if (typeof id === 'string' && id.startsWith('oc_')) {
      const cachedOc = (outputConsolesCache || []).find(x => x.id === id);
      const sz = (cachedOc && cachedOc.size) || {};
      w = Math.max(180, Math.min(1200, sz.w || 380));
      h = Math.max(120, Math.min(900, sz.h || 240));
    }
    if (id === '__float') {
      setFloatingPos({ x: left, y: top });
    } else if (id === '__ai-helper') {
      // od v3.2.21 — AI Helper: pozycja w _aiHelperWin (sessionStorage per-tab).
      if (typeof _aiHelperWin !== 'undefined' && _aiHelperWin) {
        _aiHelperWin.position = absToAnchorPosition(left, top, w, h);
        _aiHelperWin.updatedAt = Date.now();
        try { saveAiHelper(); } catch (_) {}
      }
    } else if (typeof id === 'string' && id.startsWith('cp_')) {
      cssPanePositions.push({ paneId: id, left, top, w, h });
    } else if (typeof id === 'string' && id.startsWith('jp_')) {
      jsPanePositions.push({ paneId: id, left, top, w, h });
    } else if (typeof id === 'string' && id.startsWith('jc_')) {
      jsConsolePositions.push({ conId: id, left, top, w, h });
    } else if (typeof id === 'string' && id.startsWith('oc_')) {
      outputConsolePositions.push({ ocId: id, left, top, w, h });
    } else {
      barPositions[id] = { x: left, y: top };
    }
  }
  if (Object.keys(barPositions).length > 0) setBarPositions(barPositions);
  // TEMP panes (od v2.13.81) — split: chrome.storage dla persistent, sessionStorage dla TEMP.
  // Pomocnik: aplikuje pozycję do pane'a w danej tablicy (cache lub _tempPanes).
  const _applyPos = (target, pp) => {
    if (!target) return false;
    target.position = absToAnchorPosition(pp.left, pp.top, pp.w, pp.h);
    target.updatedAt = Date.now();
    return true;
  };
  const _splitTemp = (positions, key) => {
    const tmp = [], persist = [];
    positions.forEach(p => (isTempPaneId(p[key]) ? tmp : persist).push(p));
    return { tmp, persist };
  };
  const _persistTempPositions = (positions, key) => {
    let mutated = false;
    positions.forEach(pp => {
      const t = _tempPanes.find(x => x.id === pp[key]);
      if (_applyPos(t, pp)) mutated = true;
    });
    if (mutated) saveTempPanes();
  };

  if (cssPanePositions.length > 0) {
    const { tmp, persist } = _splitTemp(cssPanePositions, 'paneId');
    if (tmp.length > 0) _persistTempPositions(tmp, 'paneId');
    if (persist.length > 0) {
      _cssPaneGroupSaveChain = _cssPaneGroupSaveChain.then(async () => {
        const data = await safeStorageGet(['cssPanes']);
        const panes = Array.isArray(data.cssPanes) ? data.cssPanes : [];
        let mutated = false;
        for (const pp of persist) {
          if (_applyPos(panes.find(x => x.id === pp.paneId), pp)) mutated = true;
        }
        if (mutated) await safeStorageSet({ cssPanes: panes });
      });
    }
  }
  if (jsPanePositions.length > 0) {
    const { tmp, persist } = _splitTemp(jsPanePositions, 'paneId');
    if (tmp.length > 0) _persistTempPositions(tmp, 'paneId');
    if (persist.length > 0) {
      _jsPaneGroupSaveChain = _jsPaneGroupSaveChain.then(async () => {
        const data = await safeStorageGet(['jsPanes']);
        const panes = Array.isArray(data.jsPanes) ? data.jsPanes : [];
        let mutated = false;
        for (const pp of persist) {
          if (_applyPos(panes.find(x => x.id === pp.paneId), pp)) mutated = true;
        }
        if (mutated) await safeStorageSet({ jsPanes: panes });
      });
    }
  }
  if (jsConsolePositions.length > 0) {
    const { tmp, persist } = _splitTemp(jsConsolePositions, 'conId');
    if (tmp.length > 0) _persistTempPositions(tmp, 'conId');
    if (persist.length > 0) {
      _jsConsoleGroupSaveChain = _jsConsoleGroupSaveChain.then(async () => {
        const data = await safeStorageGet(['jsConsoles']);
        const cons = Array.isArray(data.jsConsoles) ? data.jsConsoles : [];
        let mutated = false;
        for (const pp of persist) {
          if (_applyPos(cons.find(x => x.id === pp.conId), pp)) mutated = true;
        }
        if (mutated) await safeStorageSet({ jsConsoles: cons });
      });
    }
  }
  if (outputConsolePositions.length > 0) {
    const { tmp, persist } = _splitTemp(outputConsolePositions, 'ocId');
    if (tmp.length > 0) _persistTempPositions(tmp, 'ocId');
    if (persist.length > 0) {
      _outputConsoleGroupSaveChain = _outputConsoleGroupSaveChain.then(async () => {
        const data = await safeStorageGet(['outputConsoles']);
        const ocs = Array.isArray(data.outputConsoles) ? data.outputConsoles : [];
        let mutated = false;
        for (const pp of persist) {
          if (_applyPos(ocs.find(x => x.id === pp.ocId), pp)) mutated = true;
        }
        if (mutated) await safeStorageSet({ outputConsoles: ocs });
      });
    }
  }
}
let _jsPaneGroupSaveChain = Promise.resolve();
let _jsConsoleGroupSaveChain = Promise.resolve();
let _outputConsoleGroupSaveChain = Promise.resolve();

function addBarConnection(c) {
  if (!positionCache) positionCache = {};
  if (!Array.isArray(positionCache.barConnections)) positionCache.barConnections = [];
  const exists = positionCache.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) positionCache.barConnections.push({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide });
  safeSendMessage({ type: 'set-position', addBarConnection: c });
}

function removeBarConnection(c) {
  if (!positionCache) positionCache = {};
  if (Array.isArray(positionCache.barConnections)) {
    positionCache.barConnections = positionCache.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)
    ));
  }
  safeSendMessage({ type: 'set-position', removeBarConnection: c });
}

// === Faza 4 — snap & connect helpers ===
const SNAP_THRESHOLD_PX = 12;
// Threshold dla auto-center (alignment) prostopadłego do osi snap. Magnetyczny pomocnik
// trafiania w środek krawędzi, gdy bary są podobnej wielkości (od v2.11.1).
const ALIGN_THRESHOLD_PX = 24;

// Zwraca grupę barów połączonych (BFS po connections). Zawsze zawiera startBarId.
// Od v2.13.53: zwraca stored start pozycję dla dowolnego ID w group (bar/float/CSS pane).
// Używane do śledzenia hidden członków grupy podczas drag — bez tego ich pozycja
// nie była aktualizowana i po re-enable lądowali w starym miejscu.
// Od v2.13.54: obsługa CSS panes (anchor-based) — konwersja do absolute via cssPanesCache.
function getStoredStartPos(id) {
  if (!positionCache) return null;
  if (id === '__float') {
    const p = positionCache.floatingPos;
    return p ? { left: p.x || 0, top: p.y || 0 } : null;
  }
  if (typeof id === 'string' && id.startsWith('cp_')) {
    const pane = (cssPanesCache || []).find(x => x.id === id);
    if (!pane) return null;
    const abs = paneAnchorToAbs(pane);
    return abs ? { left: abs.left, top: abs.top, w: abs.w, h: abs.h } : null;
  }
  if (typeof id === 'string' && id.startsWith('jp_')) {
    const pane = (jsPanesCache || []).find(x => x.id === id);
    if (!pane) return null;
    const abs = paneAnchorToAbs(pane);
    return abs ? { left: abs.left, top: abs.top, w: abs.w, h: abs.h } : null;
  }
  if (typeof id === 'string' && id.startsWith('jc_')) {
    const con = (jsConsolesCache || []).find(x => x.id === id);
    if (!con) return null;
    const abs = paneAnchorToAbs(con);
    return abs ? { left: abs.left, top: abs.top, w: abs.w, h: abs.h } : null;
  }
  if (typeof id === 'string' && id.startsWith('oc_')) {
    const oc = (outputConsolesCache || []).find(x => x.id === id);
    if (!oc) return null;
    const abs = paneAnchorToAbs(oc);
    return abs ? { left: abs.left, top: abs.top, w: abs.w, h: abs.h } : null;
  }
  const p = positionCache.barPositions && positionCache.barPositions[id];
  return p ? { left: p.x || 0, top: p.y || 0 } : null;
}

// Anchor → absolute (vp-relative) — extracted z applyCssPanePosition żeby działało dla
// hidden pane (bez DOM elementu). Zwraca { left, top, w, h }.
function paneAnchorToAbs(pane) {
  const pos = pane.position || { x: 20, y: 60, anchor: 'top-right' };
  const size = pane.size || { w: 360, h: 240 };
  const w = Math.max(180, Math.min(1200, size.w || 360));
  const h = Math.max(120, Math.min(900, size.h || 240));
  const x = typeof pos.x === 'number' ? pos.x : 20;
  const y = typeof pos.y === 'number' ? pos.y : 60;
  let left, top;
  switch (pos.anchor) {
    case 'top-left':     left = x;                            top = y;                              break;
    case 'bottom-right': left = window.innerWidth - x - w;    top = window.innerHeight - y - h;     break;
    case 'bottom-left':  left = x;                            top = window.innerHeight - y - h;     break;
    default:             left = window.innerWidth - x - w;    top = y;                              break;
  }
  left = Math.max(-w + 60, Math.min(window.innerWidth - 60, left));
  top  = Math.max(0, Math.min(window.innerHeight - 30, top));
  return { left, top, w, h };
}

function getBarGroup(startBarId) {
  const conns = (positionCache && positionCache.barConnections) || [];
  const visited = new Set([startBarId]);
  const queue = [startBarId];
  while (queue.length) {
    const cur = queue.shift();
    conns.forEach(c => {
      const other = c.aId === cur ? c.bId : (c.bId === cur ? c.aId : null);
      if (other && !visited.has(other)) {
        visited.add(other);
        queue.push(other);
      }
    });
  }
  return Array.from(visited);
}

function rangesOverlap(a1, a2, b1, b2) {
  return Math.max(a1, b1) < Math.min(a2, b2);
}

// Dla baru będącego w pozycji `aRect`, sprawdź czy jakikolwiek inny bar (NIE w tej samej grupie)
// jest w odległości snap. Zwraca { otherBarId, snapDX, snapDY, aSide, bSide } lub null.
function findSnapCandidate(draggedBarId, aRect, allBarPanels, group) {
  const groupSet = new Set(group);
  // Uniwersalny align (od v2.11.2) — wybiera najbliższy z 3 punktów wyrównania:
  // dla horizontal snap (left/right) prostopadle: top, bottom, center.
  // dla vertical snap (top/bottom) prostopadle: left, right, center.
  // Bez warunków na rozmiar — działa dla pionowy↔poziomy, dowolnych wymiarów.
  const bestAlign = (candidates) => {
    let bestDiff = ALIGN_THRESHOLD_PX;
    let bestOffset = 0;
    for (const diff of candidates) {
      if (Math.abs(diff) < bestDiff) { bestDiff = Math.abs(diff); bestOffset = diff; }
    }
    return bestOffset;
  };

  // Też wymagamy minimalnego nakładania, ALBO bliskości krawędzi w prostopadłym wymiarze.
  // Dla snap "do krawędzi narożnika" akceptujemy jeśli krawędzie są blisko (<SNAP_THRESHOLD).
  const overlapOrTouching = (a1, a2, b1, b2) => {
    if (rangesOverlap(a1, a2, b1, b2)) return true;
    // Krawędzie blisko — np. a.bottom blisko b.top.
    const minDiff = Math.min(Math.abs(a1 - b2), Math.abs(a2 - b1), Math.abs(a1 - b1), Math.abs(a2 - b2));
    return minDiff < SNAP_THRESHOLD_PX + ALIGN_THRESHOLD_PX;
  };

  for (const ob of allBarPanels) {
    if (groupSet.has(ob.id)) continue;
    const b = ob.rect;
    const a = aRect;
    const aCY = (a.top + a.bottom) / 2, bCY = (b.top + b.bottom) / 2;
    const aCX = (a.left + a.right) / 2, bCX = (b.left + b.right) / 2;

    // Horizontal snap: a.right ↔ b.left
    if (Math.abs(a.right - b.left) < SNAP_THRESHOLD_PX && overlapOrTouching(a.top, a.bottom, b.top, b.bottom)) {
      return { otherBarId: ob.id, snapDX: b.left - a.right,
               snapDY: bestAlign([b.top - a.top, b.bottom - a.bottom, bCY - aCY]),
               aSide: 'right', bSide: 'left' };
    }
    // a.left ↔ b.right
    if (Math.abs(a.left - b.right) < SNAP_THRESHOLD_PX && overlapOrTouching(a.top, a.bottom, b.top, b.bottom)) {
      return { otherBarId: ob.id, snapDX: b.right - a.left,
               snapDY: bestAlign([b.top - a.top, b.bottom - a.bottom, bCY - aCY]),
               aSide: 'left', bSide: 'right' };
    }
    // Vertical snap: a.top ↔ b.bottom
    if (Math.abs(a.top - b.bottom) < SNAP_THRESHOLD_PX && overlapOrTouching(a.left, a.right, b.left, b.right)) {
      return { otherBarId: ob.id, snapDY: b.bottom - a.top,
               snapDX: bestAlign([b.left - a.left, b.right - a.right, bCX - aCX]),
               aSide: 'top', bSide: 'bottom' };
    }
    // a.bottom ↔ b.top
    if (Math.abs(a.bottom - b.top) < SNAP_THRESHOLD_PX && overlapOrTouching(a.left, a.right, b.left, b.right)) {
      return { otherBarId: ob.id, snapDY: b.top - a.bottom,
               snapDX: bestAlign([b.left - a.left, b.right - a.right, bCX - aCX]),
               aSide: 'bottom', bSide: 'top' };
    }
  }
  return null;
}

// Helper — czyści snap visual ze wszystkich barów + floating btn + pane'ów.
// od v3.2.33: dodane `.jz-pane` (light DOM) i pane'y w shadow root (od v3.2.30
// wszystkie pane'y CSS/JS/Console/Output żyją w shadow → light-DOM querySelectorAll
// ich nie łapie). AI Helper jest też `.jz-pane` więc objęty.
function clearAllSnapEdges() {
  const CLASSES = ['jz-snap-edge-top', 'jz-snap-edge-bottom', 'jz-snap-edge-left', 'jz-snap-edge-right'];
  document.querySelectorAll('.jz-actions-bar, #' + FLOAT_ID + ', .jz-pane').forEach(el => {
    el.classList.remove.apply(el.classList, CLASSES);
  });
  document.querySelectorAll('[data-jz-shadow-host="1"], [data-jz-temp-shadow="1"]').forEach(host => {
    if (!host.shadowRoot) return;
    host.shadowRoot.querySelectorAll('.jz-pane').forEach(el => {
      el.classList.remove.apply(el.classList, CLASSES);
    });
  });
}

// Zwraca wszystkie draggable elementy (bary akcji + floating btn + CSS panes) jako
// candidates do snap. Floating btn ma special id '__float'. CSS panes (od v2.13.42)
// używają tego samego ID space co bary — barConnections może zawierać miks.
function getDraggablePanelsForSnap() {
  const out = [];
  document.querySelectorAll('.jz-actions-bar').forEach(p => {
    if (p.dataset.jzBarId) out.push({ id: p.dataset.jzBarId, rect: p.getBoundingClientRect() });
  });
  // od v3.2.30 — wszystkie pane'y w shadow root. Iterujemy po hostach
  // (`[data-jz-shadow-host]`), wyciągamy real pane z shadow + id z hosta.
  // Legacy `[data-jz-temp-shadow]` (z v3.2.29) też uwzględnione.
  const PANE_PREFIX_BY_CLASS = [
    { cls: '.jz-pane-css',     prefix: CSS_PANE_PREFIX },
    { cls: '.jz-pane-js',      prefix: JS_PANE_PREFIX },
    { cls: '.jz-pane-console', prefix: JS_CONSOLE_PREFIX },
    { cls: '.jz-pane-output',  prefix: OUTPUT_CONSOLE_PREFIX },
  ];
  document.querySelectorAll('[data-jz-shadow-host="1"], [data-jz-temp-shadow="1"]').forEach(host => {
    if (!host.shadowRoot) return;
    for (const { cls, prefix } of PANE_PREFIX_BY_CLASS) {
      const p = host.shadowRoot.querySelector(cls);
      if (!p) continue;
      const id = host.id.startsWith(prefix) ? host.id.slice(prefix.length) : null;
      if (id) out.push({ id, rect: p.getBoundingClientRect() });
      break;
    }
  });
  // Fallback dla legacy pane'ów które jeszcze nie są w shadow (po awarii rerender'u).
  PANE_PREFIX_BY_CLASS.forEach(({ cls, prefix }) => {
    document.querySelectorAll(cls).forEach(p => {
      if (!p.id || !p.id.startsWith(prefix)) return;
      // Pomijamy jeśli pane jest wewnątrz shadow (już dodany wyżej).
      if (p.getRootNode && p.getRootNode() !== document) return;
      out.push({ id: p.id.slice(prefix.length), rect: p.getBoundingClientRect() });
    });
  });
  const floatBtn = document.getElementById(FLOAT_ID);
  if (floatBtn) out.push({ id: '__float', rect: floatBtn.getBoundingClientRect() });
  // od v3.2.21 — AI Helper bierze udział w snap-systemie tak samo jak bary/panes.
  // od v3.2.76 — okno żyje w shadow root (host ma display:contents → 0×0); resolve do real pane.
  const aiHelper = _jzResolveShadow(document.getElementById('jz-ai-helper'), '.jz-pane-ai');
  if (aiHelper) out.push({ id: '__ai-helper', rect: aiHelper.getBoundingClientRect() });
  return out;
}

// Zwraca DOM element po id (bar / floating btn / CSS pane / JS pane / JS console). Rozpoznajemy
// po prefiksie: cp_* → CSS pane, jp_* → JS pane, jc_* → JS console (od v2.13.66),
// '__float' → floating btn, inne → bar.
function getPanelElementById(id) {
  if (id === '__float') return document.getElementById(FLOAT_ID);
  // od v3.2.21 (od v3.2.76 — okno w shadow, resolve host → real pane)
  if (id === '__ai-helper') return _jzResolveShadow(document.getElementById('jz-ai-helper'), '.jz-pane-ai');
  // od v3.2.30 — wszystkie pane'y JustZixa (cp_/jp_/jc_/oc_) żyją w shadow root.
  // Lookup zwraca host (z id=elId), `_jzResolveShadow` rozwiązuje host → real pane.
  if (typeof id === 'string') {
    if (id.startsWith('cp_')) return _jzResolveShadow(document.getElementById(CSS_PANE_PREFIX + id), '.jz-pane-css');
    if (id.startsWith('jp_')) return _jzResolveShadow(document.getElementById(JS_PANE_PREFIX + id), '.jz-pane-js');
    if (id.startsWith('jc_')) return _jzResolveShadow(document.getElementById(JS_CONSOLE_PREFIX + id), '.jz-pane-console');
    if (id.startsWith('oc_')) return _jzResolveShadow(document.getElementById(OUTPUT_CONSOLE_PREFIX + id), '.jz-pane-output');
  }
  return document.getElementById(BAR_PANEL_PREFIX + id);
}

function getPageKey(url) {
  try { const u = new URL(url); return u.origin + u.pathname; }
  catch (e) { return url; }
}

// Normalizuje wzorce URL folderu do tablicy. Akceptuje nowy `urlPatterns: string[]`
// LUB legacy `urlPattern: string` (przed v2.7 / schemaVersion 4). Duplikat z options.js —
// świadomie, żeby content script nie ciągnął wspólnego modułu.
function getFolderPatterns(folder) {
  if (!folder) return [];
  if (Array.isArray(folder.urlPatterns)) {
    return folder.urlPatterns.map(p => (p || '').trim()).filter(Boolean);
  }
  const legacy = (folder.urlPattern || '').trim();
  return legacy ? [legacy] : [];
}

function folderMatchesUrl(folder, url) {
  const patterns = getFolderPatterns(folder);
  if (patterns.length === 0) return false;
  return patterns.some(p => urlMatches(p, url));
}

function urlMatches(pattern, url) {
  if (!pattern) return false;
  try {
    const trimmed = pattern.trim();
    if (trimmed.includes('*')) {
      const regex = new RegExp(
        '^' + trimmed.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$'
      );
      return regex.test(url);
    }
    return getPageKey(url) === getPageKey(trimmed) || url === trimmed;
  } catch (e) { return false; }
}

async function getData() {
  const data = await safeStorageGet([
    'folders', 'groups', 'rules', 'actionBars',
    'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles',
    'floatingHidden', 'globalEnabled', 'actionMemory'
  ]);
  // actionMemoryCache aktualizowana TUTAJ (od v2.13.2) — single storage op gwarantuje że
  // przy każdym applyAll cache jest świeża, NIE wymaga osobnego await w applyAll (bug v2.13.0)
  // ani osobnego fire-and-forget z race przy init (bug v2.13.1 fix). Cache ładowana ATOMICALLY
  // z resztą danych — renderSelectElement/renderInputElement zawsze widzi aktualne wartości.
  actionMemoryCache = data.actionMemory || {};
  // Od v2.13.54: cache cssPanes dla sync-access podczas drag (getStoredStartPos dla hidden
  // CSS panes potrzebuje pos+size do konwersji anchor → absolute, bez async storage read).
  cssPanesCache = Array.isArray(data.cssPanes) ? data.cssPanes : [];
  // Od v2.13.56: analogiczna cache jsPanes.
  jsPanesCache = Array.isArray(data.jsPanes) ? data.jsPanes : [];
  // Od v2.13.66: cache jsConsoles (do drag/snap, identycznie jak panes).
  jsConsolesCache = Array.isArray(data.jsConsoles) ? data.jsConsoles : [];
  // Od v2.13.72: cache outputConsoles (analogicznie).
  outputConsolesCache = Array.isArray(data.outputConsoles) ? data.outputConsoles : [];
  return {
    folders: data.folders || [],
    groups: data.groups || [],
    rules: data.rules || [],
    actionBars: data.actionBars || [],   // od v2.10 (Faza 3)
    // Faza 2 "Okna na froncie" (v12, od v2.13.40): 4 nowe typy okien. Tylko CSS panes
    // mają renderery w tej fazie — pozostałe 3 są dla data plumbing i kolejnych faz.
    cssPanes: data.cssPanes || [],
    jsPanes: data.jsPanes || [],
    jsConsoles: data.jsConsoles || [],
    outputConsoles: data.outputConsoles || [],
    floatingHidden: data.floatingHidden || {},
    globalEnabled: data.globalEnabled !== false // domyślnie true
  };
}

// Reguła aktywna iff:
//   globalEnabled
//   && folder.enabled && URL pasuje do dowolnego folder.urlPatterns[]
//      (lub legacy folder.urlPattern z urządzeń pre-2.7)
//   && group.enabled
//   && (group.urlPatterns puste LUB co najmniej jeden pasuje)
//   && rule.enabled
//   && (rule.urlPatterns puste LUB co najmniej jeden pasuje)
function getActiveRules(folders, groups, rules, url, globalEnabled) {
  if (!globalEnabled) return [];
  const matchingFolders = folders.filter(f =>
    f.active !== false && f.enabled && folderMatchesUrl(f, url)
  );
  const matchingFolderIds = new Set(matchingFolders.map(f => f.id));
  const eligibleGroups = (groups || []).filter(g => {
    if (g.active === false) return false;
    if (g.enabled === false) return false;
    if (!matchingFolderIds.has(g.folderId)) return false;
    const gp = Array.isArray(g.urlPatterns) ? g.urlPatterns.filter(p => p && p.trim()) : [];
    if (gp.length === 0) return true;
    return gp.some(p => urlMatches(p, url));
  });
  const eligibleGroupIds = new Set(eligibleGroups.map(g => g.id));
  return rules.filter(r => {
    if (r.active === false) return false;
    if (r.enabled !== true) return false;
    if (!eligibleGroupIds.has(r.groupId)) return false;
    const patterns = Array.isArray(r.urlPatterns) ? r.urlPatterns.filter(p => p && p.trim()) : [];
    if (patterns.length === 0) return true;
    return patterns.some(p => urlMatches(p, url));
  });
}

// === Liczniki użycia (jzUsageStats, od v2.13.150) ===========================
// Lekki tracker faktycznych wstrzyknięć: CSS / JS / okna na froncie. Dedup per
// page-load przez Sety (każdy zestaw/okno liczy się raz na załadowanie strony),
// inkrementy buforowane w pamięci i zapisywane batchowo (debounce 4 s + pagehide
// + visibilitychange→hidden), żeby nie robić write storma w hot-path applyAll.
const JZ_USAGE_SEEN = { css: new Set(), js: new Set(), win: new Set() };
let _jzUsagePending = { cssApplied: 0, jsExecuted: 0, windowsShown: 0 };
let _jzUsageFlushTimer = null;
let _jzUsageFlushChain = Promise.resolve();

function jzUsageBump(kind, key) {
  const seen = JZ_USAGE_SEEN[kind];
  if (!seen || seen.has(key)) return;
  seen.add(key);
  if (kind === 'css') _jzUsagePending.cssApplied++;
  else if (kind === 'js') _jzUsagePending.jsExecuted++;
  else _jzUsagePending.windowsShown++;
  if (_jzUsageFlushTimer === null) {
    _jzUsageFlushTimer = setTimeout(jzUsageFlush, 4000);
  }
}

function jzUsageFlush() {
  if (_jzUsageFlushTimer !== null) { clearTimeout(_jzUsageFlushTimer); _jzUsageFlushTimer = null; }
  const pend = _jzUsagePending;
  if (!pend.cssApplied && !pend.jsExecuted && !pend.windowsShown) return;
  _jzUsagePending = { cssApplied: 0, jsExecuted: 0, windowsShown: 0 };
  // Serializacja przez łańcuch — analogicznie do _clickCountChain. Cross-tab race
  // (read-modify-write z innej karty) jest akceptowalny, jak przy clickCount.
  _jzUsageFlushChain = _jzUsageFlushChain.then(async () => {
    try {
      const data = await safeStorageGet(['jzUsageStats']);
      const st = data.jzUsageStats || {};
      st.cssApplied = (st.cssApplied || 0) + pend.cssApplied;
      st.jsExecuted = (st.jsExecuted || 0) + pend.jsExecuted;
      st.windowsShown = (st.windowsShown || 0) + pend.windowsShown;
      if (!st.since) st.since = Date.now();
      await safeStorageSet({ jzUsageStats: st });
    } catch (e) { console.debug('JustZix: jzUsageFlush — zapis licznika użyć nie powiódł się', e); }
  });
}
window.addEventListener('pagehide', jzUsageFlush);
document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'hidden') jzUsageFlush();
});

function applyCss(rule) {
  const id = STYLE_ID_PREFIX + rule.id;
  let style = document.getElementById(id);
  if (!style) {
    style = document.createElement('style');
    style.id = id;
    style.setAttribute('data-justzix', 'true');
    (document.head || document.documentElement).appendChild(style);
  }
  if (style.textContent !== (rule.css || '')) {
    style.textContent = rule.css || '';
  }
  if ((rule.css || '').trim()) jzUsageBump('css', rule.id);
}

function removeCss(ruleId) {
  const el = document.getElementById(STYLE_ID_PREFIX + ruleId);
  if (el) el.remove();
}

function injectJs(rule) {
  if (JS_EXECUTED.has(rule.id)) return;
  if (!rule.js || !rule.js.trim()) return;
  safeSendMessage({ type: 'execute-js', code: rule.js, ruleId: rule.id });
  JS_EXECUTED.add(rule.id);
  // jsRunOnce: zapamiętaj, że reguła już ruszyła — po SPA-nawigacji nie ruszy
  // ponownie. Odznaczenie opcji kasuje wpis, więc reguła znów reaguje na nawigację.
  if (rule.jsRunOnce) JS_RUNONCE_DONE.add(rule.id);
  else JS_RUNONCE_DONE.delete(rule.id);
  jzUsageBump('js', rule.id);
}

let lastActiveIds = new Set();

async function applyAll() {
  const dataRaw = await getData();
  // TEMP panes (od v2.13.81) — wstrzyknięte do collections przed rozpakowaniem do
  // local vars. discoverRelevant + paneCascadeEnabled mają branche na parentType='temp'.
  injectTempPanesIntoData(dataRaw);
  const { folders, groups, rules, actionBars, cssPanes, jsPanes, jsConsoles, outputConsoles, globalEnabled, floatingHidden } = dataRaw;
  // Memory cache ładowany fire-and-forget przy init + na storage.onChanged.actionMemory.
  // NIE awaiting tutaj — żeby slow storage nie blokował renderu pasków (bug w v2.13.0).
  // Pierwszy render może użyć empty cache; gdy cache się załaduje, _memoryFirstLoadDone
  // flag triggeruje re-render aby pokazać zapamiętane wartości.
  const active = getActiveRules(folders, groups, rules, location.href, globalEnabled);
  const activeIds = new Set(active.map(r => r.id));

  lastActiveIds.forEach(id => {
    if (!activeIds.has(id)) removeCss(id);
  });

  active.forEach(rule => applyCss(rule));
  active.forEach(rule => injectJs(rule));
  lastActiveIds = activeIds;

  renderFloatingButton(folders, groups, rules, globalEnabled);
  renderActionBars(folders, groups, rules, actionBars, active);
  renderCssPanes(folders, groups, rules, cssPanes, globalEnabled, floatingHidden);
  renderJsPanes(folders, groups, rules, jsPanes, globalEnabled, floatingHidden);
  renderJsConsoles(folders, groups, rules, jsConsoles, globalEnabled, floatingHidden);
  renderOutputConsoles(folders, groups, rules, outputConsoles, globalEnabled, floatingHidden);
  rebuildShortcutHandlers(folders, groups, rules, globalEnabled);
}

// === Pływający główny przycisk ===

function getMatchingFoldersForPage(folders) {
  return folders.filter(f => folderMatchesUrl(f, location.href));
}

async function renderFloatingButton(folders, groups, rules, globalEnabled) {
  const matching = getMatchingFoldersForPage(folders);

  const data = await safeStorageGet(['floatingHidden']);
  const hidden = data.floatingHidden || {};
  const positions = await getPositions();
  const pos = positions.floatingPos;
  const visibleFolders = matching.filter(f => !hidden[f.id]);

  // Re-check existing PO await (od v2.13.3) — równoległe applyAll calls przy init
  // (script start + DOMContentLoaded + load) wszystkie awaitują przed sprawdzeniem.
  // Bez re-check każdy createFloatingButton tworzyłby duplikat → "dwa przyciski przy drag".
  const existing = document.getElementById(FLOAT_ID);
  const panel = document.getElementById(PANEL_ID);

  if (visibleFolders.length === 0) {
    if (existing) existing.remove();
    if (panel) panel.remove();
    return;
  }

  // Globalnie wyłączone: pokazujemy przycisk jako "off"
  const allEnabled = globalEnabled !== false && visibleFolders.every(f => f.enabled);
  const anyEnabled = globalEnabled !== false && visibleFolders.some(f => f.enabled);

  // Bierzemy kolor z pierwszego pasującego katalogu, który ma ustawiony kolor.
  const folderColor = (visibleFolders.find(f => f.color) || {}).color || '';
  // Etykieta na przycisku — z pierwszego pasującego katalogu, który ma ustawione `icon` (max 3 znaki, UPPERCASE). Fallback: 'ZIX'.
  const rawLabel = (visibleFolders.find(f => f.icon && String(f.icon).trim()) || {}).icon || '';
  const folderLabel = (rawLabel ? String(rawLabel).toUpperCase().slice(0, 3) : 'ZIX');

  if (existing) {
    existing.style.left = pos.x + 'px';
    existing.style.top = pos.y + 'px';
    updateFloatingButtonState(existing, allEnabled, anyEnabled, globalEnabled, folderColor, folderLabel);
    return;
  }

  createFloatingButton(visibleFolders, pos, allEnabled, anyEnabled, globalEnabled, folderColor, folderLabel);
}

function updateFloatingButtonState(btn, allEnabled, anyEnabled, globalEnabled, folderColor, folderLabel) {
  btn.classList.toggle('jz-on', allEnabled);
  btn.classList.toggle('jz-mixed', anyEnabled && !allEnabled);
  btn.classList.toggle('jz-off', !anyEnabled);
  btn.classList.toggle('jz-globally-off', globalEnabled === false);
  // Kolor katalogu — używany dla stanów jz-on / jz-mixed / jz-off (gdy nie globalnie wyłączone).
  if (folderColor && globalEnabled !== false) {
    btn.classList.add('jz-custom-color');
    btn.style.setProperty('--jz-folder-color', folderColor);
  } else {
    btn.classList.remove('jz-custom-color');
    btn.style.removeProperty('--jz-folder-color');
  }
  // Etykieta — synchronizujemy zawartość <span>; fallback 'ZIX'.
  const label = folderLabel || 'ZIX';
  const span = btn.querySelector('span');
  if (span && span.textContent !== label) span.textContent = label;
  if (globalEnabled === false) {
    btn.title = JZ_I18N.t('content.float.titleGloballyOff');
  } else {
    btn.title = JZ_I18N.t('content.float.titleNormal');
  }
}

function createFloatingButton(visibleFolders, pos, allEnabled, anyEnabled, globalEnabled, folderColor, folderLabel) {
  const btn = document.createElement('div');
  btn.id = FLOAT_ID;
  btn.innerHTML = '<span></span>';
  btn.style.left = pos.x + 'px';
  btn.style.top = pos.y + 'px';
  updateFloatingButtonState(btn, allEnabled, anyEnabled, globalEnabled, folderColor, folderLabel);
  document.documentElement.appendChild(btn);

  // Drag - tylko z lewym klikiem. Od v2.11.2 floating btn też uczestniczy w snap & connect
  // (jako pseudo-bar z id '__float' w connections). Pozycje grupy zapisywane batch.
  let dragging = false;
  let dragStarted = false;
  let startX, startY;
  let groupIds = [];
  let groupStarts = {};
  let lastSnap = null;

  // groupCurrent — śledzi nową pozycję KAŻDEGO członka grupy (od v2.13.53), również hidden.
  // Hidden członek nie ma DOM elementu, ale trzeba zapisać jego nową pozycję żeby po
  // re-enable był w odpowiednim miejscu (utrzymanie snap connection).
  let groupCurrent = {};

  btn.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return;
    dragging = true;
    dragStarted = false;
    startX = e.clientX;
    startY = e.clientY;
    groupIds = getBarGroup('__float');
    groupStarts = {};
    groupCurrent = {};
    groupIds.forEach(bid => {
      const p = getPanelElementById(bid);
      if (p) {
        groupStarts[bid] = { left: parseInt(p.style.left) || 0, top: parseInt(p.style.top) || 0, inDOM: true };
      } else {
        // Hidden członek grupy (np. disabled bar) — czytamy z storage żeby też go przesunąć.
        const stored = getStoredStartPos(bid);
        if (stored) groupStarts[bid] = { left: stored.left, top: stored.top, inDOM: false };
      }
    });
    lastSnap = null;
    // Listenery document tylko na czas przeciągania — bez tego każde odtworzenie
    // pływającego przycisku (toggle folderu) zostawiało wiszącą parę listenerów.
    document.addEventListener('mousemove', onFloatMove);
    document.addEventListener('mouseup', onFloatUp);
    e.preventDefault();
  });

  const onFloatMove = (e) => {
    if (!dragging) return;
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    if (Math.abs(dx) > 4 || Math.abs(dy) > 4) dragStarted = true;
    if (!dragStarted) return;

    groupCurrent = {};
    groupIds.forEach(bid => {
      const start = groupStarts[bid];
      if (!start) return;
      const newLeft = start.left + dx;
      const newTop = start.top + dy;
      groupCurrent[bid] = { left: newLeft, top: newTop };
      if (start.inDOM) {
        const p = getPanelElementById(bid);
        if (p) { p.style.left = newLeft + 'px'; p.style.top = newTop + 'px'; }
      }
    });

    clearAllSnapEdges();
    const aRect = btn.getBoundingClientRect();
    const candidates = getDraggablePanelsForSnap();
    const snap = findSnapCandidate('__float', aRect, candidates, groupIds);
    lastSnap = snap;
    if (snap) {
      groupIds.forEach(bid => {
        const cur = groupCurrent[bid];
        if (!cur) return;
        cur.left += snap.snapDX;
        cur.top += snap.snapDY;
        const start = groupStarts[bid];
        if (start && start.inDOM) {
          const p = getPanelElementById(bid);
          if (p) { p.style.left = cur.left + 'px'; p.style.top = cur.top + 'px'; }
        }
      });
      btn.classList.add('jz-snap-edge-' + snap.aSide);
      const otherPanel = getPanelElementById(snap.otherBarId);
      if (otherPanel) otherPanel.classList.add('jz-snap-edge-' + snap.bSide);
    }
  };

  const onFloatUp = async (e) => {
    if (!dragging) return;
    const wasDragging = dragStarted;
    dragging = false;
    document.removeEventListener('mousemove', onFloatMove);
    document.removeEventListener('mouseup', onFloatUp);
    clearAllSnapEdges();
    if (wasDragging) {
      if (lastSnap) {
        addBarConnection({ aId: '__float', aSide: lastSnap.aSide, bId: lastSnap.otherBarId, bSide: lastSnap.bSide });
      }
      // groupCurrent zawiera pozycje też dla hidden członków grupy (od v2.13.53).
      saveGroupPositions(groupIds, groupCurrent);
      lastSnap = null;
      applyHiddenNamesByConnections();
    } else if (e.button === 0) {
      await toggleFolders();
    }
  };

  // Prawy klik — zawsze otwiera floating panel z folderami/grupami/zestawami + paski akcji.
  // Od v2.13.24: jeśli floating btn ma połączenia z paskami, sekcja "Połączenia" w panelu
  // dostarcza akcji rozłączenia (wcześniej wyświetlał osobne menu showBarContextMenu i tracił
  // dostęp do toggle widoczności pasków).
  btn.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    e.stopPropagation();
    togglePanel();
  });

  // Klik na samym przycisku - blokujemy default, żeby nie selekcjonować tekstu
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
  });
}

async function toggleFolders() {
  const { folders, groups, rules, floatingHidden } = await getData();
  const matching = getMatchingFoldersForPage(folders);
  const visibleFolders = matching.filter(f => !floatingHidden[f.id]);
  if (visibleFolders.length === 0) return;

  const allEnabled = visibleFolders.every(f => f.enabled);
  const newState = !allEnabled;

  visibleFolders.forEach(folder => {
    folder.enabled = newState;
    const folderGroups = groups.filter(g => g.folderId === folder.id);
    folderGroups.filter(g => g.active !== false).forEach(g => { g.enabled = newState; });
    const folderGroupIds = new Set(folderGroups.map(g => g.id));
    rules.filter(r => folderGroupIds.has(r.groupId) && r.active).forEach(r => {
      r.enabled = newState;
    });
  });

  await safeStorageSet({ folders, groups, rules });
}

// Discover bars relevant for current page (matching folder/group/rule scope + having actions).
// Zwraca: [{ barId, name, orientation, enabled, isLegacy }]. Pole `enabled` to PERSYSTYWNY
// stan (actionBar.enabled w storage albo folder.showActionsPanel dla legacy) — checkbox w
// popup/floating panel/chrome contextmenu odzwierciedla i toggle-uje TEN stan, żeby zmiany
// były widoczne w panelu edycji (modal "Paski akcji"). Zwraca też bary z enabled=false
// (żeby user mógł je włączyć z frontu) — różnica względem renderActionBars (który filtruje).
async function discoverRelevantBars() {
  const { folders, groups, rules, actionBars, floatingHidden } = await getData();
  const matching = getMatchingFoldersForPage(folders);
  // Od v2.13.52: pokazujemy bary też dla hidden folderów (zamiast filtrować je tu) —
  // popup pokaże je jako disabled (cascadeEnabled=false). Renderowanie na stronie
  // (renderActionBars) osobno respektuje !hidden && f.enabled. Discovery filter tylko: f.active.
  const eligibleFolderIds = new Set(
    matching.filter(f => f.active !== false).map(f => f.id)
  );

  // Wszystkie rules per scope (włącznie z disabled) — żeby pokazać bary nawet gdy rule.enabled=false,
  // bo user może chcieć włączyć bar i zauważyć że trzeba też włączyć rules.
  const allByFolder = new Map();
  const allByGroup  = new Map();
  const allByRuleId = new Map();
  (rules || []).filter(r => r.active !== false).forEach(r => {
    allByRuleId.set(r.id, r);
    const g = (groups || []).find(gx => gx.id === r.groupId);
    if (!g) return;
    if (!allByGroup.has(g.id)) allByGroup.set(g.id, []);
    allByGroup.get(g.id).push(r);
    if (!allByFolder.has(g.folderId)) allByFolder.set(g.folderId, []);
    allByFolder.get(g.folderId).push(r);
  });

  const out = [];

  (actionBars || []).forEach(bar => {
    let scopedRules = [];
    let cascadeEnabled = false;
    if (bar.parentType === 'folder') {
      if (!eligibleFolderIds.has(bar.parentId)) return;
      scopedRules = allByFolder.get(bar.parentId) || [];
      const f = (folders || []).find(x => x.id === bar.parentId);
      cascadeEnabled = !!f && !floatingHidden[f.id] && f.active !== false && f.enabled;
    } else if (bar.parentType === 'group') {
      const g = (groups || []).find(gx => gx.id === bar.parentId);
      if (!g || !eligibleFolderIds.has(g.folderId)) return;
      scopedRules = allByGroup.get(bar.parentId) || [];
      const f = (folders || []).find(x => x.id === g.folderId);
      cascadeEnabled = !!f && !floatingHidden[f.id] && f.active !== false && f.enabled
                       && g.active !== false && g.enabled !== false;
    } else if (bar.parentType === 'rule') {
      const r = allByRuleId.get(bar.parentId);
      if (!r) return;
      const g = (groups || []).find(gx => gx.id === r.groupId);
      if (!g || !eligibleFolderIds.has(g.folderId)) return;
      scopedRules = [r];
      const f = (folders || []).find(x => x.id === g.folderId);
      cascadeEnabled = !!f && !floatingHidden[f.id] && f.active !== false && f.enabled
                       && g.active !== false && g.enabled !== false
                       && r.active !== false && r.enabled === true;
    }
    if (scopedRules.length === 0) return;
    const hasAnyAction = scopedRules.some(r =>
      Array.isArray(r.actions) && r.actions.some(a => a && a.barId === bar.id)
    );
    if (!hasAnyAction) return;
    out.push({
      barId: bar.id,
      name: bar.name || '',
      orientation: bar.orientation === 'horizontal' ? 'horizontal' : 'vertical',
      enabled: bar.enabled !== false,
      cascadeEnabled, // od v2.13.50: czy łańcuch rodziców enabled (do wyświetlenia "effective" w popup)
      isLegacy: false,
    });
  });

  // Legacy ghost bary — `enabled` = folder.showActionsPanel.
  matching.forEach(folder => {
    if (!eligibleFolderIds.has(folder.id)) return;
    const hasOwnBar = (actionBars || []).some(b => b.parentType === 'folder' && b.parentId === folder.id);
    if (hasOwnBar) return;
    const folderRules = allByFolder.get(folder.id) || [];
    const hasAnyAction = folderRules.some(r =>
      Array.isArray(r.actions) && r.actions.some(a => a && !a.barId)
    );
    if (!hasAnyAction) return;
    const legacyId = '__legacy_' + folder.id;
    out.push({
      barId: legacyId,
      name: folder.name || '',
      orientation: folder.actionsOrientation === 'horizontal' ? 'horizontal' : 'vertical',
      enabled: folder.showActionsPanel === true,
      cascadeEnabled: !floatingHidden[folder.id] && folder.active !== false && folder.enabled,
      isLegacy: true,
    });
  });

  return out;
}

// Toggle visibility paska — zapisuje persistent state (actionBar.enabled lub
// folder.showActionsPanel dla legacy ghost barów), dzięki czemu zmiana propaguje się do
// modal "Paski akcji" w panelu edycji (storage onChanged listener). Przy włączaniu czyści
// też per-window barsHidden (bo legacy "Ukryj do reloadu" mogło zostawić go true) — żeby
// jeden toggle w popup/floating panel/contextmenu zawsze dawał widoczny rezultat.
// Serializacja przez `_barVisibilityChain` — bez niej szybkie klik-klik (popup +
// menu kontekstowe, dwa okna) robiło równoległy read-modify-write na actionBars
// i gubiło zapis (objaw „co drugie kliknięcie"). Analogicznie do setCssPaneEnabled.
let _barVisibilityChain = Promise.resolve();
function setBarVisibility(barId, visible) {
  if (typeof barId !== 'string') return Promise.resolve();
  _barVisibilityChain = _barVisibilityChain.then(async () => {
    if (barId.startsWith('__legacy_')) {
      const folderId = barId.slice('__legacy_'.length);
      const data = await safeStorageGet(['folders']);
      const folders = Array.isArray(data.folders) ? data.folders : [];
      const folder = folders.find(f => f.id === folderId);
      if (!folder) return;
      folder.showActionsPanel = !!visible;
      await safeStorageSet({ folders });
    } else {
      const data = await safeStorageGet(['actionBars']);
      const actionBars = Array.isArray(data.actionBars) ? data.actionBars : [];
      const bar = actionBars.find(b => b.id === barId);
      if (!bar) return;
      bar.enabled = !!visible;
      await safeStorageSet({ actionBars });
    }
    if (visible) {
      setBarHidden(barId, false);
    }
  });
  return _barVisibilityChain;
}

// CSS pane enable/disable (od v2.13.41, serializacja od v2.13.42) — analogicznie do
// setBarVisibility. Pisze persistent pane.enabled w storage cssPanes; options.js (jeśli
// otwarty) diff'em wykryje zmianę i enqueueChange → sync na backend. Sync z innym
// urządzeniem nastąpi dopiero gdy options jest otwarty (content.js sam nie wywołuje
// enqueueChange — pattern jak dla bars).
//
// Serializacja przez `_cssPaneEnabledChain` (od v2.13.42) — bez niej szybkie klik-klik
// w widget popup lub browser action popup robił read-modify-write race:
//   Handler1: read (enabled=true) → write (false)  [interleaved]
//   Handler2: read (enabled=true!) → write (true)  [obie czytały przed pierwszym write]
// Druga wartość wygrywała → user widział "raz działa raz nie działa". Chain gwarantuje
// że każdy setCssPaneEnabled poczeka na poprzedni przed read.
let _cssPaneEnabledChain = Promise.resolve();
function setCssPaneEnabled(paneId, enabled) {
  if (typeof paneId !== 'string') return Promise.resolve();
  _cssPaneEnabledChain = _cssPaneEnabledChain.then(async () => {
    const data = await safeStorageGet(['cssPanes']);
    const panes = Array.isArray(data.cssPanes) ? data.cssPanes : [];
    const pane = panes.find(p => p.id === paneId);
    if (!pane) return;
    pane.enabled = !!enabled;
    pane.updatedAt = Date.now();
    await safeStorageSet({ cssPanes: panes });
  });
  return _cssPaneEnabledChain;
}

// Listener „klik poza panelem zamyka" — trzymany w module, by dało się go zdjąć
// przy każdej drodze usunięcia panelu (nie tylko gdy sam się odpali). Bez tego
// przy przebudowach panelu (storage.onChanged, disconnect) listenery `mousedown`
// narastały na document w nieskończoność.
let _panelCloser = null;
let _panelCloserTimer = null;
function removeFloatingPanel() {
  const p = document.getElementById(PANEL_ID);
  if (p) p.remove();
  // Anuluj oczekujący setTimeout rejestrujący closer — gdy panel zamknięto w
  // oknie 100 ms, bez tego pending timeout odpaliłby się później i dorzucił
  // listener mousedown osierocony (trzymający referencję do usuniętego panelu).
  if (_panelCloserTimer) {
    clearTimeout(_panelCloserTimer);
    _panelCloserTimer = null;
  }
  if (_panelCloser) {
    document.removeEventListener('mousedown', _panelCloser, true);
    _panelCloser = null;
  }
}

async function togglePanel() {
  const existing = document.getElementById(PANEL_ID);
  if (existing) {
    removeFloatingPanel();
    return;
  }
  const { folders, groups, rules, floatingHidden } = await getData();
  const matching = getMatchingFoldersForPage(folders);
  const visibleFolders = matching.filter(f => !floatingHidden[f.id]);
  if (visibleFolders.length === 0) return;

  const btn = document.getElementById(FLOAT_ID);
  if (!btn) return;
  const rect = btn.getBoundingClientRect();

  const panel = document.createElement('div');
  panel.id = PANEL_ID;
  panel.style.left = (rect.left) + 'px';
  panel.style.top = (rect.bottom + 4) + 'px';

  let html = '';
  visibleFolders.forEach(folder => {
    const folderGroups = groups.filter(g => g.folderId === folder.id);
    let groupsHtml = '';
    folderGroups.forEach(group => {
      // Tylko zestawy "aktywne" (rule.active=true) — niezależne ukrywamy w popupie/panelu.
      const groupRules = rules.filter(r => r.groupId === group.id && r.active === true);
      if (groupRules.length === 0) return;
      // Jeżeli folder ma tylko jedną grupę "Domyślną" — nie pokazujemy nagłówka grupy, tylko zestawy.
      const onlyDefault = folderGroups.length === 1 && (group.name === 'Domyślna' || group.name === 'Default' || !group.name);
      groupsHtml += `
        <div class="jz-group-block">
          ${onlyDefault ? '' : `
            <label class="jz-group-row">
              <input type="checkbox" data-group-id="${group.id}" ${group.enabled !== false ? 'checked' : ''}>
              <span class="jz-group-name">${escapeHtml(group.name || 'Grupa')}</span>
            </label>
          `}
          <div class="jz-rules">
            ${groupRules.map(r => `
              <label class="jz-rule-row">
                <input type="checkbox" data-rule-id="${r.id}" ${r.enabled ? 'checked' : ''}>
                <span class="jz-rule-name">${escapeHtml(r.name || '(bez nazwy)')}</span>
              </label>
            `).join('')}
          </div>
        </div>
      `;
    });
    html += `
      <div class="jz-folder-block">
        <label class="jz-folder-row">
          <input type="checkbox" data-folder-id="${folder.id}" ${folder.enabled ? 'checked' : ''}>
          <strong>${escapeHtml(folder.name || '(bez nazwy)')}</strong>
        </label>
        ${groupsHtml || `<div class="jz-empty">${escapeHtml(JZ_I18N.t('content.panel.empty'))}</div>`}
      </div>
    `;
  });

  // Sekcja "Paski akcji" — lista barów relevantnych dla strony z checkboxami toggle enabled.
  // Od v2.13.24 toggle modyfikuje persistent state (actionBar.enabled / folder.showActionsPanel)
  // zamiast per-window barsHidden, żeby zmiana była widoczna w panelu edycji.
  const relevantBars = await discoverRelevantBars();
  if (relevantBars.length > 0) {
    let barsHtml = '';
    relevantBars.forEach(b => {
      const displayName = b.name
        || (b.isLegacy ? (folders.find(f => f.id === b.barId.slice('__legacy_'.length))?.name || '') : '')
        || JZ_I18N.t('content.bar.fallbackName');
      // Od v2.13.50: checkbox pokazuje EFFECTIVE state = bar.enabled && cascadeEnabled.
      // Gdy cascade off (folder/group/rule disabled) checkbox disabled — user musi najpierw
      // włączyć rodzica. Eliminuje confusion gdy click na "włączony" bar nie zmienia nic.
      const effective = b.enabled && b.cascadeEnabled;
      const disabled = !b.cascadeEnabled;
      barsHtml += `
        <label class="jz-bar-row${disabled ? ' jz-bar-row-disabled' : ''}">
          <input type="checkbox" data-bar-id="${escapeHtml(b.barId)}" ${effective ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
          <span class="jz-bar-name">${escapeHtml(displayName)}</span>
        </label>
      `;
    });
    html += `
      <div class="jz-panel-section">
        <div class="jz-section-title">${escapeHtml(JZ_I18N.t('content.panel.actionBarsTitle'))}</div>
        <div class="jz-bars-list">${barsHtml}</div>
      </div>
    `;
  }

  // Sekcja "Okna CSS" (od v2.13.41) — toggle enabled per pane relevant dla strony.
  // Pisze pane.enabled w storage cssPanes — pull-write tag NIE jest ustawiany, więc
  // options.js next saveAll diff'em zauważy zmianę i enqueueChange → sync.
  const dataAll = await safeStorageGet(['cssPanes']);
  const allCssPanes = Array.isArray(dataAll.cssPanes) ? dataAll.cssPanes : [];
  // Discovery z BIEŻĄCYMI panes (nie tylko enabled) — żeby user mógł włączyć z poziomu widgetu.
  // Reuse discoverRelevantCssPanes ale z patchem: enabled=true tymczasowo dla wszystkich
  // żeby wszystkie relevantne (matching URL) zwrócił.
  const patchedPanes = allCssPanes.map(p => ({ ...p, enabled: true }));
  const relevantCssPanes = discoverRelevantCssPanes(folders, groups, rules, patchedPanes, floatingHidden)
    .map(p => allCssPanes.find(orig => orig.id === p.id))
    .filter(Boolean);
  if (relevantCssPanes.length > 0) {
    let panesHtml = '';
    relevantCssPanes.forEach(p => {
      const displayName = p.name || JZ_I18N.t('cssPanes.btn.label');
      // Od v2.13.50: cascade-aware checkbox jak dla bars (patrz wyżej).
      const cascade = paneCascadeEnabled(p, folders, groups, rules, floatingHidden);
      const effective = (p.enabled !== false) && cascade;
      const disabled = !cascade;
      panesHtml += `
        <label class="jz-bar-row${disabled ? ' jz-bar-row-disabled' : ''}">
          <input type="checkbox" data-css-pane-id="${escapeHtml(p.id)}" ${effective ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
          <span class="jz-bar-dot" style="background:${(p.color || '#2D9D53').replace(/[<"']/g, '')}"></span>
          <span class="jz-bar-name">${escapeHtml(displayName)}</span>
        </label>
      `;
    });
    html += `
      <div class="jz-panel-section">
        <div class="jz-section-title">${escapeHtml(JZ_I18N.t('cssPanes.btn.label'))}</div>
        <div class="jz-bars-list">${panesHtml}</div>
      </div>
    `;
  }

  // Sekcja "Okna JS" (od v2.13.56) — analogicznie do CSS panes.
  const dataAllJs = await safeStorageGet(['jsPanes']);
  const allJsPanes = Array.isArray(dataAllJs.jsPanes) ? dataAllJs.jsPanes : [];
  const patchedJsPanes = allJsPanes.map(p => ({ ...p, enabled: true }));
  const relevantJsPanes = discoverRelevantJsPanes(folders, groups, rules, patchedJsPanes, floatingHidden)
    .map(p => allJsPanes.find(orig => orig.id === p.id))
    .filter(Boolean);
  if (relevantJsPanes.length > 0) {
    let jsPanesHtml = '';
    relevantJsPanes.forEach(p => {
      const displayName = p.name || JZ_I18N.t('jsPanes.btn.label');
      const cascade = paneCascadeEnabled(p, folders, groups, rules, floatingHidden);
      const effective = (p.enabled !== false) && cascade;
      const disabled = !cascade;
      jsPanesHtml += `
        <label class="jz-bar-row${disabled ? ' jz-bar-row-disabled' : ''}">
          <input type="checkbox" data-js-pane-id="${escapeHtml(p.id)}" ${effective ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
          <span class="jz-bar-dot" style="background:${(p.color || '#2D9D53').replace(/[<"']/g, '')}"></span>
          <span class="jz-bar-name">${escapeHtml(displayName)}</span>
        </label>
      `;
    });
    html += `
      <div class="jz-panel-section">
        <div class="jz-section-title">${escapeHtml(JZ_I18N.t('jsPanes.btn.label'))}</div>
        <div class="jz-bars-list">${jsPanesHtml}</div>
      </div>
    `;
  }

  // Sekcja "Konsole JS" (od v2.13.69) — analogicznie do JS panes.
  const dataAllJc = await safeStorageGet(['jsConsoles']);
  const allJsConsoles = Array.isArray(dataAllJc.jsConsoles) ? dataAllJc.jsConsoles : [];
  const patchedJsConsoles = allJsConsoles.map(c => ({ ...c, enabled: true }));
  const relevantJsConsoles = discoverRelevantJsConsoles(folders, groups, rules, patchedJsConsoles, floatingHidden)
    .map(c => allJsConsoles.find(orig => orig.id === c.id))
    .filter(Boolean);
  if (relevantJsConsoles.length > 0) {
    let jsConsolesHtml = '';
    relevantJsConsoles.forEach(c => {
      const displayName = c.name || JZ_I18N.t('jsConsoles.btn.label');
      const cascade = paneCascadeEnabled(c, folders, groups, rules, floatingHidden);
      const effective = (c.enabled !== false) && cascade;
      const disabled = !cascade;
      jsConsolesHtml += `
        <label class="jz-bar-row${disabled ? ' jz-bar-row-disabled' : ''}">
          <input type="checkbox" data-js-console-id="${escapeHtml(c.id)}" ${effective ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
          <span class="jz-bar-dot" style="background:${(c.color || '#7C3AED').replace(/[<"']/g, '')}"></span>
          <span class="jz-bar-name">${escapeHtml(displayName)}</span>
        </label>
      `;
    });
    html += `
      <div class="jz-panel-section">
        <div class="jz-section-title">${escapeHtml(JZ_I18N.t('jsConsoles.btn.label'))}</div>
        <div class="jz-bars-list">${jsConsolesHtml}</div>
      </div>
    `;
  }

  // Sekcja "Konsole Output" (od v2.13.72).
  const dataAllOc = await safeStorageGet(['outputConsoles']);
  const allOutputConsoles = Array.isArray(dataAllOc.outputConsoles) ? dataAllOc.outputConsoles : [];
  const patchedOutputConsoles = allOutputConsoles.map(o => ({ ...o, enabled: true }));
  const relevantOutputConsoles = discoverRelevantOutputConsoles(folders, groups, rules, patchedOutputConsoles, floatingHidden)
    .map(o => allOutputConsoles.find(orig => orig.id === o.id))
    .filter(Boolean);
  if (relevantOutputConsoles.length > 0) {
    let outputConsolesHtml = '';
    relevantOutputConsoles.forEach(o => {
      const displayName = o.name || JZ_I18N.t('outputConsoles.btn.label');
      const cascade = paneCascadeEnabled(o, folders, groups, rules, floatingHidden);
      const effective = (o.enabled !== false) && cascade;
      const disabled = !cascade;
      outputConsolesHtml += `
        <label class="jz-bar-row${disabled ? ' jz-bar-row-disabled' : ''}">
          <input type="checkbox" data-output-console-id="${escapeHtml(o.id)}" ${effective ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
          <span class="jz-bar-dot" style="background:${(o.color || '#10B981').replace(/[<"']/g, '')}"></span>
          <span class="jz-bar-name">${escapeHtml(displayName)}</span>
        </label>
      `;
    });
    html += `
      <div class="jz-panel-section">
        <div class="jz-section-title">${escapeHtml(JZ_I18N.t('outputConsoles.btn.label'))}</div>
        <div class="jz-bars-list">${outputConsolesHtml}</div>
      </div>
    `;
  }

  // Sekcja "Połączenia" — jeśli floating btn jest sklejony z paskami, oferuj rozłączenie
  // (od v2.13.24, zastępuje wcześniejsze osobne menu showBarContextMenu które zabierało
  // dostęp do togglePanel gdy floating btn miał połączenia).
  const floatConns = (positionCache && Array.isArray(positionCache.barConnections))
    ? positionCache.barConnections.filter(c => c.aId === '__float' || c.bId === '__float')
    : [];
  if (floatConns.length > 0) {
    let connsHtml = '';
    floatConns.forEach((c, idx) => {
      const otherId = c.aId === '__float' ? c.bId : c.aId;
      let otherName = '';
      if (otherId.startsWith('__legacy_')) {
        const fid = otherId.slice('__legacy_'.length);
        otherName = folders.find(f => f.id === fid)?.name || otherId;
      } else {
        const otherPanel = document.getElementById(BAR_PANEL_PREFIX + otherId);
        otherName = otherPanel?.querySelector('.jz-bar-name')?.textContent || otherId;
      }
      const label = JZ_I18N.t('content.bar.disconnectFrom').replace(':name', otherName);
      connsHtml += `<button class="jz-disconnect-btn" data-disconnect-idx="${idx}">${escapeHtml(label)}</button>`;
    });
    if (floatConns.length > 1) {
      connsHtml += `<button class="jz-disconnect-btn jz-disconnect-all" data-disconnect-all>${escapeHtml(JZ_I18N.t('content.bar.disconnectAll'))}</button>`;
    }
    html += `
      <div class="jz-panel-section">
        <div class="jz-section-title">${escapeHtml(JZ_I18N.t('content.panel.connectionsTitle'))}</div>
        <div class="jz-conns-list">${connsHtml}</div>
      </div>
    `;
  }

  panel.innerHTML = html;
  document.documentElement.appendChild(panel);

  // Eventy
  panel.querySelectorAll('input[data-folder-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const folderId = e.target.dataset.folderId;
      const enable = e.target.checked;
      const data = await safeStorageGet(['folders', 'groups', 'rules']);
      const folders = data.folders || [];
      const groups = data.groups || [];
      const rules = data.rules || [];
      const folder = folders.find(f => f.id === folderId);
      if (folder) {
        folder.enabled = enable;
        // Cascade DOWN tylko przy WYŁĄCZANIU (off propaguje w dół żeby ukryć).
        // Przy WŁĄCZANIU NIE propagujemy w dół — user może chcieć włączyć tylko folder
        // a zostawić disabled groups/rules. Od v2.13.53.
        if (!enable) {
          const folderGroups = groups.filter(g => g.folderId === folderId);
          folderGroups.filter(g => g.active !== false).forEach(g => { g.enabled = false; });
          const folderGroupIds = new Set(folderGroups.map(g => g.id));
          rules.filter(r => folderGroupIds.has(r.groupId) && r.active).forEach(r => { r.enabled = false; });
        }
        await safeStorageSet({ folders, groups, rules });
      }
    });
  });

  panel.querySelectorAll('input[data-group-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const groupId = e.target.dataset.groupId;
      const enable = e.target.checked;
      const data = await safeStorageGet(['folders', 'groups', 'rules']);
      const folders = data.folders || [];
      const groups = data.groups || [];
      const rules = data.rules || [];
      const group = groups.find(g => g.id === groupId);
      if (group) {
        group.enabled = enable;
        if (enable) {
          // Cascade UP przy WŁĄCZANIU (od v2.13.53): włącz rodzica folder.
          const parentFolder = folders.find(f => f.id === group.folderId);
          if (parentFolder) parentFolder.enabled = true;
        } else {
          // Cascade DOWN przy WYŁĄCZANIU: rules w grupie też off.
          rules.filter(r => r.groupId === groupId && r.active).forEach(r => { r.enabled = false; });
        }
        await safeStorageSet({ folders, groups, rules });
      }
    });
  });

  panel.querySelectorAll('input[data-rule-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const ruleId = e.target.dataset.ruleId;
      const enable = e.target.checked;
      const data = await safeStorageGet(['folders', 'groups', 'rules']);
      const folders = data.folders || [];
      const groups = data.groups || [];
      const rules = data.rules || [];
      const rule = rules.find(r => r.id === ruleId);
      if (rule) {
        rule.enabled = enable;
        if (enable) {
          // Cascade UP przy WŁĄCZANIU (od v2.13.53): włącz group i folder.
          const parentGroup = groups.find(g => g.id === rule.groupId);
          if (parentGroup) {
            parentGroup.enabled = true;
            const parentFolder = folders.find(f => f.id === parentGroup.folderId);
            if (parentFolder) parentFolder.enabled = true;
          }
        }
        await safeStorageSet({ folders, groups, rules });
      }
    });
  });

  // Toggle enabled per bar (od v2.13.24) — persistent state, propaguje do panelu edycji.
  panel.querySelectorAll('input[data-bar-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const barId = e.target.dataset.barId;
      await setBarVisibility(barId, e.target.checked);
      // Storage onChanged rerendruje bary i odświeża floating panel.
    });
  });

  // Toggle enabled per CSS pane (od v2.13.41) — analogicznie do bars. Pisze
  // pane.enabled w cssPanes; storage onChanged → applyAll → re-render panes na stronie.
  panel.querySelectorAll('input[data-css-pane-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const paneId = e.target.dataset.cssPaneId;
      await setCssPaneEnabled(paneId, e.target.checked);
    });
  });

  // Toggle enabled per JS pane (od v2.13.56) — analogicznie do CSS pane.
  panel.querySelectorAll('input[data-js-pane-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const paneId = e.target.dataset.jsPaneId;
      await setJsPaneEnabled(paneId, e.target.checked);
    });
  });

  // Toggle enabled per JS console (od v2.13.69).
  panel.querySelectorAll('input[data-js-console-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const consoleId = e.target.dataset.jsConsoleId;
      await setJsConsoleEnabled(consoleId, e.target.checked);
    });
  });

  // Toggle enabled per Output console (od v2.13.72).
  panel.querySelectorAll('input[data-output-console-id]').forEach(cb => {
    cb.addEventListener('change', async (e) => {
      const ocId = e.target.dataset.outputConsoleId;
      await setOutputConsoleEnabled(ocId, e.target.checked);
    });
  });

  // Sekcja "Połączenia" — disconnect handlers (od v2.13.24).
  panel.querySelectorAll('button[data-disconnect-idx]').forEach(btn => {
    btn.addEventListener('click', () => {
      const idx = parseInt(btn.dataset.disconnectIdx, 10);
      const c = floatConns[idx];
      if (c) removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide });
      removeFloatingPanel();
      togglePanel();
    });
  });
  const disconnectAllBtn = panel.querySelector('button[data-disconnect-all]');
  if (disconnectAllBtn) {
    disconnectAllBtn.addEventListener('click', () => {
      floatConns.forEach(c => removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide }));
      removeFloatingPanel();
      togglePanel();
    });
  }

  // Klik poza panelem zamyka
  _panelCloserTimer = setTimeout(() => {
    _panelCloserTimer = null;
    // Panel mógł zniknąć w oknie 100 ms (szybkie ponowne kliknięcie / przebudowa).
    if (!document.getElementById(PANEL_ID)) return;
    const closer = (e) => {
      if (!panel.contains(e.target) && e.target.id !== FLOAT_ID && (!document.getElementById(FLOAT_ID) || !document.getElementById(FLOAT_ID).contains(e.target))) {
        removeFloatingPanel();
      }
    };
    _panelCloser = closer;
    document.addEventListener('mousedown', closer, true);
  }, 100);
}

// === Paski akcji — wielo-paskowy renderer (Faza 3, od v2.10) ===
//
// Każdy ActionBar (z storage `actionBars[]`) renderuje się jako osobny pływający panel,
// jeśli w aktualnym kontekście strony ma >0 widocznych+enabled elementów. Bary mogą być
// przypisane do folderu, grupy lub zestawu (parentType + parentId); widoczność dziedziczona
// w dół. Pozycja per-bar trzymana w `positions.barPositions[barId]` (per-okno cache).
//
// Legacy fallback: dla folderów z `showActionsPanel=true` które nie mają WŁASNEGO baru
// (parentType=folder, parentId=folder.id) i mają actions bez `barId` w aktywnych rules —
// tworzymy "ghost bar" `__legacy_<folderId>` (in-memory, nie w storage), żeby zachować
// dotychczasowe zachowanie panelu akcji folderu.

const BAR_PANEL_PREFIX = 'justzix-actions-bar-';

// Flaga ustawiana przez attachBarDrag na czas przeciągania paska. Re-render w trakcie
// draga odtworzyłby panel i wskoczyłby on z powrotem na zapisaną pozycję (analogicznie
// do jzDragging guardu paneli CSS).
let _barDragInProgress = false;

async function renderActionBars(folders, groups, rules, actionBars, activeRules) {
  // Pomiń re-render w trakcie przeciągania paska. Po zakończeniu draga
  // onUp→saveGroupPositions zapisuje storage → wyzwala kolejny render.
  if (_barDragInProgress) return;
  const data = await safeStorageGet(['floatingHidden']);
  const hidden = data.floatingHidden || {};
  const positions = await getPositions();

  const matching = getMatchingFoldersForPage(folders);
  const eligibleFolderIds = new Set(
    matching.filter(f => !hidden[f.id] && f.active !== false && f.enabled).map(f => f.id)
  );

  // Zindeksuj aktywne rules per folder/group dla O(1) lookup.
  const activeByFolder = new Map();
  const activeByGroup  = new Map();
  const activeByRuleId = new Map();
  activeRules.forEach(r => {
    activeByRuleId.set(r.id, r);
    const g = (groups || []).find(gx => gx.id === r.groupId);
    if (!g) return;
    if (!activeByGroup.has(g.id)) activeByGroup.set(g.id, []);
    activeByGroup.get(g.id).push(r);
    if (!activeByFolder.has(g.folderId)) activeByFolder.set(g.folderId, []);
    activeByFolder.get(g.folderId).push(r);
  });

  // Lista barów do renderowania — z storage + legacy ghost.
  const barsToRender = [];

  (actionBars || []).forEach(bar => {
    if (bar.enabled === false) return;
    let scopedRules = [];
    if (bar.parentType === 'folder') {
      if (!eligibleFolderIds.has(bar.parentId)) return;
      scopedRules = activeByFolder.get(bar.parentId) || [];
    } else if (bar.parentType === 'group') {
      const g = (groups || []).find(gx => gx.id === bar.parentId);
      if (!g || !eligibleFolderIds.has(g.folderId)) return;
      scopedRules = activeByGroup.get(bar.parentId) || [];
    } else if (bar.parentType === 'rule') {
      const r = activeByRuleId.get(bar.parentId);
      if (!r) return;
      const g = (groups || []).find(gx => gx.id === r.groupId);
      if (!g || !eligibleFolderIds.has(g.folderId)) return;
      scopedRules = [r];
    }
    if (scopedRules.length === 0) return;
    const buttons = collectActionButtons(scopedRules, a => a.barId === bar.id);
    if (buttons.length === 0) return;
    // od v3.2.24 — bar.actionOrder (string[] id-ków akcji) wymusza kolejność
    // renderu w trybie flow. Akcje nie wymienione w order → na końcu (zachowują
    // naturalną kolejność z collectActionButtons). Custom mode i tak korzysta
    // z action.layout absolutnego, więc order tylko porządkuje DOM.
    if (Array.isArray(bar.actionOrder) && bar.actionOrder.length) {
      const orderMap = new Map(bar.actionOrder.map((id, i) => [id, i]));
      buttons.sort((a, b) => {
        const ai = orderMap.has(a.id) ? orderMap.get(a.id) : 1e9;
        const bi = orderMap.has(b.id) ? orderMap.get(b.id) : 1e9;
        return ai - bi;
      });
    }
    barsToRender.push({ bar, buttons });
  });

  // Legacy ghost bary — dla folderów z showActionsPanel=true bez własnego baru.
  matching.forEach(folder => {
    if (!folder.showActionsPanel || hidden[folder.id]) return;
    if (!eligibleFolderIds.has(folder.id)) return;
    const hasOwnBar = (actionBars || []).some(b => b.parentType === 'folder' && b.parentId === folder.id);
    if (hasOwnBar) return;
    const folderRules = activeByFolder.get(folder.id) || [];
    const buttons = collectActionButtons(folderRules, a => !a.barId);
    if (buttons.length === 0) return;
    const ghostBar = {
      id: '__legacy_' + folder.id,
      __legacy: true,
      parentType: 'folder',
      parentId: folder.id,
      orientation: folder.actionsOrientation === 'horizontal' ? 'horizontal' : 'vertical',
      enabled: true,
      name: '',
      color: '',
    };
    barsToRender.push({ bar: ghostBar, buttons });
  });

  // Render każdego baru, zbierz seenIds.
  const seenBarIds = new Set();
  let cascadeIdx = 0;
  barsToRender.forEach(({ bar, buttons }) => {
    seenBarIds.add(bar.id);
    if (positions.barsHidden && positions.barsHidden[bar.id]) {
      const ex = document.getElementById(BAR_PANEL_PREFIX + cssEscape(bar.id));
      if (ex) ex.remove();
      return;
    }
    const stored = positions.barPositions ? positions.barPositions[bar.id] : null;
    const pos = stored || { x: 80 + cascadeIdx * 18, y: 20 + cascadeIdx * 18 };
    if (!stored) cascadeIdx++;
    renderOneBar(bar, buttons, pos);
  });

  // Usuń panele dla barów które już nie powinny być widoczne (stale).
  document.querySelectorAll(`[id^="${BAR_PANEL_PREFIX}"]`).forEach(el => {
    const id = el.id.slice(BAR_PANEL_PREFIX.length);
    if (!seenBarIds.has(id)) el.remove();
  });

  // Usuń stary singleton panel z poprzedniej wersji (jeśli wisiał).
  const oldSingleton = document.getElementById(ACTIONS_PANEL_ID);
  if (oldSingleton) oldSingleton.remove();

  // Po renderze: ukryj nazwę barów które mają połączenie po stronie top
  // (label jest na top: -10, więc nakładałby się na sąsiada powyżej i przesłaniał jego elementy).
  applyHiddenNamesByConnections();
}

function applyHiddenNamesByConnections() {
  // Geometryczna detekcja: ukryj label gdy jego rect koliduje z ciałem innego baru
  // lub floating btn. Działa zarówno dla snap-connected, jak i dla barów ułożonych
  // ręcznie blisko siebie (np. stos pionowy gdzie label dolnego baru wchodzi w przyciski górnego).
  const bars = Array.from(document.querySelectorAll('.jz-actions-bar'));
  if (bars.length === 0) return;

  // Usuń klasę żeby móc zmierzyć prawdziwy rect labela (display:none → rect 0,0).
  bars.forEach(p => p.classList.remove('jz-no-name'));

  const obstacles = bars.map(p => p.getBoundingClientRect());
  const floatBtn = document.getElementById(FLOAT_ID);
  if (floatBtn) obstacles.push(floatBtn.getBoundingClientRect());

  bars.forEach((panel, idx) => {
    const label = panel.querySelector('.jz-bar-name');
    if (!label) return;
    const lr = label.getBoundingClientRect();
    if (lr.width === 0 || lr.height === 0) return;
    const ownRect = obstacles[idx];
    const overlaps = obstacles.some((r, i) => {
      if (r === ownRect) return false;
      return !(lr.right <= r.left || lr.left >= r.right || lr.bottom <= r.top || lr.top >= r.bottom);
    });
    if (overlaps) panel.classList.add('jz-no-name');
  });
}

// CSS-bezpieczny escape ID dla querySelector — ID barów to `b_xxx` lub `__legacy_<folderId>`.
function cssEscape(s) {
  return String(s).replace(/[^a-zA-Z0-9_-]/g, c => '\\' + c.charCodeAt(0).toString(16) + ' ');
}

// Faza 5 scaffold (v2.12.0): typed actions. Każdy `action` ma `type` (po migracji v6
// default to 'button'). Kolejne typy (select, input, slider, toggle3, textarea) wprowadzą
// kolejne minor releasy — tutaj definiujemy stałą + dispatcher renderowania.
const JZ_ACTION_TYPES = {
  BUTTON: 'button',
  SELECT: 'select',
  INPUT: 'input',
  SLIDER: 'slider',
  TOGGLE3: 'toggle3',
  TEXTAREA: 'textarea',
};

function getActionType(action) {
  return (action && action.type) || JZ_ACTION_TYPES.BUTTON;
}

// Walidacja widoczności w UI per typ. Action który nie spełnia walidacji jest pomijany
// w renderze (ale wciąż istnieje w storage).
//   button:        wymaga label + code
//   select static: wymaga code (opcje mogą być puste — pokaże dropdown z placeholderem)
//   select js:     wymaga code (sam zarządza opcjami)
//   input:         zawsze renderowalny od v2.13.7 (memory zawsze ON — singleton per action.id)
function isActionRenderable(action) {
  const type = getActionType(action);
  if (type === JZ_ACTION_TYPES.BUTTON) return !!(action.label && action.code);
  if (type === JZ_ACTION_TYPES.SELECT) {
    return !!(action.code && action.code.trim());
  }
  if (type === JZ_ACTION_TYPES.INPUT) {
    // Od v2.13.7: INPUT zawsze ma memory (jedna wartość per action.id). Renderowalny
    // gdy istnieje (label/placeholder/defaultValue/code — cokolwiek do wyświetlenia).
    return true;
  }
  if (type === JZ_ACTION_TYPES.SLIDER) {
    // Od v2.13.17: SLIDER renderowalny gdy ma sensowny zakres (min < max).
    // Memory zawsze ON (singleton per action.id). Label opcjonalny.
    const min = Number(action.min);
    const max = Number(action.max);
    return Number.isFinite(min) && Number.isFinite(max) && min < max;
  }
  if (type === JZ_ACTION_TYPES.TEXTAREA) {
    // Od v2.13.21: TEXTAREA zawsze renderowalny — analogiczna semantyka jak INPUT
    // (memory zawsze ON, label/placeholder/defaultValue/code opcjonalne).
    return true;
  }
  if (type === JZ_ACTION_TYPES.TOGGLE3) {
    // Od v2.13.25: TOGGLE3 wymaga dokładnie 3 stanów. Każdy stan musi mieć label
    // (value jest opcjonalne — fallback do label przy runActionCode).
    if (!Array.isArray(action.states) || action.states.length !== 3) return false;
    return action.states.every(s => s && typeof s.label === 'string' && s.label.length > 0);
  }
  return false;
}

function collectActionButtons(scopedRules, predicate) {
  const out = [];
  scopedRules.forEach(rule => {
    (rule.actions || []).forEach(action => {
      if (!predicate(action)) return;
      if (!action.visible) return;
      if (!isActionRenderable(action)) return;
      out.push({
        id: action.id,
        ruleId: rule.id,
        type: getActionType(action),
        action,
        // Wspólne pola (button + select + input + slider):
        label: (action.label || '').toUpperCase(),
        color: action.color || '#006870',
        // Drugi kolor (od v2.13.18) — per typ:
        //   BUTTON / SELECT button-face / INPUT  → kolor tekstu
        //   SLIDER                                → kolor paska (accent-color) WYŁĄCZNIE; tekst label/value
        //                                           jest sterowany osobnymi polami colorLabel/colorValue
        // undefined → renderer fallback do białego (CSS default).
        colorText: action.colorText,
        code: action.code,
        // SELECT (od v2.12.2): variant + options[{id,name,value}]
        variant: action.variant || 'static',
        options: Array.isArray(action.options) ? action.options.slice() : [],
        // INPUT (od v2.13.0) / SLIDER (od v2.13.17): placeholder + default value.
        // `defaultValue` typed-aware: string dla INPUT, number dla SLIDER. Zachowujemy 0 (falsy)
        // przez explicit undefined check — `0 || ''` dawało regression w SLIDER (default=0 → '').
        placeholder: action.placeholder || '',
        defaultValue: action.defaultValue !== undefined ? action.defaultValue : '',
        // SLIDER (od v2.13.17): zakres + krok + jednostka.
        min: action.min,
        max: action.max,
        step: action.step,
        unit: action.unit || '',
        // TEXTAREA (od v2.13.21): liczba widocznych wierszy.
        rows: action.rows,
        // TOGGLE3 (od v2.13.25): 3 stany + opcjonalny defaultStateIdx (0-2). Każdy state:
        //   { id, label, value }. Memory trzyma INDEX aktywnego stanu (0-2) per action.id.
        states: Array.isArray(action.states) ? action.states.slice() : [],
        defaultStateIdx: Number.isInteger(action.defaultStateIdx) ? action.defaultStateIdx : 0,
        // TOGGLE3 extra colors (v2.13.27 colorBg/colorHover, v2.13.32 colorInactiveText):
        //   colorBg          — tło wrap container
        //   colorHover       — hover bg na nieaktywnym buttonie
        //   colorInactiveText — kolor tekstu nieaktywnych stanów (active używa colorText)
        colorBg: action.colorBg,
        colorHover: action.colorHover,
        colorInactiveText: action.colorInactiveText,
        // SLIDER extra colors (od v2.13.19) — w edytorze.
        colorLabel: action.colorLabel,
        colorValue: action.colorValue,
        // SELECT extra colors (od v2.13.19) — w edytorze.
        //   dropdownBg = tło popup panelu (static) + tło <option> (js variant)
        //   option     = kolor tekstu nazwy opcji
        //   selected   = tło wybranej opcji (static variant only)
        //   hover      = tło hover-state opcji (static variant only)
        colorDropdownBg: action.colorDropdownBg,
        colorOption: action.colorOption,
        colorSelected: action.colorSelected,
        colorHover: action.colorHover,
        // INPUT extra color (od v2.13.19) — w edytorze.
        colorPlaceholder: action.colorPlaceholder,
      });
    });
  });
  return out;
}

function renderOneBar(bar, buttons, pos) {
  const elId = BAR_PANEL_PREFIX + bar.id;
  const existing = document.getElementById(elId);
  const orientation = bar.orientation === 'horizontal' ? 'horizontal' : 'vertical';

  // CSS variable --jz-bar-bg używane przez label .jz-bar-name (wystający tab nad krawędzią).
  const computedBg = bar.color || 'rgba(31, 41, 55, 0.95)';

  if (existing) {
    existing.style.left = pos.x + 'px';
    existing.style.top = pos.y + 'px';
    existing.classList.toggle('jz-horizontal', orientation === 'horizontal');
    // CSS używa `background: ... !important` — żeby nadpisać inline musi też być important.
    if (bar.color) existing.style.setProperty('background', bar.color, 'important');
    else existing.style.removeProperty('background');
    existing.style.setProperty('--jz-bar-bg', computedBg);
    fillBarPanel(existing, bar, buttons);
    return;
  }

  const panel = document.createElement('div');
  panel.id = elId;
  panel.className = 'jz-actions-bar';
  panel.dataset.jzBarId = bar.id;
  // Addressability (od v2.12.2) — parent context dla user JS:
  //   document.querySelector('.jz-actions-bar[data-jz-bar-id="..."]')
  //   document.querySelectorAll('.jz-actions-bar[data-jz-bar-parent-type="folder"]')
  panel.dataset.jzBarParentType = bar.parentType || '';
  panel.dataset.jzBarParentId = bar.parentId || '';
  panel.dataset.jzBarName = bar.name || '';
  panel.style.left = pos.x + 'px';
  panel.style.top = pos.y + 'px';
  panel.style.setProperty('--jz-bar-bg', computedBg);
  if (orientation === 'horizontal') panel.classList.add('jz-horizontal');
  if (bar.color) panel.style.setProperty('background', bar.color, 'important');

  panel.addEventListener('contextmenu', (e) => {
    // Tryb edycji — menu "zamknij pasek" nie ma sensu i jest mylące; prawy klik
    // na labelce pokazuje menu usuwania, na pozostałych elementach jest wyciszany.
    if (_editMode) {
      e.preventDefault();
      e.stopPropagation();
      const lbl = e.target.closest && e.target.closest('.jz-bar-label');
      if (lbl) showLabelContextMenu(lbl, e.clientX, e.clientY);
      return;
    }
    if (e.target.classList.contains('jz-action-btn')) return;
    e.preventDefault();
    e.stopPropagation();
    showBarContextMenu(bar.id, e.clientX, e.clientY);
  });

  document.documentElement.appendChild(panel);
  fillBarPanel(panel, bar, buttons);
}

function fillBarPanel(panel, bar, buttons) {
  // Zamknij otwarte dropdowny SELECT — przebudowa innerHTML poniżej zniszczy ich
  // przyciski, osierocając dropdown na documentElement wraz z globalnymi listenerami.
  closeAllActionSelects();
  const nameLabel = (bar.name || '').toUpperCase().slice(0, 6);
  const handleTitle = (typeof JZ_I18N !== 'undefined') ? JZ_I18N.t('content.actionsPanel.dragHandle').replace(/"/g, '&quot;') : 'Drag';
  panel.innerHTML = `
    <div class="jz-actions-handle" title="${handleTitle}">
      <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="none">
        <circle cx="9" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/>
        <circle cx="15" cy="6" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="15" cy="18" r="1.5"/>
      </svg>
    </div>
    ${nameLabel ? `<div class="jz-bar-name">${escapeBarName(nameLabel)}</div>` : ''}
    <div class="jz-actions-buttons"></div>
  `;
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  buttons.forEach(item => renderActionElement(item, btnContainer));
  // Labelki paska (od v2.13.99) — bar-owned etykiety tekstowe, niezależne od akcji.
  (bar.labels || []).forEach(label => renderBarLabel(label, btnContainer));
  // Custom layout (od v2.13.94) — pasek w trybie 'custom' renderuje elementy
  // absolutnie wg action.layout/action.size. 'flow' = klasyczny flexbox.
  // Obecność labelek wymusza custom (labelki potrzebują pozycjonowania absolutnego).
  const hasLabels = Array.isArray(bar.labels) && bar.labels.length > 0;
  if (bar.layoutMode === 'custom' || hasLabels) applyCustomLayout(panel, bar, buttons);
  else panel.classList.remove('jz-layout-custom');
  attachBarDrag(panel, bar.id);
  attachBarEditInteraction(panel);
  addBarResizeHandles(panel);
  addBarLabelButton(panel);
  addBarResetButton(panel);
  if (_editMode) applyEditSelectionClasses();
}

// =============================================================================
// Tryb edycji paska akcji (Faza C+D, od v2.13.94)
// -----------------------------------------------------------------------------
// CTRL+ALT+S przełącza globalny tryb edycji. W trybie edycji elementy akcji
// można zaznaczać (klik / Ctrl+klik) i przesuwać myszką. Pierwsze przesunięcie
// elementu paska 'flow' konwertuje pasek na 'custom' (snapshot geometrii).
// Zmiany: bar.layoutMode + bar.size (actionBars), action.layout + action.size
// (rules) — wszystko synchronizowane przez istniejący sync entity.
let _editMode = false;
const _editSelection = new Set(); // action.id zaznaczonych elementów
let _editBanner = null;

// Aplikuje pozycje/rozmiary z action.layout/size na wyrenderowane elementy.
// Element akcji bez zapisanego action.layout dostaje pozycję ze swojego układu
// flow (zmierzoną przed przełączeniem na absolute) — zamiast piętrzyć w (0,0).
// Labelki pomijamy — renderBarLabel pozycjonuje je samodzielnie.
function applyCustomLayout(panel, bar, items) {
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  if (!btnContainer) return;
  // Pomiar pozycji flow MUSI odbywać się bez klasy jz-layout-custom — inaczej
  // CSS ustawia container na display:block i świeże elementy mierzą się jako
  // blok (bez flex-gap, dosunięte do lewej). Klasę zdejmujemy na czas pomiaru.
  panel.classList.remove('jz-layout-custom');
  const cRect = btnContainer.getBoundingClientRect();
  const flowPos = {};
  Array.from(btnContainer.children).forEach(child => {
    const aid = child.dataset && child.dataset.jzActionId;
    if (!aid || child.classList.contains('jz-bar-label')) return;
    const r = child.getBoundingClientRect();
    flowPos[aid] = {
      x: Math.round(r.left - cRect.left),
      y: Math.round(r.top - cRect.top),
      w: Math.round(r.width),
      h: Math.round(r.height),
    };
  });
  panel.classList.add('jz-layout-custom');
  const size = bar.size || {
    w: Math.max(40, Math.round(cRect.width)  || 220),
    h: Math.max(40, Math.round(cRect.height) || 160),
  };
  btnContainer.style.setProperty('width',  Math.max(40, size.w | 0) + 'px', 'important');
  btnContainer.style.setProperty('height', Math.max(40, size.h | 0) + 'px', 'important');
  // Dwa przebiegi: najpierw elementy z zapisanym action.layout (zajmują miejsce),
  // potem elementy bez layoutu (świeżo dodane) — dla nich szukamy wolnego slotu,
  // by nowy element nie wylądował na innym (od v2.13.107).
  const placedRects = [];
  const unplaced = [];
  Array.from(btnContainer.children).forEach(child => {
    const aid = child.dataset && child.dataset.jzActionId;
    if (!aid || child.classList.contains('jz-bar-label')) return;
    const item = items.find(it => it.id === aid);
    const layout = item && item.action && item.action.layout;
    const esize  = item && item.action && item.action.size;
    const fp = flowPos[aid] || { x: 0, y: 0, w: 0, h: 0 };
    const w = (esize && esize.w) ? esize.w : fp.w;
    const h = (esize && esize.h) ? esize.h : fp.h;
    // position:absolute ustawiamy inline RAZEM z left/top (atomowo) — element
    // nigdy nie jest absolute bez pozycji.
    child.style.setProperty('position', 'absolute', 'important');
    if (w && h) {
      child.style.setProperty('width',  (w | 0) + 'px', 'important');
      child.style.setProperty('height', (h | 0) + 'px', 'important');
    }
    if (layout && typeof layout.x === 'number' && typeof layout.y === 'number') {
      child.style.left = layout.x + 'px';
      child.style.top  = layout.y + 'px';
      placedRects.push({ x: layout.x, y: layout.y, w: w || 0, h: h || 0 });
    } else {
      unplaced.push({ child, w: w || 0, h: h || 0 });
    }
  });
  let grownW = size.w | 0, grownH = size.h | 0;
  unplaced.forEach(u => {
    const slot = findFreeSlot(placedRects, u.w, u.h, size.w | 0);
    u.child.style.left = slot.x + 'px';
    u.child.style.top  = slot.y + 'px';
    placedRects.push({ x: slot.x, y: slot.y, w: u.w, h: u.h });
    grownW = Math.max(grownW, slot.x + u.w);
    grownH = Math.max(grownH, slot.y + u.h);
  });
  // Element bez miejsca rozrasta płótno paska — nie ląduje poza nim (od v2.13.109).
  if (grownW > (size.w | 0) || grownH > (size.h | 0)) {
    btnContainer.style.setProperty('width',  grownW + 'px', 'important');
    btnContainer.style.setProperty('height', grownH + 'px', 'important');
  }
}

// Znajduje element akcji (bezpośrednie dziecko .jz-actions-buttons) nad targetem.
function editTargetElement(target) {
  let node = target;
  while (node && node.nodeType === 1 && node !== document.documentElement) {
    const parent = node.parentElement;
    if (parent && parent.classList && parent.classList.contains('jz-actions-buttons')
        && node.dataset && node.dataset.jzActionId) {
      return node;
    }
    node = parent;
  }
  return null;
}

function applyEditSelectionClasses() {
  document.querySelectorAll('.jz-action-el-selected')
    .forEach(el => el.classList.remove('jz-action-el-selected'));
  if (_editMode) {
    _editSelection.forEach(aid => {
      document.querySelectorAll('[data-jz-action-id="' + aid + '"]').forEach(el => {
        if (el.closest('.jz-actions-bar')) el.classList.add('jz-action-el-selected');
      });
    });
  }
  updateEditResizeFrame();
}

// Konwersja paska flow → custom: snapshot bieżącej geometrii wszystkich elementów,
// przełączenie kontenera na pozycjonowanie absolutne. Wizualnie bez zmian.
function convertBarPanelToCustom(panel) {
  if (panel.classList.contains('jz-layout-custom')) return;
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  if (!btnContainer) return;
  const cRect = btnContainer.getBoundingClientRect();
  const snaps = [];
  Array.from(btnContainer.children).forEach(child => {
    if (!child.dataset || !child.dataset.jzActionId) return;
    const r = child.getBoundingClientRect();
    snaps.push({
      child,
      x: Math.round(r.left - cRect.left),
      y: Math.round(r.top - cRect.top),
      w: Math.round(r.width),
      h: Math.round(r.height),
    });
  });
  btnContainer.style.setProperty('width',  Math.round(cRect.width)  + 'px', 'important');
  btnContainer.style.setProperty('height', Math.round(cRect.height) + 'px', 'important');
  panel.classList.add('jz-layout-custom');
  snaps.forEach(s => {
    s.child.style.setProperty('position', 'absolute', 'important');
    s.child.style.left = s.x + 'px';
    s.child.style.top  = s.y + 'px';
    s.child.style.setProperty('width',  s.w + 'px', 'important');
    s.child.style.setProperty('height', s.h + 'px', 'important');
  });
}

async function persistBarLayout(barId, fields) {
  if (!barId || barId.startsWith('__legacy_')) return; // ghost bary nie mają wpisu w storage
  const data = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
  const bar = bars.find(b => b.id === barId);
  if (!bar) return;
  Object.assign(bar, fields);
  bar.updatedAt = Date.now();
  await safeStorageSet({ actionBars: bars });
}

// Serializacja zapisów układu trybu edycji. Operacje onUp (move/resize), etykiety
// i reset robią read-modify-write na actionBars/rules; bez kolejki dwie szybkie
// operacje nadpisałyby sobie geometrię. Kolejkujemy tylko OPERACJE (najwyższy
// poziom) — pomocniki persistBarPanelLayout/persistActionGeometry/captureUndoSnapshot
// zostają poza kolejką, bo wołają je operacje już będące w sekcji krytycznej
// (zakolejkowanie ich osobno = zakleszczenie).
let _barLayoutChain = Promise.resolve();
function serializeBarLayout(fn) {
  _barLayoutChain = _barLayoutChain.then(fn, fn);
  return _barLayoutChain;
}

async function persistActionGeometry(updates) {
  if (!updates || updates.length === 0) return;
  const data = await safeStorageGet(['rules']);
  const rules = Array.isArray(data.rules) ? data.rules : [];
  let mutated = false;
  updates.forEach(u => {
    const rule = rules.find(r => r.id === u.ruleId);
    if (!rule || !Array.isArray(rule.actions)) return;
    const action = rule.actions.find(a => a.id === u.actionId);
    if (!action) return;
    action.layout = u.layout;
    action.size = u.size;
    action.updatedAt = Date.now();
    mutated = true;
  });
  if (mutated) await safeStorageSet({ rules });
}

// Zapis całej geometrii paska (po przesunięciu/resize) — bar.size + layoutMode,
// layout/size akcji (→ rules) oraz x/y/w/h labelek (→ bar.labels).
async function persistBarPanelLayout(panel) {
  const barId = panel.dataset.jzBarId;
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  if (!barId || !btnContainer) return;
  const size = { w: btnContainer.offsetWidth, h: btnContainer.offsetHeight };
  const actionUpdates = [];
  const labelGeom = {}; // labelId → {x,y,w,h}
  Array.from(btnContainer.children).forEach(child => {
    const eid = child.dataset && child.dataset.jzActionId;
    if (!eid) return;
    const geo = {
      x: parseInt(child.style.left) || 0,
      y: parseInt(child.style.top)  || 0,
      w: child.offsetWidth,
      h: child.offsetHeight,
    };
    if (child.classList.contains('jz-bar-label')) {
      labelGeom[eid] = geo;
    } else if (child.dataset.jzRuleId) {
      actionUpdates.push({
        ruleId: child.dataset.jzRuleId,
        actionId: eid,
        layout: { x: geo.x, y: geo.y },
        size: { w: geo.w, h: geo.h },
      });
    }
  });
  await persistActionGeometry(actionUpdates);
  if (barId.startsWith('__legacy_')) return;
  const data = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
  const bar = bars.find(b => b.id === barId);
  if (!bar) return;
  bar.layoutMode = 'custom';
  bar.size = size;
  if (Array.isArray(bar.labels)) {
    bar.labels.forEach(lb => {
      const g = labelGeom[lb.id];
      if (g) { lb.x = g.x; lb.y = g.y; lb.w = g.w; lb.h = g.h; }
    });
  }
  bar.updatedAt = Date.now();
  await safeStorageSet({ actionBars: bars });
}

// Per-panel handlery edycji — instalowane raz (panel przeżywa rebuild innerHTML).
function attachBarEditInteraction(panel) {
  if (panel.dataset.jzEditWired) return;
  panel.dataset.jzEditWired = '1';

  // Klik na element w trybie edycji — zablokuj wykonanie kodu akcji.
  panel.addEventListener('click', (e) => {
    if (!_editMode) return;
    if (editTargetElement(e.target)) { e.preventDefault(); e.stopPropagation(); }
  }, true);

  panel.addEventListener('mousedown', (e) => {
    if (!_editMode || e.button !== 0) return;
    // Uchwyt / nazwa paska — zostaw attachBarDrag (przesuwanie całego paska).
    // Uchwyty resize (ramka elementu / płótno paska) — zostaw ich własne handlery.
    if (e.target.closest('.jz-actions-handle') || e.target.closest('.jz-bar-name')
        || e.target.closest('.jz-resize-frame') || e.target.closest('.jz-bar-resize')) return;
    const elx = editTargetElement(e.target);
    if (!elx) {
      e.preventDefault(); e.stopPropagation();
      if (e.shiftKey) {
        startRubberBand(panel, e);
      } else {
        _editSelection.clear();
        applyEditSelectionClasses();
      }
      return;
    }
    e.preventDefault(); e.stopPropagation();
    const aid = elx.dataset.jzActionId;
    const ctrl = e.ctrlKey || e.metaKey;
    if (ctrl) {
      if (_editSelection.has(aid)) _editSelection.delete(aid);
      else _editSelection.add(aid);
    } else if (!_editSelection.has(aid)) {
      _editSelection.clear();
      _editSelection.add(aid);
    }
    applyEditSelectionClasses();
    if (_editSelection.has(aid)) startEditMove(e);
  }, true);
}

// Przeciąganie zaznaczonych elementów. Konwertuje paski flow → custom na starcie.
// Siatka edycji (od v2.13.98) — przesuwanie i resize dosuwają do co 8 px.
const EDIT_GRID = 8;
function snapToGrid(v) { return Math.round(v / EDIT_GRID) * EDIT_GRID; }

// Czy prostokąt r {x,y,w,h} nachodzi na którykolwiek z rects?
function rectOverlapsAny(r, rects) {
  return rects.some(o =>
    !(r.x + r.w <= o.x || r.x >= o.x + o.w || r.y + r.h <= o.y || r.y >= o.y + o.h));
}

// Zbiera prostokąty elementów akcji w pasku (pomija te z excludeSet).
function collectElementRects(btnContainer, excludeSet) {
  const rects = [];
  Array.from(btnContainer.children).forEach(child => {
    if (!child.dataset || !child.dataset.jzActionId) return;
    if (excludeSet && excludeSet.has(child)) return;
    rects.push({
      x: parseInt(child.style.left) || 0,
      y: parseInt(child.style.top) || 0,
      w: child.offsetWidth,
      h: child.offsetHeight,
      el: child,
    });
  });
  return rects;
}

// Znajduje wolne miejsce na siatce dla prostokąta w×h tak, by nie nachodził na
// żaden z rects. Skanuje wierszami od góry-lewej; gdy w wierszu brak miejsca,
// schodzi niżej. Używane przy dodawaniu nowego elementu/labelki (od v2.13.107).
function findFreeSlot(rects, w, h, canvasW) {
  const ew = Math.max(1, w | 0), eh = Math.max(1, h | 0);
  const maxX = Math.max(0, (canvasW | 0) - ew);
  for (let y = 0; y <= 4000; y += EDIT_GRID) {
    for (let x = 0; x <= maxX; x += EDIT_GRID) {
      if (!rectOverlapsAny({ x, y, w: ew, h: eh }, rects)) return { x, y };
    }
  }
  return { x: 0, y: 0 };
}

function startEditMove(e) {
  const startX = e.clientX, startY = e.clientY;
  const moving = [];
  const panels = new Set();
  _editSelection.forEach(aid => {
    const el = document.querySelector('[data-jz-action-id="' + aid + '"]');
    if (!el) return;
    const panel = el.closest('.jz-actions-bar');
    if (!panel) return;
    convertBarPanelToCustom(panel);
    panels.add(panel);
    moving.push({ el, panel, startLeft: parseInt(el.style.left) || 0, startTop: parseInt(el.style.top) || 0 });
  });
  if (moving.length === 0) return;
  // Statyczne (niezaznaczone) elementy + wymiary płótna — per panel, raz na starcie.
  const movingEls = new Set(moving.map(m => m.el));
  const panelInfo = new Map();
  panels.forEach(panel => {
    const bc = panel.querySelector('.jz-actions-buttons');
    panelInfo.set(panel, {
      staticRects: collectElementRects(bc, movingEls),
      canvasW: bc.offsetWidth,
      canvasH: bc.offsetHeight,
    });
  });
  let moved = false;
  let invalid = false;
  let highlightedConflicts = new Set(); // statyczne elementy podświetlone jako konflikt
  const onMove = (ev) => {
    const dx = ev.clientX - startX, dy = ev.clientY - startY;
    if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
    // 1. docelowe pozycje — snap do siatki, bez ujemnych.
    const placed = moving.map(m => {
      const w = m.el.offsetWidth, h = m.el.offsetHeight;
      const x = Math.max(0, snapToGrid(m.startLeft + dx));
      const y = Math.max(0, snapToGrid(m.startTop + dy));
      return { m, x, y, w, h };
    });
    // 1b. Auto-rozrost płótna — element przeciągany do/za krawędź powiększa
    // pasek, więc zawsze jest dokąd go przenieść (w wolne miejsce, bez kolizji).
    panels.forEach(panel => {
      const info = panelInfo.get(panel);
      let needW = info.canvasW, needH = info.canvasH;
      placed.forEach(p => {
        if (p.m.panel !== panel) return;
        needW = Math.max(needW, Math.min(4000, p.x + p.w));
        needH = Math.max(needH, Math.min(4000, p.y + p.h));
      });
      if (needW !== info.canvasW || needH !== info.canvasH) {
        info.canvasW = needW;
        info.canvasH = needH;
        const bc = panel.querySelector('.jz-actions-buttons');
        if (bc) {
          bc.style.setProperty('width',  needW + 'px', 'important');
          bc.style.setProperty('height', needH + 'px', 'important');
        }
      }
    });
    // 2. detekcja kolizji — vs statyczne oraz vs inne przesuwane w tym samym pasku.
    //    Zbieramy też statyczne elementy konfliktu, by je podświetlić na czerwono.
    const conflictEls = new Set();
    invalid = false;
    placed.forEach((p, i) => {
      const info = panelInfo.get(p.m.panel);
      const r = { x: p.x, y: p.y, w: p.w, h: p.h };
      info.staticRects.forEach(o => {
        if (rectOverlapsAny(r, [o])) {
          invalid = true;
          if (o.el) conflictEls.add(o.el);
        }
      });
      placed.forEach((q, j) => {
        if (j !== i && q.m.panel === p.m.panel
            && rectOverlapsAny(r, [{ x: q.x, y: q.y, w: q.w, h: q.h }])) {
          invalid = true;
        }
      });
    });
    // 3. zastosuj + oznacz nieprawidłowe na czerwono (przesuwane + elementy konfliktu).
    placed.forEach(p => {
      p.m.el.style.left = p.x + 'px';
      p.m.el.style.top  = p.y + 'px';
      p.m.el.classList.toggle('jz-edit-invalid', invalid);
    });
    highlightedConflicts.forEach(el => {
      if (!conflictEls.has(el)) el.classList.remove('jz-edit-invalid');
    });
    conflictEls.forEach(el => el.classList.add('jz-edit-invalid'));
    highlightedConflicts = conflictEls;
    if (moving.length === 1) positionResizeFrame(moving[0].el);
  };
  const onUp = async () => {
    document.removeEventListener('mousemove', onMove, true);
    document.removeEventListener('mouseup', onUp, true);
    moving.forEach(m => m.el.classList.remove('jz-edit-invalid'));
    highlightedConflicts.forEach(el => el.classList.remove('jz-edit-invalid'));
    if (!moved) return;
    if (invalid) {
      // Kolizja — przywróć elementy na pozycje sprzed przeciągania.
      moving.forEach(m => {
        m.el.style.left = m.startLeft + 'px';
        m.el.style.top  = m.startTop + 'px';
      });
      if (moving.length === 1) positionResizeFrame(moving[0].el);
      return;
    }
    await serializeBarLayout(async () => {
      await captureUndoSnapshot();
      for (const panel of panels) await persistBarPanelLayout(panel);
    });
  };
  document.addEventListener('mousemove', onMove, true);
  document.addEventListener('mouseup', onUp, true);
}

function showEditBanner() {
  if (_editBanner) return;
  _editBanner = document.createElement('div');
  _editBanner.className = 'jz-edit-banner';
  _editBanner.textContent = (typeof JZ_I18N !== 'undefined')
    ? JZ_I18N.t('content.editMode.banner')
    : 'Tryb edycji paska — Ctrl+Alt+S aby wyjść';
  document.documentElement.appendChild(_editBanner);
}
function hideEditBanner() {
  if (_editBanner) { _editBanner.remove(); _editBanner = null; }
}

function toggleEditMode() {
  _editMode = !_editMode;
  document.documentElement.classList.toggle('jz-edit-mode', _editMode);
  if (_editMode) {
    showEditBanner();
  } else {
    _editSelection.clear();
    hideEditBanner();
    _undoStack.length = 0; // historia cofania dotyczy sesji edycji
  }
  applyEditSelectionClasses();
}

// --- Cofanie (Ctrl+Z, od v2.13.102) -----------------------------------------
// Przed każdą operacją zmieniającą geometrię zapisujemy snapshot: pełne
// actionBars + layout/size każdej akcji. Ctrl+Z przywraca ostatni snapshot.
const _undoStack = [];
async function captureUndoSnapshot() {
  try {
    const data = await safeStorageGet(['actionBars', 'rules']);
    const actionBars = JSON.parse(JSON.stringify(data.actionBars || []));
    const actionGeom = {};
    (data.rules || []).forEach(r => (r.actions || []).forEach(a => {
      actionGeom[a.id] = {
        layout: a.layout ? { x: a.layout.x, y: a.layout.y } : null,
        size:   a.size   ? { w: a.size.w,   h: a.size.h }   : null,
      };
    }));
    _undoStack.push({ actionBars, actionGeom });
    if (_undoStack.length > 30) _undoStack.shift();
  } catch (_) {}
}

async function undoLastEdit() {
  const snap = _undoStack.pop();
  if (!snap) {
    showInfoToast(typeof JZ_I18N !== 'undefined' ? JZ_I18N.t('content.editMode.undoEmpty') : 'Nothing to undo');
    return;
  }
  await safeStorageSet({ actionBars: snap.actionBars });
  const data = await safeStorageGet(['rules']);
  const rules = Array.isArray(data.rules) ? data.rules : [];
  rules.forEach(r => (r.actions || []).forEach(a => {
    const g = snap.actionGeom[a.id];
    if (!g) return;
    if (g.layout) a.layout = g.layout; else delete a.layout;
    if (g.size)   a.size   = g.size;   else delete a.size;
  }));
  await safeStorageSet({ rules });
}

let _editModeKeydownInstalled = false;
function _ensureEditModeKeydown() {
  if (_editModeKeydownInstalled) return;
  _editModeKeydownInstalled = true;
  try {
    document.addEventListener('keydown', (e) => {
      // Esc — usuń zaznaczenie elementów (tylko w trybie edycji, gdy coś zaznaczone
      // i nie jest otwarty edytor labelki, który Esc zamyka samodzielnie).
      if (e.key === 'Escape' && _editMode && !_labelEditor && _editSelection.size > 0) {
        e.preventDefault();
        e.stopPropagation();
        _editSelection.clear();
        applyEditSelectionClasses();
        return;
      }
      // Delete — usuń zaznaczone labelki (akcje należą do reguł, ich nie ruszamy).
      // Działa dla pojedynczej i wielu zaznaczonych (np. Shift+zaznaczenie).
      if (e.key === 'Delete' && _editMode && !_labelEditor && _editSelection.size > 0) {
        const toDelete = [];
        _editSelection.forEach(aid => {
          const el = document.querySelector(
            '.jz-bar-label[data-jz-action-id="' + cssEscape(aid) + '"]');
          if (!el) return;
          const panel = el.closest('.jz-actions-bar');
          const barId = panel && panel.dataset.jzBarId;
          if (barId) toDelete.push({ barId, labelId: aid });
        });
        if (toDelete.length > 0) {
          e.preventDefault();
          e.stopPropagation();
          toDelete.forEach(d => _editSelection.delete(d.labelId));
          applyEditSelectionClasses();
          serializeBarLayout(() => deleteBarLabels(toDelete));
        }
        return;
      }
      if (!(e.ctrlKey || e.metaKey)) return;
      // Ctrl+Alt+S — przełącz tryb edycji. e.code (fizyczny klawisz) zamiast
      // e.key — na układach z AltGr (np. polski) Ctrl+Alt+S daje 'ś', nie 's'.
      if (e.altKey && !e.shiftKey && e.code === 'KeyS') {
        e.preventDefault();
        e.stopPropagation();
        toggleEditMode();
        return;
      }
      // Ctrl+Z — cofnij ostatnią zmianę (tylko w trybie edycji).
      if (_editMode && !e.altKey && !e.shiftKey && e.code === 'KeyZ') {
        e.preventDefault();
        e.stopPropagation();
        serializeBarLayout(() => undoLastEdit());
      }
    }, true);
  } catch (_) {}
}
_ensureEditModeKeydown();

// --- Resize elementów akcji (od v2.13.95) -----------------------------------
// Ramka resize z 8 uchwytami nad pojedynczym zaznaczonym elementem. Reużywa
// mapę PANE_RESIZE_HANDLES (narożniki + krawędzie). Resize konwertuje pasek
// flow → custom (jak przesuwanie).
const EL_W_MIN = 24, EL_H_MIN = 20, EL_SIZE_MAX = 1000;

function positionResizeFrame(el) {
  const panel = el.closest('.jz-actions-bar');
  if (!panel) return;
  const frame = panel.querySelector('.jz-resize-frame');
  const btnContainer = el.closest('.jz-actions-buttons');
  if (!frame || !btnContainer) return;
  const r = el.getBoundingClientRect();
  const c = btnContainer.getBoundingClientRect();
  frame.style.left   = (r.left - c.left) + 'px';
  frame.style.top    = (r.top  - c.top)  + 'px';
  frame.style.width  = r.width  + 'px';
  frame.style.height = r.height + 'px';
}

function updateEditResizeFrame() {
  document.querySelectorAll('.jz-resize-frame').forEach(f => f.remove());
  if (!_editMode || _editSelection.size !== 1) return;
  const aid = Array.from(_editSelection)[0];
  const el = document.querySelector('[data-jz-action-id="' + aid + '"]');
  if (!el) return;
  const panel = el.closest('.jz-actions-bar');
  const btnContainer = panel && panel.querySelector('.jz-actions-buttons');
  if (!btnContainer) return;
  const frame = document.createElement('div');
  frame.className = 'jz-resize-frame';
  Object.keys(PANE_RESIZE_HANDLES).forEach(name => {
    const spec = PANE_RESIZE_HANDLES[name];
    const h = document.createElement('div');
    h.className = 'jz-resize-handle jz-resize-handle-' + name;
    h.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      startEditResize(el, spec, e);
    });
    frame.appendChild(h);
  });
  btnContainer.appendChild(frame);
  positionResizeFrame(el);
}

function startEditResize(el, spec, e) {
  e.preventDefault();
  e.stopPropagation();
  const panel = el.closest('.jz-actions-bar');
  if (!panel) return;
  convertBarPanelToCustom(panel);
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  const canvasW = btnContainer ? btnContainer.offsetWidth : EL_SIZE_MAX;
  const canvasH = btnContainer ? btnContainer.offsetHeight : EL_SIZE_MAX;
  const staticRects = btnContainer ? collectElementRects(btnContainer, new Set([el])) : [];
  const startX = e.clientX, startY = e.clientY;
  const startLeft = parseInt(el.style.left) || 0;
  const startTop  = parseInt(el.style.top)  || 0;
  const startW = el.offsetWidth, startH = el.offsetHeight;
  const fixedRight = startLeft + startW, fixedBottom = startTop + startH;
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  let invalid = false;
  const onMove = (ev) => {
    const dx = ev.clientX - startX, dy = ev.clientY - startY;
    let w = startW, h = startH, left = startLeft, top = startTop;
    // Snap do siatki; krawędź przeciwległa do uchwytu trzymana w miejscu.
    if (spec.ew === 'e')      { w = clamp(snapToGrid(startW + dx), EL_W_MIN, Math.min(EL_SIZE_MAX, canvasW - startLeft)); left = startLeft; }
    else if (spec.ew === 'w') { left = clamp(snapToGrid(startLeft + dx), 0, fixedRight - EL_W_MIN); w = fixedRight - left; }
    if (spec.ns === 's')      { h = clamp(snapToGrid(startH + dy), EL_H_MIN, Math.min(EL_SIZE_MAX, canvasH - startTop)); top = startTop; }
    else if (spec.ns === 'n') { top = clamp(snapToGrid(startTop + dy), 0, fixedBottom - EL_H_MIN); h = fixedBottom - top; }
    // Kolizja z innym elementem → czerwona ramka.
    invalid = rectOverlapsAny({ x: left, y: top, w, h }, staticRects);
    el.style.left = left + 'px';
    el.style.top  = top + 'px';
    el.style.setProperty('width',  w + 'px', 'important');
    el.style.setProperty('height', h + 'px', 'important');
    el.classList.toggle('jz-edit-invalid', invalid);
    positionResizeFrame(el);
  };
  const onUp = async () => {
    document.removeEventListener('mousemove', onMove, true);
    document.removeEventListener('mouseup', onUp, true);
    el.classList.remove('jz-edit-invalid');
    if (invalid) {
      // Kolizja — przywróć rozmiar i pozycję sprzed resize.
      el.style.left = startLeft + 'px';
      el.style.top  = startTop + 'px';
      el.style.setProperty('width',  startW + 'px', 'important');
      el.style.setProperty('height', startH + 'px', 'important');
      positionResizeFrame(el);
      return;
    }
    await serializeBarLayout(async () => {
      await captureUndoSnapshot();
      await persistBarPanelLayout(panel);
    });
  };
  document.addEventListener('mousemove', onMove, true);
  document.addEventListener('mouseup', onUp, true);
}

// --- Resize płótna paska (od v2.13.95) --------------------------------------
// Uchwyty E/S/SE na panelu — zmiana bar.size. Kotwica top-left (pozycja paska
// na stronie bez zmian). Widoczne tylko w trybie edycji + custom.
function addBarResizeHandles(panel) {
  ['e', 's', 'se'].forEach(name => {
    const h = document.createElement('div');
    h.className = 'jz-bar-resize jz-bar-resize-' + name;
    h.addEventListener('mousedown', (e) => {
      if (e.button !== 0 || !_editMode) return;
      startBarCanvasResize(panel, name, e);
    });
    panel.appendChild(h);
  });
}

function startBarCanvasResize(panel, name, e) {
  e.preventDefault();
  e.stopPropagation();
  convertBarPanelToCustom(panel);
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  if (!btnContainer) return;
  const startX = e.clientX, startY = e.clientY;
  const startW = btnContainer.offsetWidth, startH = btnContainer.offsetHeight;
  // Minimalny rozmiar płótna = bounding box elementów — pasek nie może się
  // skurczyć tak, by jakikolwiek element znalazł się poza nim.
  let minW = 60, minH = 40;
  Array.from(btnContainer.children).forEach(child => {
    if (!child.dataset || !child.dataset.jzActionId) return;
    minW = Math.max(minW, (parseInt(child.style.left) || 0) + child.offsetWidth);
    minH = Math.max(minH, (parseInt(child.style.top)  || 0) + child.offsetHeight);
  });
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  const onMove = (ev) => {
    const dx = ev.clientX - startX, dy = ev.clientY - startY;
    if (name === 'e' || name === 'se') btnContainer.style.setProperty('width',  clamp(snapToGrid(startW + dx), minW, 2000) + 'px', 'important');
    if (name === 's' || name === 'se') btnContainer.style.setProperty('height', clamp(snapToGrid(startH + dy), minH, 2000) + 'px', 'important');
  };
  const onUp = async () => {
    document.removeEventListener('mousemove', onMove, true);
    document.removeEventListener('mouseup', onUp, true);
    await serializeBarLayout(async () => {
      await captureUndoSnapshot();
      await persistBarPanelLayout(panel);
    });
  };
  document.addEventListener('mousemove', onMove, true);
  document.addEventListener('mouseup', onUp, true);
}

// --- Zaznaczanie zakresowe (Shift+przeciągnięcie, od v2.13.95) ---------------
function startRubberBand(panel, e) {
  const btnContainer = panel.querySelector('.jz-actions-buttons');
  if (!btnContainer) return;
  const startX = e.clientX, startY = e.clientY;
  const band = document.createElement('div');
  band.className = 'jz-rubber-band';
  btnContainer.appendChild(band);
  let moved = false;
  const onMove = (ev) => {
    moved = true;
    const x1 = Math.min(startX, ev.clientX), y1 = Math.min(startY, ev.clientY);
    const x2 = Math.max(startX, ev.clientX), y2 = Math.max(startY, ev.clientY);
    const c = btnContainer.getBoundingClientRect();
    band.style.left   = (x1 - c.left) + 'px';
    band.style.top    = (y1 - c.top)  + 'px';
    band.style.width  = (x2 - x1) + 'px';
    band.style.height = (y2 - y1) + 'px';
    _editSelection.clear();
    Array.from(btnContainer.children).forEach(child => {
      const cid = child.dataset && child.dataset.jzActionId;
      if (!cid) return;
      const r = child.getBoundingClientRect();
      const hit = !(r.right < x1 || r.left > x2 || r.bottom < y1 || r.top > y2);
      if (hit) _editSelection.add(cid);
    });
    applyEditSelectionClasses();
  };
  const onUp = () => {
    document.removeEventListener('mousemove', onMove, true);
    document.removeEventListener('mouseup', onUp, true);
    band.remove();
    if (!moved) { _editSelection.clear(); }
    applyEditSelectionClasses();
  };
  document.addEventListener('mousemove', onMove, true);
  document.addEventListener('mouseup', onUp, true);
}

// --- Labelki paska (bar-owned, od v2.13.99) ---------------------------------
// Etykiety tekstowe należące do paska (bar.labels[]) — niezależne od akcji/reguł.
// Dodawane przyciskiem „+ Label" w trybie edycji. Renderowane z data-jz-action-id
// (generyczne id edytowalne) + markerem data-jz-label — dzięki temu cała
// infrastruktura edycji (zaznaczanie/przesuwanie/resize) działa bez zmian.
const LABEL_FONTS = [
  { v: '',                                  l: 'Inter (domyślny)' },
  { v: 'ui-monospace, Consolas, monospace', l: 'Monospace' },
  { v: 'Georgia, serif',                    l: 'Serif' },
  { v: 'Arial, Helvetica, sans-serif',      l: 'Arial' },
  { v: '"Courier New", monospace',          l: 'Courier New' },
];

function renderBarLabel(label, container) {
  const el = document.createElement('span');
  el.className = 'jz-bar-label';
  el.textContent = label.text || '';
  el.dataset.jzActionId = label.id;
  el.dataset.jzLabel = '1';
  el.style.left = (label.x | 0) + 'px';
  el.style.top  = (label.y | 0) + 'px';
  if (label.w) el.style.setProperty('width',  (label.w | 0) + 'px', 'important');
  if (label.h) el.style.setProperty('height', (label.h | 0) + 'px', 'important');
  if (label.font)     el.style.setProperty('font-family', label.font, 'important');
  if (label.fontSize) el.style.setProperty('font-size', (label.fontSize | 0) + 'px', 'important');
  if (label.color)    el.style.setProperty('color', label.color, 'important');
  el.title = label.text || '';
  el.addEventListener('mousedown', (e) => e.stopPropagation());
  el.addEventListener('dblclick', (e) => {
    if (!_editMode) return;
    e.preventDefault();
    e.stopPropagation();
    openLabelEditor(el);
  });
  container.appendChild(el);
}

function addBarLabelButton(panel) {
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'jz-bar-add-label';
  btn.textContent = (typeof JZ_I18N !== 'undefined') ? JZ_I18N.t('content.editMode.addLabel') : '+ Label';
  btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    if (_editMode) serializeBarLayout(() => addBarLabel(panel));
  });
  panel.appendChild(btn);
}

async function addBarLabel(panel) {
  const barId = panel.dataset.jzBarId;
  if (!barId || barId.startsWith('__legacy_')) return;
  await captureUndoSnapshot();
  // Konwersja flow→custom + zapis bieżącej geometrii akcji (inaczej skoczyłyby na 0,0).
  convertBarPanelToCustom(panel);
  // Wolny slot liczymy SYNCHRONICZNIE tuż po konwersji — DOM odzwierciedla wtedy
  // bieżące pozycje wszystkich elementów, żaden re-render jeszcze nie nastąpił.
  // Liczenie po await-ach łapało stan w trakcie re-renderu → labelka na elemencie
  // (od v2.13.108).
  const lblContainer = panel.querySelector('.jz-actions-buttons');
  const lblRects = lblContainer ? collectElementRects(lblContainer, null) : [];
  const slot = findFreeSlot(lblRects, 72, 24, lblContainer ? lblContainer.offsetWidth : 220);
  await persistBarPanelLayout(panel);
  const data = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
  const bar = bars.find(b => b.id === barId);
  if (!bar) return;
  if (!Array.isArray(bar.labels)) bar.labels = [];
  bar.labels.push({
    id: 'lbl_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6),
    text: 'Label',
    x: slot.x, y: slot.y, w: 72, h: 24,
    font: '', fontSize: 12, color: '#ffffff',
  });
  // Brak miejsca → powiększ pasek, by labelka zmieściła się w środku (od v2.13.109).
  const baseSize = bar.size || { w: 220, h: 160 };
  bar.size = {
    w: Math.max(baseSize.w | 0, slot.x + 72),
    h: Math.max(baseSize.h | 0, slot.y + 24),
  };
  bar.layoutMode = 'custom';
  bar.updatedAt = Date.now();
  await safeStorageSet({ actionBars: bars });
}

async function updateBarLabel(barId, labelId, fields) {
  await captureUndoSnapshot();
  const data = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
  const bar = bars.find(b => b.id === barId);
  if (!bar || !Array.isArray(bar.labels)) return;
  const lb = bar.labels.find(l => l.id === labelId);
  if (!lb) return;
  Object.assign(lb, fields);
  bar.updatedAt = Date.now();
  await safeStorageSet({ actionBars: bars });
}

async function deleteBarLabel(barId, labelId) {
  await captureUndoSnapshot();
  const data = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
  const bar = bars.find(b => b.id === barId);
  if (!bar || !Array.isArray(bar.labels)) return;
  bar.labels = bar.labels.filter(l => l.id !== labelId);
  bar.updatedAt = Date.now();
  await safeStorageSet({ actionBars: bars });
}

// Wsadowe usunięcie labelek (od v2.13.109) — klawisz Delete dla wielu zaznaczonych.
// items: [{ barId, labelId }]. Jeden snapshot undo i jeden zapis storage.
async function deleteBarLabels(items) {
  if (!Array.isArray(items) || items.length === 0) return;
  await captureUndoSnapshot();
  const data = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
  let mutated = false;
  items.forEach(({ barId, labelId }) => {
    const bar = bars.find(b => b.id === barId);
    if (!bar || !Array.isArray(bar.labels)) return;
    const before = bar.labels.length;
    bar.labels = bar.labels.filter(l => l.id !== labelId);
    if (bar.labels.length !== before) { bar.updatedAt = Date.now(); mutated = true; }
  });
  if (mutated) await safeStorageSet({ actionBars: bars });
}

// Menu kontekstowe labelki (od v2.13.108) — prawy klik na etykiecie w trybie
// edycji. Jedna pozycja: usunięcie labelki.
function showLabelContextMenu(labelEl, x, y) {
  const old = document.getElementById('justzix-actions-menu');
  if (old) old.remove();
  const panel = labelEl.closest('.jz-actions-bar');
  const barId = panel && panel.dataset.jzBarId;
  const labelId = labelEl.dataset.jzActionId;
  if (!barId || !labelId) return;
  const t = (key, fb) => (typeof JZ_I18N !== 'undefined' && JZ_I18N.t) ? JZ_I18N.t(key) : fb;
  const menu = document.createElement('div');
  menu.id = 'justzix-actions-menu';
  menu.style.left = x + 'px';
  menu.style.top = y + 'px';
  menu.innerHTML = `
    <div class="jz-menu-item" data-action="delete-label">
      <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
        <polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/><path d="M9 6V3h6v3"/>
      </svg> ${escapeBarName(t('content.labelContextMenu.delete', 'Usuń etykietę'))}
    </div>`;
  document.documentElement.appendChild(menu);
  menu.querySelector('[data-action="delete-label"]').addEventListener('click', () => {
    serializeBarLayout(() => deleteBarLabel(barId, labelId));
    menu.remove();
  });
  setTimeout(() => {
    const closer = (e) => {
      if (!menu.contains(e.target)) {
        menu.remove();
        document.removeEventListener('mousedown', closer, true);
      }
    };
    document.addEventListener('mousedown', closer, true);
  }, 50);
}

let _labelEditor = null;
function _labelEditorOutside(e) {
  if (_labelEditor && !_labelEditor.contains(e.target)) closeLabelEditor();
}
function _labelEditorEsc(e) {
  if (e.key === 'Escape') closeLabelEditor();
}
function closeLabelEditor() {
  if (_labelEditor) { _labelEditor.remove(); _labelEditor = null; }
  document.removeEventListener('mousedown', _labelEditorOutside, true);
  document.removeEventListener('keydown', _labelEditorEsc, true);
}

function openLabelEditor(el) {
  closeLabelEditor();
  const labelId = el.dataset.jzActionId;
  const panel = el.closest('.jz-actions-bar');
  const barId = panel && panel.dataset.jzBarId;
  if (!barId) return;
  safeStorageGet(['actionBars']).then(data => {
    const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
    const bar = bars.find(b => b.id === barId);
    const lb = bar && Array.isArray(bar.labels) && bar.labels.find(l => l.id === labelId);
    if (!lb) return;
    const t = (k) => (typeof JZ_I18N !== 'undefined') ? JZ_I18N.t(k) : k;
    const fontOpts = LABEL_FONTS.map(f =>
      `<option value="${f.v}" ${(lb.font || '') === f.v ? 'selected' : ''}>${f.l}</option>`).join('');
    const pop = document.createElement('div');
    pop.className = 'jz-label-editor';
    pop.innerHTML = `
      <div class="jz-le-row"><label>${t('content.labelEditor.text')}</label>
        <input type="text" class="jz-le-text"></div>
      <div class="jz-le-row"><label>${t('content.labelEditor.font')}</label>
        <select class="jz-le-font">${fontOpts}</select></div>
      <div class="jz-le-row"><label>${t('content.labelEditor.size')}</label>
        <input type="number" class="jz-le-size" min="8" max="48" value="${lb.fontSize || 12}"></div>
      <div class="jz-le-row"><label>${t('content.labelEditor.color')}</label>
        <input type="color" class="jz-le-color" value="${/^#[0-9a-f]{6}$/i.test(lb.color || '') ? lb.color : '#ffffff'}"></div>
      <div class="jz-le-actions">
        <button type="button" class="jz-le-delete">${t('content.labelEditor.delete')}</button>
        <button type="button" class="jz-le-close">${t('content.labelEditor.close')}</button>
      </div>`;
    document.documentElement.appendChild(pop);
    pop.querySelector('.jz-le-text').value = lb.text || '';
    const r = el.getBoundingClientRect();
    pop.style.left = Math.max(8, Math.min(window.innerWidth - 250, r.left)) + 'px';
    pop.style.top  = Math.max(8, Math.min(window.innerHeight - 230, r.bottom + 8)) + 'px';
    _labelEditor = pop;
    pop.addEventListener('mousedown', (e) => e.stopPropagation());
    const textIn  = pop.querySelector('.jz-le-text');
    const fontIn  = pop.querySelector('.jz-le-font');
    const sizeIn  = pop.querySelector('.jz-le-size');
    const colorIn = pop.querySelector('.jz-le-color');
    textIn.addEventListener('change', () => serializeBarLayout(() => updateBarLabel(barId, labelId, { text: textIn.value })));
    fontIn.addEventListener('change', () => serializeBarLayout(() => updateBarLabel(barId, labelId, { font: fontIn.value })));
    sizeIn.addEventListener('change', () => {
      const n = parseInt(sizeIn.value, 10);
      if (Number.isFinite(n) && n >= 8 && n <= 48) serializeBarLayout(() => updateBarLabel(barId, labelId, { fontSize: n }));
    });
    colorIn.addEventListener('change', () => serializeBarLayout(() => updateBarLabel(barId, labelId, { color: colorIn.value })));
    pop.querySelector('.jz-le-delete').addEventListener('click', () => {
      serializeBarLayout(() => deleteBarLabel(barId, labelId));
      closeLabelEditor();
    });
    pop.querySelector('.jz-le-close').addEventListener('click', closeLabelEditor);
    setTimeout(() => {
      document.addEventListener('mousedown', _labelEditorOutside, true);
      document.addEventListener('keydown', _labelEditorEsc, true);
    }, 0);
  });
}

// --- Reset paska do układu domyślnego (od v2.13.100) ------------------------
// Przywraca pasek do trybu 'flow' (flexbox): kasuje layoutMode/size, czyści
// layout/size wszystkich akcji paska oraz usuwa wszystkie labelki.
function addBarResetButton(panel) {
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'jz-bar-reset';
  btn.textContent = (typeof JZ_I18N !== 'undefined') ? JZ_I18N.t('content.editMode.resetBar') : 'Reset';
  btn.addEventListener('mousedown', (e) => { e.preventDefault(); e.stopPropagation(); });
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    if (_editMode) serializeBarLayout(() => resetBarLayout(panel));
  });
  panel.appendChild(btn);
}

async function resetBarLayout(panel) {
  const barId = panel.dataset.jzBarId;
  if (!barId || barId.startsWith('__legacy_')) return;
  const msg = (typeof JZ_I18N !== 'undefined')
    ? JZ_I18N.t('content.editMode.resetConfirm')
    : 'Reset bar layout to default? Custom positions, sizes and labels will be removed.';
  let ok = false;
  try { ok = window.confirm(msg); } catch (_) { ok = true; }
  if (!ok) return;
  await captureUndoSnapshot();
  // 1. Wyczyść layout/size akcji przypisanych do tego paska.
  const rData = await safeStorageGet(['rules']);
  const rules = Array.isArray(rData.rules) ? rData.rules : [];
  let rMutated = false;
  rules.forEach(rule => {
    (rule.actions || []).forEach(a => {
      if (a.barId === barId && (a.layout || a.size)) {
        delete a.layout;
        delete a.size;
        a.updatedAt = Date.now();
        rMutated = true;
      }
    });
  });
  if (rMutated) await safeStorageSet({ rules });
  // 2. Pasek → flow, bez custom rozmiaru i labelek.
  const bData = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(bData.actionBars) ? bData.actionBars : [];
  const bar = bars.find(b => b.id === barId);
  if (bar) {
    bar.layoutMode = 'flow';
    delete bar.size;
    delete bar.labels;
    bar.updatedAt = Date.now();
    await safeStorageSet({ actionBars: bars });
  }
  _editSelection.clear();
}

// Dispatcher per typ akcji (Faza 5 scaffold). Każdy typ ma własny renderer; tutaj
// rozgałęziamy. Nieznany / nieobsługiwany typ → silent skip (ale walidacja
// `isActionRenderable` powinna była już to wyłapać wyżej).
function renderActionElement(item, container) {
  switch (item.type) {
    case JZ_ACTION_TYPES.BUTTON:
      return renderButtonElement(item, container);
    case JZ_ACTION_TYPES.SELECT:
      return renderSelectElement(item, container);
    case JZ_ACTION_TYPES.INPUT:
      return renderInputElement(item, container);
    case JZ_ACTION_TYPES.SLIDER:
      return renderSliderElement(item, container);
    case JZ_ACTION_TYPES.TEXTAREA:
      return renderTextareaElement(item, container);
    case JZ_ACTION_TYPES.TOGGLE3:
      return renderToggle3Element(item, container);
    default:
      return;
  }
}

function renderButtonElement(b, container) {
  const el = document.createElement('button');
  el.className = 'jz-action-btn';
  el.textContent = b.label;
  el.style.background = b.color;
  // Drugi kolor (od v2.13.18) — kolor tekstu. Override CSS białego defaultu przez !important.
  if (b.colorText) el.style.setProperty('color', b.colorText, 'important');
  el.title = b.label;
  // Addressability (od v2.12.2) — przewidywalne data-jz-* atrybuty pozwalają
  // userowi querować elementy z poziomu JS: querySelector('[data-jz-action-id="..."]').
  el.dataset.jzActionId = b.id;
  el.dataset.jzActionType = 'button';
  el.dataset.jzRuleId = b.ruleId || '';
  el.dataset.jzLabel = b.label || '';
  el.dataset.jzKey = (b.label || '').toUpperCase();
  // Legacy alias — zachowane dla wstecznej kompatybilności (background.js JZ helpers).
  el.dataset.actionId = b.id;
  el.dataset.code = b.code;
  el.addEventListener('click', (e) => {
    e.stopPropagation();
    e.preventDefault();
    runActionCode(b.code, b.id);
  });
  container.appendChild(el);
}

// === Action memory infrastructure (od v2.13.0) ===
// Persistent storage dla wartości elementów stanowych (SELECT, INPUT, w przyszłości SLIDER/TEXTAREA/TOGGLE3).
// Przeżywa reload strony. Per-device (nie synced — różny state per urządzenie ma sens dla page-scoped wartości).
//
// Scopes:
//   'none'   — bez persystencji (in-memory map fallback)
//   'page'   — per pełny URL (location.href)
//   'host'   — per hostname (location.hostname)
//   'global' — wspólne dla wszystkich stron
//
// Storage layout:
//   actionMemory: { [actionId]: { page: {[url]: val}, host: {[hostname]: val}, global: val } }
//
// Cache w `actionMemoryCache` — load raz na start (refreshActionMemory), refresh przy
// storage.onChanged. Wszystkie reads synchroniczne (render path jest sync).
// actionMemoryCache aktualizowana wewnątrz getData() — atomically z resztą danych
// (od v2.13.2). Brak osobnego init/refresh — każdy applyAll() ma świeżą cache.
let actionMemoryCache = null;
// Od v2.13.54: synchroniczna cache cssPanes (analog do actionMemoryCache) — używana przez
// getStoredStartPos dla hidden CSS pane (potrzeba pos+size do konwersji anchor → absolute).
let cssPanesCache = [];

// Memory helpers (uproszczone w v2.13.7): single value per action.id.
// Akcja zachowuje wartość gdziekolwiek jest widoczna (rule set URL patterns) — bez
// konfiguracji scope (mylące dla usera, zawsze sensowne).
//
// Storage layout:
//   sessionStorage['__jz_mem__<actionId>'] = JSON value      // sync, F5-safe
//   chrome.storage.local.actionMemory[actionId] = value      // async, durable, cross-tab
//
// SECURITY: sessionStorage jest readable przez skrypty strony (same origin).
// Dla wrażliwych pól user powinien NIE używać INPUT/SELECT (lub clear storage).
function _memSessionKey(actionId) { return '__jz_mem__' + actionId; }

function _memSessionGet(actionId) {
  try {
    const raw = sessionStorage.getItem(_memSessionKey(actionId));
    return raw === null ? undefined : JSON.parse(raw);
  } catch (_) { return undefined; }
}
function _memSessionSet(actionId, value) {
  try { sessionStorage.setItem(_memSessionKey(actionId), JSON.stringify(value)); } catch (_) {}
}

function getActionMemoryFromCache(actionId) {
  if (!actionId) return undefined;
  // 1. sessionStorage (sync, per-tab, F5-safe) — primary
  const sess = _memSessionGet(actionId);
  if (sess !== undefined) return sess;
  // 2. chrome.storage.local cache — fallback (fresh tab po reopen)
  if (actionMemoryCache && actionMemoryCache[actionId] !== undefined) {
    return actionMemoryCache[actionId];
  }
  return undefined;
}

function setActionMemory(actionId, value) {
  if (!actionId) return;
  // 1. sessionStorage — SYNCHRONOUS write, F5-safe
  _memSessionSet(actionId, value);
  // 2. chrome.storage.local — async durable backup
  if (!actionMemoryCache) actionMemoryCache = {};
  actionMemoryCache[actionId] = value;
  try { safeStorageSet({ actionMemory: actionMemoryCache }); } catch (_) {}
}

// Fallback in-memory map dla scope='none' lub gdy memory niedostępne (pre-v8 actions w cache).
// Zachowuje wybor w ramach sesji strony — przeżywa re-render ale gubi się przy reload.
const actionSelectState = new Map();

// Rejestr funkcji zamykających otwarte dropdowny SELECT akcji. Dropdown jest
// dopinany do documentElement (poza panelem paska) + 2 globalne listenery; przy
// re-renderze paska (fillBarPanel przebudowuje innerHTML) przycisk znika, ale
// dropdown i listenery zostają. closeAllActionSelects() sprząta je przed przebudową.
const _actionSelectClosers = new Set();
function closeAllActionSelects() {
  Array.from(_actionSelectClosers).forEach(fn => { try { fn(); } catch (_) {} });
}

function renderSelectElement(item, container) {
  const variant = item.variant || 'static';

  if (variant === 'js') {
    // JS variant: pusty <select> (user code populuje + handluje przez $el).
    // Zachowujemy <select> bo user code spodziewa się .innerHTML/.addEventListener
    // na standardowym HTMLSelectElement.
    const sel = document.createElement('select');
    sel.className = 'jz-action-select jz-variant-js';
    sel.dataset.jzActionId = item.id;
    sel.dataset.jzActionType = 'select';
    sel.dataset.jzActionVariant = 'js';
    sel.dataset.jzRuleId = item.ruleId || '';
    sel.dataset.jzLabel = item.label || '';
    sel.dataset.jzKey = (item.label || '').toUpperCase();
    sel.title = item.label || '';
    if (item.color) sel.style.setProperty('background-color', item.color, 'important');
    // Drugi kolor (od v2.13.18) — kolor tekstu select (js variant).
    if (item.colorText) sel.style.setProperty('color', item.colorText, 'important');
    sel.addEventListener('mousedown', (e) => e.stopPropagation());
    // Dropdown styling dla js variant (od v2.13.19) — native <option> styling. Aplikujemy
    // inline na sam <select> (`background-color` opcji dziedziczy z parenta w wielu browserach)
    // ORAZ później per-<option> w MutationObserver (user code może dodać opcje dynamicznie).
    if (item.colorDropdownBg || item.colorOption) {
      const applyOptionStyle = (opt) => {
        if (item.colorDropdownBg) opt.style.setProperty('background-color', item.colorDropdownBg, 'important');
        if (item.colorOption) opt.style.setProperty('color', item.colorOption, 'important');
      };
      // Initial styling (gdy code już dodał opcje przed obserwerem)
      Array.from(sel.options).forEach(applyOptionStyle);
      // Obserwer — gdy user code doda nowe <option> przez setTimeout, styling aplikuje się automatycznie.
      const optObserver = new MutationObserver((mutations) => {
        mutations.forEach(m => {
          m.addedNodes.forEach(n => {
            if (n.tagName === 'OPTION') applyOptionStyle(n);
          });
        });
      });
      optObserver.observe(sel, { childList: true });
    }
    container.appendChild(sel);
    setTimeout(() => {
      runActionCode(item.code, item.id, { _jsVariant: true });
    }, 0);
    return;
  }

  // STATIC variant: custom dropdown matching jz-action-btn style. Klik na button →
  // popup panel z opcjami (też w stylu bar buttonów). Wybór → zapamiętany w
  // actionSelectState (przeżywa re-render) + run code z `value` w scope.
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'jz-action-select';
  btn.dataset.jzActionId = item.id;
  btn.dataset.jzActionType = 'select';
  btn.dataset.jzActionVariant = 'static';
  btn.dataset.jzRuleId = item.ruleId || '';
  btn.dataset.jzLabel = item.label || '';
  btn.dataset.jzKey = (item.label || '').toUpperCase();
  btn.title = item.label || '';
  if (item.color) btn.style.setProperty('background-color', item.color, 'important');
  // Drugi kolor (od v2.13.18) — text + arrow (dziedziczą currentColor z parenta).
  if (item.colorText) btn.style.setProperty('color', item.colorText, 'important');

  function getCurrentSelectedValue() {
    // Persistent memory zawsze (od v2.13.7 — bez scope option). Fallback in-memory map.
    const v = getActionMemoryFromCache(item.id);
    if (v !== undefined) return String(v);
    return actionSelectState.get(item.id);
  }

  function refreshButtonText() {
    btn.innerHTML = '';
    const stateValue = getCurrentSelectedValue();
    let displayText = item.label || '';
    if (stateValue !== undefined) {
      const found = item.options.find(o => String(o.value) === stateValue);
      if (found) displayText = (found.name || String(found.value) || displayText);
    }
    // data-jz-value (od v2.13.28) — wystawia aktualną wartość na DOM dla JZ.value() helpera.
    if (stateValue !== undefined) btn.dataset.jzValue = stateValue;
    else delete btn.dataset.jzValue;
    const txt = document.createElement('span');
    txt.className = 'jz-action-select-text';
    txt.textContent = displayText;
    btn.appendChild(txt);
    const arrow = document.createElement('span');
    arrow.className = 'jz-action-select-arrow';
    arrow.textContent = '▾';
    btn.appendChild(arrow);
  }
  refreshButtonText();

  let panel = null;
  function closePanel() {
    if (panel) { panel.remove(); panel = null; }
    document.removeEventListener('click', outsideClick, true);
    document.removeEventListener('keydown', escClose, true);
    _actionSelectClosers.delete(closePanel);
  }
  function outsideClick(e) {
    if (panel && !panel.contains(e.target) && e.target !== btn && !btn.contains(e.target)) closePanel();
  }
  function escClose(e) { if (e.key === 'Escape') closePanel(); }

  function openPanel() {
    closePanel();
    panel = document.createElement('div');
    panel.className = 'jz-action-select-panel';
    panel.dataset.jzActionId = item.id;
    // CSS variables (od v2.13.19) — customizacja per-action: tło panelu, kolor tekstu opcji,
    // tło wybranej, tło hover. Wszystkie fallback do CSS defaults gdy undefined.
    if (item.colorDropdownBg) panel.style.setProperty('--jz-dropdown-bg', item.colorDropdownBg);
    if (item.colorOption)     panel.style.setProperty('--jz-option-color', item.colorOption);
    if (item.colorSelected)   panel.style.setProperty('--jz-selected-bg', item.colorSelected);
    if (item.colorHover)      panel.style.setProperty('--jz-hover-bg', item.colorHover);
    const currentVal = getCurrentSelectedValue();
    item.options.forEach((opt, idx) => {
      const row = document.createElement('button');
      row.type = 'button';
      row.className = 'jz-action-select-item';
      const optValue = opt.value != null ? String(opt.value) : '';
      if (currentVal === optValue) row.classList.add('selected');
      row.textContent = opt.name || optValue || `OPT${idx + 1}`;
      row.addEventListener('click', (e) => {
        e.stopPropagation();
        actionSelectState.set(item.id, optValue);
        setActionMemory(item.id, optValue);
        refreshButtonText();
        closePanel();
        runActionCode(item.code, item.id, { value: optValue });
      });
      panel.appendChild(row);
    });
    document.documentElement.appendChild(panel);
    const rect = btn.getBoundingClientRect();
    panel.style.position = 'fixed';
    panel.style.top = (rect.bottom + 4) + 'px';
    panel.style.left = rect.left + 'px';
    panel.style.minWidth = rect.width + 'px';
    requestAnimationFrame(() => {
      const pr = panel.getBoundingClientRect();
      if (pr.bottom > window.innerHeight - 8) {
        panel.style.top = Math.max(8, rect.top - pr.height - 4) + 'px';
      }
    });
    _actionSelectClosers.add(closePanel);
    setTimeout(() => {
      document.addEventListener('click', outsideClick, true);
      document.addEventListener('keydown', escClose, true);
    }, 0);
  }

  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    e.preventDefault();
    if (panel) closePanel();
    else openPanel();
  });
  btn.addEventListener('mousedown', (e) => e.stopPropagation());

  // jz-set-value (od v2.13.33) — programmatic setter z JZ.setValue(). MAIN world dispatchuje
  // CustomEvent → content.js handler aktualizuje state w sposób identyczny z user click:
  // actionSelectState + memory + refreshButtonText (które ustawia data-jz-value) + runActionCode.
  btn.addEventListener('jz-set-value', (e) => {
    const optValue = String((e.detail && e.detail.value !== undefined) ? e.detail.value : '');
    actionSelectState.set(item.id, optValue);
    setActionMemory(item.id, optValue);
    refreshButtonText();
    if (item.code) runActionCode(item.code, item.id, { value: optValue });
  });

  container.appendChild(btn);
}

// INPUT type (od v2.13.0) — pole tekstowe z persistent memory (singleton per action.id
// od v2.13.7 — sessionStorage primary + chrome.storage.local backup) i opcjonalnym kodem JS
// na `change` (blur/Enter).
function renderInputElement(item, container) {
  const inp = document.createElement('input');
  inp.type = 'text';
  inp.className = 'jz-action-input';
  inp.dataset.jzActionId = item.id;
  inp.dataset.jzActionType = 'input';
  inp.dataset.jzRuleId = item.ruleId || '';
  inp.dataset.jzLabel = item.label || '';
  inp.dataset.jzKey = (item.label || '').toUpperCase();
  inp.placeholder = item.placeholder || item.label || '';
  inp.title = item.label || '';
  if (item.color) inp.style.setProperty('background-color', item.color, 'important');
  // Drugi kolor (od v2.13.18) — kolor tekstu pola.
  if (item.colorText) inp.style.setProperty('color', item.colorText, 'important');
  // Kolor placeholder (od v2.13.19) — przez CSS variable (pseudo-element ::placeholder
  // nie da się stylować przez element.style bezpośrednio).
  if (item.colorPlaceholder) inp.style.setProperty('--jz-placeholder-color', item.colorPlaceholder);

  // Initial value: memory → defaultValue → empty.
  const remembered = getActionMemoryFromCache(item.id);
  if (remembered !== undefined) {
    inp.value = String(remembered);
  } else if (item.defaultValue) {
    inp.value = item.defaultValue;
  }

  // Blokuj dragstart + mousedown propagation żeby nie kolidowało z drag baru.
  inp.addEventListener('mousedown', (e) => e.stopPropagation());

  // Save do memory na KAŻDY keystroke — sync sessionStorage (F5-safe od v2.13.6) +
  // async chrome.storage.local backup. Code odpalany tylko na `change` (Enter/blur)
  // żeby nie spamować executeScript przy keystroke spam.
  inp.addEventListener('input', () => {
    setActionMemory(item.id, inp.value);
  });

  inp.addEventListener('change', () => {
    setActionMemory(item.id, inp.value);
    if (item.code) runActionCode(item.code, item.id, { value: inp.value });
  });

  // Enter wymusza change (blur).
  inp.addEventListener('keydown', (e) => {
    if (e.key === 'Enter') {
      e.preventDefault();
      inp.blur();
    }
  });

  container.appendChild(inp);
}

// SLIDER type (od v2.13.17) — range slider z persistent memory (singleton per action.id —
// sessionStorage primary + chrome.storage.local backup, jak INPUT) i opcjonalnym kodem JS
// na `change` (po release suwaka, NIE na `input` — żeby nie spamować executeScript przy
// drag spam). W context user-JS dostaje `value` jako Number (nie string).
function renderSliderElement(item, container) {
  const wrap = document.createElement('div');
  wrap.className = 'jz-action-slider-wrap';
  wrap.dataset.jzActionId = item.id;
  wrap.dataset.jzActionType = 'slider';
  wrap.dataset.jzRuleId = item.ruleId || '';
  wrap.dataset.jzLabel = item.label || '';
  wrap.dataset.jzKey = (item.label || '').toUpperCase();
  wrap.title = item.label || '';
  if (item.color) wrap.style.setProperty('background-color', item.color, 'important');
  // SLIDER: drugi kolor (`colorText`) NIE wpływa już na text — od v2.13.19 dotyczy WYŁĄCZNIE
  // wypełnienia paska (accent-color slidera, ustawiane poniżej). Tekst sterowany osobno:
  // `colorLabel` (po lewej) i `colorValue` (po prawej), oba konfigurowalne w edytorze SLIDER.

  if (item.label) {
    const lbl = document.createElement('span');
    lbl.className = 'jz-action-slider-label';
    lbl.textContent = item.label;
    if (item.colorLabel) lbl.style.setProperty('color', item.colorLabel, 'important');
    wrap.appendChild(lbl);
  }

  const slider = document.createElement('input');
  slider.type = 'range';
  slider.className = 'jz-action-slider';
  slider.min = String(item.min ?? 0);
  slider.max = String(item.max ?? 100);
  slider.step = String(item.step ?? 1);
  // accent-color z item.colorText (drugi kolor) — fallback do CSS białego. Stary inline override
  // z item.color powodował zlewanie się fill suwaka z tłem wrap; teraz user explicite wybiera kontrast.
  if (item.colorText) slider.style.setProperty('accent-color', item.colorText, 'important');

  // Initial value: memory → defaultValue → środek zakresu.
  const min = Number(item.min ?? 0);
  const max = Number(item.max ?? 100);
  const remembered = getActionMemoryFromCache(item.id);
  let initial;
  if (remembered !== undefined && remembered !== '' && Number.isFinite(Number(remembered))) {
    initial = Number(remembered);
  } else if (item.defaultValue !== undefined && item.defaultValue !== '' && Number.isFinite(Number(item.defaultValue))) {
    initial = Number(item.defaultValue);
  } else {
    initial = Math.round((min + max) / 2);
  }
  // Clamp do zakresu (gdy defaultValue jest poza min/max — sane fallback).
  initial = Math.max(min, Math.min(max, initial));
  slider.value = String(initial);

  // Current value display (po prawej) — z opcjonalną jednostką.
  const valDisplay = document.createElement('span');
  valDisplay.className = 'jz-action-slider-value';
  if (item.colorValue) valDisplay.style.setProperty('color', item.colorValue, 'important');
  function refreshValueDisplay() {
    const u = item.unit || '';
    valDisplay.textContent = u ? slider.value + u : slider.value;
  }
  refreshValueDisplay();

  // dataset.jzValue (od v2.13.33) — wystawia aktualną wartość suwaka na wrap div
  // dla JZ.value() helpera. JZ.action('LBL') zwraca wrap, więc value musi tam być
  // (samo `slider.value` byłoby dostępne tylko przy querySelector inside).
  wrap.dataset.jzValue = slider.value;

  // Blokuj drag baru gdy user dotyka suwaka.
  slider.addEventListener('mousedown', (e) => e.stopPropagation());
  slider.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: true });

  // `input` event — live podczas drag. Save memory + odśwież display, NIE odpalaj code.
  slider.addEventListener('input', () => {
    setActionMemory(item.id, slider.value);
    refreshValueDisplay();
    wrap.dataset.jzValue = slider.value;
  });

  // `change` event — po release. Save memory + odpal code (z Number value w context).
  slider.addEventListener('change', () => {
    setActionMemory(item.id, slider.value);
    wrap.dataset.jzValue = slider.value;
    if (item.code) runActionCode(item.code, item.id, { value: Number(slider.value) });
  });

  wrap.appendChild(slider);
  wrap.appendChild(valDisplay);
  container.appendChild(wrap);
}

// TEXTAREA type (od v2.13.21) — multi-line text field z persistent memory (singleton per
// action.id — sessionStorage primary + chrome.storage.local backup, jak INPUT) i opcjonalnym
// kodem JS na `change` (blur). UWAGA: Enter w textarea to LEGIT newline — NIE robimy
// Enter→blur jak w INPUT.
function renderTextareaElement(item, container) {
  const ta = document.createElement('textarea');
  ta.className = 'jz-action-textarea';
  ta.dataset.jzActionId = item.id;
  ta.dataset.jzActionType = 'textarea';
  ta.dataset.jzRuleId = item.ruleId || '';
  ta.dataset.jzLabel = item.label || '';
  ta.dataset.jzKey = (item.label || '').toUpperCase();
  ta.placeholder = item.placeholder || item.label || '';
  ta.title = item.label || '';
  // rows: clamp 1-20 (sensowny zakres dla pola w pasku akcji). Default 3.
  const rowsNum = Number(item.rows);
  ta.rows = Number.isFinite(rowsNum) && rowsNum > 0 ? Math.min(20, Math.max(1, Math.round(rowsNum))) : 3;
  if (item.color) ta.style.setProperty('background-color', item.color, 'important');
  if (item.colorText) ta.style.setProperty('color', item.colorText, 'important');
  // Kolor placeholder (od v2.13.19 — re-used z INPUT) przez CSS variable.
  if (item.colorPlaceholder) ta.style.setProperty('--jz-placeholder-color', item.colorPlaceholder);

  // Initial value: memory → defaultValue → empty.
  const remembered = getActionMemoryFromCache(item.id);
  if (remembered !== undefined) {
    ta.value = String(remembered);
  } else if (item.defaultValue) {
    ta.value = item.defaultValue;
  }

  // Blokuj dragstart + mousedown propagation żeby nie kolidowało z drag baru.
  ta.addEventListener('mousedown', (e) => e.stopPropagation());

  // Save do memory na KAŻDY keystroke — sync sessionStorage (F5-safe) +
  // async chrome.storage.local backup. Code odpalany tylko na `change` (blur).
  ta.addEventListener('input', () => {
    setActionMemory(item.id, ta.value);
  });

  ta.addEventListener('change', () => {
    setActionMemory(item.id, ta.value);
    if (item.code) runActionCode(item.code, item.id, { value: ta.value });
  });

  // NIE robimy Enter→blur jak w INPUT — w textarea Enter = legit newline.
  // Jeśli user chce zapis bez utraty fokusa, może użyć Tab (natywny blur).

  container.appendChild(ta);
}

// TOGGLE3 type (od v2.13.25) — 3-stanowy segmented control. Wymaga dokładnie 3 elementów
// `item.states[]` (każdy { id, label, value }); user kliknięcie na nieaktywny stan aktywuje
// go, dezaktywuje pozostałe i odpala `item.code` z `value` = states[idx].value (fallback
// do .label gdy value puste), plus `stateIdx` (0-2) i `stateLabel` jako extras.
//
// Memory: trzyma INDEX aktywnego stanu (0-2) per action.id. Initial: memory → defaultStateIdx
// → 0. Memory zapisywana sync (sessionStorage) + async (chrome.storage.local) jak inne typed.
//
// Visual: pasek 3 mini-buttonów (jak radio segmented). Aktywny stan ma tło `item.color`
// i tekst `item.colorText` (CSS variables --jz-active-bg/--jz-active-text). Nieaktywne
// przezroczyste z dimowanym tekstem.
function renderToggle3Element(item, container) {
  const wrap = document.createElement('div');
  wrap.className = 'jz-action-toggle3';
  wrap.dataset.jzActionId = item.id;
  wrap.dataset.jzActionType = 'toggle3';
  wrap.dataset.jzRuleId = item.ruleId || '';
  wrap.dataset.jzLabel = item.label || '';
  wrap.dataset.jzKey = (item.label || '').toUpperCase();
  if (item.label) wrap.title = item.label;
  // CSS variables — active state, wrap bg, hover bg, inactive text. Wszystkie z fallback
  // do CSS defaults (jz-active-bg=brand teal, jz-active-text=white, jz-hover-bg=rgba 0.08,
  // jz-inactive-text=rgba 0.55).
  if (item.color)             wrap.style.setProperty('--jz-active-bg', item.color);
  if (item.colorText)         wrap.style.setProperty('--jz-active-text', item.colorText);
  if (item.colorBg)           wrap.style.setProperty('background-color', item.colorBg, 'important');
  if (item.colorHover)        wrap.style.setProperty('--jz-hover-bg', item.colorHover);
  if (item.colorInactiveText) wrap.style.setProperty('--jz-inactive-text', item.colorInactiveText);

  // Initial state z memory lub defaultStateIdx (clamp 0-2).
  let currentIdx = Math.max(0, Math.min(2, item.defaultStateIdx || 0));
  const remembered = getActionMemoryFromCache(item.id);
  if (remembered !== undefined) {
    const n = parseInt(remembered, 10);
    if (Number.isInteger(n) && n >= 0 && n < item.states.length) currentIdx = n;
  }

  function stateValueFor(idx) {
    const state = item.states[idx] || {};
    return (state.value !== undefined && state.value !== '') ? state.value : (state.label || '');
  }

  function activate(idx, dispatch) {
    currentIdx = idx;
    wrap.querySelectorAll('.jz-toggle3-state').forEach((b, i) => {
      b.classList.toggle('active', i === idx);
    });
    // data-jz-value (od v2.13.28) — aktualna wartość na DOM dla JZ.value() helpera.
    wrap.dataset.jzValue = stateValueFor(idx);
    setActionMemory(item.id, idx);
    if (dispatch && item.code) {
      const state = item.states[idx] || {};
      runActionCode(item.code, item.id, { value: stateValueFor(idx), stateIdx: idx, stateLabel: state.label || '' });
    }
  }

  // Initial dataset.jzValue (przed pierwszym kliknięciem) — żeby JZ.value() działało od razu.
  wrap.dataset.jzValue = stateValueFor(currentIdx);

  item.states.forEach((state, idx) => {
    const btn = document.createElement('button');
    btn.type = 'button';
    btn.className = 'jz-toggle3-state';
    btn.dataset.jzStateIdx = String(idx);
    if (idx === currentIdx) btn.classList.add('active');
    const stateLabel = (state.label || '').toUpperCase().slice(0, 5) || `S${idx + 1}`;
    btn.textContent = stateLabel;
    if (state.label) btn.title = state.label;
    btn.addEventListener('mousedown', (e) => e.stopPropagation());
    btn.addEventListener('click', (e) => {
      e.stopPropagation();
      e.preventDefault();
      if (idx === currentIdx) return;
      activate(idx, true);
    });
    wrap.appendChild(btn);
  });

  // jz-set-value (od v2.13.33) — programmatic setter z JZ.setValue(). Lookup state idx:
  // najpierw po state.value (lub fallback do label gdy value puste), potem po numerycznym
  // idx 0-2. activate(idx, true) → aktualizuje DOM + memory + odpala kod tak samo jak klik.
  wrap.addEventListener('jz-set-value', (e) => {
    const target = (e.detail && e.detail.value !== undefined) ? e.detail.value : '';
    const targetStr = String(target);
    let idx = item.states.findIndex(s => {
      const v = (s && s.value !== undefined && s.value !== '') ? String(s.value) : String((s && s.label) || '');
      return v === targetStr;
    });
    if (idx < 0) {
      const n = parseInt(target, 10);
      if (Number.isInteger(n) && n >= 0 && n < item.states.length) idx = n;
    }
    if (idx >= 0) activate(idx, true);
  });

  container.appendChild(wrap);
}

function escapeBarName(s) {
  return String(s).replace(/[<>&"']/g, c => ({ '<':'&lt;', '>':'&gt;', '&':'&amp;', '"':'&quot;', "'":'&#39;' }[c]));
}

function showBarContextMenu(elementId, x, y) {
  const old = document.getElementById('justzix-actions-menu');
  if (old) old.remove();
  const menu = document.createElement('div');
  menu.id = 'justzix-actions-menu';
  menu.style.left = x + 'px';
  menu.style.top = y + 'px';

  const t = (key, fallback, vars) => {
    if (typeof JZ_I18N === 'undefined' || !JZ_I18N.t) return fallback;
    const out = JZ_I18N.t(key);
    if (vars) return Object.entries(vars).reduce((s, [k, v]) => s.replace(':' + k, v), out);
    return out;
  };

  // Od v2.13.55: menu działa też dla CSS panes (cp_*). Od v2.13.56: też dla JS panes (jp_*).
  // Od v2.13.69: też dla JS Console (jc_*).
  const isCssPane = typeof elementId === 'string' && elementId.startsWith('cp_');
  const isJsPane = typeof elementId === 'string' && elementId.startsWith('jp_');
  const isJsConsole = typeof elementId === 'string' && elementId.startsWith('jc_');
  const isOutputConsole = typeof elementId === 'string' && elementId.startsWith('oc_');
  const isPane = isCssPane || isJsPane || isJsConsole || isOutputConsole;

  // Sekcja połączeń (od v2.11.0) — opcje "Rozłącz od: <nazwa>" dla każdego połączenia.
  const connections = (positionCache && Array.isArray(positionCache.barConnections))
    ? positionCache.barConnections.filter(c => c.aId === elementId || c.bId === elementId)
    : [];

  const items = [];

  // od v3.2.21 — Z-order: dwie pozycje na samym początku menu. Bazują na rect-overlap
  // (nie nadpisują z-index wszystkich okien, tylko skaczą ponad/pod kolidujące sąsiedztwo).
  items.push(`
    <div class="jz-menu-item" data-action="z-up">
      <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
        <polyline points="6 9 12 4 18 9"/><line x1="12" y1="4" x2="12" y2="20"/>
      </svg> ${escapeBarName(t('aiHelper.zorder.up', 'Ustaw wyżej'))}
    </div>
    <div class="jz-menu-item" data-action="z-down">
      <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
        <polyline points="6 15 12 20 18 15"/><line x1="12" y1="4" x2="12" y2="20"/>
      </svg> ${escapeBarName(t('aiHelper.zorder.down', 'Ustaw niżej'))}
    </div>
    <div class="jz-menu-sep"></div>
  `);

  if (connections.length > 0) {
    connections.forEach((c, idx) => {
      const otherId = c.aId === elementId ? c.bId : c.aId;
      let otherName;
      if (otherId === '__float') {
        otherName = t('content.float.label', '⊕ Główny przycisk');
      } else if (typeof otherId === 'string' && otherId.startsWith('cp_')) {
        // od v3.2.30 — getPanelElementById shadow-aware (wszystkie pane'y w shadow).
        const otherPane = getPanelElementById(otherId);
        otherName = otherPane?.querySelector('.jz-pane-title')?.textContent
                 || (cssPanesCache.find(p => p.id === otherId)?.name)
                 || JZ_I18N.t('cssPanes.btn.label');
      } else if (typeof otherId === 'string' && otherId.startsWith('jp_')) {
        const otherPane = getPanelElementById(otherId);
        otherName = otherPane?.querySelector('.jz-pane-title')?.textContent
                 || (jsPanesCache.find(p => p.id === otherId)?.name)
                 || JZ_I18N.t('jsPanes.btn.label');
      } else if (typeof otherId === 'string' && otherId.startsWith('jc_')) {
        const otherCon = getPanelElementById(otherId);
        otherName = otherCon?.querySelector('.jz-pane-title')?.textContent
                 || (jsConsolesCache.find(c => c.id === otherId)?.name)
                 || JZ_I18N.t('jsConsoles.btn.label');
      } else if (typeof otherId === 'string' && otherId.startsWith('oc_')) {
        // od v3.2.29 — getPanelElementById jest shadow-aware (TEMP Output w shadow).
        const otherOc = getPanelElementById(otherId);
        otherName = otherOc?.querySelector('.jz-pane-title')?.textContent
                 || (outputConsolesCache.find(o => o.id === otherId)?.name)
                 || JZ_I18N.t('outputConsoles.btn.label');
      } else {
        const otherPanel = document.getElementById(BAR_PANEL_PREFIX + otherId);
        otherName = otherPanel?.querySelector('.jz-bar-name')?.textContent
                 || otherId.replace(/^b_/, '').replace(/^__legacy_/, '📁 ').slice(0, 14);
      }
      const label = t('content.bar.disconnectFrom', 'Rozłącz od: :name', { name: otherName });
      items.push(`
        <div class="jz-menu-item" data-action="disconnect" data-conn-idx="${idx}">
          <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
            <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/>
            <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>
            <line x1="2" y1="2" x2="22" y2="22" stroke="#CC3333"/>
          </svg> ${escapeBarName(label)}
        </div>
      `);
    });
    if (connections.length > 1) {
      items.push(`
        <div class="jz-menu-item" data-action="disconnect-all">
          <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
            <line x1="2" y1="2" x2="22" y2="22" stroke="#CC3333" stroke-width="2.5"/>
          </svg> ${escapeBarName(t('content.bar.disconnectAll', 'Rozłącz wszystkie połączenia'))}
        </div>
      `);
    }
    items.push('<div class="jz-menu-sep"></div>');
  }

  // "Ukryj" — dla zwykłych barów per-window (setBarHidden, do reloadu); dla CSS pane
  // persistent enabled=false (od v2.13.55). Floating btn ma własny mechanizm.
  if (elementId !== '__float') {
    items.push(`
      <div class="jz-menu-item" data-action="hide-bar">
        <svg class="jz-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter">
          <circle cx="12" cy="12" r="10"/><line x1="5" y1="5" x2="19" y2="19"/>
        </svg> ${escapeBarName(
          isCssPane    ? t('cssPanes.contextMenu.disable', 'Wyłącz to okno CSS')
          : isJsPane   ? t('jsPanes.contextMenu.disable', 'Wyłącz to okno JS')
          : isJsConsole ? t('jsConsoles.contextMenu.disable', 'Wyłącz tę konsolę')
          : isOutputConsole ? t('outputConsoles.contextMenu.disable', 'Wyłącz tę konsolę output')
          : t('content.bar.hideUntilReload', 'Ukryj ten pasek (do reloadu)')
        )}
      </div>
    `);
  }

  // Font + size selectors (od v2.13.70) — tylko dla pane'ów (CSS/JS pane + JS Console).
  // System monospace fonts; user może też wpisać "" (default — wraca do CSS default'u).
  const FONT_OPTIONS = ['', 'Consolas', 'Menlo', 'Monaco', 'Courier New', 'DejaVu Sans Mono', 'Lucida Console', 'Inconsolata'];
  const SIZE_OPTIONS = [10, 11, 12, 13, 14, 16, 18, 20, 22, 24];
  let curFont = '', curSize = 12;
  if (isPane) {
    let paneObj = null;
    if (isCssPane)    paneObj = (cssPanesCache    || []).find(p => p.id === elementId);
    else if (isJsPane)   paneObj = (jsPanesCache    || []).find(p => p.id === elementId);
    else if (isJsConsole) paneObj = (jsConsolesCache || []).find(p => p.id === elementId);
    else if (isOutputConsole) paneObj = (outputConsolesCache || []).find(p => p.id === elementId);
    if (paneObj) {
      curFont = (typeof paneObj.bodyFont === 'string') ? paneObj.bodyFont : '';
      curSize = (typeof paneObj.bodyFontSize === 'number') ? paneObj.bodyFontSize : 12;
    }
    items.push('<div class="jz-menu-sep"></div>');
    const fontOpts = FONT_OPTIONS.map(f => {
      const lbl = f === '' ? t('panes.contextMenu.defaultFont', 'Domyślny') : f;
      const sel = f === curFont ? 'selected' : '';
      return `<option value="${escapeBarName(f)}" ${sel}>${escapeBarName(lbl)}</option>`;
    }).join('');
    const sizeOpts = SIZE_OPTIONS.map(s => {
      const sel = s === curSize ? 'selected' : '';
      return `<option value="${s}" ${sel}>${s}px</option>`;
    }).join('');
    items.push(`
      <div class="jz-menu-row">
        <span class="jz-menu-row-label">${escapeBarName(t('panes.contextMenu.font', 'Font'))}:</span>
        <select class="jz-menu-font">${fontOpts}</select>
      </div>
    `);
    items.push(`
      <div class="jz-menu-row">
        <span class="jz-menu-row-label">${escapeBarName(t('panes.contextMenu.size', 'Rozmiar'))}:</span>
        <select class="jz-menu-size">${sizeOpts}</select>
      </div>
    `);
  }

  menu.innerHTML = items.join('');
  document.documentElement.appendChild(menu);

  // Font / size selectors handlers — od v2.13.70.
  const fontSel = menu.querySelector('.jz-menu-font');
  if (fontSel) fontSel.addEventListener('change', async () => {
    await setPaneStyleField(elementId, 'bodyFont', fontSel.value || null);
  });
  const sizeSel = menu.querySelector('.jz-menu-size');
  if (sizeSel) sizeSel.addEventListener('change', async () => {
    const v = parseInt(sizeSel.value, 10);
    await setPaneStyleField(elementId, 'bodyFontSize', isNaN(v) ? null : v);
  });

  const hideBarEl = menu.querySelector('[data-action="hide-bar"]');
  if (hideBarEl) hideBarEl.addEventListener('click', () => {
    // TEMP (od v3.2.2) — close = ukrycie okna, dane zostają w sesji (closeTempPane).
    if (isPane && isTempPaneId(elementId)) {
      closeTempPane(elementId);
    } else if (isCssPane) {
      setCssPaneEnabled(elementId, false);
    } else if (isJsPane) {
      setJsPaneEnabled(elementId, false);
    } else if (isJsConsole) {
      setJsConsoleEnabled(elementId, false);
    } else if (isOutputConsole) {
      setOutputConsoleEnabled(elementId, false);
    } else {
      setBarHidden(elementId, true);
      const panel = document.getElementById(BAR_PANEL_PREFIX + elementId);
      if (panel) panel.remove();
    }
    menu.remove();
  });

  menu.querySelectorAll('[data-action="disconnect"]').forEach(item => {
    item.addEventListener('click', () => {
      const idx = parseInt(item.dataset.connIdx);
      const c = connections[idx];
      if (c) removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide });
      menu.remove();
    });
  });

  // od v3.2.21 — Z-order handlers.
  const zUpEl = menu.querySelector('[data-action="z-up"]');
  if (zUpEl) zUpEl.addEventListener('click', () => {
    const target = getPanelElementById(elementId);
    if (target) _jzMoveWindowZ(target, 'top');
    menu.remove();
  });
  const zDownEl = menu.querySelector('[data-action="z-down"]');
  if (zDownEl) zDownEl.addEventListener('click', () => {
    const target = getPanelElementById(elementId);
    if (target) _jzMoveWindowZ(target, 'bottom');
    menu.remove();
  });

  const disconnectAllEl = menu.querySelector('[data-action="disconnect-all"]');
  if (disconnectAllEl) {
    disconnectAllEl.addEventListener('click', () => {
      connections.forEach(c => removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide }));
      menu.remove();
    });
  }

  setTimeout(() => {
    const closer = (e) => {
      if (!menu.contains(e.target)) {
        menu.remove();
        document.removeEventListener('mousedown', closer, true);
      }
    };
    document.addEventListener('mousedown', closer, true);
  }, 50);
}

function attachBarDrag(panel, barId) {
  const handle = panel.querySelector('.jz-actions-handle');
  const nameLabel = panel.querySelector('.jz-bar-name');
  // Drag i dblclick działają z obu uchwytów: oryginalny handle (kropki) ORAZ etykieta nazwy.
  const dragHandles = [handle, nameLabel].filter(Boolean);
  if (dragHandles.length === 0) return;

  let dragging = false;
  let dragStarted = false;
  let startX, startY;
  // Group drag — startowe pozycje wszystkich barów w grupie (od v2.11.0).
  let groupBarIds = [];
  let groupStartPositions = {}; // { barId: { left, top, inDOM } }
  let groupCurrentPositions = {}; // { barId: { left, top } } — od v2.13.53, dla hidden członków
  let lastSnapCandidate = null;

  // Podwójny klik — przełącza orientację paska (pionowy ↔ poziomy). Też dla legacy
  // ghost barów (`__legacy_<folderId>` → folder.actionsOrientation).
  // Zawsze rozłącza wszystkie połączenia tego baru (od v2.11.1) — żeby nowy układ nie
  // nakładał się na sąsiadów. User może je ponownie skleić ręcznie po zmianie orientacji.
  const onDblClick = async (e) => {
    e.preventDefault();
    e.stopPropagation();
    if (!isExtensionContextValid()) return;

    // Najpierw zerwij wszystkie połączenia tego baru.
    const conns = (positionCache && Array.isArray(positionCache.barConnections))
      ? positionCache.barConnections.filter(c => c.aId === barId || c.bId === barId)
      : [];
    conns.forEach(c => removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide }));

    if (barId.startsWith('__legacy_')) {
      const folderId = barId.slice('__legacy_'.length);
      const data = await safeStorageGet(['folders']);
      const fs = Array.isArray(data.folders) ? data.folders : [];
      const f = fs.find(ff => ff.id === folderId);
      if (!f) return;
      f.actionsOrientation = f.actionsOrientation === 'horizontal' ? 'vertical' : 'horizontal';
      await safeStorageSet({ folders: fs });
    } else {
      const data = await safeStorageGet(['actionBars']);
      const bars = Array.isArray(data.actionBars) ? data.actionBars : [];
      const bar = bars.find(b => b.id === barId);
      if (!bar) return;
      bar.orientation = bar.orientation === 'horizontal' ? 'vertical' : 'horizontal';
      await safeStorageSet({ actionBars: bars });
    }
    // Storage onChanged listener wywoła applyAll() → renderActionBars() → re-render.
  };
  dragHandles.forEach(el => el.addEventListener('dblclick', onDblClick));

  const onMouseDown = (e) => {
    if (e.button !== 0) return;
    dragging = true;
    dragStarted = false;
    startX = e.clientX;
    startY = e.clientY;
    groupBarIds = getBarGroup(barId);
    groupStartPositions = {};
    groupCurrentPositions = {};
    groupBarIds.forEach(bid => {
      const p = getPanelElementById(bid);
      if (p) {
        groupStartPositions[bid] = { left: parseInt(p.style.left) || 0, top: parseInt(p.style.top) || 0, inDOM: true };
      } else {
        // Od v2.13.53: hidden członek grupy — czytamy stored pozycję.
        const stored = getStoredStartPos(bid);
        if (stored) groupStartPositions[bid] = { left: stored.left, top: stored.top, inDOM: false };
      }
    });
    lastSnapCandidate = null;
    // Listenery document tylko na czas trwania przeciągania — inaczej attachBarDrag
    // (wołane przy każdym re-renderze paska) dokładałoby je w nieskończoność.
    // Defensywne removeEventListener — gdyby poprzedni mouseup zaginął (release poza
    // oknem / przechwycony przez inny element), stare listenery zostałyby podwojone.
    document.removeEventListener('mousemove', onMove);
    document.removeEventListener('mouseup', onUp);
    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
    e.preventDefault();
    e.stopPropagation();
  };
  dragHandles.forEach(el => el.addEventListener('mousedown', onMouseDown));

  const onMove = (e) => {
    if (!dragging) return;
    // Lost-mouseup — przycisk już puszczony (release poza oknem / przechwycony przez
    // inny element). Domknij drag zamiast ciągnąć pasek za kursorem w nieskończoność.
    if (e.buttons === 0) { onUp(); return; }
    const dx = e.clientX - startX;
    const dy = e.clientY - startY;
    if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragStarted = true;
    if (!dragStarted) return;
    _barDragInProgress = true;

    groupCurrentPositions = {};
    groupBarIds.forEach(bid => {
      const start = groupStartPositions[bid];
      if (!start) return;
      const newLeft = start.left + dx;
      const newTop = start.top + dy;
      groupCurrentPositions[bid] = { left: newLeft, top: newTop };
      if (start.inDOM) {
        const p = getPanelElementById(bid);
        if (p) { p.style.left = newLeft + 'px'; p.style.top = newTop + 'px'; }
      }
    });

    clearAllSnapEdges();
    const draggedPanel = getPanelElementById(barId);
    if (!draggedPanel) return;
    const aRect = draggedPanel.getBoundingClientRect();
    const candidates = getDraggablePanelsForSnap();

    const snap = findSnapCandidate(barId, aRect, candidates, groupBarIds);
    lastSnapCandidate = snap;
    if (snap) {
      groupBarIds.forEach(bid => {
        const cur = groupCurrentPositions[bid];
        if (!cur) return;
        cur.left += snap.snapDX;
        cur.top += snap.snapDY;
        const start = groupStartPositions[bid];
        if (start && start.inDOM) {
          const p = getPanelElementById(bid);
          if (p) { p.style.left = cur.left + 'px'; p.style.top = cur.top + 'px'; }
        }
      });
      const otherPanel = getPanelElementById(snap.otherBarId);
      if (draggedPanel) draggedPanel.classList.add('jz-snap-edge-' + snap.aSide);
      if (otherPanel) otherPanel.classList.add('jz-snap-edge-' + snap.bSide);
    }
  };

  const onUp = () => {
    if (!dragging) return;
    dragging = false;
    _barDragInProgress = false;
    document.removeEventListener('mousemove', onMove);
    document.removeEventListener('mouseup', onUp);
    if (!dragStarted) return;

    clearAllSnapEdges();

    if (lastSnapCandidate) {
      const { otherBarId, aSide, bSide } = lastSnapCandidate;
      addBarConnection({ aId: barId, aSide, bId: otherBarId, bSide });
    }

    // groupCurrentPositions zawiera pozycje też dla hidden członków (od v2.13.53).
    saveGroupPositions(groupBarIds, groupCurrentPositions);
    lastSnapCandidate = null;
    applyHiddenNamesByConnections();
  };
}

// === Skróty klawiszowe ===
// Helper musi byc spojny z options.js#jzEventToShortcut.
function jzEventToShortcut(e) {
  const code = e.code;
  if (!code || /^(Control|Alt|Shift|Meta|OS)(Left|Right)$/.test(code)) return null;
  let key;
  if (code.startsWith('Key')) key = code.slice(3).toLowerCase();
  else if (code.startsWith('Digit')) key = code.slice(5);
  else if (code.startsWith('Numpad')) key = 'numpad' + code.slice(6).toLowerCase();
  else if (/^F\d+$/.test(code)) key = code.toLowerCase();
  else key = code.toLowerCase();
  const parts = [];
  if (e.ctrlKey) parts.push('ctrl');
  if (e.altKey) parts.push('alt');
  if (e.shiftKey) parts.push('shift');
  if (e.metaKey) parts.push('meta');
  parts.push(key);
  return parts.join('+');
}

// === CSS panes (Faza 2 "Okna na froncie", od v2.13.40) ============================
//
// Floating textarea wstrzykiwana na strony pasujące do URL parent scope'a (folder/group/rule).
// Body = textarea. Treść → live `<style id="jz-pane-style-${id}">` w head strony (debounce
// 200ms). Treść per tab w sessionStorage; metadane (name, color, parent, position, size,
// enabled) w storage syncującym się chmurowo.
//
// Discovery analogicznie do ActionBar:
//   - pane folderu pokazuje się gdy URL pasuje do folder.urlPatterns
//   - pane grupy: gdy URL pasuje do folder I (group.urlPatterns puste LUB pasuje)
//   - pane zestawu: gdy URL pasuje do folder I group I rule
// Brak rules-check — pane nie zależy od konkretnej aktywnej reguły, bo to scratch pad
// luźno powiązany ze scope'm (user przeglądający stronę folderu może chcieć dorzucać
// CSS bez tworzenia reguły).

const CSS_PANE_PREFIX = 'jz-pane-css-';
const CSS_PANE_STYLE_PREFIX = 'jz-pane-style-';
const CSS_PANE_CONTENT_KEY = (id) => `jz_pane_${id}_content`;
// Per-pane debounce timers — uniknięcie spamu writeów do sessionStorage + live style.
const _cssPaneDebounceTimers = new Map();

// JS pane (od v2.13.56, Faza 2 cont.) — analogicznie do CSS pane, ale content
// uruchamiany jako kod JS w MAIN world (via background `execute-js-pane` msg).
// Live debounce 500ms (vs 200ms dla CSS — JS może być cięższy do egzekucji).
const JS_PANE_PREFIX = 'jz-pane-js-';
const JS_PANE_CONTENT_KEY = (id) => `jz_pane_${id}_content`;  // same kluczem co CSS, bo IDs unique (jp_*)
const _jsPaneDebounceTimers = new Map();
let jsPanesCache = [];

// JS Console (od v2.13.66, Faza 2 cont.) — REPL: input area + output log. Każdy Ctrl+Enter
// wykonuje wpisany kod, output appenduje (z prefixem czasowym/markerem). Persistent log per-tab.
const JS_CONSOLE_PREFIX = 'jz-console-';
const JS_CONSOLE_INPUT_KEY = (id) => `jz_console_${id}_input`;     // bieżący tekst inputu
const JS_CONSOLE_OUTPUT_KEY = (id) => `jz_console_${id}_output`;   // log entries (JSON array)
const JS_CONSOLE_HISTORY_KEY = (id) => `jz_console_${id}_history`; // history komend (od v2.13.68)
let jsConsolesCache = [];

// Discovery: które CSS panes są relevant dla aktualnego URL.
// Od v2.13.52: NIE filtruje przez floatingHidden ani folder/group/rule.enabled — chcemy
// pokazać panes także dla hidden/disabled folderów żeby popup pokazał effective state.
// Filtr cascade (folder/group/rule.enabled + floatingHidden) jest w paneCascadeEnabled,
// używanym przez renderCssPanes (visibility na page) i przez UI w popup (checkbox state).
// Parameter `floatingHidden` zachowany dla zgodności sygnatury z poprzednią wersją (ignored).
function discoverRelevantCssPanes(folders, groups, rules, cssPanes, floatingHidden) {
  const matching = getMatchingFoldersForPage(folders);
  const matchingFolderIds = new Set(matching.map(f => f.id));
  const out = [];
  (cssPanes || []).forEach(pane => {
    if (!pane || !pane.id || !pane.parentType || !pane.parentId) return;
    if (pane.enabled === false) return;
    // TEMP (od v2.13.81) — always show on this tab, no folder match.
    if (pane.parentType === 'temp') { out.push(pane); return; }
    if (pane.parentType === 'folder') {
      if (!matchingFolderIds.has(pane.parentId)) return;
    } else if (pane.parentType === 'group') {
      const g = (groups || []).find(gx => gx.id === pane.parentId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      // Group URL filter — jeśli group ma własne urlPatterns, dorzuć je do match check.
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const groupMatches = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!groupMatches) return;
      }
    } else if (pane.parentType === 'rule') {
      const r = (rules || []).find(rx => rx.id === pane.parentId);
      if (!r) return;
      const g = (groups || []).find(gx => gx.id === r.groupId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const groupMatches = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!groupMatches) return;
      }
      if (Array.isArray(r.urlPatterns) && r.urlPatterns.length > 0) {
        const ruleMatches = r.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ruleMatches) return;
      }
    } else {
      return;
    }
    out.push(pane);
  });
  return out;
}

// urlMatchesPattern — używane wewnętrznie w content.js dla group/rule URL filters.
// Spójne z folderMatchesUrl ale ogólne dla dowolnego patterna.
function urlMatchesPattern(pattern, url) {
  if (!pattern) return false;
  const trimmed = String(pattern).trim();
  if (!trimmed) return false;
  if (trimmed.includes('*')) {
    try {
      const regex = new RegExp(
        '^' + trimmed.split('*').map(s => s.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$'
      );
      return regex.test(url);
    } catch (_) { return false; }
  }
  try {
    const a = new URL(trimmed); const b = new URL(url);
    return a.origin === b.origin && a.pathname === b.pathname;
  } catch (_) {
    return url === trimmed;
  }
}

// Render single pane. Tworzy/aktualizuje DOM. Idempotent — wywoływany przy applyAll +
// storage.onChanged. Treść textarea czytana z sessionStorage przy pierwszym renderze;
// zmiany debounce'owane do sessionStorage + live style. Od v2.13.41: brak close button
// (user na froncie nie ma jak "przywrócić" okno z niczego, więc on/off przeniesione do
// popup widgetu i browser action popup). Drag headerem zmienia position w storage (sync).
function renderCssPaneElement(pane) {
  const elId = CSS_PANE_PREFIX + pane.id;
  let el = document.getElementById(elId);
  el = _jzResolveShadow(el, '.jz-pane-css'); // od v3.2.30
  // sessionStorage może być zablokowane przez sandbox itd. — zabezpiecz.
  let ss = null;
  try { ss = window.sessionStorage; } catch (_) {}
  // Stwórz nowy element jeśli nie istnieje.
  if (!el) {
    el = document.createElement('div');
    el.id = elId;
    el.className = 'jz-pane jz-pane-css';
    const initialContent = ss ? (ss.getItem(CSS_PANE_CONTENT_KEY(pane.id)) || '') : '';
    el.innerHTML = `
      <div class="jz-pane-header">
        <span class="jz-pane-dot" style="background:${(pane.color || '#2D9D53').replace(/[<"']/g, '')};"></span>
        <span class="jz-pane-title"></span>
      </div>
      <textarea class="jz-pane-body" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
    `;
    document.documentElement.appendChild(_jzWrapInShadow(el, elId)); // od v3.2.30
    el.querySelector('.jz-pane-body').value = initialContent;
    // Live <style> injection — debounce 200ms.
    const ta = el.querySelector('.jz-pane-body');
    const applyContent = (val) => {
      // <style> w head
      let styleEl = document.getElementById(CSS_PANE_STYLE_PREFIX + pane.id);
      if (!styleEl) {
        styleEl = document.createElement('style');
        styleEl.id = CSS_PANE_STYLE_PREFIX + pane.id;
        (document.head || document.documentElement).appendChild(styleEl);
      }
      styleEl.textContent = val;
      // sessionStorage content per tab
      if (ss) try { ss.setItem(CSS_PANE_CONTENT_KEY(pane.id), val); } catch (_) {}
    };
    applyContent(initialContent);
    ta.addEventListener('input', () => {
      const prev = _cssPaneDebounceTimers.get(pane.id);
      if (prev) clearTimeout(prev);
      _cssPaneDebounceTimers.set(pane.id, setTimeout(() => applyContent(ta.value), 200));
    });
    // Drag — mousedown na headerze (poza textarea body — zostawia tekst zaznaczalny).
    attachPaneDrag(el, pane);
    attachPaneResize(el, pane);
  }
  // Update title + color (mogą się zmienić przy sync).
  const titleEl = el.querySelector('.jz-pane-title');
  if (titleEl) titleEl.textContent = pane.name || 'CSS';
  const dotEl = el.querySelector('.jz-pane-dot');
  if (dotEl) dotEl.style.background = (pane.color || '#2D9D53').replace(/[<"']/g, '');
  applyPaneCustomStyles(el, pane);
  _ensureTempClearBtn(el, pane);
  _ensureTempCloseBtn(el, pane);
  _ensurePersistentCloseBtn(el, pane); // od v3.2.34
  _ensurePaneCollapseBtn(el, pane);
  // Position from pane.position (anchor=top-right by default). Klampuj do viewport.
  // Pomijamy gdy aktualnie trwa drag — bo applyAll po storage.onChanged (z naszego writeu)
  // zresetowałby pozycję na "oficjalną" w trakcie ruchu myszką.
  if (!el.dataset.jzDragging) applyCssPanePosition(el, pane);
}

// Aplikuje custom kolory (headerBg/headerText/bodyBg/bodyText/runColor) jako inline style.
// Puste pola → reset inline (z !important w CSS default'y). Od v2.13.58, runColor od v2.13.64.
function applyPaneCustomStyles(el, pane) {
  const safe = (v) => (typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v)) ? v : '';
  const header = el.querySelector('.jz-pane-header');
  const title  = el.querySelector('.jz-pane-title');
  const body   = el.querySelector('.jz-pane-body');
  const runBtn = el.querySelector('.jz-pane-run');
  // JS Console — output + input zamiast pojedynczego body.
  const conOut = el.querySelector('.jz-console-output');
  const conIn  = el.querySelector('.jz-console-input');
  // Output Console (od v2.13.72) — read-only log viewer, single body .jz-output-log.
  const outLog = el.querySelector('.jz-output-log');
  const setOrClear = (node, prop, val) => {
    if (!node) return;
    if (val) node.style.setProperty(prop, val, 'important');
    else node.style.removeProperty(prop);
  };
  setOrClear(header, 'background', safe(pane.headerBg));
  setOrClear(title,  'color',      safe(pane.headerText));
  setOrClear(body,   'background', safe(pane.bodyBg));
  setOrClear(body,   'color',      safe(pane.bodyText));
  // JS Console — bodyBg/bodyText aplikowane do output + input.
  setOrClear(conOut, 'background', safe(pane.bodyBg));
  setOrClear(conOut, 'color',      safe(pane.bodyText));
  setOrClear(conIn,  'background', safe(pane.bodyBg));
  setOrClear(conIn,  'color',      safe(pane.bodyText));
  // Output Console — bodyBg/bodyText aplikowane do log area.
  setOrClear(outLog, 'background', safe(pane.bodyBg));
  setOrClear(outLog, 'color',      safe(pane.bodyText));

  // Font + size (od v2.13.70). Aplikujemy do body (CSS/JS pane) i output+input (Console).
  // Font fallback: zachowujemy domyślny stack monospace żeby nieistniejący font nie spadł
  // do serif (browser default for unknown family).
  const fontFam  = (typeof pane.bodyFont === 'string' && pane.bodyFont) ? `${pane.bodyFont}, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` : '';
  const fontSize = (typeof pane.bodyFontSize === 'number' && pane.bodyFontSize >= 8 && pane.bodyFontSize <= 32) ? (pane.bodyFontSize + 'px') : '';
  [body, conOut, conIn, outLog].forEach(node => {
    setOrClear(node, 'font-family', fontFam);
    setOrClear(node, 'font-size',   fontSize);
  });

  // Run button (JS pane / JS Console) lub Clear button (Output Console — od v2.13.72).
  // Custom runColor → CSS variables na pane'ie + klasa marker; CSS w content.css używa
  // var(...) gdy klasa obecna. Dirty state automatycznie korzysta z tych samych zmiennych.
  const outClearBtn = el.querySelector('.jz-output-clear');
  if (runBtn || outClearBtn) {
    const rc = safe(pane.runColor);
    if (rc) {
      const rgb = jzHexToRgb(rc);
      if (rgb) {
        el.style.setProperty('--jz-run-text',   rc);
        el.style.setProperty('--jz-run-border', `rgba(${rgb.r},${rgb.g},${rgb.b},0.6)`);
        el.style.setProperty('--jz-run-bg',     `rgba(${rgb.r},${rgb.g},${rgb.b},0.12)`);
        el.classList.add('jz-run-custom');
      }
    } else {
      el.style.removeProperty('--jz-run-text');
      el.style.removeProperty('--jz-run-border');
      el.style.removeProperty('--jz-run-bg');
      el.classList.remove('jz-run-custom');
    }
  }
}

// Setter pola pane'a po elementId — uniwersalny dla cp_/jp_/jc_. Used by context menu
// dla font/size pickerów (od v2.13.70). Zwraca Promise<bool> success.
async function setPaneStyleField(elementId, field, value) {
  // TEMP (od v2.13.81) — sessionStorage routing zamiast chrome.storage.
  if (isTempPaneId(elementId)) {
    const t = _tempPanes.find(p => p.id === elementId);
    if (!t) return false;
    if (value === null || value === undefined || value === '') {
      delete t[field];
    } else {
      t[field] = value;
    }
    t.updatedAt = Date.now();
    saveTempPanes();
    applyAll().catch(() => {});
    return true;
  }
  const collection = (typeof elementId !== 'string') ? null
                  : elementId.startsWith('cp_') ? 'cssPanes'
                  : elementId.startsWith('jp_') ? 'jsPanes'
                  : elementId.startsWith('jc_') ? 'jsConsoles'
                  : elementId.startsWith('oc_') ? 'outputConsoles'
                  : null;
  if (!collection) return false;
  const data = await safeStorageGet([collection]);
  const arr = Array.isArray(data[collection]) ? data[collection] : [];
  const item = arr.find(x => x.id === elementId);
  if (!item) return false;
  if (value === null || value === undefined || value === '') {
    delete item[field];
  } else {
    item[field] = value;
  }
  item.updatedAt = Date.now();
  await safeStorageSet({ [collection]: arr });
  return true;
}

// Hex #rrggbb → {r,g,b}. Wraca null dla invalid input. Used by applyPaneCustomStyles (od v2.13.64).
function jzHexToRgb(hex) {
  const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || '');
  if (!m) return null;
  return { r: parseInt(m[1], 16), g: parseInt(m[2], 16), b: parseInt(m[3], 16) };
}

// Drag handler — mousedown na .jz-pane-header → mousemove document → reposition →
// mouseup → save position. Od v2.13.42: pełna integracja ze snap systemem barów —
// pane może być sklejone z barem akcji / floating btn / innym pane'em. Connection
// żyje w barConnections (per-window); pozycja w pane.position (synced). Group drag
// (gdy pane jest w grupie z innymi elementami) propaguje ruch na całą grupę.
// Drag handler — wspólny dla CSS pane i JS pane (od v2.13.56, wcześniej attachCssPaneDrag).
function attachPaneDrag(el, pane) {
  const header = el.querySelector('.jz-pane-header');
  if (!header) return;
  header.style.cursor = 'grab';
  // Od v2.13.55: prawy klik na header otwiera kontekstowe menu z opcjami "Rozłącz od X" /
  // "Rozłącz wszystkie połączenia" / "Wyłącz okno" — analog do bar context menu.
  header.addEventListener('contextmenu', (e) => {
    e.preventDefault();
    e.stopPropagation();
    showBarContextMenu(pane.id, e.clientX, e.clientY);
  });
  // Od v2.13.201: dla okien TEMP — podwójny klik w belkę nagłówka przywraca zapamiętany
  // (per-device) rozmiar danego typu okna. Brak zapamiętanego rozmiaru → no-op.
  if (pane && pane.isTemp) {
    header.addEventListener('dblclick', (e) => {
      e.preventDefault();
      e.stopPropagation();
      const remembered = _tempPaneSizes && _tempPaneSizes[pane.type];
      if (!remembered) return;
      const remW = Math.max(PANE_W_MIN, Math.min(PANE_W_MAX, remembered.w || 0));
      const remH = Math.max(PANE_H_MIN, Math.min(PANE_H_MAX, remembered.h || 0));
      if (!remW || !remH) return;
      const defW = TEMP_PANE_DEFAULT_SIZE.w, defH = TEMP_PANE_DEFAULT_SIZE.h;
      // Toggle: gdy okno jest w rozmiarze domyślnym → przywróć zapamiętany; w innym
      // wypadku → ustaw domyślny. Kolejne dblclicki przełączają zapamiętany ↔ domyślny.
      const atDefault = (parseInt(el.style.width) || 0) === defW
                     && (parseInt(el.style.height) || 0) === defH;
      const w = atDefault ? remW : defW;
      const h = atDefault ? remH : defH;
      const left = parseInt(el.style.left) || 0;
      const top  = parseInt(el.style.top)  || 0;
      el.style.width  = w + 'px';
      el.style.height = h + 'px';
      el.style.left   = left + 'px';
      el.style.top    = top + 'px';
      el.style.right  = '';
      el.style.bottom = '';
      // rememberTempSize=false — dblclick NIE nadpisuje zapamiętanego rozmiaru.
      persistPaneGeometry(pane.id, { left, top, w, h }, false);
    });
  } else if (pane) {
    // od v3.2.34 — persistent pane (cp_/jp_/jc_/oc_) dblclick na header → toggle collapse.
    // Spójne z istniejącym `_ensurePaneCollapseBtn` (button też istnieje w header).
    header.addEventListener('dblclick', async (e) => {
      e.preventDefault();
      e.stopPropagation();
      const next = !pane.collapsed;
      pane.collapsed = next;
      el.classList.toggle('jz-pane-collapsed', next);
      // Sync ikony przycisku collapse jeśli już jest w header.
      const btn = header.querySelector('.jz-pane-collapse-btn');
      if (btn) {
        btn.innerHTML = _paneCollapseSvg(next);
        btn.title = _paneCollapseTitle(next);
      }
      try { await setPaneStyleField(pane.id, 'collapsed', next ? true : null); } catch (_) {}
    });
  }
  header.addEventListener('mousedown', (e) => {
    if (e.button !== 0) return;
    e.preventDefault();
    e.stopPropagation();
    header.style.cursor = 'grabbing';
    el.dataset.jzDragging = '1';
    const startMouseX = e.clientX;
    const startMouseY = e.clientY;
    let dragStarted = false;
    // Group detection — pane.id + wszyscy connected (przez barConnections). getBarGroup
    // jest agnostic na typ ID (bar/pane/float), więc działa też dla nas.
    const groupIds = getBarGroup(pane.id);
    if (!groupIds.includes(pane.id)) groupIds.push(pane.id);
    const groupStartPositions = {};
    let groupCurrentPositions = {};
    groupIds.forEach(id => {
      const p = getPanelElementById(id);
      if (p) {
        groupStartPositions[id] = { left: parseInt(p.style.left) || 0, top: parseInt(p.style.top) || 0, inDOM: true };
      } else {
        // Od v2.13.53: hidden członek grupy.
        const stored = getStoredStartPos(id);
        if (stored) groupStartPositions[id] = { left: stored.left, top: stored.top, inDOM: false };
      }
    });
    let lastSnap = null;

    const onMove = (ev) => {
      const dx = ev.clientX - startMouseX;
      const dy = ev.clientY - startMouseY;
      if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragStarted = true;
      if (!dragStarted) return;
      groupCurrentPositions = {};
      groupIds.forEach(id => {
        const start = groupStartPositions[id];
        if (!start) return;
        const newLeft = start.left + dx;
        const newTop = start.top + dy;
        groupCurrentPositions[id] = { left: newLeft, top: newTop };
        if (start.inDOM) {
          const p = getPanelElementById(id);
          if (p) {
            p.style.left = newLeft + 'px';
            p.style.top  = newTop + 'px';
            p.style.right = '';
            p.style.bottom = '';
          }
        }
      });
      clearAllSnapEdges();
      const aRect = el.getBoundingClientRect();
      const candidates = getDraggablePanelsForSnap();
      const snap = findSnapCandidate(pane.id, aRect, candidates, groupIds);
      lastSnap = snap;
      if (snap) {
        groupIds.forEach(id => {
          const cur = groupCurrentPositions[id];
          if (!cur) return;
          cur.left += snap.snapDX;
          cur.top += snap.snapDY;
          const start = groupStartPositions[id];
          if (start && start.inDOM) {
            const p = getPanelElementById(id);
            if (p) { p.style.left = cur.left + 'px'; p.style.top = cur.top + 'px'; }
          }
        });
        el.classList.add('jz-snap-edge-' + snap.aSide);
        const otherPanel = getPanelElementById(snap.otherBarId);
        if (otherPanel) otherPanel.classList.add('jz-snap-edge-' + snap.bSide);
      }
    };
    const onUp = () => {
      document.removeEventListener('mousemove', onMove, true);
      document.removeEventListener('mouseup', onUp, true);
      delete el.dataset.jzDragging;
      header.style.cursor = 'grab';
      if (!dragStarted) return;
      clearAllSnapEdges();
      if (lastSnap) {
        addBarConnection({ aId: pane.id, aSide: lastSnap.aSide, bId: lastSnap.otherBarId, bSide: lastSnap.bSide });
      }
      saveGroupPositions(groupIds, groupCurrentPositions);
    };
    document.addEventListener('mousemove', onMove, true);
    document.addEventListener('mouseup', onUp, true);
  });
}

// Resize (od v2.13.91) — uchwyty w 4 narożnikach pane'a. Zmiana rozmiaru myszką;
// przeciwległy róg trzymany w miejscu. Po drag-end nowy size+position zapisywany
// (synced przez pane entity). Idempotent — handle dodawane raz przy tworzeniu elementu.
const PANE_W_MIN = 180, PANE_W_MAX = 1200, PANE_H_MIN = 120, PANE_H_MAX = 900;

// Bieżący rozmiar pane'a wg cache (content-box w/h — offsetWidth ma drift od bordera).
function getPaneSizeForResize(paneId) {
  let pane = null;
  if (isTempPaneId(paneId)) pane = _tempPanes.find(p => p.id === paneId);
  else if (paneId.startsWith('cp_')) pane = (cssPanesCache || []).find(x => x.id === paneId);
  else if (paneId.startsWith('jp_')) pane = (jsPanesCache || []).find(x => x.id === paneId);
  else if (paneId.startsWith('jc_')) pane = (jsConsolesCache || []).find(x => x.id === paneId);
  else if (paneId.startsWith('oc_')) pane = (outputConsolesCache || []).find(x => x.id === paneId);
  const def = paneId.startsWith('jc_') ? { w: 380, h: 280 }
            : paneId.startsWith('oc_') ? { w: 380, h: 240 }
            : { w: 360, h: 240 };
  const sz = (pane && pane.size) || {};
  return {
    w: Math.max(PANE_W_MIN, Math.min(PANE_W_MAX, sz.w || def.w)),
    h: Math.max(PANE_H_MIN, Math.min(PANE_H_MAX, sz.h || def.h)),
  };
}

// Zapis geometrii pane'a po resize — size + position (anchor). TEMP → sessionStorage.
function persistPaneGeometry(paneId, geo, rememberTempSize) {
  const size = { w: Math.round(geo.w), h: Math.round(geo.h) };
  const position = absToAnchorPosition(geo.left, geo.top, size.w, size.h);
  if (isTempPaneId(paneId)) {
    const t = _tempPanes.find(p => p.id === paneId);
    if (!t) return;
    t.size = size;
    t.position = position;
    t.updatedAt = Date.now();
    saveTempPanes();
    // Zapamiętaj rozmiar per typ okna TEMP (per-device) — przywracany dblclickiem w belkę.
    // rememberTempSize===false (dblclick toggle) NIE nadpisuje zapamiętanego rozmiaru.
    if (rememberTempSize !== false && t.type) {
      _tempPaneSizes[t.type] = { w: size.w, h: size.h };
      saveTempPaneSizes();
    }
    return;
  }
  const collection = paneId.startsWith('cp_') ? 'cssPanes'
                   : paneId.startsWith('jp_') ? 'jsPanes'
                   : paneId.startsWith('jc_') ? 'jsConsoles'
                   : paneId.startsWith('oc_') ? 'outputConsoles'
                   : null;
  if (!collection) return;
  safeStorageGet([collection]).then(data => {
    const arr = Array.isArray(data[collection]) ? data[collection] : [];
    const item = arr.find(x => x.id === paneId);
    if (!item) return;
    item.size = size;
    item.position = position;
    item.updatedAt = Date.now();
    return safeStorageSet({ [collection]: arr });
  });
}

// 8 uchwytów: 4 narożniki (nw/ne/sw/se) + 4 krawędzie (n/s/e/w). `ns`/`ew` mówią,
// która krawędź jest przeciągana ('' = brak zmiany w tej osi).
const PANE_RESIZE_HANDLES = {
  nw: { ns: 'n', ew: 'w' }, ne: { ns: 'n', ew: 'e' },
  sw: { ns: 's', ew: 'w' }, se: { ns: 's', ew: 'e' },
  n:  { ns: 'n', ew: ''  }, s:  { ns: 's', ew: ''  },
  e:  { ns: '',  ew: 'e' }, w:  { ns: '',  ew: 'w' },
};

// Od v2.13.203: scroll wewnątrz pane'a nie przewija strony pod spodem. Wheel
// nad scrollowalnym obszarem pane'a przewija tylko ten obszar; na jego granicy
// lub nad obszarem nieprzewijalnym — preventDefault (brak scroll chaining do strony).
function attachPaneScrollContain(el) {
  if (el.dataset.jzScrollContain === '1') return; // idempotent
  el.dataset.jzScrollContain = '1';
  el.addEventListener('wheel', (e) => {
    let node = e.target, scroller = null;
    while (node) {
      if (node.nodeType === 1) {
        const oy = getComputedStyle(node).overflowY;
        if ((oy === 'auto' || oy === 'scroll') && node.scrollHeight > node.clientHeight + 1) {
          scroller = node;
          break;
        }
      }
      if (node === el) break;
      node = node.parentElement;
    }
    if (!scroller) { e.preventDefault(); return; } // brak scrollera → blokuj scroll strony
    const dY = e.deltaY;
    const atTop = scroller.scrollTop <= 0;
    const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 1;
    if ((dY < 0 && atTop) || (dY > 0 && atBottom)) e.preventDefault(); // granica → bez chaining
  }, { passive: false });
}

function attachPaneResize(el, pane) {
  attachPaneScrollContain(el);
  if (el.querySelector('.jz-pane-resize')) return; // idempotent
  Object.keys(PANE_RESIZE_HANDLES).forEach(name => {
    const spec = PANE_RESIZE_HANDLES[name];
    const handle = document.createElement('div');
    handle.className = 'jz-pane-resize jz-pane-resize-' + name;
    el.appendChild(handle);
    handle.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();
      el.dataset.jzDragging = '1';
      const startX = e.clientX, startY = e.clientY;
      const startLeft = parseInt(el.style.left) || 0;
      const startTop  = parseInt(el.style.top)  || 0;
      const start = getPaneSizeForResize(pane.id);
      const fixedRight  = startLeft + start.w;
      const fixedBottom = startTop + start.h;
      const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
      let cur = { left: startLeft, top: startTop, w: start.w, h: start.h };
      const onMove = (ev) => {
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;
        let w = start.w, h = start.h, left = startLeft, top = startTop;
        if (spec.ew === 'e')      { w = clamp(start.w + dx, PANE_W_MIN, PANE_W_MAX); left = startLeft; }
        else if (spec.ew === 'w') { w = clamp(start.w - dx, PANE_W_MIN, PANE_W_MAX); left = fixedRight - w; }
        if (spec.ns === 's')      { h = clamp(start.h + dy, PANE_H_MIN, PANE_H_MAX); top = startTop; }
        else if (spec.ns === 'n') { h = clamp(start.h - dy, PANE_H_MIN, PANE_H_MAX); top = fixedBottom - h; }
        cur = { left, top, w, h };
        el.style.width  = w + 'px';
        el.style.height = h + 'px';
        el.style.left   = left + 'px';
        el.style.top    = top + 'px';
        el.style.right  = '';
        el.style.bottom = '';
      };
      const onUp = () => {
        document.removeEventListener('mousemove', onMove, true);
        document.removeEventListener('mouseup', onUp, true);
        delete el.dataset.jzDragging;
        persistPaneGeometry(pane.id, cur);
      };
      document.addEventListener('mousemove', onMove, true);
      document.addEventListener('mouseup', onUp, true);
    });
  });
}

// Od v2.13.42: zawsze ustawiamy left+top (skonwertowane z anchor) — żeby snap system
// (bar drag może wchodzić w grupę z pane) mógł czytać/pisać p.style.left/top spójnie.
// Anchor żyje w storage (sticky na resize okna), ale w DOM zawsze absolutna pozycja.
function applyCssPanePosition(el, pane) {
  const pos = pane.position || { x: 20, y: 60, anchor: 'top-right' };
  const size = pane.size || { w: 360, h: 240 };
  const w = Math.max(180, Math.min(1200, size.w || 360));
  const h = Math.max(120, Math.min(900, size.h || 240));
  el.style.width = w + 'px';
  el.style.height = h + 'px';
  const x = typeof pos.x === 'number' ? pos.x : 20;
  const y = typeof pos.y === 'number' ? pos.y : 60;
  let left, top;
  switch (pos.anchor) {
    case 'top-left':     left = x;                            top = y;                              break;
    case 'bottom-right': left = window.innerWidth - x - w;    top = window.innerHeight - y - h;     break;
    case 'bottom-left':  left = x;                            top = window.innerHeight - y - h;     break;
    default:             left = window.innerWidth - x - w;    top = y;                              break; // top-right
  }
  // Clamp do viewport — żeby nawet po sync z innej rozdzielczości header został widoczny.
  left = Math.max(-w + 60, Math.min(window.innerWidth - 60, left));
  top  = Math.max(0, Math.min(window.innerHeight - 30, top));
  el.style.left = left + 'px';
  el.style.top  = top + 'px';
  el.style.right = '';
  el.style.bottom = '';
}

// Konwersja absolutnej pozycji (left, top, w, h) → {x, y, anchor} — wybór anchor'a
// po bliższym rogu viewportu, sticky na resize. Używane po drag end CSS pane'a.
function absToAnchorPosition(left, top, w, h) {
  const vw = window.innerWidth;
  const vh = window.innerHeight;
  const distLeft = left;
  const distRight = vw - (left + w);
  const distTop = top;
  const distBottom = vh - (top + h);
  const horizAnchor = distLeft <= distRight ? 'left' : 'right';
  const vertAnchor  = distTop  <= distBottom ? 'top'  : 'bottom';
  const anchor = `${vertAnchor}-${horizAnchor}`;
  const x = horizAnchor === 'left' ? Math.max(0, Math.round(distLeft)) : Math.max(0, Math.round(distRight));
  const y = vertAnchor  === 'top'  ? Math.max(0, Math.round(distTop))  : Math.max(0, Math.round(distBottom));
  return { x, y, anchor };
}

// Cascade enabled check (od v2.13.49, floatingHidden od v2.13.52) — pane render-able iff
// cały łańcuch rodziców enabled (folder.enabled, group.enabled, rule.enabled) ORAZ folder
// nie jest hidden via floatingHidden. Discovery (discoverRelevantCssPanes) tego nie sprawdza —
// UI listings (popup) potrzebują pełnej listy żeby pokazać effective state + toggle.
function paneCascadeEnabled(pane, folders, groups, rules, floatingHidden) {
  const hidden = floatingHidden || {};
  // TEMP (od v2.13.81) — zawsze enabled (nie ma cascade folder/group/rule).
  if (pane.parentType === 'temp') return true;
  if (pane.parentType === 'folder') {
    const f = (folders || []).find(x => x.id === pane.parentId);
    if (!f || hidden[f.id]) return false;
    return f.active !== false && f.enabled;
  }
  if (pane.parentType === 'group') {
    const g = (groups || []).find(x => x.id === pane.parentId);
    if (!g || g.active === false || g.enabled === false) return false;
    const f = (folders || []).find(x => x.id === g.folderId);
    if (!f || hidden[f.id]) return false;
    return f.active !== false && f.enabled;
  }
  if (pane.parentType === 'rule') {
    const r = (rules || []).find(x => x.id === pane.parentId);
    if (!r || r.active === false || r.enabled !== true) return false;
    const g = (groups || []).find(x => x.id === r.groupId);
    if (!g || g.active === false || g.enabled === false) return false;
    const f = (folders || []).find(x => x.id === g.folderId);
    if (!f || hidden[f.id]) return false;
    return f.active !== false && f.enabled;
  }
  return false;
}

function renderCssPanes(folders, groups, rules, cssPanes, globalEnabled, floatingHidden) {
  if (!globalEnabled) {
    // Globalny off — usuń wszystkie panes z DOM (treść w sessionStorage zostaje).
    document.querySelectorAll('[id^="' + CSS_PANE_PREFIX + '"]').forEach(el => el.remove());
    document.querySelectorAll('[id^="' + CSS_PANE_STYLE_PREFIX + '"]').forEach(el => el.remove());
    return;
  }
  // Discovery z floatingHidden filter; potem cascade enabled filter (folder/group/rule.enabled).
  const relevant = discoverRelevantCssPanes(folders, groups, rules, cssPanes, floatingHidden)
    .filter(p => paneCascadeEnabled(p, folders, groups, rules, floatingHidden));
  const relevantIds = new Set(relevant.map(p => p.id));
  // Cleanup panes które przestały być relevant (np. enabled toggle, parent removed).
  document.querySelectorAll('[id^="' + CSS_PANE_PREFIX + '"]').forEach(el => {
    const paneId = el.id.slice(CSS_PANE_PREFIX.length);
    if (!relevantIds.has(paneId)) {
      el.remove();
      const styleEl = document.getElementById(CSS_PANE_STYLE_PREFIX + paneId);
      if (styleEl) styleEl.remove();
    }
  });
  relevant.forEach(p => jzUsageBump('win', 'cp:' + p.id));
  relevant.forEach(renderCssPaneElement);
}

// === JS panes (od v2.13.56, Faza 2 cont.) ====================================
//
// Floating textarea analogiczna do CSS pane, ale content uruchamiany jako kod JS w MAIN
// world strony (przez background `execute-js-pane` msg → chrome.scripting.executeScript).
// Live debounce 500ms (vs 200ms CSS, bo JS execution jest cięższe).
// Errors łapane w try/catch wrapper i wyświetlane jako overlay w stopce okna.
// Persystencja content w sessionStorage per tab — znika po zamknięciu.

// Discovery: które JS panes są relevant dla aktualnego URL. Sygnatura+semantyka
// IDENTYCZNA jak discoverRelevantCssPanes — patrz tamten komentarz.
function discoverRelevantJsPanes(folders, groups, rules, jsPanes, floatingHidden) {
  const matching = getMatchingFoldersForPage(folders);
  const matchingFolderIds = new Set(matching.map(f => f.id));
  const out = [];
  (jsPanes || []).forEach(pane => {
    if (!pane || !pane.id || !pane.parentType || !pane.parentId) return;
    if (pane.enabled === false) return;
    if (pane.parentType === 'temp') { out.push(pane); return; }
    if (pane.parentType === 'folder') {
      if (!matchingFolderIds.has(pane.parentId)) return;
    } else if (pane.parentType === 'group') {
      const g = (groups || []).find(gx => gx.id === pane.parentId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const groupMatches = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!groupMatches) return;
      }
    } else if (pane.parentType === 'rule') {
      const r = (rules || []).find(rx => rx.id === pane.parentId);
      if (!r) return;
      const g = (groups || []).find(gx => gx.id === r.groupId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const groupMatches = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!groupMatches) return;
      }
      if (Array.isArray(r.urlPatterns) && r.urlPatterns.length > 0) {
        const ruleMatches = r.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ruleMatches) return;
      }
    } else {
      return;
    }
    out.push(pane);
  });
  return out;
}

function renderJsPaneElement(pane) {
  const elId = JS_PANE_PREFIX + pane.id;
  let el = document.getElementById(elId);
  el = _jzResolveShadow(el, '.jz-pane-js'); // od v3.2.30
  let ss = null;
  try { ss = window.sessionStorage; } catch (_) {}
  if (!el) {
    el = document.createElement('div');
    el.id = elId;
    el.className = 'jz-pane jz-pane-js';
    const initialContent = ss ? (ss.getItem(JS_PANE_CONTENT_KEY(pane.id)) || '') : '';
    // Od v2.13.58: eval NIE odpala się na input — tylko po kliku "Run" lub Ctrl+Enter.
    // Save button + dirty indicator w headerze. sessionStorage zapisuje co edycję
    // (żeby reload zachował tekst), eval tylko na żądanie.
    const runLabel = (pane.name || 'JS') + ' — Run (Ctrl+Enter)';
    el.innerHTML = `
      <div class="jz-pane-header">
        <span class="jz-pane-dot" style="background:${(pane.color || '#D65D0E').replace(/[<"']/g, '')};"></span>
        <span class="jz-pane-title"></span>
        <button class="jz-pane-run" type="button" title="${runLabel.replace(/"/g, '&quot;')}" aria-label="Run">▶</button>
      </div>
      <textarea class="jz-pane-body" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
      <div class="jz-pane-error" style="display:none;"></div>
    `;
    document.documentElement.appendChild(_jzWrapInShadow(el, elId)); // od v3.2.30
    const ta = el.querySelector('.jz-pane-body');
    const errBox = el.querySelector('.jz-pane-error');
    const runBtn = el.querySelector('.jz-pane-run');
    ta.value = initialContent;
    // Ostatnio wykonana treść — porównujemy z aktualną żeby pokazać dirty state.
    el.dataset.jzLastRun = initialContent;
    const refreshDirty = () => {
      const dirty = ta.value !== (el.dataset.jzLastRun || '');
      el.classList.toggle('jz-dirty', dirty);
    };
    const runCode = () => {
      const val = ta.value;
      el.dataset.jzLastRun = val;
      el.classList.remove('jz-dirty');
      if (!val || !val.trim()) {
        errBox.style.display = 'none';
        return;
      }
      // Wykonanie w MAIN world via background → chrome.scripting.executeScript.
      // Wrapper try/catch przekazuje sync errors; async errors via window.onerror
      // nie są przechwytywane (TODO: dispatch custom event z page → content script).
      safeSendMessage({ type: 'execute-js-pane', code: val, paneId: pane.id }, (response) => {
        if (response && response.cspBlocked) {
          errBox.textContent = (typeof JZ_I18N !== 'undefined')
            ? JZ_I18N.t('content.exec.cspBlocked')
            : 'This page blocks script execution (CSP).';
          errBox.style.display = 'block';
        } else if (response && response.error) {
          errBox.textContent = 'JS error: ' + response.error;
          errBox.style.display = 'block';
        } else {
          errBox.style.display = 'none';
        }
      });
    };
    // Auto-resume po render: jeśli sessionStorage ma treść z poprzedniej edycji,
    // odpal ją (zachowuje stan strony po F5). User dalej musi save'ować ręcznie.
    if (initialContent) runCode();
    ta.addEventListener('input', () => {
      // Zapis do sessionStorage co input (lekki debounce), bez eval. Eval na żądanie.
      const prev = _jsPaneDebounceTimers.get(pane.id);
      if (prev) clearTimeout(prev);
      _jsPaneDebounceTimers.set(pane.id, setTimeout(() => {
        if (ss) try { ss.setItem(JS_PANE_CONTENT_KEY(pane.id), ta.value); } catch (_) {}
      }, 200));
      refreshDirty();
    });
    ta.addEventListener('keydown', (e) => {
      // Ctrl+Enter / Cmd+Enter — run. preventDefault żeby textarea nie wstawił newline.
      if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
        e.preventDefault();
        // Flush sessionStorage natychmiast przed eval (bez czekania na debounce).
        if (ss) try { ss.setItem(JS_PANE_CONTENT_KEY(pane.id), ta.value); } catch (_) {}
        runCode();
      }
    });
    runBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      if (ss) try { ss.setItem(JS_PANE_CONTENT_KEY(pane.id), ta.value); } catch (_) {}
      runCode();
    });
    // v2.13.70: mousedown.stopPropagation żeby drag nie startował na buttonie. Wcześniej
    // dragStarted check >3px zapobiegał ruchowi pane'a, ale cursor zmieniał się na grabbing
    // i UX był mylący — user widział że trzyma element, mimo że chciał kliknąć ▶.
    runBtn.addEventListener('mousedown', (e) => e.stopPropagation());
    attachPaneDrag(el, pane);
    attachPaneResize(el, pane);
  }
  const titleEl = el.querySelector('.jz-pane-title');
  if (titleEl) titleEl.textContent = pane.name || 'JS';
  const dotEl = el.querySelector('.jz-pane-dot');
  if (dotEl) dotEl.style.background = (pane.color || '#D65D0E').replace(/[<"']/g, '');
  applyPaneCustomStyles(el, pane);
  _ensureTempClearBtn(el, pane);
  _ensureTempCloseBtn(el, pane);
  _ensurePersistentCloseBtn(el, pane); // od v3.2.34
  _ensurePaneCollapseBtn(el, pane);
  if (!el.dataset.jzDragging) applyCssPanePosition(el, pane);
}

function renderJsPanes(folders, groups, rules, jsPanes, globalEnabled, floatingHidden) {
  if (!globalEnabled) {
    document.querySelectorAll('[id^="' + JS_PANE_PREFIX + '"]').forEach(el => el.remove());
    return;
  }
  const relevant = discoverRelevantJsPanes(folders, groups, rules, jsPanes, floatingHidden)
    .filter(p => paneCascadeEnabled(p, folders, groups, rules, floatingHidden));
  const relevantIds = new Set(relevant.map(p => p.id));
  document.querySelectorAll('[id^="' + JS_PANE_PREFIX + '"]').forEach(el => {
    const paneId = el.id.slice(JS_PANE_PREFIX.length);
    if (!relevantIds.has(paneId)) el.remove();
  });
  relevant.forEach(p => jzUsageBump('win', 'jp:' + p.id));
  relevant.forEach(renderJsPaneElement);
}

// JS pane enable/disable — analog do setCssPaneEnabled.
let _jsPaneEnabledChain = Promise.resolve();
function setJsPaneEnabled(paneId, enabled) {
  if (typeof paneId !== 'string') return Promise.resolve();
  _jsPaneEnabledChain = _jsPaneEnabledChain.then(async () => {
    const data = await safeStorageGet(['jsPanes']);
    const panes = Array.isArray(data.jsPanes) ? data.jsPanes : [];
    const pane = panes.find(p => p.id === paneId);
    if (!pane) return;
    pane.enabled = !!enabled;
    pane.updatedAt = Date.now();
    await safeStorageSet({ jsPanes: panes });
  });
  return _jsPaneEnabledChain;
}

// === JS Console (od v2.13.66, Faza 2 cont.) =================================
// Discovery analog do discoverRelevantJsPanes — taki sam URL-matching, taki sam enabled-skip.
function discoverRelevantJsConsoles(folders, groups, rules, jsConsoles, floatingHidden) {
  const matching = getMatchingFoldersForPage(folders);
  const matchingFolderIds = new Set(matching.map(f => f.id));
  const out = [];
  (jsConsoles || []).forEach(con => {
    if (!con || !con.id || !con.parentType || !con.parentId) return;
    if (con.enabled === false) return;
    if (con.parentType === 'temp') { out.push(con); return; }
    if (con.parentType === 'folder') {
      if (!matchingFolderIds.has(con.parentId)) return;
    } else if (con.parentType === 'group') {
      const g = (groups || []).find(gx => gx.id === con.parentId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const ok = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ok) return;
      }
    } else if (con.parentType === 'rule') {
      const r = (rules || []).find(rx => rx.id === con.parentId);
      if (!r) return;
      const g = (groups || []).find(gx => gx.id === r.groupId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const ok = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ok) return;
      }
      if (Array.isArray(r.urlPatterns) && r.urlPatterns.length > 0) {
        const ok = r.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ok) return;
      }
    } else { return; }
    out.push(con);
  });
  return out;
}

function renderJsConsoleElement(con) {
  const elId = JS_CONSOLE_PREFIX + con.id;
  let el = document.getElementById(elId);
  el = _jzResolveShadow(el, '.jz-pane-console'); // od v3.2.30
  let ss = null; try { ss = window.sessionStorage; } catch (_) {}

  if (!el) {
    el = document.createElement('div');
    el.id = elId;
    el.className = 'jz-pane jz-pane-console';
    const initInput  = ss ? (ss.getItem(JS_CONSOLE_INPUT_KEY(con.id))  || '') : '';
    const initOutput = ss ? (ss.getItem(JS_CONSOLE_OUTPUT_KEY(con.id)) || '[]') : '[]';
    const runLabel = (con.name || 'Console') + ' — Run (Ctrl+Enter)';
    el.innerHTML = `
      <div class="jz-pane-header">
        <span class="jz-pane-dot" style="background:${(con.color || '#7C3AED').replace(/[<"']/g, '')};"></span>
        <span class="jz-pane-title"></span>
        <button class="jz-console-clear" type="button" title="${JZ_I18N.t('jsConsole.clear.title').replace(/"/g, '&quot;')}" aria-label="Clear">⌫</button>
        <button class="jz-pane-run" type="button" title="${runLabel.replace(/"/g, '&quot;')}" aria-label="Run">▶</button>
      </div>
      <div class="jz-console-output"></div>
      <textarea class="jz-console-input" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"></textarea>
    `;
    document.documentElement.appendChild(_jzWrapInShadow(el, elId)); // od v3.2.30
    const ta = el.querySelector('.jz-console-input');
    const out = el.querySelector('.jz-console-output');
    const runBtn = el.querySelector('.jz-pane-run');
    const clearBtn = el.querySelector('.jz-console-clear');
    ta.value = initInput;
    ta.placeholder = JZ_I18N.t('jsConsole.input.placeholder');

    // Output log = JSON array of { type: 'in'|'ok'|'err', text, t }; persist + re-render.
    let log = [];
    try { log = JSON.parse(initOutput); if (!Array.isArray(log)) log = []; } catch (_) { log = []; }
    const renderLog = () => {
      out.innerHTML = '';
      log.forEach(entry => {
        const line = document.createElement('div');
        line.className = 'jz-console-line jz-console-line-' + (entry.type || 'in');
        const prefix = entry.type === 'in' ? '> ' : (entry.type === 'err' ? '! ' : '  ');
        line.textContent = prefix + (entry.text || '');
        out.appendChild(line);
      });
      out.scrollTop = out.scrollHeight;
    };
    const persistLog = () => {
      // Trim PRZED guardem sessionStorage — inaczej przy zablokowanym ss tablica
      // `log` rosłaby w pamięci bez ograniczenia przez cały czas życia karty.
      if (log.length > 200) log = log.slice(-200);
      if (!ss) return;
      try { ss.setItem(JS_CONSOLE_OUTPUT_KEY(con.id), JSON.stringify(log)); } catch (_) {}
    };
    renderLog();

    // History (od v2.13.68) — ↑/↓ na pierwszej/ostatniej linii inputu nawiguje przez wcześniejsze
    // komendy. Persist per-tab w sessionStorage, max 100 wpisów, dedup ostatniego.
    let history = [];
    try {
      const raw = ss ? ss.getItem(JS_CONSOLE_HISTORY_KEY(con.id)) : null;
      if (raw) history = JSON.parse(raw); if (!Array.isArray(history)) history = [];
    } catch (_) { history = []; }
    let histIdx = -1;        // -1 = poza historią (current draft); 0..len-1 = pozycja w history
    let histDraft = '';      // textarea content przed wejściem w historię (do przywrócenia ↓ na końcu)
    const persistHistory = () => {
      if (!ss) return;
      if (history.length > 100) history = history.slice(-100);
      try { ss.setItem(JS_CONSOLE_HISTORY_KEY(con.id), JSON.stringify(history)); } catch (_) {}
    };

    const runCode = () => {
      const code = ta.value.trim();
      if (!code) return;
      log.push({ type: 'in', text: code });
      renderLog(); persistLog();
      // Append do history (dedup ostatniego wpisu).
      if (history[history.length - 1] !== code) {
        history.push(code);
        persistHistory();
      }
      histIdx = -1;
      histDraft = '';
      safeSendMessage({ type: 'execute-js-console', code, consoleId: con.id }, (resp) => {
        // v2.13.67: najpierw przepisujemy zalogowane wartości (console.log/warn/error/info
        // wywołane podczas eval), potem zwrotkę z funkcji — tak jak DevTools console.
        if (resp && Array.isArray(resp.captured)) {
          resp.captured.forEach(c => {
            const klass = (c.type === 'error') ? 'err'
                        : (c.type === 'warn')  ? 'warn'
                                               : 'log';
            log.push({ type: klass, text: c.text });
          });
        }
        if (resp && resp.cspBlocked) {
          log.push({ type: 'err', text: (typeof JZ_I18N !== 'undefined')
            ? JZ_I18N.t('content.exec.cspBlocked')
            : 'This page blocks script execution (CSP).' });
        } else if (resp && resp.error) {
          log.push({ type: 'err', text: resp.error });
        } else if (resp && typeof resp.result === 'string') {
          log.push({ type: 'ok', text: resp.result });
        } else {
          log.push({ type: 'ok', text: 'undefined' });
        }
        renderLog(); persistLog();
      });
      ta.value = '';
      if (ss) try { ss.setItem(JS_CONSOLE_INPUT_KEY(con.id), ''); } catch (_) {}
    };

    ta.addEventListener('input', () => {
      if (ss) try { ss.setItem(JS_CONSOLE_INPUT_KEY(con.id), ta.value); } catch (_) {}
      // User edytuje ręcznie — wyjdź z trybu history (kolejne ↑ startuje od końca).
      histIdx = -1;
    });
    ta.addEventListener('keydown', (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
        e.preventDefault();
        runCode();
        return;
      }
      // Ctrl+L (od v2.13.69) — terminal-style clear. Browser default to "address bar focus",
      // ale skoro user jest w naszym textarea inside webpage, preventDefault gra. Bezpieczne.
      if ((e.ctrlKey || e.metaKey) && (e.key === 'l' || e.key === 'L')) {
        e.preventDefault();
        log = [];
        renderLog(); persistLog();
        return;
      }
      // History nav: ↑ na pierwszej linii inputu, ↓ na ostatniej. Multi-line code — strzałki
      // pozwalają normalnie nawigować wewnątrz, dopiero na krańcu wchodzą w history.
      if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
        if (history.length === 0) return;
        const pos = ta.selectionStart;
        const val = ta.value;
        const onFirstLine = val.indexOf('\n') === -1 || pos <= val.indexOf('\n');
        const onLastLine  = val.lastIndexOf('\n') === -1 || pos > val.lastIndexOf('\n');
        if (e.key === 'ArrowUp' && onFirstLine) {
          e.preventDefault();
          if (histIdx === -1) {
            histDraft = val;
            histIdx = history.length - 1;
          } else if (histIdx > 0) {
            histIdx -= 1;
          }
          ta.value = history[histIdx] || '';
          ta.selectionStart = ta.selectionEnd = ta.value.length;
        } else if (e.key === 'ArrowDown' && onLastLine && histIdx !== -1) {
          e.preventDefault();
          if (histIdx < history.length - 1) {
            histIdx += 1;
            ta.value = history[histIdx] || '';
          } else {
            // Poza końcem history → wracamy do draftu sprzed wejścia w historię.
            histIdx = -1;
            ta.value = histDraft;
          }
          ta.selectionStart = ta.selectionEnd = ta.value.length;
        }
      }
    });
    runBtn.addEventListener('click', (e) => { e.stopPropagation(); runCode(); });
    clearBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      log = [];
      renderLog(); persistLog();
    });
    // v2.13.71: klik w obszarze outputu fokusuje input (terminal/DevTools UX). Pomijamy
    // gdy user zaznacza tekst — wtedy zostawiamy selection bez kradzieży focusu.
    out.addEventListener('click', () => {
      try {
        const sel = window.getSelection();
        if (sel && sel.toString().length > 0) return;
      } catch (_) {}
      ta.focus();
    });
    // v2.13.70: mousedown.stopPropagation żeby drag nie startował na buttonach. Buttony
    // ze stretch height = full bar height, więc cała pionowa strefa nad ▶/⌫ to button area.
    runBtn.addEventListener('mousedown',  (e) => e.stopPropagation());
    clearBtn.addEventListener('mousedown', (e) => e.stopPropagation());
    attachPaneDrag(el, con);
    attachPaneResize(el, con);
  }
  const titleEl = el.querySelector('.jz-pane-title');
  if (titleEl) titleEl.textContent = con.name || 'Console';
  const dotEl = el.querySelector('.jz-pane-dot');
  if (dotEl) dotEl.style.background = (con.color || '#7C3AED').replace(/[<"']/g, '');
  applyPaneCustomStyles(el, con);
  _ensureTempCloseBtn(el, con);
  _ensurePersistentCloseBtn(el, con); // od v3.2.34
  _ensurePaneCollapseBtn(el, con);
  if (!el.dataset.jzDragging) applyCssPanePosition(el, con);
}

function renderJsConsoles(folders, groups, rules, jsConsoles, globalEnabled, floatingHidden) {
  if (!globalEnabled) {
    document.querySelectorAll('[id^="' + JS_CONSOLE_PREFIX + '"]').forEach(el => el.remove());
    return;
  }
  const relevant = discoverRelevantJsConsoles(folders, groups, rules, jsConsoles, floatingHidden)
    .filter(c => paneCascadeEnabled(c, folders, groups, rules, floatingHidden));
  const relevantIds = new Set(relevant.map(c => c.id));
  document.querySelectorAll('[id^="' + JS_CONSOLE_PREFIX + '"]').forEach(el => {
    const conId = el.id.slice(JS_CONSOLE_PREFIX.length);
    if (!relevantIds.has(conId)) el.remove();
  });
  relevant.forEach(c => jzUsageBump('win', 'jc:' + c.id));
  relevant.forEach(renderJsConsoleElement);
}

let _jsConsoleEnabledChain = Promise.resolve();
function setJsConsoleEnabled(consoleId, enabled) {
  if (typeof consoleId !== 'string') return Promise.resolve();
  _jsConsoleEnabledChain = _jsConsoleEnabledChain.then(async () => {
    const data = await safeStorageGet(['jsConsoles']);
    const cons = Array.isArray(data.jsConsoles) ? data.jsConsoles : [];
    const con = cons.find(c => c.id === consoleId);
    if (!con) return;
    con.enabled = !!enabled;
    con.updatedAt = Date.now();
    await safeStorageSet({ jsConsoles: cons });
  });
  return _jsConsoleEnabledChain;
}

// === Output Console (od v2.13.72, Faza 2 finish — 4. typ okna na froncie) ===
// Read-only log viewer. Wrapper w MAIN world przechwytuje console.log/warn/error/info i
// posyła do content scriptu via window.postMessage. Output console renderuje stream jako
// kolorowe linie. Persistent log per-tab w sessionStorage (jak JS Console).
const OUTPUT_CONSOLE_PREFIX = 'jz-output-';
const OUTPUT_CONSOLE_LOG_KEY = (id) => `jz_output_${id}_log`;          // log entries (JSON array)
const OUTPUT_CONSOLE_AUTOSCROLL_KEY = (id) => `jz_output_${id}_autoscroll`; // 'on'/'off'
let outputConsolesCache = [];
// Lista wszystkich rendered output consoles na stronie — dla broadcast z page hook listener.
// Każdy wpis: { id: ocId, append: (entry) => void, clear: () => void, ... }
const _outputConsoleSinks = new Map();

// Iteruje żywe sinki Output Console, usuwając te, których element DOM już nie
// istnieje (usunięty inną drogą niż renderOutputConsoles — np. strona wyczyściła
// documentElement). Bez tego martwy sink rósłby w nieskończoność (jego tablica
// `log`) przy każdym zdarzeniu sieciowym/console.
function _forEachLiveOutputSink(fn) {
  _outputConsoleSinks.forEach((sink, id) => {
    if (!document.getElementById(OUTPUT_CONSOLE_PREFIX + id)) {
      _outputConsoleSinks.delete(id);
      return;
    }
    try { fn(sink); } catch (_) {}
  });
}

// === TEMP panes (od v2.13.81) — globalne, per-tab, sessionStorage ===
// User wywołuje Ctrl+Shift+Alt+H/J/K/L (lub chrome contextMenu) żeby utworzyć tymczasowy
// pane na dowolnej stronie — nawet gdy nie ma folder match dla tej strony. TEMP panes
// nie żyją w chrome.storage (nie syncują), tylko w sessionStorage per-tab. Każdy ma
// "TEMP" w nazwie, distinct kolor i krzyżyk close.
//
// Implementacja: TEMP panes dostają parentType='temp' + parentId='temp'. ID: cp_temp_xxx,
// jp_temp_xxx, jc_temp_xxx, oc_temp_xxx — substring '_temp_' jest markerem dla routing
// (saveGroupPositions, setPaneStyleField). discoverRelevant + paneCascadeEnabled mają
// branche dla parentType='temp' (always show, no cascade). Render path bez zmian — tylko
// dodatkowy close button gdy pane.isTemp.
const TEMP_PANES_KEY = 'jz_temp_panes';
const TEMP_COLORS = {
  css:       '#F97316', // orange (vs default green #2D9D53)
  js:        '#EF4444', // red (vs default amber)
  jsconsole: '#EC4899', // pink (vs default purple #7C3AED)
  output:    '#06B6D4', // cyan (vs default emerald #10B981)
};
const TEMP_TYPE_PREFIX = {
  css:       'cp',
  js:        'jp',
  jsconsole: 'jc',
  output:    'oc',
};
let _tempPanes = []; // [{ id, type, parentType:'temp', parentId:'temp', isTemp:true, name, color, position, size, ... }]

// Od v2.13.201: pamięć ostatniego niestandardowego rozmiaru okna TEMP — per typ (css/js/
// jsconsole/output), per urządzenie. Trzymane w chrome.storage.local (przeżywa restart
// przeglądarki) pod kluczem SPOZA allowlisty sync → nie synchronizuje się między urządzeniami.
// Okno TEMP przy otwarciu nadal dostaje rozmiar domyślny (createTempPane) — zapamiętany
// rozmiar przywraca dopiero podwójny klik w belkę nagłówka.
const TEMP_PANE_SIZES_KEY = 'jzTempPaneSizes';
const TEMP_PANE_DEFAULT_SIZE = { w: 380, h: 240 }; // rozmiar startowy okna TEMP
let _tempPaneSizes = {}; // { css:{w,h}, js:{w,h}, jsconsole:{w,h}, output:{w,h} }

function isTempPaneId(id) {
  // Dokładny wzorzec id okna TEMP (createTempPane): <prefix>_temp_<...>, gdzie
  // prefix ∈ cp/jp/jc/oc. Wcześniej luźny indexOf('_temp_') zaklasyfikowałby
  // jako TEMP każde id zawierające ten fragment gdziekolwiek.
  return typeof id === 'string' && /^(?:cp|jp|jc|oc)_temp_/.test(id);
}

function loadTempPanes() {
  try {
    const raw = sessionStorage.getItem(TEMP_PANES_KEY);
    if (!raw) return;
    const arr = JSON.parse(raw);
    if (Array.isArray(arr)) _tempPanes = arr;
  } catch (_) { _tempPanes = []; }
}

function saveTempPanes() {
  try {
    sessionStorage.setItem(TEMP_PANES_KEY, JSON.stringify(_tempPanes));
  } catch (_) {}
}

function loadTempPaneSizes() {
  try {
    safeStorageGet([TEMP_PANE_SIZES_KEY]).then(data => {
      const v = data && data[TEMP_PANE_SIZES_KEY];
      if (v && typeof v === 'object') _tempPaneSizes = v;
    }).catch(() => {});
  } catch (_) {}
}

function saveTempPaneSizes() {
  try { safeStorageSet({ [TEMP_PANE_SIZES_KEY]: _tempPaneSizes }); } catch (_) {}
}

// od v3.2.21 — Z-order: zmieniaj kolejność okien JustZix, bazując na tym z czym
// el AKTUALNIE się nakłada. Nie trzeba klikać naście razy, bo jednym ruchem
// przeskakujemy ponad/pod cały zbiór kolidujących sąsiadów. direction = 'top' lub
// 'bottom'. Bez persistencji — po refresh strony z-index wraca do default'u z CSS.
function _jzMoveWindowZ(el) {
  const dir = arguments[1] || 'top';
  if (!el) return;
  const myR = el.getBoundingClientRect();
  const overlapping = [];
  try {
    document.querySelectorAll(
      '.jz-pane, #jz-ai-helper, .jz-actions-bar, #justzix-floating-panel, #justzix-floating-btn'
    ).forEach((other) => {
      if (other === el) return;
      // od v3.2.76 — AI Helper żyje w shadow root; matchowany host ma display:contents
      // (0×0 rect), więc resolve do real pane'a, by Z-stacking brał go pod uwagę.
      if (other.dataset && other.dataset.jzShadowHost === '1') {
        const real = _jzResolveShadow(other, '.jz-pane-ai');
        if (!real || real === el) return;
        other = real;
      }
      const r = other.getBoundingClientRect();
      if (r.width < 1 || r.height < 1) return;
      const overlap = !(myR.right <= r.left || myR.left >= r.right ||
                        myR.bottom <= r.top || myR.top >= r.bottom);
      if (!overlap) return;
      const cs = parseInt(getComputedStyle(other).zIndex, 10);
      overlapping.push(isNaN(cs) ? 0 : cs);
    });
  } catch (_) {}
  if (!overlapping.length) return; // nie nakłada się z niczym → no-op
  let nextZ;
  if (dir === 'top') {
    nextZ = Math.min(2147483647, Math.max.apply(null, overlapping) + 1);
  } else {
    nextZ = Math.max(1, Math.min.apply(null, overlapping) - 1);
  }
  // CSS .jz-pane ma `z-index: 2147483640 !important` — inline bez !important
  // nie wygra. setProperty z 'important' bije stylesheet !important
  // (specyficzność: inline+!important > stylesheet+!important).
  el.style.setProperty('z-index', String(nextZ), 'important');
}

// od v3.2.22 — czytelna nazwa okna do menu „Rozłącz od X" w AI Helper menu.
// Lookup po DOM (tytuł) z fallbackiem do cache i prefix-based defaults.
function _jzWindowDisplayName(id) {
  try {
    if (id === '__float') {
      return (typeof JZ_I18N !== 'undefined' && JZ_I18N.t)
        ? JZ_I18N.t('content.float.label') : '⊕ Główny przycisk';
    }
    if (id === '__ai-helper') return 'AI Helper';
    const el = getPanelElementById(id);
    if (el) {
      const t = (el.querySelector('.jz-pane-title')
              || el.querySelector('.jz-bar-name')
              || el.querySelector('.jz-ai-title'));
      if (t && t.textContent) return t.textContent.trim().slice(0, 40);
    }
    if (typeof id === 'string') {
      if (id.startsWith('cp_')) {
        const p = (cssPanesCache || []).find(x => x.id === id);
        if (p && p.name) return p.name;
        return JZ_I18N.t('cssPanes.btn.label');
      }
      if (id.startsWith('jp_')) {
        const p = (jsPanesCache || []).find(x => x.id === id);
        if (p && p.name) return p.name;
        return JZ_I18N.t('jsPanes.btn.label');
      }
      if (id.startsWith('jc_')) {
        const c = (jsConsolesCache || []).find(x => x.id === id);
        if (c && c.name) return c.name;
        return JZ_I18N.t('jsConsoles.btn.label');
      }
      if (id.startsWith('oc_')) {
        const o = (outputConsolesCache || []).find(x => x.id === id);
        if (o && o.name) return o.name;
        return JZ_I18N.t('outputConsoles.btn.label');
      }
    }
  } catch (_) {}
  return String(id || '').slice(0, 30);
}

// od v3.2.16 — znajdź pozycję dla nowego okna front, która NIE nakłada się na
// istniejące widoczne elementy JustZix (panes, action bary, FAB, panel FAB,
// AI Helper). Zwraca {x, y} viewport-relative. Algorytm: 6 predefiniowanych
// kandydatów (rogi + centrum), potem kaskada offsetowa jako fallback. Działa
// w viewportcie bieżącej karty; ignoruje scroll (window position absolute
// liczy od top-left viewportu — zgodne z naszym anchor='top-left' + applyCssPanePosition).
function _findFreeWindowSlot(prefW, prefH) {
  const vw = window.innerWidth, vh = window.innerHeight;
  const MARGIN = 16;
  const TOP_PAD = 50; // odsuw od krawędzi góry — żeby nie kolidować z paskiem przeglądarki
  // Lista zajętych prostokątów — wszystko widoczne z naszego UI.
  const occupied = [];
  try {
    document.querySelectorAll(
      '.jz-pane, .jz-actions-bar, #justzix-floating-panel, #justzix-floating-btn'
    ).forEach((el) => {
      const r = el.getBoundingClientRect();
      if (r.width > 1 && r.height > 1) {
        occupied.push({ left: r.left, top: r.top, right: r.right, bottom: r.bottom });
      }
    });
  } catch (_) {}
  const overlaps = (a, b) => !(a.right <= b.left || a.left >= b.right ||
                               a.bottom <= b.top || a.top >= b.bottom);
  const tryPos = (x, y) => {
    // Klamp do viewportu z marginesem.
    const cx = Math.max(MARGIN, Math.min(x, vw - prefW - MARGIN));
    const cy = Math.max(MARGIN, Math.min(y, vh - prefH - MARGIN));
    const rect = { left: cx, top: cy, right: cx + prefW, bottom: cy + prefH };
    return occupied.some(o => overlaps(rect, o)) ? null : { x: cx, y: cy };
  };
  // Sześć kandydatów w sensownej kolejności wizualnej.
  const candidates = [
    [MARGIN, TOP_PAD],                                        // lewy-góra
    [vw - prefW - MARGIN, TOP_PAD],                           // prawy-góra
    [Math.round((vw - prefW) / 2), TOP_PAD],                  // środek-góra
    [MARGIN, vh - prefH - MARGIN],                            // lewy-dół
    [vw - prefW - MARGIN, vh - prefH - MARGIN],               // prawy-dół
    [Math.round((vw - prefW) / 2), Math.round((vh - prefH) / 2)], // środek
  ];
  for (const c of candidates) {
    const p = tryPos(c[0], c[1]);
    if (p) return p;
  }
  // Cascade fallback — offset 28px aż znajdzie wolne miejsce (max 24 prób).
  for (let i = 0; i < 24; i++) {
    const off = MARGIN + i * 28;
    const p = tryPos(off, TOP_PAD + off);
    if (p) return p;
  }
  // Ostatecznie — centrum (zawsze coś dajemy).
  return {
    x: Math.max(MARGIN, Math.round((vw - prefW) / 2)),
    y: Math.max(MARGIN, Math.round((vh - prefH) / 2)),
  };
}

function createTempPane(type) {
  const prefix = TEMP_TYPE_PREFIX[type];
  if (!prefix) return null;
  // Idempotentne (od v3.2.2): jeśli okno TEMP tego typu już istnieje w sesji,
  // tylko je odsłaniamy (closed=false) — zachowuje zawartość, geometrię, viewMode.
  // Bez tego ponowne otwarcie tworzyło nowe id i traciło dane poprzedniego okna.
  const existingPane = _tempPanes.find(p => p.type === type);
  if (existingPane) {
    if (existingPane.closed) {
      existingPane.closed = false;
      existingPane.updatedAt = Date.now();
      saveTempPanes();
      applyAll().catch(() => {});
    }
    return existingPane;
  }
  const id = prefix + '_temp_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6);
  // Od v3.2.16: anti-overlap — nowe okno TEMP ląduje w wolnym miejscu względem
  // wszystkich istniejących widocznych okien JustZix (poprzednie zachowanie:
  // kaskada +20px per istniejący TEMP, ignorująca pozostałe okna).
  const slot = _findFreeWindowSlot(TEMP_PANE_DEFAULT_SIZE.w, TEMP_PANE_DEFAULT_SIZE.h);
  const pane = {
    id,
    type,
    parentType: 'temp',
    parentId:   'temp',
    isTemp:     true,
    closed:     false,
    enabled:    true,
    name:       'TEMP ' + type.toUpperCase(),
    color:      TEMP_COLORS[type],
    position:   { x: slot.x, y: slot.y, anchor: 'top-left' },
    size:       { w: TEMP_PANE_DEFAULT_SIZE.w, h: TEMP_PANE_DEFAULT_SIZE.h },
    createdAt:  Date.now(),
    updatedAt:  Date.now(),
  };
  // Dla output console — default viewMode='both' (per oc spec).
  if (type === 'output') pane.viewMode = 'both';
  _tempPanes.push(pane);
  saveTempPanes();
  applyAll().catch(() => {});
  return pane;
}

// Toggle (od v2.13.84) — max 1 instancja per type. Drugie wywołanie skrótu/menu
// zamyka otwarte okno; kolejne otwiera je ponownie z zachowaną zawartością.
function toggleTempPane(type) {
  const existing = _tempPanes.find(p => p.type === type);
  if (existing && !existing.closed) {
    closeTempPane(existing.id);
    return null;
  }
  return createTempPane(type);
}

// Zamknięcie okna TEMP (od v3.2.2) — okno znika z ekranu, ale jego dane (kod,
// log, geometria) ZOSTAJĄ w sesji; ponowne otwarcie okna tego samego typu je
// przywraca. Wcześniej close kasował wpisy sessionStorage powiązane z id okna
// → każde zamknięcie bezpowrotnie usuwało pracę użytkownika.
function closeTempPane(id) {
  const t = _tempPanes.find(p => p.id === id);
  if (!t || t.closed) return false;
  t.closed = true;
  t.updatedAt = Date.now();
  saveTempPanes();
  // applyAll → injectTempPanesIntoData pomija closed → renderXxx usuwa DOM okna.
  applyAll().catch(() => {});
  return true;
}

function updateTempPaneFields(id, patch) {
  const t = _tempPanes.find(p => p.id === id);
  if (!t) return false;
  if (patch && typeof patch === 'object') {
    Object.keys(patch).forEach(k => {
      if (patch[k] === null || patch[k] === undefined) {
        delete t[k];
      } else {
        t[k] = patch[k];
      }
    });
    t.updatedAt = Date.now();
    saveTempPanes();
  }
  return true;
}

// Wstrzyknięcie TEMP do data collections przed renderem. Wołane z applyAll po getData().
// Od v2.13.85 też update caches — bo right-click context menu, filter popover i
// connection lookup czytają z cssPanesCache/jsPanesCache/etc. Bez tego TEMP nie były
// znajdowane tam (caches setowane w getData z chrome.storage tylko).
function injectTempPanesIntoData(data) {
  // Tylko otwarte okna TEMP trafiają do renderu — closed pozostają w sesji
  // (z zawartością), ale nie są rysowane (od v3.2.2).
  const openTemp = _tempPanes.filter(t => !t.closed);
  data.cssPanes       = [...(data.cssPanes || []),       ...openTemp.filter(t => t.type === 'css')];
  data.jsPanes        = [...(data.jsPanes || []),        ...openTemp.filter(t => t.type === 'js')];
  data.jsConsoles     = [...(data.jsConsoles || []),     ...openTemp.filter(t => t.type === 'jsconsole')];
  data.outputConsoles = [...(data.outputConsoles || []), ...openTemp.filter(t => t.type === 'output')];
  cssPanesCache       = data.cssPanes;
  jsPanesCache        = data.jsPanes;
  jsConsolesCache     = data.jsConsoles;
  outputConsolesCache = data.outputConsoles;
}

// Idempotentny helper (od v3.2.9) — przycisk „zwiń do belki" w nagłówku każdego okna
// (TEMP, persistent CSS/JS/JS-Console/Output, AI Helper). Toggle pane.collapsed +
// klasa .jz-pane-collapsed na elemencie; CSS chowa wszystkie dzieci poza headerem.
function _ensurePaneCollapseBtn(el, pane) {
  if (!pane || !el) return;
  const header = el.querySelector('.jz-pane-header');
  if (!header) return;
  const existing = header.querySelector('.jz-pane-collapse-btn');
  if (existing) {
    // Re-render po zmianie pane.collapsed (z innego źródła) — sync ikony + klasy.
    existing.innerHTML = _paneCollapseSvg(!!pane.collapsed);
    existing.title = _paneCollapseTitle(!!pane.collapsed);
    el.classList.toggle('jz-pane-collapsed', !!pane.collapsed);
    return;
  }
  const btn = document.createElement('button');
  btn.className = 'jz-pane-collapse-btn';
  btn.type = 'button';
  btn.title = _paneCollapseTitle(!!pane.collapsed);
  btn.setAttribute('aria-label', 'Collapse');
  btn.innerHTML = _paneCollapseSvg(!!pane.collapsed);
  btn.addEventListener('click', async (e) => {
    e.preventDefault();
    e.stopPropagation();
    const next = !pane.collapsed;
    pane.collapsed = next;
    el.classList.toggle('jz-pane-collapsed', next);
    btn.innerHTML = _paneCollapseSvg(next);
    btn.title = _paneCollapseTitle(next);
    // Persist: TEMP → sessionStorage (saveTempPanes); persistent pane → chrome.storage
    // via setPaneStyleField. setPaneStyleField sam rozróżnia po id.
    try { await setPaneStyleField(pane.id, 'collapsed', next ? true : null); } catch (_) {}
  });
  btn.addEventListener('mousedown', (e) => e.stopPropagation());
  // Pozycja w nagłówku: tuż przed krzyżykiem close (jeśli jest — TEMP `.jz-pane-temp-close`
  // lub persistent `.jz-pane-close` od v3.2.34), inaczej koniec. Od v3.2.35 close jest
  // skrajnie po prawej, więc collapse zawsze trafia PRZED close.
  const anchorClose = header.querySelector('.jz-pane-close') || header.querySelector('.jz-pane-temp-close');
  if (anchorClose) header.insertBefore(btn, anchorClose);
  else header.appendChild(btn);
  el.classList.toggle('jz-pane-collapsed', !!pane.collapsed);
}

// Wariant dla AI Helpera — state w _aiHelperWin, persist przez saveAiHelper.
function _ensureAiHelperCollapseBtn(el) {
  if (!el || !_aiHelperWin) return;
  const header = el.querySelector('.jz-pane-header');
  if (!header) return;
  const existing = header.querySelector('.jz-pane-collapse-btn');
  const collapsed = !!_aiHelperWin.collapsed;
  if (existing) {
    existing.innerHTML = _paneCollapseSvg(collapsed);
    existing.title = _paneCollapseTitle(collapsed);
    el.classList.toggle('jz-pane-collapsed', collapsed);
    return;
  }
  const btn = document.createElement('button');
  btn.className = 'jz-pane-collapse-btn';
  btn.type = 'button';
  btn.title = _paneCollapseTitle(collapsed);
  btn.setAttribute('aria-label', 'Collapse');
  btn.innerHTML = _paneCollapseSvg(collapsed);
  btn.addEventListener('click', (e) => {
    e.preventDefault(); e.stopPropagation();
    if (!_aiHelperWin) return;
    const next = !_aiHelperWin.collapsed;
    _aiHelperWin.collapsed = next;
    el.classList.toggle('jz-pane-collapsed', next);
    btn.innerHTML = _paneCollapseSvg(next);
    btn.title = _paneCollapseTitle(next);
    saveAiHelper();
  });
  btn.addEventListener('mousedown', (e) => e.stopPropagation());
  // Przed .jz-ai-close (krzyżyk zamykający, też w headerze).
  const aiClose = header.querySelector('.jz-ai-close');
  if (aiClose) header.insertBefore(btn, aiClose);
  else header.appendChild(btn);
  el.classList.toggle('jz-pane-collapsed', collapsed);
}

function _paneCollapseSvg(collapsed) {
  // Chevron-up gdy zwinięte (klik rozwija ↑); chevron-down gdy rozwinięte (klik zwija ↓).
  const pts = collapsed ? '6 15 12 9 18 15' : '6 9 12 15 18 9';
  return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
    + 'stroke-linecap="square" stroke-linejoin="miter" width="13" height="13" aria-hidden="true">'
    + '<polyline points="' + pts + '"/></svg>';
}
function _paneCollapseTitle(collapsed) {
  try { return JZ_I18N.t(collapsed ? 'pane.expand.title' : 'pane.collapse.title'); }
  catch (_) { return collapsed ? 'Expand' : 'Collapse'; }
}

// od v3.2.34 — krzyżyk close dla persistent pane'ów (cp_/jp_/jc_/oc_). Analog
// do `_ensureTempCloseBtn`, ale onClick robi `setXxxEnabled(id, false)` per typu
// — okno znika ze strony, persistuje (enabled=false), w popup checkbox można
// ponownie włączyć. No-op dla TEMP (TEMP dostaje swój własny close z innego helpera).
// od v3.2.35 — close ZAWSZE last w headerze (skrajnie po prawej). Jeśli inny
// element został dodany po nim (np. collapse z poprzedniej wersji), re-append
// wymusza re-position.
function _ensurePersistentCloseBtn(el, pane) {
  if (!pane || pane.isTemp || !el) return;
  const id = pane.id;
  if (typeof id !== 'string') return;
  const header = el.querySelector('.jz-pane-header');
  if (!header) return;
  const existing = header.querySelector('.jz-pane-close');
  if (existing) {
    if (existing !== header.lastElementChild) header.appendChild(existing);
    return;
  }
  const btn = document.createElement('button');
  btn.className = 'jz-pane-close';
  btn.type = 'button';
  btn.title = (JZ_I18N.t('pane.close.title') || 'Close');
  btn.setAttribute('aria-label', 'Close');
  btn.textContent = '×';
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    if (id.startsWith('cp_')) setCssPaneEnabled(id, false);
    else if (id.startsWith('jp_')) setJsPaneEnabled(id, false);
    else if (id.startsWith('jc_')) setJsConsoleEnabled(id, false);
    else if (id.startsWith('oc_')) setOutputConsoleEnabled(id, false);
  });
  btn.addEventListener('mousedown', (e) => e.stopPropagation());
  header.appendChild(btn);
}

// Idempotentny helper — dodaje krzyżyk close do header'a TEMP pane'a. Wywołany na końcu
// każdej renderXxxElement (po applyPaneCustomStyles). No-op gdy nie isTemp lub button już jest.
function _ensureTempCloseBtn(el, pane) {
  if (!pane || !pane.isTemp || !el) return;
  // .jz-pane-temp class na el (od v2.13.85) — pozwala CSS rules różnicować TEMP visually
  // (border-color całego pane'a w kolorze TEMP zamiast generic dark).
  if (!el.classList.contains('jz-pane-temp')) el.classList.add('jz-pane-temp');
  const header = el.querySelector('.jz-pane-header');
  if (!header || header.querySelector('.jz-pane-temp-close')) return;
  const btn = document.createElement('button');
  btn.className = 'jz-pane-temp-close';
  btn.type = 'button';
  btn.title = JZ_I18N.t('tempPane.close.title') || 'Close';
  btn.setAttribute('aria-label', 'Close');
  btn.textContent = '×';
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    closeTempPane(pane.id);
  });
  // mousedown.stopPropagation żeby drag z header nie startował na buttonie.
  btn.addEventListener('mousedown', (e) => e.stopPropagation());
  header.appendChild(btn);
}

// Idempotentny helper (od v3.2.3) — przycisk „wyczyść" w headerze okna TEMP
// CSS/JS. Czyści edytor; gdy przeglądarka wspiera execCommand, czyszczenie
// trafia do historii undo (Ctrl+Z je cofa). Dispatch 'input' uruchamia
// persystencję sessionStorage i (dla CSS) aktualizację żywego <style>.
function _ensureTempClearBtn(el, pane) {
  if (!pane || !pane.isTemp || !el) return;
  if (pane.type !== 'css' && pane.type !== 'js') return;
  const header = el.querySelector('.jz-pane-header');
  if (!header || header.querySelector('.jz-pane-temp-clear')) return;
  const btn = document.createElement('button');
  btn.className = 'jz-pane-temp-clear';
  btn.type = 'button';
  btn.title = JZ_I18N.t('tempPane.clear.title') || 'Clear';
  btn.setAttribute('aria-label', 'Clear');
  btn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
    + 'stroke-linecap="square" stroke-linejoin="miter" width="14" height="14" aria-hidden="true">'
    + '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/>'
    + '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V3h6v3"/></svg>';
  btn.addEventListener('click', (e) => {
    e.preventDefault();
    e.stopPropagation();
    const ta = el.querySelector('.jz-pane-body');
    if (!ta || !ta.value) return;
    ta.focus();
    let cleared = false;
    try {
      ta.setSelectionRange(0, ta.value.length);
      cleared = document.execCommand('delete');
    } catch (_) {}
    if (!cleared || ta.value !== '') ta.value = '';
    ta.dispatchEvent(new Event('input', { bubbles: true }));
  });
  btn.addEventListener('mousedown', (e) => e.stopPropagation());
  header.appendChild(btn);
}

// Init — load TEMP panes z sessionStorage przy starcie content scriptu.
loadTempPanes();
loadTempPaneSizes();
loadAiHelperSize();
loadAiHelperStyles();

// === Ctrl+Alt+{I,O,P} (od v3.2.4) — otwiera panel edycji w osobnym oknie =====
// Znajduje pierwszy w drzewie (folder → grupa → zestaw, w kolejności tablic)
// zestaw, który jest włączony+aktywny na bieżącej stronie (cascade enabled +
// dopasowanie wzorców URL na każdym poziomie). Cel wysyłany do background,
// który otwiera/aktywuje okno panelu edycji i zapisuje pending-nav w storage —
// options.js przy starcie / na storage.onChanged konsumuje cel i przechodzi
// do wybranego zestawu na wskazanej zakładce (CSS / JS / Akcje).
async function _openEditPanelForCurrentPage(tab) {
  let target = null;
  try {
    const data = await safeStorageGet(['folders', 'groups', 'rules', 'globalEnabled']);
    const folders = data.folders || [];
    const groups = data.groups || [];
    const rules = data.rules || [];
    const ge = data.globalEnabled !== false;
    const active = getActiveRules(folders, groups, rules, location.href, ge);
    const activeIds = new Set(active.map(r => r.id));
    outer: for (const f of folders) {
      const fGroups = groups.filter(g => g.folderId === f.id);
      for (const g of fGroups) {
        const gRules = rules.filter(r => r.groupId === g.id);
        for (const r of gRules) {
          if (activeIds.has(r.id)) {
            target = { folderId: f.id, groupId: g.id, ruleId: r.id };
            break outer;
          }
        }
      }
    }
  } catch (_) {}
  safeSendMessage({ type: 'open-edit-panel', target: target, tab: tab });
}

// === Global Ctrl+Alt+{G,H,J,K} (od v2.13.83, prev. H/J/K/L w v2.13.82) — tworzy TEMP pane.
// Cluster przesunięty o 1 w lewo bo Ctrl+Alt+L na Linux GNOME = lock screen (system-level
// shortcut przejmie event przed naszym capture listenerem). G/H/J/K wszystkie wolne w
// Chrome/Edge/Firefox + Windows/Mac/Linux. Adjacent home row right hand. Capture phase.
// !shiftKey defensive — przypadkowy Ctrl+Shift+Alt+G NIE odpala TEMP.
let _tempGlobalKeydownInstalled = false;
function _ensureTempGlobalKeydown() {
  if (_tempGlobalKeydownInstalled) return;
  _tempGlobalKeydownInstalled = true;
  try {
    document.addEventListener('keydown', (e) => {
      if (!(e.ctrlKey || e.metaKey) || !e.altKey || e.shiftKey) return;
      // Ctrl+Alt+\ — AI Helper (od v2.13.127). e.code bo na AltGr '\' bywa innym znakiem.
      if (e.code === 'Backslash') {
        e.preventDefault();
        e.stopPropagation();
        toggleAiHelper();
        return;
      }
      const key = (e.key || '').toLowerCase();
      // Ctrl+Alt+{I,O,P} (od v3.2.4) — otwiera panel edycji w osobnym oknie
      // dla pierwszego włączonego+aktywnego zestawu na tej stronie, z wybraną
      // zakładką. Brak pasującego zestawu → panel otwiera się bez nawigacji.
      //
      // Dopasowanie po e.code (od v3.2.5) — na układzie PL (Programmers)
      // AltGr+O = ó, więc e.key daje 'ó' zamiast 'o' i listener po e.key nie
      // odpalał. Fizyczny klawisz O zawsze ma e.code === 'KeyO' niezależnie
      // od układu i AltGr. Analogicznie I i P na innych układach (np. czeskim).
      let editTab = null;
      if (e.code === 'KeyI') editTab = 'css';
      else if (e.code === 'KeyO') editTab = 'js';
      else if (e.code === 'KeyP') editTab = 'actions';
      if (editTab) {
        e.preventDefault();
        e.stopPropagation();
        _openEditPanelForCurrentPage(editTab);
        return;
      }
      let type = null;
      if (key === 'g') type = 'css';
      else if (key === 'h') type = 'js';
      else if (key === 'j') type = 'jsconsole';
      else if (key === 'k') type = 'output';
      if (!type) return;
      e.preventDefault();
      e.stopPropagation();
      toggleTempPane(type);
    }, true);
  } catch (_) {}
}
// Rejestrujemy od razu (TEMP shortcuts działają zawsze, nawet bez aktywnego folderu).
_ensureTempGlobalKeydown();

// ============================================================
// AI Helper — okno czatu z modelem AI (od v2.13.127)
// ============================================================
// Okno tymczasowe wg zasad TEMP: per-tab, sessionStorage, jedna instancja,
// krzyżyk close. Stan trzymany POZA _tempPanes (to nie edytor CSS/JS) — własne
// klucze sessionStorage, własny render/toggle. Klucze API czyta background;
// content script nigdy ich nie dotyka.
const AI_HELPER_WIN_KEY  = 'jz_ai_helper';       // { id, position, size }
const AI_HELPER_CHAT_KEY = 'jz_ai_helper_chat';  // [{ role, content }]
const AI_HELPER_META_KEY = 'jz_ai_helper_meta';  // { provider, model }
// v3.2.45 — Telemetry totals for the current chat session. Persistent through
// F5 (sessionStorage), reset on clear chat. Tracks:
//   • tokensCurrentInput/Output — tokens used since the last user message
//   • tokensSessionInput/Output — cumulative tokens since session started
//   • agentTotalMs — accumulated agent-active time (sum of LLM call durations
//     plus tool-exec durations) since session started; NOT wall-clock time
//   • lastUserAt — timestamp of the last user message (for "since user" timer)
//   • lastToolAt — timestamp of the last tool exchange (for "since tool" timer)
//   • lastCallInputTokens / lastCallOutputTokens / lastCallDurationMs — per
//     last LLM call (rendered in per-message footer)
//   • lastToolDurationMs — per last tool execution (rendered in per-toolchip)
const AI_HELPER_TOTALS_KEY = 'jz_ai_helper_totals';
const AI_HELPER_EL_ID    = 'jz-ai-helper';
// od v3.2.76 — AI Helper window żyje w shadow root (parytet z pozostałymi pane'ami z v3.2.30).
// Lookup po id zwraca HOST (display:contents, attachShadow); real pane jest w shadowRoot pod
// `.jz-pane-ai`. Operacje czytające/zapisujące zawartość okna muszą używać `_aiWinEl()`;
// `document.getElementById(AI_HELPER_EL_ID)` zostawiamy tylko dla operacji na hoście
// (remove, sprawdzenie czy okno istnieje).
function _aiWinHost() { return document.getElementById(AI_HELPER_EL_ID); }
function _aiWinEl()   { return _jzResolveShadow(document.getElementById(AI_HELPER_EL_ID), '.jz-pane-ai'); }
function _aiWinShadowRoot() {
  const host = document.getElementById(AI_HELPER_EL_ID);
  return host && host.shadowRoot ? host.shadowRoot : null;
}
let _aiHelperWin = null;
let _aiHelperBusy = false;
let _aiStopRequested = false;                      // od v3.2.8 — soft-stop (sprawdzane po każdym await w _aiRunAgent)
const AI_HELPER_DEFAULT_SIZE = { w: 440, h: 520 }; // od v3.2.8 — referencja do dblclick toggle rozmiaru
// Cross-session pamięć rozmiaru (od v3.2.10) — chrome.storage.local, per-device, NIE syncowany.
// Wcześniej sizeRemembered żył tylko w _aiHelperWin (sessionStorage), więc zamknięcie okna
// czyściło pamięć — dblclick po ponownym otwarciu nie miał do czego wrócić.
const AI_HELPER_SIZE_KEY = 'jzAiHelperSize';
let _aiHelperSizeRemembered = null;
function loadAiHelperSize() {
  try {
    safeStorageGet([AI_HELPER_SIZE_KEY]).then((data) => {
      const v = data && data[AI_HELPER_SIZE_KEY];
      if (v && typeof v === 'object' && v.w && v.h) _aiHelperSizeRemembered = { w: v.w | 0, h: v.h | 0 };
    });
  } catch (_) {}
}
function saveAiHelperSize(w, h) {
  _aiHelperSizeRemembered = { w: w, h: h };
  try { safeStorageSet({ [AI_HELPER_SIZE_KEY]: { w: w, h: h } }); } catch (_) {}
}

// === Per-role styling (od v3.2.18) =========================================
// Strukturalne ustawienia wyglądu AI Helpera per rola: user input, assistant
// responses, tool results, inline forms. Każda rola: { font, size, color, bg }.
// Persistent per-device w chrome.storage.local (NIE syncowany). Aplikowane
// przez dynamiczny <style id="jz-ai-fs-style"> z najwyższym z możliwych
// selektorem (#jz-ai-helper .role-class).
const AI_HELPER_STYLES_KEY = 'jzAiHelperStyles';
const AI_STYLE_ROLES = ['user', 'assistant', 'tool', 'form'];
let _aiHelperStyles = null; // { user:{font,size,color,bg}, assistant:..., tool:..., form:... }
function loadAiHelperStyles() {
  try {
    safeStorageGet([AI_HELPER_STYLES_KEY]).then((data) => {
      const v = data && data[AI_HELPER_STYLES_KEY];
      _aiHelperStyles = (v && typeof v === 'object') ? v : null;
      const el = _aiWinEl();
      if (el) _aiApplyStyles(el);
    });
  } catch (_) {}
}
function saveAiHelperStyles() {
  try { safeStorageSet({ [AI_HELPER_STYLES_KEY]: _aiHelperStyles || {} }); } catch (_) {}
}
function _aiGetRoleStyle(role) {
  if (!_aiHelperStyles || typeof _aiHelperStyles !== 'object') return {};
  return _aiHelperStyles[role] || {};
}
function _aiSetRoleStyle(role, patch) {
  if (!_aiHelperStyles) _aiHelperStyles = {};
  const cur = _aiHelperStyles[role] || {};
  _aiHelperStyles[role] = Object.assign(cur, patch);
  saveAiHelperStyles();
}
function _aiResetRoleStyle(role) {
  if (!_aiHelperStyles) return;
  delete _aiHelperStyles[role];
  saveAiHelperStyles();
}
function _aiResetAllStyles() {
  _aiHelperStyles = null;
  try { safeStorageSet({ [AI_HELPER_STYLES_KEY]: {} }); } catch (_) {}
}

function loadAiHelper() {
  try {
    const raw = sessionStorage.getItem(AI_HELPER_WIN_KEY);
    _aiHelperWin = raw ? JSON.parse(raw) : null;
  } catch (_) { _aiHelperWin = null; }
}
function saveAiHelper() {
  try {
    if (_aiHelperWin) sessionStorage.setItem(AI_HELPER_WIN_KEY, JSON.stringify(_aiHelperWin));
    else sessionStorage.removeItem(AI_HELPER_WIN_KEY);
  } catch (_) {}
}
function _aiGetChat() {
  try { const a = JSON.parse(sessionStorage.getItem(AI_HELPER_CHAT_KEY) || '[]'); return Array.isArray(a) ? a : []; }
  catch (_) { return []; }
}
function _aiSetChat(arr) {
  try { sessionStorage.setItem(AI_HELPER_CHAT_KEY, JSON.stringify(arr || [])); } catch (_) {}
}
function _aiGetMeta() {
  try { return JSON.parse(sessionStorage.getItem(AI_HELPER_META_KEY) || '{}') || {}; }
  catch (_) { return {}; }
}
function _aiSetMeta(m) {
  try { sessionStorage.setItem(AI_HELPER_META_KEY, JSON.stringify(m || {})); } catch (_) {}
}

// v3.2.45 — Session totals storage + helpers.
function _aiGetTotals() {
  try {
    const t = JSON.parse(sessionStorage.getItem(AI_HELPER_TOTALS_KEY) || '{}');
    return Object.assign({
      tokensCurrentInput: 0,
      tokensCurrentOutput: 0,
      tokensCurrentCached: 0,
      tokensSessionInput: 0,
      tokensSessionOutput: 0,
      tokensSessionCached: 0,
      agentTotalMs: 0,
      lastUserAt: 0,
      lastToolAt: 0,
      turnEndedAt: 0,
      lastCallInputTokens: 0,
      lastCallOutputTokens: 0,
      lastCallDurationMs: 0,
    }, t);
  } catch (_) {
    return {
      tokensCurrentInput: 0, tokensCurrentOutput: 0, tokensCurrentCached: 0,
      tokensSessionInput: 0, tokensSessionOutput: 0, tokensSessionCached: 0,
      agentTotalMs: 0, lastUserAt: 0, lastToolAt: 0, turnEndedAt: 0,
      lastCallInputTokens: 0, lastCallOutputTokens: 0, lastCallDurationMs: 0,
    };
  }
}
function _aiSetTotals(t) {
  try { sessionStorage.setItem(AI_HELPER_TOTALS_KEY, JSON.stringify(t || {})); } catch (_) {}
}
function _aiClearTotals() {
  try { sessionStorage.removeItem(AI_HELPER_TOTALS_KEY); } catch (_) {}
}
// Mark fresh user message — resets "current action" tokens and starts both timers.
function _aiMarkUserActivity() {
  const t = _aiGetTotals();
  t.lastUserAt = Date.now();
  t.lastToolAt = Date.now(); // reset też tool timer
  t.turnEndedAt = 0;         // v3.2.46 — tura wznowiona, timery znów live
  t.tokensCurrentInput = 0;
  t.tokensCurrentOutput = 0;
  t.tokensCurrentCached = 0;
  _aiSetTotals(t);
}
// v3.2.46 — zamrożenie timerów u:/t: gdy agent skończył turę (czeka na usera).
// Bez tego "since user"/"since tool" rosły w nieskończoność po odpowiedzi.
function _aiMarkTurnEnded() {
  const t = _aiGetTotals();
  t.turnEndedAt = Date.now();
  _aiSetTotals(t);
}
// Mark fresh tool exchange — resets "since tool" timer.
function _aiMarkToolActivity(durationMs) {
  const t = _aiGetTotals();
  t.lastToolAt = Date.now();
  if (typeof durationMs === 'number' && durationMs >= 0) {
    t.lastToolDurationMs = durationMs;
    t.agentTotalMs += durationMs;
  }
  _aiSetTotals(t);
}
// Add tokens from one LLM call (+ duration). Updates current/session + last-call info.
function _aiAccumulateCall(inputTokens, outputTokens, durationMs, cachedTokens) {
  const t = _aiGetTotals();
  const i = Math.max(0, Number(inputTokens) || 0);
  const o = Math.max(0, Number(outputTokens) || 0);
  const d = Math.max(0, Number(durationMs) || 0);
  // cached ⊆ input (znormalizowane per dostawca w background _aiChat); clamp do input.
  const c = Math.min(i, Math.max(0, Number(cachedTokens) || 0));
  t.tokensCurrentInput += i;
  t.tokensCurrentOutput += o;
  t.tokensCurrentCached += c;
  t.tokensSessionInput += i;
  t.tokensSessionOutput += o;
  t.tokensSessionCached += c;
  t.lastCallInputTokens = i;
  t.lastCallOutputTokens = o;
  t.lastCallDurationMs = d;
  t.agentTotalMs += d;
  _aiSetTotals(t);
}

// Format helpers — short, human-readable.
function _aiFormatTokens(n) {
  n = Math.max(0, Math.round(Number(n) || 0));
  if (n < 1000) return String(n);
  if (n < 100000) return (n / 1000).toFixed(1).replace(/\.0$/, '') + 'K';
  if (n < 1000000) return Math.round(n / 1000) + 'K';
  return (n / 1000000).toFixed(2).replace(/\.?0+$/, '') + 'M';
}
function _aiFormatDuration(ms) {
  const s = Math.max(0, Math.round((Number(ms) || 0) / 1000));
  if (s < 60) return s + 's';
  const m = Math.floor(s / 60);
  const sec = s % 60;
  if (m < 60) return m + 'm' + (sec < 10 ? '0' + sec : sec) + 's';
  const h = Math.floor(m / 60);
  const min = m % 60;
  return h + 'h' + (min < 10 ? '0' + min : min) + 'm';
}

// v3.2.53 — history_steps storage + get_history_step recovery REMOVED. With
// client-side trim (one tool call per turn) and wide-output limits keeping
// each result small, results stay full in the chat history — there is nothing
// to "recover", so the recovery tool only caused the agent to waste rounds
// re-fetching results it already had. Tool results live in the normal chat.
function _aiT(k, vars) { try { return JZ_I18N.t(k, vars); } catch (_) { return k; } }
function _aiEsc(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

async function toggleAiHelper() {
  if (_aiHelperWin) { closeAiHelper(); return; }
  // od v3.2.17 — naprawiamy race: loadAiHelperSize() jest async (chrome.storage),
  // a tu czytaliśmy synchronicznie. Jeżeli user otworzył AI Helper PRZED tym jak
  // chrome.storage zwróciło wartość — startSize spadał na default. Teraz dociągamy
  // synchronicznie gdy potrzeba.
  if (!_aiHelperSizeRemembered) {
    try {
      const d = await safeStorageGet([AI_HELPER_SIZE_KEY]);
      const v = d && d[AI_HELPER_SIZE_KEY];
      if (v && typeof v === 'object' && v.w && v.h) {
        _aiHelperSizeRemembered = { w: v.w | 0, h: v.h | 0 };
      }
    } catch (_) {}
  }
  // od v3.2.12: cross-tab pamięć rozmiaru — przy świeżym otwarciu (nowa karta,
  // brak _aiHelperWin w sessionStorage) użyj zapamiętanego rozmiaru z
  // chrome.storage.local.jzAiHelperSize, jeśli istnieje. F5 tej samej karty
  // i tak dostawał _aiHelperWin z sessionStorage z jego ostatnim size.
  const startSize = (_aiHelperSizeRemembered && _aiHelperSizeRemembered.w && _aiHelperSizeRemembered.h)
    ? { w: _aiHelperSizeRemembered.w, h: _aiHelperSizeRemembered.h }
    : { w: AI_HELPER_DEFAULT_SIZE.w, h: AI_HELPER_DEFAULT_SIZE.h };
  // od v3.2.16 — anti-overlap: AI Helper otwiera się w wolnym slocie względem
  // istniejących okien JustZix. Wcześniej fixed {x:90, y:80}.
  const slot = _findFreeWindowSlot(startSize.w, startSize.h);
  _aiHelperWin = {
    id: 'ai_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5),
    position: { x: slot.x, y: slot.y, anchor: 'top-left' },
    size: startSize,
  };
  saveAiHelper();
  renderAiHelper();
}
function closeAiHelper() {
  // Anuluj ewentualny otwarty inline-form (od v3.2.11), żeby pętla agentowa
  // czekająca na user input nie zawisła.
  if (_aiPendingForm) {
    try { _aiPendingForm.cancel(); } catch (_) {}
    _aiPendingForm = null;
  }
  _aiHelperWin = null;
  saveAiHelper();
  const el = document.getElementById(AI_HELPER_EL_ID);
  if (el) el.remove();
}

// Tani zarys strony do promptu AI — bez serializacji całego DOM. Zamiast
// document.documentElement.outerHTML (kosztowne na dużych stronach, a wołane przy
// każdej wiadomości czatu) zwraca meta description + zarys elementów najwyższego
// poziomu <body>. Pełną strukturę AI bada narzędziami get_page_summary / get_dom_outline / search_elements.
function _aiPageOutline() {
  const lines = [];
  try {
    const meta = document.querySelector('meta[name="description"]');
    const desc = meta && meta.getAttribute('content');
    if (desc) lines.push('Opis (meta): ' + desc.trim().slice(0, 300));
  } catch (_) {}
  try {
    const body = document.body;
    if (body) {
      const kids = body.children;
      const lim = Math.min(kids.length, 40);
      lines.push('Struktura <body> (' + kids.length + ' elementów najwyższego poziomu'
        + (kids.length > lim ? ', pokazano ' + lim : '') + '):');
      for (let i = 0; i < lim; i++) {
        const el = kids[i];
        let sel = el.tagName.toLowerCase();
        if (el.id) sel += '#' + el.id;
        const cls = (el.getAttribute('class') || '').trim();
        if (cls) sel += '.' + cls.split(/\s+/).slice(0, 3).join('.');
        lines.push('  <' + sel + '> (' + el.childElementCount + ' dzieci)');
      }
    }
  } catch (_) {}
  return lines.length ? lines.join('\n') : '(brak danych o stronie)';
}

// Prompt systemowy: rola, workflow, protokół narzędzi, kontekst strony.
// v3.2.40 — Compact EN system prompt (~1500 chars). Tool documentation is NOT
// inlined; the agent discovers tools on demand via meta-tools (list_tool_groups,
// list_tools, get_tool_doc, search_tool_docs) backed by window.JZ_AI_TOOL_REGISTRY.
function _aiBuildSystem() {
  return [
    'You are "AI Helper" — an agent in JustZix (Chrome extension that injects CSS/JS into web pages and builds interactive action bars).',
    '',
    'DATA MODEL',
    'folder → group → rule (CSS+JS injected on matching URLs) + action bars + actions + persistent windows (CSS/JS panes & JS/Output consoles).',
    '',
    'TOOL PROTOCOL',
    '@@JZ_TOOL@@',
    '{"tool":"NAME","args":{...}}',
    '@@END@@',
    'Output ONE tool block per response, then wait for the result. It returns as @@JZ_RESULT@@ tool_name — a JSON envelope {data, meta, suggestions?, warnings?}. If you output multiple tool blocks, only the first one runs.',
    '',
    'HOW TO WORK — discover tools on demand, do not guess names or args',
    '- list_tool_groups() → list_tools({group}) → get_tool_doc({name}) when you need a tool\'s exact args.',
    '- The user may write in any language; tool names/args are English.',
    '',
    'REQUIRED PROCESS (not optional — follow on every change task)',
    '1. AMBIGUOUS TARGET → CONFIRM FIRST. If the request could match several things ("the banner", "the popup", "that element"), find the candidates and confirm WHICH one before changing anything — do NOT act on the first match.',
    '   ASK VIA FORMS: for choices and confirmations use request_user_input (options = buttons, or fields) — it is clearer than a plain-text question. Reserve plain text for open-ended explanation. (Every form also has a free-text field.)',
    '2. EXPLORE before coding: search_elements / inspect_element; test_selector before relying on a selector. Never assume selectors.',
    '3. SHOW + VERIFY. Put the final code in a TEMP pane (set_temp_pane_code) so the user SEES it, then VERIFY with read_temp_pane / screenshot_page BEFORE saying it is shown/done. Never claim success you have not checked. (preview_css / preview_js are for your own headless checks.)',
    '4. ITERATE on feedback (adjust → update TEMP → show again) until the user is satisfied.',
    '5. ASK BEFORE PERSISTING. After the user accepts the preview, ask whether to save it permanently — "no" is valid (stays in TEMP this session). On yes, follow PERSIST RULES.',
    '',
    'PERSIST RULES (when saving permanently — common mistakes to avoid)',
    '- PRE-FILL every request_user_input field with a sensible default — NEVER show an empty name. Folder/rule name from the task + domain (e.g. "swiatsupli — ukryj baner"); leave nothing for the user to type from scratch.',
    '- ALWAYS set URL patterns to the current site (e.g. https://HOST/*), on the folder. Without them the rule NEVER matches and nothing applies. Never leave URL patterns empty.',
    '- create_folder ALREADY creates a default group and returns its groupId — USE that groupId for create_rule. NEVER call create_group after create_folder.',
    '- TRANSFER the code you tested in TEMP into the PERSISTENT entity — never leave working code only in TEMP (it is session-only, lost on reload): CSS → the rule\'s css; JS that runs on page load → the rule\'s js; JS that runs when a BUTTON is clicked → that action\'s code field.',
    '',
    'WHEN BLOCKED OR DEGRADED — be honest',
    '- If the proper approach is blocked (e.g. the page forbids custom JS via Trusted Types / CSP) or impossible, do NOT silently ship an inferior workaround as if it solved the task. State the limitation and the trade-off, and ask how to proceed.',
    '- Prefer DYNAMIC, general solutions (logic that reads values and adapts) over hardcoding a fixed list of specific items — a static snapshot breaks as soon as the page content changes. If only a static workaround is possible, say so explicitly.',
    '',
    'STYLE',
    '- Respond in the user\'s language, concisely. Finish by summarising what changed and asking if they want anything else.',
    '',
    'TOOL GROUPS (call list_tools({group}) for contents)',
    'meta (docs discovery — use first) · dom (page inspection) · tree (folders/groups/rules) · bars (action bars + actions) · temp (test windows) · panes (persistent CSS/JS) · consoles (JS/Output console) · interaction (simulate input) · ui (user forms) · vision (screenshot)',
    '',
    'META-TOOLS (always available)',
    'list_tool_groups() · list_tools({group?}) · get_tool_doc({name, level?}) · get_tool_schema({name}) · get_tool_examples({name}) · search_tool_docs({query})',
    '',
    'CORE TOOLS (used in most tasks)',
    '- search_elements({text?, selector?, role?, tag?, attributes?, visibleOnly?, limit?}) — find page elements',
    '- request_user_input({title, description?, fields[], submitLabel?, cancelLabel?}) → {action, values, text}. Every form ALSO has a free-text field — the user can answer in text instead of / alongside structured fields.',
    '- list_structure() — folder→group→rule tree + persistent windows',
    '',
    'ROUND BUDGET',
    'Discovery (list_/get_/search_/inspect_/observe_/test_/read_) — unlimited. Action (preview_/set_/create_/update_/simulate_/screenshot_/run_js_probe/open_temp_pane/request_user_input) — max 10; request_user_input resets the counter.',
    '',
    'PAGE CONTEXT',
    'URL: ' + location.href,
    'Title: ' + (document.title || '(none)'),
    'For page structure call get_page_summary() or get_dom_outline() — do not assume.',
  ].join('\n');
}

function renderAiHelper() {
  if (!_aiHelperWin || !document.documentElement) return;
  if (document.getElementById(AI_HELPER_EL_ID)) return;
  const el = document.createElement('div');
  el.id = AI_HELPER_EL_ID;
  el.className = 'jz-pane jz-pane-ai';
  el.innerHTML =
    '<div class="jz-pane-header">' +
      '<span class="jz-pane-dot" style="background:#8B5CF6;"></span>' +
      '<span class="jz-pane-title">' + _aiEsc(_aiT('aiHelper.win.title')) + '</span>' +
      '<button class="jz-pane-temp-close jz-ai-close" type="button" title="' + _aiEsc(_aiT('aiHelper.win.close')) + '">×</button>' +
    '</div>' +
    '<div class="jz-ai-bar">' +
      '<select class="jz-ai-provider" title="' + _aiEsc(_aiT('aiHelper.win.provider')) + '"></select>' +
      '<select class="jz-ai-model" title="' + _aiEsc(_aiT('aiHelper.win.model')) + '"></select>' +
      // v3.2.10: font do menu pod prawym klikiem na belce (spójne z pozostałymi oknami).
      // v3.2.12: clear z powrotem w barze jako ikona kosza (jak w TEMP CSS/JS) — szybszy
      // dostęp niż przez menu kontekstowe. W menu kontekstowym zostają font + rozmiar.
      '<span class="jz-ai-ctx-usage" title="' + _aiEsc(_aiT('aiHelper.win.ctxUsage') || 'Wykorzystanie kontekstu modelu') + '">0%</span>' + // od v3.2.38
      '<button class="jz-ai-tool jz-ai-compact" type="button" title="' + _aiEsc(_aiT('aiHelper.win.compact') || 'Kompaktuj rozmowę (LLM summary)') + '">' + // od v3.2.38
        '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" width="13" height="13" aria-hidden="true">' +
          '<polyline points="4 14 10 14 10 20"/><polyline points="20 10 14 10 14 4"/>' +
          '<line x1="14" y1="10" x2="21" y2="3"/><line x1="3" y1="21" x2="10" y2="14"/>' +
        '</svg>' +
      '</button>' +
      // od v3.2.81 — dropdown z 2 opcjami: cała rozmowa / ostatni input usera.
      // od v3.2.83 — ikony zamiast tekstu, tooltip (title) z opisem.
      '<div class="jz-ai-copy-wrap">' +
        '<button class="jz-ai-tool jz-ai-copy" type="button" title="' + _aiEsc(_aiT('aiHelper.win.copy')) + '" aria-haspopup="true" aria-expanded="false">&#x2398; <span class="jz-ai-copy-caret">▾</span></button>' +
        '<div class="jz-ai-copy-menu" role="menu" hidden>' +
          '<button class="jz-ai-copy-item" type="button" data-copy="all" role="menuitem" title="' + _aiEsc(_aiT('aiHelper.win.copy.all') || 'Cała rozmowa') + '">&#x2398;</button>' +
          '<button class="jz-ai-copy-item" type="button" data-copy="lastInput" role="menuitem" title="' + _aiEsc(_aiT('aiHelper.win.copy.lastInput') || 'Ostatni input') + '">&#10148;</button>' +
        '</div>' +
      '</div>' +
      '<button class="jz-ai-tool jz-ai-clear" type="button" title="' + _aiEsc(_aiT('aiHelper.win.clear')) + '">' +
        '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" width="13" height="13" aria-hidden="true">' +
          '<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14H6L5 6"/>' +
          '<path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V3h6v3"/>' +
        '</svg>' +
      '</button>' +
    '</div>' +
    // v3.2.45 — telemetry row (timers + tokens). Live updated via _aiUpdateTelemetryUI.
    // Compact, monospaced, two visual groups separated by " · " for timers and " | " between groups.
    '<div class="jz-ai-telem" title="' + _aiEsc(_aiT('aiHelper.win.telemetryTooltip')) + '">' +
      '<span class="jz-ai-telem-timers">⏱ —</span>' +
      '<span class="jz-ai-telem-sep">|</span>' +
      '<span class="jz-ai-telem-current">—↑/—↓</span>' +
      '<span class="jz-ai-telem-sep">|</span>' +
      '<span class="jz-ai-telem-session">Σ —</span>' +
    '</div>' +
    '<div class="jz-ai-msgs"></div>' +
    '<div class="jz-ai-input-row">' +
      '<textarea class="jz-ai-input" rows="2" spellcheck="false" placeholder="' + _aiEsc(_aiT('aiHelper.win.inputPlaceholder')) + '"></textarea>' +
      '<button class="jz-ai-send" type="button" title="' + _aiEsc(_aiT('aiHelper.win.send')) + '">&#10148;</button>' +
      '<button class="jz-ai-stop" type="button" title="' + _aiEsc(_aiT('aiHelper.win.stop')) + '" hidden>&#9632;</button>' +
    '</div>';
  // od v3.2.76 — pakuj w shadow root (parytet z innymi pane'ami z v3.2.30), żeby globalne
  // style strony (np. play.google.com) nie wyciekały do UI AI Helpera. Id przejmuje host;
  // `_aiWinEl()` / `_aiWinShadowRoot()` rozwiązują host → real pane.
  document.documentElement.appendChild(_jzWrapInShadow(el, AI_HELPER_EL_ID));
  applyCssPanePosition(el, _aiHelperWin);
  _aiAttachDragResize(el);
  // v3.2.44 — scroll containment (wheel nad oknem nie scrolluje strony pod spodem).
  // Inne pane'y miały to od v2.13.203 via attachPaneResize → attachPaneScrollContain;
  // AI Helper używa własnego _aiAttachDragResize, więc dorzucamy explicit.
  attachPaneScrollContain(el);

  el.querySelectorAll('.jz-ai-bar, .jz-ai-input-row').forEach(n => {
    n.addEventListener('mousedown', (e) => e.stopPropagation());
  });
  el.querySelector('.jz-ai-close').addEventListener('click', closeAiHelper);
  // od v3.2.81 — dropdown menu zamiast prostego klika.
  _aiWireCopyDropdown(el);
  el.querySelector('.jz-ai-compact').addEventListener('click', () => _aiCompactConversation(el)); // od v3.2.38
  // od v3.2.12 — clear z baru (kosz), używa inline-form confirm zamiast window.confirm.
  el.querySelector('.jz-ai-clear').addEventListener('click', async () => {
    const ok = await _aiInlineConfirm(el, _aiT('aiHelper.win.clearConfirm'));
    if (!ok) return;
    _aiSetChat([]);
    _aiClearTotals(); // v3.2.45 — reset session telemetry (tokens, timers, agentTotalMs)
    _aiRenderMessages(el);
    _aiApplyFontSize(el);
  });
  el.querySelector('.jz-ai-stop').addEventListener('click', () => {
    _aiStopRequested = true;
  });
  const provSel = el.querySelector('.jz-ai-provider');
  const modelSel = el.querySelector('.jz-ai-model');
  provSel.addEventListener('change', () => {
    const meta = _aiGetMeta();
    meta.provider = provSel.value; meta.model = '';
    _aiSetMeta(meta);
    _aiLoadModels(el);
  });
  modelSel.addEventListener('change', () => {
    const meta = _aiGetMeta();
    meta.model = modelSel.value;
    _aiSetMeta(meta);
  });
  const input = el.querySelector('.jz-ai-input');
  el.querySelector('.jz-ai-send').addEventListener('click', () => _aiSend(el));
  input.addEventListener('keydown', (e) => {
    if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); _aiSend(el); }
  });

  _aiRenderMessages(el);
  _aiApplyStyles(el);
  _ensureAiHelperCollapseBtn(el);
  // (v3.2.18 dodał tu setTimeout(_aiSaveGeometry,100) jako „initial save" — ale
  // to nadpisywało remembered na current SIZE wszędzie tam, gdzie current ≠
  // user-set. Usunięte w v3.2.19. saveAiHelperSize wykonuje się teraz TYLKO
  // przy resize-handle mouseup — czyli tylko gdy user faktycznie zresize'ował.)
  _aiInitProviders(el);
  // v3.2.45 — telemetry: initial render + 1s ticker for live timers.
  _aiUpdateTelemetryUI(el);
  _aiStartTelemetryTicker(el);
  // v3.2.44 — auto-focus textarea po otwarciu okna. Initial po RAF (layout done);
  // re-focus w _aiLoadModels callback gdyby async load select'a przejął focus.
  requestAnimationFrame(() => {
    _aiFocusInput(el);
  });
}

// v3.2.44 — focus textareę inputu, jeśli nie disabled i jeśli ktoś nie kliknął
// celowo gdzie indziej (active na innym INPUT/TEXTAREA wewnątrz okna AI Helpera).
function _aiFocusInput(el) {
  if (!el || !el.isConnected) return;
  const inp = el.querySelector('.jz-ai-input');
  if (!inp || inp.disabled) return;
  const active = document.activeElement;
  // Nie odbieraj focusu jeśli user już pisze w innym polu (np. inline-form
  // request_user_input, modal). Akceptuj refocus z select/body/null.
  if (active && active !== document.body && active !== inp) {
    const tag = (active.tagName || '').toUpperCase();
    const insideAi = el.contains(active);
    if (insideAi && (tag === 'INPUT' || tag === 'TEXTAREA')) return;
  }
  try { inp.focus({ preventScroll: true }); } catch (_) { inp.focus(); }
}

function _aiInitProviders(el) {
  // Klucze (w tym te synchronizowane przez JZ_SYNC) żyją w chrome.storage.local.aiHelper.
  safeStorageGet(['aiHelper']).then((d) => {
    const local = d.aiHelper || {};
    const keys = local.keys || {};
    const cfg = { keys, defaultProvider: local.defaultProvider || '', defaultModel: local.defaultModel || {} };
    const provs = ['openai', 'anthropic', 'gemini'].filter(p => keys[p]);
    const provSel = el.querySelector('.jz-ai-provider');
    if (!provs.length) {
      _aiShowNotice(el, _aiT('aiHelper.win.noKeys'));
      el.querySelector('.jz-ai-input').disabled = true;
      el.querySelector('.jz-ai-send').disabled = true;
      el.querySelector('.jz-ai-bar').style.display = 'none';
      return;
    }
    // Klucze są — usuń ewentualny notice „No API keys" i odblokuj UI. Potrzebne, gdy
    // okno otwarto bez kluczy, a te dosynchronizowały się później (re-run przez
    // storage.onChanged) — bez tego notice i disabled input zostawały.
    const staleNotice = el.querySelector('.jz-ai-msgs .jz-ai-notice');
    if (staleNotice) staleNotice.remove();
    el.querySelector('.jz-ai-input').disabled = false;
    el.querySelector('.jz-ai-send').disabled = false;
    el.querySelector('.jz-ai-bar').style.display = '';
    const LABEL = { openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini' };
    provSel.innerHTML = '';
    provs.forEach(p => {
      const o = document.createElement('option');
      o.value = p; o.textContent = LABEL[p];
      provSel.appendChild(o);
    });
    provSel.style.display = provs.length > 1 ? '' : 'none';
    const meta = _aiGetMeta();
    const cur = (meta.provider && provs.includes(meta.provider)) ? meta.provider
              : (provs.includes(cfg.defaultProvider) ? cfg.defaultProvider : provs[0]);
    provSel.value = cur;
    meta.provider = cur;
    _aiSetMeta(meta);
    _aiLoadModels(el);
  });
}

function _aiLoadModels(el) {
  const provider = el.querySelector('.jz-ai-provider').value;
  const modelSel = el.querySelector('.jz-ai-model');
  if (!provider) return;
  modelSel.disabled = true;
  modelSel.innerHTML = '<option>' + _aiEsc(_aiT('aiHelper.win.loadingModels')) + '</option>';
  safeSendMessage({ type: 'jz-ai-list-models', provider }, (resp) => {
    const elNow = _aiWinEl();
    if (!elNow) return;
    modelSel.disabled = false;
    modelSel.innerHTML = '';
    if (chrome.runtime.lastError || !resp || !resp.ok) {
      const o = document.createElement('option');
      o.value = ''; o.textContent = _aiT('aiHelper.win.modelsError');
      modelSel.appendChild(o);
      return;
    }
    (resp.models || []).forEach(m => {
      const o = document.createElement('option');
      o.value = m; o.textContent = m;
      modelSel.appendChild(o);
    });
    safeStorageGet(['aiHelper']).then((d) => {
      const cfg = d.aiHelper || {};
      const meta = _aiGetMeta();
      const dm = (cfg.defaultModel || {})[provider] || '';
      const models = resp.models || [];
      const pick = (meta.model && models.includes(meta.model)) ? meta.model
                 : (dm && models.includes(dm)) ? dm
                 : (resp.suggested || models[0] || '');
      modelSel.value = pick;
      meta.model = pick;
      _aiSetMeta(meta);
      // v3.2.44 — after async list fetch + selection, re-focus the textarea if
      // the user hasn't started typing somewhere else. Browser sometimes leaves
      // focus on the select after async value assignment.
      _aiFocusInput(elNow);
    });
  });
}

function _aiRenderMessages(el) {
  const box = el.querySelector('.jz-ai-msgs');
  const chat = _aiGetChat();
  box.innerHTML = '';
  if (!chat.length) {
    const w = document.createElement('div');
    w.className = 'jz-ai-welcome';
    w.textContent = _aiT('aiHelper.win.welcome');
    box.appendChild(w);
  }
  chat.forEach(m => box.appendChild(_aiMsgEl(m)));
  box.scrollTop = box.scrollHeight;
  // od v3.2.12 — nowe wiadomości potrzebują inline-style fallback dla fontu.
  _aiApplyFontSize(el);
  // od v3.2.38 — odśwież counter wykorzystania kontekstu po każdej zmianie czatu.
  try { _aiUpdateContextUsage(el); } catch (_) {}
}

// od v3.2.38 — limit kontekstu per (provider, model). Wartości wzorcowe; jeżeli
// model nieznany, fallback to 100k tokens (konserwatywnie). Lepsze niż brak
// counter'a wcale.
const _AI_CTX_LIMITS = [
  { provider: 'anthropic', pattern: /./i,                       limit: 200000 },
  { provider: 'gemini',    pattern: /1\.5|^gemini-2|^gemini-3/i, limit: 1000000 },
  { provider: 'gemini',    pattern: /./i,                       limit: 32000 },
  { provider: 'openai',    pattern: /^gpt-4\.1|^gpt-5/i,         limit: 1000000 },
  { provider: 'openai',    pattern: /^gpt-4o|^o3|^o4/i,          limit: 128000 },
  { provider: 'openai',    pattern: /./i,                       limit: 32000 },
];
function _aiGetContextLimit(provider, model) {
  const list = _AI_CTX_LIMITS.filter(x => x.provider === provider);
  for (const x of list) if (x.pattern.test(String(model || ''))) return x.limit;
  return 100000;
}
// Estymata: 1 token ≈ 4 znaki (konserwatywnie). System prompt + wszystkie wiadomości.
function _aiEstimateTokens(chat, systemText) {
  let chars = (systemText || '').length;
  (chat || []).forEach(m => { chars += String(m.content || '').length; });
  return Math.ceil(chars / 4);
}
// v3.2.71 — realny rozmiar bieżącego kontekstu. Zamiast estymaty znaki/4 bierzemy
// PRAWDZIWY usage z ostatniego wywołania API (usage.input = system prompt + cała
// wysłana historia; + output = odpowiedź, która jest teraz w historii). To dokładne
// (uwzględnia obrazy/JSON), zgłaszane przez dostawcę. Fallback do estymaty tylko
// zanim padnie pierwsze wywołanie (brak usage). Używane do wskaźnika kontekstu i
// progu auto-compaction — żeby oba liczyły z tej samej, realnej liczby.
function _aiCurrentContextTokens() {
  const chat = _aiGetChat();
  for (let i = chat.length - 1; i >= 0; i--) {
    const u = chat[i] && chat[i]._usage;
    if (u && typeof u.input === 'number') {
      return u.input + (typeof u.output === 'number' ? u.output : 0);
    }
  }
  return _aiEstimateTokens(_aiBuildApiMessages(), _aiBuildSystem());
}
// v3.2.45 — live updater for the telemetry row in the toolbar.
// Three timers (since user / since tool / agent total active time) +
// two token counts (current action / session total). No costs.
function _aiUpdateTelemetryUI(el) {
  if (!el || !el.isConnected) return;
  const tEl = el.querySelector('.jz-ai-telem-timers');
  const cEl = el.querySelector('.jz-ai-telem-current');
  const sEl = el.querySelector('.jz-ai-telem-session');
  if (!tEl || !cEl || !sEl) return;
  const t = _aiGetTotals();
  // v3.2.46 — gdy agent nie pracuje (czeka na usera), timery u:/t: zamrażają się
  // na turnEndedAt zamiast rosnąć kalendarzowo. Przy nowym pytaniu _aiMarkUserActivity
  // czyści turnEndedAt → znów live.
  const ref = _aiHelperBusy ? Date.now() : (t.turnEndedAt || Date.now());
  // "since user" — czas pracy agenta nad bieżącą turą (zamrożony po zakończeniu).
  const sinceUser = t.lastUserAt ? _aiFormatDuration(ref - t.lastUserAt) : '—';
  // "since tool" — czas od ostatniego narzędzia (zamrożony po zakończeniu tury).
  const sinceTool = t.lastToolAt ? _aiFormatDuration(ref - t.lastToolAt) : '—';
  // "agent total" — accumulated wall-clock time agent spent actively (LLM calls + tool exec).
  const agentTotal = _aiFormatDuration(t.agentTotalMs);
  // Symbol ⏱ (uniwersalny zegar) zostaje; emoji 📊 usunięte (nie z zestawu).
  // Labele "u/t/a" i "↑/↓" są uniwersalnymi skrótami; pełny opis w tooltipie (i18n).
  tEl.textContent = '⏱ u:' + sinceUser + ' · t:' + sinceTool + ' · a:' + agentTotal;
  tEl.title = _aiT('aiHelper.win.telemetryTimers');
  // Current action — input/output tokens since last user message.
  const ci = t.tokensCurrentInput;
  const co = t.tokensCurrentOutput;
  cEl.textContent = _aiFormatTokens(ci) + '↑/' + _aiFormatTokens(co) + '↓';
  cEl.title = _aiT('aiHelper.win.telemetryCurrent');
  // Session total — cumulative since session start (or clear chat). v3.2.73 — pokazane
  // ROZŁĄCZNIE: in = input pełnopłatny (świeży), cache = input z cache (taniej),
  // out = output. in + cache = cały input. (tokensSessionInput trzymamy jako CAŁY
  // input — tego potrzebuje liczenie kontekstu; świeży = input − cached liczymy do
  // ekranu.) Tooltip: % całego inputu, który poszedł z cache.
  const si = t.tokensSessionInput;       // cały input (z cache)
  const so = t.tokensSessionOutput;
  const sc = Math.min(si, t.tokensSessionCached); // cache (podzbiór inputu)
  const fresh = Math.max(0, si - sc);    // input pełnopłatny
  sEl.textContent = 'Σ in ' + _aiFormatTokens(fresh) + ' · cache ' + _aiFormatTokens(sc) + ' · out ' + _aiFormatTokens(so);
  const cachePct = si > 0 ? Math.round((sc / si) * 100) : 0;
  sEl.title = (_aiT('aiHelper.win.telemetrySession') || 'Sesja')
    + ' — in: pełnopłatny input, cache: input z cache (taniej), out: output. Cache = '
    + cachePct + '% całego inputu.';
}

// Start/stop a 1-second interval that refreshes the telemetry timers while
// the AI Helper window exists. Stored on the element to be cancellable.
function _aiStartTelemetryTicker(el) {
  if (!el) return;
  if (el._aiTelemTicker) clearInterval(el._aiTelemTicker);
  el._aiTelemTicker = setInterval(function () {
    const live = _aiWinEl();
    if (!live) {
      if (el._aiTelemTicker) clearInterval(el._aiTelemTicker);
      el._aiTelemTicker = null;
      return;
    }
    _aiUpdateTelemetryUI(live);
  }, 1000);
}

function _aiUpdateContextUsage(el) {
  const span = el.querySelector('.jz-ai-ctx-usage');
  if (!span) return;
  const meta = _aiGetMeta();
  const provider = meta.provider || '';
  const model = meta.model || '';
  const limit = _aiGetContextLimit(provider, model);
  const used = _aiCurrentContextTokens();
  const pct = Math.min(999, Math.round((used / limit) * 100));
  span.textContent = pct + '%';
  span.title = (_aiT('aiHelper.win.ctxUsageDetail') || 'Kontekst')
    + ': ~' + Math.round(used / 1000) + 'k / ' + Math.round(limit / 1000) + 'k tokenów (' + pct + '%)';
  span.classList.toggle('jz-ai-ctx-warn', pct >= 70 && pct < 85);
  span.classList.toggle('jz-ai-ctx-crit', pct >= 85);
}

// od v3.2.38 — kompaktowanie historii rozmowy przez LLM-summary. Zachowuje
// system prompt + ostatnie KEEP_RECENT_MSGS wiadomości. Resztę (starsze msgs)
// wysyła do tego samego dostawcy z prompt'em o streszczenie; wynik wstrzykuje
// jako 1 pseudo-tool message na początku chata. Auto-trigger w _aiRunAgent gdy
// wykorzystanie kontekstu > 85%; manual via przycisk ↺ w toolbar.
const _AI_KEEP_RECENT_MSGS = 8;
let _aiCompactedAt = 0;
let _aiCompacting = false;
async function _aiCompactConversation(el, opts) {
  if (_aiCompacting) return;
  const chat = _aiGetChat();
  if (chat.length <= _AI_KEEP_RECENT_MSGS + 2) {
    // Nic do kompaktowania — historia za krótka.
    if (!(opts && opts.silent)) {
      try { showInfoToast(_aiT('aiHelper.win.compactNoop') || 'Historia jest za krótka — nie ma czego kompaktować.'); } catch (_) {}
    }
    return;
  }
  const meta = _aiGetMeta();
  const provider = meta.provider || '';
  const model = meta.model || '';
  if (!provider || !model) {
    try { showInfoToast(_aiT('aiHelper.win.compactNoProvider') || 'Wybierz najpierw dostawcę i model.'); } catch (_) {}
    return;
  }
  _aiCompacting = true;
  try {
    const head = chat.slice(0, -_AI_KEEP_RECENT_MSGS);
    const tail = chat.slice(-_AI_KEEP_RECENT_MSGS);
    const summarySystem = 'Streszczasz historię rozmowy z asystentem JustZix AI Helper. '
      + 'Zachowaj: (1) cel zadania, (2) decyzje użytkownika, (3) utworzone encje z ID '
      + '(folder/group/rule/bar/action/pane), (4) wyniki testów kodu (sukces/błąd), '
      + '(5) aktualny stan implementacji. Pomiń pełne treści kodu, długie HTML, '
      + 'powtarzające się komunikaty. Max 600 słów. Odpowiedz po polsku.';
    const messages = head.map(m => ({
      role: m.role === 'assistant' ? 'assistant' : 'user',
      content: String(m.content || ''),
    }));
    const resp = await new Promise((res) => {
      safeSendMessage({
        type: 'jz-ai-chat', provider, model,
        system: summarySystem,
        messages: messages,
      }, (r) => {
        if (chrome.runtime.lastError) res({ ok: false, error: chrome.runtime.lastError.message });
        else res(r || { ok: false, error: 'no response' });
      });
    });
    if (!resp.ok) {
      try { showInfoToast((_aiT('aiHelper.win.compactErr') || 'Błąd kompaktowania') + ': ' + (resp.error || '?')); } catch (_) {}
      return;
    }
    const summaryText = String(resp.content || '').trim();
    if (!summaryText) {
      try { showInfoToast(_aiT('aiHelper.win.compactEmpty') || 'Model zwrócił pusty wynik.'); } catch (_) {}
      return;
    }
    const summaryMsg = {
      role: 'user',
      kind: 'tool',
      content: '@@JZ_RESULT@@ __compact__\n'
        + '↺ ' + (_aiT('aiHelper.win.compactedPrefix') || 'Zsumowano')
        + ' ' + head.length + ' '
        + (_aiT('aiHelper.win.compactedSuffix') || 'wcześniejszych wiadomości:')
        + '\n\n' + summaryText,
    };
    _aiSetChat([summaryMsg].concat(tail));
    _aiCompactedAt = Date.now();
    _aiRenderMessages(el);
  } finally {
    _aiCompacting = false;
  }
}
function _aiMsgEl(m) {
  const role = m.role === 'user' ? 'user' : 'assistant';
  // Wynik narzędzia — chip z nagłówkiem klikalnym (od v3.2.10 zwinięty domyślnie,
  // klik w nagłówek rozwija/zwija body). v3.2.8: podział na nagłówek (nazwa narzędzia)
  // i body w <pre>; wcześniej max-height 130px sprawiał, że dłuższe wyniki wyglądały
  // jak zwinięte/niewidoczne.
  if (m.kind === 'tool') {
    const c = document.createElement('div');
    c.className = 'jz-ai-toolchip jz-ai-toolchip-result';
    const text = String(m.content || '').replace(/^@@JZ_RESULT@@\s*/, '');
    const nl = text.indexOf('\n');
    const toolName = nl >= 0 ? text.slice(0, nl) : text;
    const body = nl >= 0 ? text.slice(nl + 1) : '';
    const head = document.createElement('div');
    head.className = 'jz-ai-toolchip-head';
    const caret = '<span class="jz-ai-toolchip-caret">' + (body ? '▸' : '') + '</span>';
    // v3.2.45 — per-toolchip duration stamp from envelope meta.durationMs.
    let durBadge = '';
    const env0 = _aiTryParseEnvelope(body);
    if (env0 && env0.meta && typeof env0.meta.durationMs === 'number') {
      durBadge = ' <span class="jz-ai-toolchip-dur" title="' + _aiEsc(_aiT('aiHelper.win.toolDur')) + '">⏱ ' +
        _aiFormatDuration(env0.meta.durationMs) + '</span>';
    }
    head.innerHTML = caret + '<span>&#x2713; ' + _aiEsc(_aiT('aiHelper.tool.label')) + ': ' + _aiEsc(toolName) + durBadge + '</span>';
    c.appendChild(head);
    // od v3.2.28 — screenshot_page / screenshot_element toolchip: miniaturka.
    // Wynik zawiera id postaci "sc_xxx" — szukamy w mapce _aiScreenshotById.
    if (/^screenshot_(page|element)/i.test(toolName)) {
      const idMatch = /\bsc_[a-z0-9]+\b/i.exec(text);
      if (idMatch && _aiScreenshotById && _aiScreenshotById.has(idMatch[0])) {
        const dataUrl = _aiScreenshotById.get(idMatch[0]);
        const img = document.createElement('img');
        img.className = 'jz-ai-screen-thumb';
        img.src = dataUrl;
        img.alt = 'screenshot ' + idMatch[0];
        img.addEventListener('click', (ev) => {
          ev.stopPropagation();
          const modal = document.createElement('div');
          modal.className = 'jz-ai-screen-modal';
          const big = document.createElement('img');
          big.src = dataUrl;
          modal.appendChild(big);
          modal.addEventListener('click', () => modal.remove());
          document.documentElement.appendChild(modal);
        });
        c.appendChild(img);
      }
    }
    if (body) {
      // v3.2.40 — Tool results are JSON envelopes {data, meta, suggestions?, warnings?}.
      // Detect and render structured; fall back to plain text for legacy / parse-error sentinels.
      const envelope = _aiTryParseEnvelope(body);
      if (envelope) {
        // Visual cues from meta/warnings: red border on error, ⚠ truncation badge.
        if (envelope.meta && envelope.meta.error) c.classList.add('jz-ai-toolchip-error');
        if (envelope.meta && envelope.meta.truncated) {
          const badge = document.createElement('span');
          badge.className = 'jz-ai-toolchip-badge';
          badge.title = 'Result truncated';
          badge.textContent = '⚠';
          head.querySelector('span:last-child').appendChild(badge);
        }
        // Warnings — visible above body, red.
        if (Array.isArray(envelope.warnings) && envelope.warnings.length) {
          const wbox = document.createElement('div');
          wbox.className = 'jz-ai-toolchip-warnings';
          envelope.warnings.forEach(w => {
            const li = document.createElement('div');
            li.className = 'jz-ai-toolchip-warn';
            li.textContent = '⚠ ' + String(w);
            wbox.appendChild(li);
          });
          c.appendChild(wbox);
        }
        // Body — JSON pretty for data + meta; collapsed by default.
        const pre = document.createElement('pre');
        pre.className = 'jz-ai-toolchip-body';
        pre.textContent = body;
        c.appendChild(pre);
        // Suggestions — gray hints below body, always visible.
        if (Array.isArray(envelope.suggestions) && envelope.suggestions.length) {
          const sbox = document.createElement('div');
          sbox.className = 'jz-ai-toolchip-suggestions';
          envelope.suggestions.forEach(s => {
            const li = document.createElement('div');
            li.className = 'jz-ai-toolchip-suggestion';
            li.textContent = '💡 ' + String(s);
            sbox.appendChild(li);
          });
          c.appendChild(sbox);
        }
      } else {
        // Legacy plain-text result (parse-error sentinels, pre-v3.2.40 messages).
        const pre = document.createElement('pre');
        pre.className = 'jz-ai-toolchip-body';
        pre.textContent = body;
        c.appendChild(pre);
      }
      head.addEventListener('click', () => {
        const open = c.classList.toggle('jz-ai-toolchip-open');
        const caretEl = head.querySelector('.jz-ai-toolchip-caret');
        if (caretEl) caretEl.textContent = open ? '▾' : '▸';
      });
    } else {
      // Brak body → nie ma czego rozwijać; head bez kursora.
      head.style.cursor = 'default';
    }
    return c;
  }
  const d = document.createElement('div');
  d.className = 'jz-ai-msg jz-ai-msg-' + role;
  const r = document.createElement('div');
  r.className = 'jz-ai-msg-role';
  r.textContent = _aiT(role === 'user' ? 'aiHelper.win.you' : 'aiHelper.win.assistant');
  d.appendChild(r);
  // Wyłuskaj blok wywołania narzędzia z odpowiedzi asystenta.
  let toolName = '';
  let content = String(m.content || '').replace(/@@JZ_TOOL@@([\s\S]*?)@@END@@/g, (full, inner) => {
    try { const o = JSON.parse(inner.trim()); toolName = (o && o.tool) || '?'; } catch (_) { toolName = '?'; }
    return '';
  }).trim();
  if (content) {
    const b = document.createElement('div');
    b.className = 'jz-ai-msg-body';
    b.innerHTML = _aiFormat(content);
    d.appendChild(b);
    // od v3.2.38 — pływająca ikona kopiowania w prawym dolnym rogu odpowiedzi
    // assistanta. Kopiuje surową treść (content) do schowka.
    if (role === 'assistant') {
      const copyBtn = document.createElement('button');
      copyBtn.type = 'button';
      copyBtn.className = 'jz-ai-msg-copy';
      copyBtn.title = _aiT('aiHelper.win.copy') || 'Skopiuj odpowiedź';
      copyBtn.setAttribute('aria-label', 'Copy');
      copyBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" '
        + 'stroke-linecap="square" stroke-linejoin="miter" width="14" height="14" aria-hidden="true">'
        + '<rect x="9" y="9" width="13" height="13" rx="2"/>'
        + '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
      copyBtn.addEventListener('click', (ev) => {
        ev.preventDefault();
        ev.stopPropagation();
        const copyText = content; // surowy tekst, nie zformatowany HTML
        try {
          if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(copyText).then(() => {
              copyBtn.classList.add('jz-ai-msg-copy-ok');
              setTimeout(() => copyBtn.classList.remove('jz-ai-msg-copy-ok'), 1500);
            }).catch(() => {});
          }
        } catch (_) {}
      });
      d.appendChild(copyBtn);
    }
  }
  if (toolName) {
    const chip = document.createElement('div');
    chip.className = 'jz-ai-toolchip';
    chip.textContent = '🔧 ' + _aiT('aiHelper.tool.label') + ': ' + toolName;
    d.appendChild(chip);
  }
  // v3.2.45 — per-message telemetry footer for assistant messages: tokens this
  // round (input/output) + LLM response time. Pulled from message metadata
  // stamped in _aiRunAgent. Only shown when usage info is present.
  if (role === 'assistant' && (m._usage || m._durationMs)) {
    const foot = document.createElement('div');
    foot.className = 'jz-ai-msg-telem';
    const parts = [];
    if (m._usage && (m._usage.input || m._usage.output)) {
      parts.push(_aiFormatTokens(m._usage.input || 0) + '↑/' + _aiFormatTokens(m._usage.output || 0) + '↓');
    }
    if (m._durationMs) parts.push('⏱ ' + _aiFormatDuration(m._durationMs));
    if (parts.length) {
      foot.textContent = parts.join(' · ');
      foot.title = _aiT('aiHelper.win.msgTelem');
      d.appendChild(foot);
    }
  }
  return d;
}
// v3.2.40 — Detect and parse the standard tool result envelope. Returns the
// parsed object iff it looks like {data, meta, suggestions?, warnings?}, else null.
function _aiTryParseEnvelope(body) {
  if (!body || typeof body !== 'string') return null;
  const trimmed = body.trim();
  if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return null;
  try {
    const o = JSON.parse(trimmed);
    if (o && typeof o === 'object' && !Array.isArray(o) && 'data' in o) return o;
  } catch (_) {}
  return null;
}

// markdown-lite: bloki ``` → <pre>, reszta jako tekst pre-wrap.
// Znane etykiety języka po otwierającym ```. Tylko taka pierwsza linia bloku
// kodu jest obcinana — wcześniejszy regex /^[a-zA-Z0-9]{1,12}$/ obcinał też
// prawdziwą linię kodu będącą pojedynczym słowem (np. `function`, `const`).
const _AI_LANG_TAGS = new Set([
  'js', 'javascript', 'jsx', 'ts', 'typescript', 'tsx',
  'css', 'scss', 'less', 'html', 'xml', 'json', 'json5',
  'bash', 'sh', 'shell', 'yaml', 'yml', 'md', 'markdown',
  'plaintext', 'text', 'txt',
]);

function _aiFormat(text) {
  const parts = String(text == null ? '' : text).split('```');
  let out = '';
  parts.forEach((seg, i) => {
    if (i % 2 === 1) {
      let body = seg;
      const nl = body.indexOf('\n');
      if (nl >= 0) {
        const tag = body.slice(0, nl).trim().toLowerCase();
        if (tag && _AI_LANG_TAGS.has(tag)) body = body.slice(nl + 1);
      }
      out += '<pre class="jz-ai-code">' + _aiEsc(body.replace(/\n$/, '')) + '</pre>';
    } else if (seg) {
      out += '<span class="jz-ai-text">' + _aiEsc(seg) + '</span>';
    }
  });
  return out;
}

// od v3.2.11 — inline-form bubble do potwierdzeń / pytań / wyborów. Zastępuje
// window.confirm w AI Helperze i zapewnia tool request_user_input dla modelu.
// spec: { title, description, fields:[{name,type,label,default,placeholder,
//   options,min,max,step,checkLabel}], submitLabel, cancelLabel }
// Zwraca Promise<{action:'submit'|'cancel', values:{name:val,…}}>.
// Tylko jedna aktywna naraz; zamknięcie okna AI Helpera anuluje pending form.
let _aiPendingForm = null;
function _aiShowInlineForm(el, spec) {
  return new Promise((resolve) => {
    if (!el || !el.isConnected) { resolve({ action: 'cancel', values: {} }); return; }
    const box = el.querySelector('.jz-ai-msgs');
    if (!box) { resolve({ action: 'cancel', values: {} }); return; }
    // Współbieżność: jeżeli inny form jest otwarty, anuluj go najpierw.
    if (_aiPendingForm) {
      try { _aiPendingForm.cancel('superseded'); } catch (_) {}
      _aiPendingForm = null;
    }
    const fields = Array.isArray(spec && spec.fields) ? spec.fields : [];
    const submitLabel = (spec && spec.submitLabel) || _aiT('aiHelper.form.submit');
    const cancelLabel = (spec && spec.cancelLabel) || _aiT('aiHelper.form.cancel');

    const d = document.createElement('div');
    d.className = 'jz-ai-msg-form';
    if (spec && spec.title) {
      const t = document.createElement('div');
      t.className = 'jz-ai-msg-form-title';
      t.textContent = spec.title;
      d.appendChild(t);
    }
    if (spec && spec.description) {
      const desc = document.createElement('div');
      desc.className = 'jz-ai-msg-form-desc';
      desc.textContent = spec.description;
      d.appendChild(desc);
    }
    fields.forEach((f) => {
      if (!f || !f.name) return;
      const wrap = document.createElement('div');
      wrap.className = 'jz-ai-form-field';
      if (f.label && f.type !== 'checkbox') {
        const l = document.createElement('label');
        l.textContent = String(f.label);
        wrap.appendChild(l);
      }
      let inp;
      const type = String(f.type || 'text');
      if (type === 'textarea') {
        inp = document.createElement('textarea');
        if (f.placeholder) inp.placeholder = String(f.placeholder);
        inp.value = (f.default != null) ? String(f.default) : '';
      } else if (type === 'select') {
        inp = document.createElement('select');
        (Array.isArray(f.options) ? f.options : []).forEach(o => {
          const op = document.createElement('option');
          const ov = (typeof o === 'object') ? o.value : o;
          const ol = (typeof o === 'object') ? (o.label || o.value) : o;
          op.value = String(ov);
          op.textContent = String(ol);
          if (String(ov) === String(f.default || '')) op.selected = true;
          inp.appendChild(op);
        });
      } else if (type === 'number' || type === 'color' || type === 'text') {
        inp = document.createElement('input');
        inp.type = type;
        if (f.placeholder) inp.placeholder = String(f.placeholder);
        if (f.default != null) inp.value = String(f.default);
        if (type === 'number') {
          if (f.min != null) inp.min = String(f.min);
          if (f.max != null) inp.max = String(f.max);
          if (f.step != null) inp.step = String(f.step);
        }
      } else if (type === 'checkbox') {
        // Wrapper-jako-label, input + tekst (checkLabel lub label).
        const lab = document.createElement('label');
        lab.className = 'jz-ai-form-check';
        const cb = document.createElement('input');
        cb.type = 'checkbox';
        cb.className = 'jz-ai-form-input';
        cb.dataset.name = f.name;
        if (f.default) cb.checked = true;
        lab.appendChild(cb);
        const sp = document.createElement('span');
        sp.textContent = String(f.checkLabel || f.label || '');
        lab.appendChild(sp);
        wrap.appendChild(lab);
        d.appendChild(wrap);
        return; // pomijamy dolny appendChild — checkbox ma wrapper-label
      } else {
        inp = document.createElement('input');
        inp.type = 'text';
        if (f.default != null) inp.value = String(f.default);
      }
      inp.className = 'jz-ai-form-input';
      inp.dataset.name = f.name;
      wrap.appendChild(inp);
      d.appendChild(wrap);
    });
    // od v3.2.38 — wolny tekst jako ALTERNATYWA dla pól formularza. User może
    // odpowiedzieć tekstem zamiast wypełniać pola — model dostaje wynik
    // z polem `text` w JSON-ie. Zawsze widoczne (nawet bez pól — wtedy główne).
    const freeWrap = document.createElement('div');
    freeWrap.className = 'jz-ai-form-free';
    const freeLabel = document.createElement('label');
    freeLabel.textContent = fields.length
      ? (_aiT('aiHelper.form.freeText') || 'Lub odpowiedz wolnym tekstem:')
      : (_aiT('aiHelper.form.freeTextOnly') || 'Wolny tekst:');
    const freeText = document.createElement('textarea');
    freeText.className = 'jz-ai-form-free-text';
    freeText.rows = fields.length ? 2 : 3;
    freeText.placeholder = _aiT('aiHelper.form.freeTextPlaceholder')
      || 'Wpisz odpowiedź wolnym tekstem (Ctrl+Enter = wyślij).';
    freeWrap.appendChild(freeLabel);
    freeWrap.appendChild(freeText);
    d.appendChild(freeWrap);

    const actions = document.createElement('div');
    actions.className = 'jz-ai-form-actions';
    const cancelBtn = document.createElement('button');
    cancelBtn.type = 'button'; cancelBtn.className = 'jz-ai-form-cancel';
    cancelBtn.textContent = cancelLabel;
    const submitBtn = document.createElement('button');
    submitBtn.type = 'button'; submitBtn.className = 'jz-ai-form-submit';
    submitBtn.textContent = submitLabel;
    actions.appendChild(cancelBtn); actions.appendChild(submitBtn);
    d.appendChild(actions);
    box.appendChild(d);
    box.scrollTop = box.scrollHeight;

    // Fokus na pierwszym polu (jeśli jest).
    const firstInput = d.querySelector('.jz-ai-form-input, .jz-ai-form-check input');
    if (firstInput) try { firstInput.focus(); } catch (_) {}

    let settled = false;
    const collectValues = () => {
      const out = {};
      d.querySelectorAll('.jz-ai-form-input, .jz-ai-form-check input').forEach((inp) => {
        const name = inp.dataset.name;
        if (!name) return;
        if (inp.type === 'checkbox') out[name] = !!inp.checked;
        else if (inp.type === 'number') {
          const n = parseFloat(inp.value);
          out[name] = isFinite(n) ? n : null;
        } else {
          out[name] = inp.value;
        }
      });
      return out;
    };
    const finish = (action) => {
      if (settled) return;
      settled = true;
      _aiPendingForm = null;
      document.removeEventListener('keydown', onKey, true);
      const values = action === 'submit' ? collectValues() : {};
      const text = action === 'submit' ? (freeText.value || '').trim() : ''; // od v3.2.38
      // Po rozstrzygnięciu form zostaje w chacie jako podsumowanie, inputy ukryte.
      d.classList.add('jz-ai-msg-form-done');
      const summary = document.createElement('div');
      summary.className = 'jz-ai-msg-form-result';
      if (action === 'submit') {
        const parts = Object.keys(values).map(k => k + '=' + (
          (typeof values[k] === 'string' && values[k].length > 40)
            ? values[k].slice(0, 40) + '…' : String(values[k])
        ));
        if (text) parts.push('text=' + (text.length > 40 ? text.slice(0, 40) + '…' : text));
        summary.textContent = '↳ ' + _aiT('aiHelper.form.confirmed')
          + (parts.length ? ': ' + parts.join(', ') : '');
      } else {
        summary.textContent = '↳ ' + _aiT('aiHelper.form.cancelled');
      }
      d.appendChild(summary);
      // od v3.2.38 — dodatkowe pole `text` w wyniku (alternatywa free-text).
      resolve({ action: action, values: values, text: text });
    };
    submitBtn.addEventListener('click', () => finish('submit'));
    cancelBtn.addEventListener('click', () => finish('cancel'));
    const onKey = (ev) => {
      if (ev.key === 'Escape') { ev.preventDefault(); finish('cancel'); }
      else if (ev.key === 'Enter' && (ev.ctrlKey || ev.metaKey)) {
        ev.preventDefault(); finish('submit');
      }
    };
    document.addEventListener('keydown', onKey, true);
    _aiPendingForm = { cancel: () => finish('cancel') };
  });
}

// Skrót: prosty Tak/Nie confirm w AI Helperze. Zastępuje window.confirm.
async function _aiInlineConfirm(el, message, title) {
  const r = await _aiShowInlineForm(el, {
    title: title || _aiT('aiHelper.form.confirmTitle'),
    description: message || '',
    fields: [],
    submitLabel: _aiT('aiHelper.form.yes'),
    cancelLabel: _aiT('aiHelper.form.no'),
  });
  return r.action === 'submit';
}

// od v3.2.15 — central logger błędów AI Helpera. Wszystkie błędy narzędziowe
// (rzucone wyjątki w _aiExecTool, niepowodzenia backend round-tripów, parse
// errors, błędy providera) trafiają tu. Trzy wyjścia:
//   1. console.error('[JustZix AI]', scope, entry) — natychmiast w DevTools.
//   2. Rolling buffer jzAiErrors w chrome.storage.local (ostatnie 50, per-device,
//      NIE syncowany). Dostęp przez chrome.storage inspector; viewer w popupie
//      to TODO.
//   3. Push do otwartych Output Console jako wpis 'error' z tagiem jz/error.
const AI_ERROR_LOG_KEY = 'jzAiErrors';
const AI_ERROR_LOG_MAX = 50;
async function _aiLogError(scope, payload) {
  payload = payload || {};
  const err = payload.error;
  const entry = {
    ts: Date.now(),
    scope: String(scope || 'unknown'),
    tool: payload.tool ? String(payload.tool) : undefined,
    args: payload.args || undefined,
    provider: payload.provider ? String(payload.provider) : undefined,
    model: payload.model ? String(payload.model) : undefined,
    round: typeof payload.round === 'number' ? payload.round : undefined,
    error: {
      message: (err && err.message) ? String(err.message)
             : (typeof err === 'string') ? err
             : String(err || ''),
      stack: (err && err.stack) ? String(err.stack).slice(0, 2000) : undefined,
    },
    extra: payload.extra || undefined,
  };
  try { console.error('[JustZix AI]', scope, entry); } catch (_) {}
  // Rolling buffer w storage — read-modify-write, ale dla 50 wpisów akceptowalne.
  try {
    const data = await safeStorageGet([AI_ERROR_LOG_KEY]);
    const log = Array.isArray(data[AI_ERROR_LOG_KEY]) ? data[AI_ERROR_LOG_KEY] : [];
    log.push(entry);
    while (log.length > AI_ERROR_LOG_MAX) log.shift();
    await safeStorageSet({ [AI_ERROR_LOG_KEY]: log });
  } catch (_) {}
  // Output Console pickup — wpis typu jz/error z naszym tagiem.
  try {
    const text = '[AI/' + entry.scope + ']'
      + (entry.tool ? ' tool=' + entry.tool : '')
      + (entry.provider ? ' provider=' + entry.provider : '')
      + ' — ' + entry.error.message;
    const ocEntry = {
      kind: 'jz', type: 'error',
      text: text,
      detail: entry.error.stack || (entry.args ? JSON.stringify(entry.args).slice(0, 500) : ''),
      t: entry.ts,
    };
    if (typeof _forEachLiveOutputSink === 'function') {
      _forEachLiveOutputSink(s => { try { s.append(ocEntry); } catch (_) {} });
    }
  } catch (_) {}
}

function _aiShowNotice(el, text) {
  const box = el.querySelector('.jz-ai-msgs');
  let n = box.querySelector('.jz-ai-notice');
  if (!n) {
    n = document.createElement('div');
    n.className = 'jz-ai-notice';
    box.insertBefore(n, box.firstChild);
  }
  n.textContent = text;
}

// od v3.2.8 — zastosuj zapamiętany font / rozmiar do treści wiadomości.
// v3.2.10: CSS variables (--jz-ai-fs / --jz-ai-ff / --jz-ai-code-fs).
// v3.2.12: dodano inline-style fallback z !important na każdym .jz-ai-msg-body /
// .jz-ai-code. Sam var() teoretycznie wystarczy, ale w praktyce u użytkownika
// rozmiar nie wchodził — inline style z !important jest najsilniejszą ścieżką
// i działa nawet, gdyby ktoś zlinkował nadpisujące style z !important.
// Wywoływane na końcu każdego _aiRenderMessages (bo nowe wiadomości tracą style).
// od v3.2.18 — per-role styling. Generuje dynamiczny CSS w <style id="jz-ai-fs-style">
// dla każdej z 4 ról (user/assistant/tool/form), każdej z 4 properties (font/size/
// color/bg). Selektor #jz-ai-helper .role-class = specyficzność 1,1,0 + !important
// — przebija wszystko. Legacy fallback: jeśli styles dla roli są puste, używamy
// _aiHelperWin.bodyFont/bodyFontSize jako wspólnego defaultu dla user+assistant
// (kompatybilność wsteczna z v3.2.10–v3.2.17 gdzie był jeden font/size dla wszystkiego).
function _aiApplyStyles(el) {
  if (!el) return;
  const legacyFont = (_aiHelperWin && typeof _aiHelperWin.bodyFont === 'string') ? _aiHelperWin.bodyFont : '';
  const legacySize = (_aiHelperWin && typeof _aiHelperWin.bodyFontSize === 'number') ? _aiHelperWin.bodyFontSize : 0;
  const get = (role, prop) => {
    const s = _aiGetRoleStyle(role);
    return s && s[prop] ? s[prop] : '';
  };
  const sizePx = (role) => {
    const s = get(role, 'size');
    if (s && s >= 10 && s <= 24) return s + 'px';
    // Legacy fallback dla user+assistant.
    if ((role === 'user' || role === 'assistant') && legacySize >= 10 && legacySize <= 24) {
      return legacySize + 'px';
    }
    return '';
  };
  const fontOf = (role) => {
    const f = get(role, 'font');
    if (f) return f;
    if ((role === 'user' || role === 'assistant') && legacyFont) return legacyFont;
    return '';
  };
  const ruleFor = (selector, role, opts) => {
    opts = opts || {};
    const fs = opts.size === false ? '' : sizePx(role);
    const ff = opts.font === false ? '' : fontOf(role);
    const cl = opts.color === false ? '' : get(role, 'color');
    const bg = opts.bg === false ? '' : get(role, 'bg');
    if (!fs && !ff && !cl && !bg) return '';
    let out = selector + '{';
    if (fs) out += 'font-size:' + fs + ' !important;';
    if (ff) out += 'font-family:' + ff + ' !important;';
    if (cl) out += 'color:' + cl + ' !important;';
    if (bg) out += 'background:' + bg + ' !important;';
    out += '}';
    return out;
  };
  // od v3.2.76 — okno żyje w shadow root; <style> jest wstrzykiwany do shadowRoot,
  // więc selektory są lokalne. Prefix .jz-pane-ai = klasa real pane'a wewnątrz hosta.
  const ID = '.jz-pane-ai';
  let css = '';
  // USER: msg-user .msg-body + input textarea (też user-input)
  css += ruleFor(ID + ' .jz-ai-msg-user .jz-ai-msg-body', 'user');
  css += ruleFor(ID + ' .jz-ai-input', 'user', { color: false, bg: false });
  // ASSISTANT: msg-assistant .msg-body + role label
  css += ruleFor(ID + ' .jz-ai-msg-assistant .jz-ai-msg-body', 'assistant');
  // Code block w odpowiedzi assistanta — rozmiar -1px, font monospace dziedziczony lokalnie
  const asize = sizePx('assistant');
  const afont = fontOf('assistant');
  const acolor = get('assistant', 'color');
  if (asize || afont || acolor) {
    let codeRule = ID + ' .jz-ai-msg-assistant .jz-ai-code{';
    if (asize) {
      const n = parseInt(asize, 10) - 1;
      codeRule += 'font-size:' + Math.max(9, n) + 'px !important;';
    }
    // .jz-ai-code ma własny monospace font — NIE nadpisujemy chyba że user explicit ustawi
    codeRule += '}';
    css += codeRule;
  }
  // TOOL: oba warianty — .jz-ai-toolchip (chip nazwy narzędzia "🔧 tool: …"
  // w odpowiedzi assistanta) ORAZ .jz-ai-toolchip-result (zielony chip z wynikiem).
  // Selector .jz-ai-toolchip pokrywa oba bo result-chip ma obie klasy. (od v3.2.19)
  css += ruleFor(ID + ' .jz-ai-toolchip', 'tool');
  const tsize = sizePx('tool');
  const tfont = fontOf('tool');
  const tcolor = get('tool', 'color');
  if (tsize || tfont || tcolor) {
    let bodyRule = ID + ' .jz-ai-toolchip-body{';
    if (tsize) {
      const n = parseInt(tsize, 10);
      bodyRule += 'font-size:' + Math.max(9, n) + 'px !important;';
    }
    if (tfont) bodyRule += 'font-family:' + tfont + ' !important;';
    if (tcolor) bodyRule += 'color:' + tcolor + ' !important;';
    bodyRule += '}';
    css += bodyRule;
    let headRule = ID + ' .jz-ai-toolchip-head{';
    if (tsize) headRule += 'font-size:' + tsize + ' !important;';
    if (tfont) headRule += 'font-family:' + tfont + ' !important;';
    if (tcolor) headRule += 'color:' + tcolor + ' !important;';
    headRule += '}';
    css += headRule;
  }
  // FORM: msg-form + dzieci (title, desc, label)
  css += ruleFor(ID + ' .jz-ai-msg-form', 'form');
  const ffont = fontOf('form');
  const fsize = sizePx('form');
  const fcolor = get('form', 'color');
  if (ffont || fsize || fcolor) {
    let chRule = ID + ' .jz-ai-msg-form-title,' + ID + ' .jz-ai-msg-form-desc,' + ID + ' .jz-ai-form-field label{';
    if (ffont) chRule += 'font-family:' + ffont + ' !important;';
    if (fcolor) chRule += 'color:' + fcolor + ' !important;';
    chRule += '}';
    css += chRule;
  }

  // od v3.2.76 — wstrzykuj <style> do shadowRoot okna (nie do document.head), bo
  // selektory są lokalne dla shadow. Fallback do document.head gdy okno jeszcze
  // nie ma shadow root (np. wczesna ścieżka rerendera) — w praktyce nie strzela.
  const sroot = _aiWinShadowRoot();
  const target = sroot || document.head || document.documentElement;
  let styleEl = target.querySelector('#jz-ai-fs-style');
  if (!styleEl) {
    styleEl = document.createElement('style');
    styleEl.id = 'jz-ai-fs-style';
    target.appendChild(styleEl);
  }
  styleEl.textContent = css;
}
// Alias dla wstecznej kompatybilności (pozostałe call-site jeszcze nie zmienione).
function _aiApplyFontSize(el) { return _aiApplyStyles(el); }

// od v3.2.8 — kopiuje całą rozmowę jako sformatowany tekst do schowka.
async function _aiCopyConversation(el) {
  const chat = _aiGetChat();
  const meta = _aiGetMeta();
  const stamp = new Date().toISOString().replace('T', ' ').slice(0, 16);
  const head = '# ' + _aiT('aiHelper.copy.title')
    + '\n' + 'Provider: ' + (meta.provider || '?') + ' | Model: ' + (meta.model || '?')
    + ' | ' + stamp + '\n\n---\n';
  const parts = [head];
  chat.forEach((m) => {
    if (m.kind === 'tool') {
      const txt = String(m.content || '').replace(/^@@JZ_RESULT@@\s*/, '');
      const nl = txt.indexOf('\n');
      const toolName = nl >= 0 ? txt.slice(0, nl) : txt;
      const body = nl >= 0 ? txt.slice(nl + 1) : '';
      parts.push('## ' + _aiT('aiHelper.copy.toolResult') + ' (' + toolName + ')\n' + body + '\n');
      return;
    }
    const role = (m.role === 'user') ? _aiT('aiHelper.win.you') : _aiT('aiHelper.win.assistant');
    let content = String(m.content || '').replace(/@@JZ_TOOL@@([\s\S]*?)@@END@@/g, (_full, inner) => {
      try {
        const o = JSON.parse(inner.trim());
        const args = o && o.args ? '\n```json\n' + JSON.stringify(o.args, null, 2) + '\n```' : '';
        return '\n→ ' + _aiT('aiHelper.tool.label') + ': ' + ((o && o.tool) || '?') + args;
      } catch (_) { return '\n→ ' + _aiT('aiHelper.tool.label') + ': ?'; }
    }).trim();
    parts.push('## ' + role + '\n' + content + '\n');
  });
  const txt = parts.join('\n');
  let ok = false;
  try { await navigator.clipboard.writeText(txt); ok = true; } catch (_) {}
  if (!ok) {
    // Fallback: textarea + execCommand.
    try {
      const ta = document.createElement('textarea');
      ta.value = txt; ta.style.position = 'fixed'; ta.style.left = '-10000px';
      document.documentElement.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      ta.remove();
      ok = true;
    } catch (_) {}
  }
  _aiShowNotice(el, _aiT(ok ? 'aiHelper.win.copied' : 'aiHelper.win.copyFailed'));
  setTimeout(() => {
    const n = el.querySelector('.jz-ai-notice');
    if (n && n.textContent === _aiT('aiHelper.win.copied')) n.remove();
  }, 2200);
}

// od v3.2.81/v3.2.83/v3.2.87 — kopiuje OSTATNI PEŁEN PROMPT wysłany do modelu (system
// + całą historię wiadomości + ostatni user input). Format = role\n<content>\n
// per turn (identyczny z debug-dumpu API). Cały blok poprzedzony instrukcją
// dla agenta na drugiej karcie: „wyślij ten dump do API jako pierwszą user-message
// nowej rozmowy — model dostanie wcześniejszy kontekst i może kontynuować pracę".
// Use case: przeniesienie zadania z jednej domeny na inną z zachowanym kontekstem.
async function _aiCopyLastUserInput(el) {
  const system = _aiBuildSystem();
  const messages = _aiBuildApiMessages();
  if (!messages.length) {
    _aiShowNotice(el, _aiT('aiHelper.win.copyNoInput') || 'Brak inputu do skopiowania');
    return;
  }
  // Serializacja identyczna z formatem debug-dumpu — `role\n<content>\n` per turn,
  // bez separatorów; model rozumie konwencję chat-transcript.
  let dump = 'system\n' + system + '\n';
  for (const m of messages) {
    const role = m.role || 'user';
    dump += role + '\n' + String(m.content || '') + '\n';
  }
  const prefix = _aiT('aiHelper.copy.lastInput.prefix')
    || '[JustZix → przekaż do API] Skopiowano CAŁY ostatni prompt wysłany do modelu (system + historia + ostatnia wiadomość). Wyślij poniższy blok do API jako PIERWSZĄ user-message nowej rozmowy. USUŃ ten nagłówek przed wysłaniem.';
  const txt = prefix + '\n\n---\n\n' + dump;
  let ok = false;
  try { await navigator.clipboard.writeText(txt); ok = true; } catch (_) {}
  if (!ok) {
    try {
      const ta = document.createElement('textarea');
      ta.value = txt; ta.style.position = 'fixed'; ta.style.left = '-10000px';
      document.documentElement.appendChild(ta);
      ta.select();
      document.execCommand('copy');
      ta.remove();
      ok = true;
    } catch (_) {}
  }
  _aiShowNotice(el, _aiT(ok ? 'aiHelper.win.copied' : 'aiHelper.win.copyFailed'));
  setTimeout(() => {
    const n = el.querySelector('.jz-ai-notice');
    if (n && n.textContent === _aiT('aiHelper.win.copied')) n.remove();
  }, 2200);
}

// Wpina dropdown menu pod buttonem .jz-ai-copy (od v3.2.81). Klik trigger → toggle menu,
// klik na item → odpala _aiCopyConversation lub _aiCopyLastUserInput i zamyka menu;
// klik poza menu → zamknij.
function _aiWireCopyDropdown(el) {
  const wrap = el.querySelector('.jz-ai-copy-wrap');
  if (!wrap) return;
  const btn  = wrap.querySelector('.jz-ai-copy');
  const menu = wrap.querySelector('.jz-ai-copy-menu');
  if (!btn || !menu) return;
  const close = () => {
    menu.hidden = true;
    btn.setAttribute('aria-expanded', 'false');
  };
  const open = () => {
    menu.hidden = false;
    btn.setAttribute('aria-expanded', 'true');
  };
  btn.addEventListener('click', (e) => {
    e.stopPropagation();
    if (menu.hidden) open(); else close();
  });
  menu.addEventListener('click', (e) => {
    const item = e.target && e.target.closest('.jz-ai-copy-item');
    if (!item) return;
    e.stopPropagation();
    const which = item.dataset.copy;
    close();
    if (which === 'all') _aiCopyConversation(el);
    else if (which === 'lastInput') _aiCopyLastUserInput(el);
  });
  // Klik poza menu → zamknij. Listener globalny w capture; e.target z document scope
  // dla shadow root retargetuje się na host, więc używamy composedPath() do detekcji
  // czy klik faktycznie był wewnątrz wrap (drugie kliknięcie trigger'a inaczej
  // close() odpalał się PRZED btn.click → menu reopen → user widzi „nic się nie dzieje").
  document.addEventListener('click', (e) => {
    if (menu.hidden) return;
    const path = (typeof e.composedPath === 'function') ? e.composedPath() : null;
    if (path && path.includes(wrap)) return; // klik w wrap (z shadow root)
    if (!path && wrap.contains(e.target)) return; // fallback dla starych browserów
    close();
  }, true);
}

// od v3.2.18 — menu kontekstowe (prawy klik na belce) z pozycją „Wygląd…"
// otwierającą rozbudowany panel per-rola (v3.2.10/12 miały tu inline font+size).
function _aiShowContextMenu(el, x, y) {
  let menu = document.getElementById('jz-ai-font-menu');
  if (menu) { menu.remove(); return; }
  menu = document.createElement('div');
  menu.id = 'jz-ai-font-menu';
  menu.className = 'jz-ai-font-menu';
  // od v3.2.21 — dodajemy opcje Z-order ("wyżej"/"niżej"). Działają na bazie
  // rect-overlap: skok ponad/pod cały zbiór kolidujących okien jednym kliknięciem.
  // od v3.2.22 — sekcja połączeń (Rozłącz od X) gdy AI Helper jest sklejony z czymś.
  const myId = '__ai-helper';
  const conns = (typeof positionCache !== 'undefined' && positionCache &&
                 Array.isArray(positionCache.barConnections))
    ? positionCache.barConnections.filter(c => c.aId === myId || c.bId === myId)
    : [];
  const parts = [];
  parts.push('<div class="jz-ai-ctx-item" data-act="zup">&#x2B06; ' + _aiEsc(_aiT('aiHelper.zorder.up')) + '</div>');
  parts.push('<div class="jz-ai-ctx-item" data-act="zdown">&#x2B07; ' + _aiEsc(_aiT('aiHelper.zorder.down')) + '</div>');
  if (conns.length > 0) {
    parts.push('<div class="jz-ai-ctx-sep"></div>');
    conns.forEach((c, idx) => {
      const otherId = c.aId === myId ? c.bId : c.aId;
      const otherName = _jzWindowDisplayName(otherId);
      const label = (typeof JZ_I18N !== 'undefined' && JZ_I18N.t)
        ? JZ_I18N.t('content.bar.disconnectFrom').replace(':name', otherName)
        : ('Rozłącz od: ' + otherName);
      parts.push('<div class="jz-ai-ctx-item" data-act="disc" data-idx="' + idx + '">&#x26D3; ' + _aiEsc(label) + '</div>');
    });
    if (conns.length > 1) {
      const lbl = (typeof JZ_I18N !== 'undefined' && JZ_I18N.t)
        ? JZ_I18N.t('content.bar.disconnectAll') : 'Rozłącz wszystkie połączenia';
      parts.push('<div class="jz-ai-ctx-item" data-act="disc-all">&#x274C; ' + _aiEsc(lbl) + '</div>');
    }
  }
  parts.push('<div class="jz-ai-ctx-sep"></div>');
  parts.push('<div class="jz-ai-ctx-item" data-act="style">&#x1F3A8; ' + _aiEsc(_aiT('aiHelper.style.title')) + '</div>');
  menu.innerHTML = parts.join('');
  document.documentElement.appendChild(menu);
  const vw = window.innerWidth, vh = window.innerHeight;
  const mw = menu.offsetWidth || 200, mh = menu.offsetHeight || 40;
  menu.style.top = Math.max(8, Math.min(y, vh - mh - 8)) + 'px';
  menu.style.left = Math.max(8, Math.min(x, vw - mw - 8)) + 'px';
  const closeMenu = () => {
    if (!menu) return;
    menu.remove(); menu = null;
    document.removeEventListener('mousedown', onDoc, true);
    document.removeEventListener('keydown', onKey, true);
  };
  menu.querySelector('[data-act="style"]').addEventListener('click', () => {
    closeMenu();
    _aiShowStylePanel(el);
  });
  menu.querySelector('[data-act="zup"]').addEventListener('click', () => {
    closeMenu();
    _jzMoveWindowZ(el, 'top');
  });
  menu.querySelector('[data-act="zdown"]').addEventListener('click', () => {
    closeMenu();
    _jzMoveWindowZ(el, 'bottom');
  });
  // od v3.2.22 — disconnect handlers (identyczne jak w showBarContextMenu).
  menu.querySelectorAll('[data-act="disc"]').forEach(item => {
    item.addEventListener('click', () => {
      const idx = parseInt(item.dataset.idx, 10);
      const c = conns[idx];
      if (c) removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide });
      closeMenu();
    });
  });
  const discAll = menu.querySelector('[data-act="disc-all"]');
  if (discAll) discAll.addEventListener('click', () => {
    conns.forEach(c => removeBarConnection({ aId: c.aId, aSide: c.aSide, bId: c.bId, bSide: c.bSide }));
    closeMenu();
  });
  const onDoc = (ev) => {
    if (menu && menu.contains(ev.target)) return;
    closeMenu();
  };
  const onKey = (ev) => { if (ev.key === 'Escape') closeMenu(); };
  setTimeout(() => {
    document.addEventListener('mousedown', onDoc, true);
    document.addEventListener('keydown', onKey, true);
  }, 0);
}

// od v3.2.18 — rozbudowany panel ustawień wyglądu AI Helpera. 4 wiersze
// (user/assistant/tool/form) × 4 kolumny (Font/Rozmiar/Tekst/Tło) + reset.
// Live-apply: każda zmiana natychmiast wywołuje _aiApplyStyles(el).
function _aiShowStylePanel(el) {
  let panel = document.getElementById('jz-ai-style-panel');
  if (panel) { panel.remove(); return; }
  const FONTS = ['', 'Inter', 'system-ui', 'Arial', 'Helvetica', 'Verdana',
                 'Georgia', 'Times New Roman', 'Consolas', 'Menlo', 'Monaco', 'Courier New'];
  const SIZES = [0, 10, 11, 12, 13, 14, 15, 16, 18, 20, 22, 24];
  const defaultLabel = _aiT('aiHelper.win.defaultFont');
  const ROLE_LABELS = {
    user:      _aiT('aiHelper.style.role.user'),
    assistant: _aiT('aiHelper.style.role.assistant'),
    tool:      _aiT('aiHelper.style.role.tool'),
    form:      _aiT('aiHelper.style.role.form'),
  };

  panel = document.createElement('div');
  panel.id = 'jz-ai-style-panel';
  panel.className = 'jz-ai-style-panel';
  let html =
    '<div class="jz-ai-sp-head">' +
      '<span class="jz-ai-sp-title">' + _aiEsc(_aiT('aiHelper.style.title')) + '</span>' +
      '<button class="jz-ai-sp-close" type="button" title="' + _aiEsc(_aiT('aiHelper.form.cancel')) + '">&times;</button>' +
    '</div>' +
    '<table class="jz-ai-sp-tbl"><thead><tr>' +
      '<th></th>' +
      '<th>' + _aiEsc(_aiT('aiHelper.style.font')) + '</th>' +
      '<th>' + _aiEsc(_aiT('aiHelper.style.size')) + '</th>' +
      '<th>' + _aiEsc(_aiT('aiHelper.style.color')) + '</th>' +
      '<th>' + _aiEsc(_aiT('aiHelper.style.bg')) + '</th>' +
      '<th></th>' +
    '</tr></thead><tbody>';
  AI_STYLE_ROLES.forEach((role) => {
    const cur = _aiGetRoleStyle(role) || {};
    const fontVal = cur.font || '';
    const sizeVal = cur.size || 0;
    const colorVal = cur.color || '';
    const bgVal = cur.bg || '';
    const fontOpts = FONTS.map(f => {
      const lbl = f === '' ? defaultLabel : f;
      const sel = (f === fontVal) ? ' selected' : '';
      return '<option value="' + _aiEsc(f) + '"' + sel + '>' + _aiEsc(lbl) + '</option>';
    }).join('');
    const sizeOpts = SIZES.map(s => {
      const lbl = s === 0 ? defaultLabel : (s + 'px');
      const sel = (s === sizeVal) ? ' selected' : '';
      return '<option value="' + s + '"' + sel + '>' + _aiEsc(lbl) + '</option>';
    }).join('');
    // Color pickers: input[type=color] + osobny checkbox "(auto)" do resetu.
    const colorAttr = colorVal ? (' value="' + _aiEsc(colorVal) + '"') : ' value="#888888"';
    const bgAttr = bgVal ? (' value="' + _aiEsc(bgVal) + '"') : ' value="#888888"';
    html +=
      '<tr data-role="' + role + '">' +
        '<td class="jz-ai-sp-role">' + _aiEsc(ROLE_LABELS[role] || role) + '</td>' +
        '<td><select class="jz-ai-sp-font">' + fontOpts + '</select></td>' +
        '<td><select class="jz-ai-sp-size">' + sizeOpts + '</select></td>' +
        '<td class="jz-ai-sp-color-cell">' +
          '<input type="color" class="jz-ai-sp-color"' + colorAttr + '>' +
          '<button class="jz-ai-sp-clear" type="button" data-field="color" title="' + _aiEsc(defaultLabel) + '">&times;</button>' +
          (colorVal ? '' : '<span class="jz-ai-sp-auto">auto</span>') +
        '</td>' +
        '<td class="jz-ai-sp-color-cell">' +
          '<input type="color" class="jz-ai-sp-bg"' + bgAttr + '>' +
          '<button class="jz-ai-sp-clear" type="button" data-field="bg" title="' + _aiEsc(defaultLabel) + '">&times;</button>' +
          (bgVal ? '' : '<span class="jz-ai-sp-auto">auto</span>') +
        '</td>' +
        '<td><button class="jz-ai-sp-row-reset" type="button" title="' + _aiEsc(_aiT('aiHelper.style.reset')) + '">&#x21BA;</button></td>' +
      '</tr>';
  });
  html += '</tbody></table>' +
    '<div class="jz-ai-sp-foot">' +
      '<button class="jz-ai-sp-reset-all" type="button">' + _aiEsc(_aiT('aiHelper.style.resetAll')) + '</button>' +
    '</div>';
  panel.innerHTML = html;
  document.documentElement.appendChild(panel);
  // Pozycja: lewa-góra z marginesem; user przeciąga belką jeżeli chce.
  panel.style.left = '40px';
  panel.style.top = '60px';

  const refreshRow = (role) => {
    const tr = panel.querySelector('tr[data-role="' + role + '"]');
    if (!tr) return;
    const cur = _aiGetRoleStyle(role) || {};
    tr.querySelector('.jz-ai-sp-font').value = cur.font || '';
    const sizeSel = tr.querySelector('.jz-ai-sp-size');
    sizeSel.value = String(cur.size || 0);
    // color cells — re-render "auto" badge
    const colorCells = tr.querySelectorAll('.jz-ai-sp-color-cell');
    [['color', cur.color], ['bg', cur.bg]].forEach((pair, idx) => {
      const cell = colorCells[idx];
      const input = cell.querySelector('input[type="color"]');
      const auto = cell.querySelector('.jz-ai-sp-auto');
      if (pair[1]) {
        input.value = pair[1];
        if (auto) auto.remove();
      } else {
        input.value = '#888888';
        if (!auto) {
          const span = document.createElement('span');
          span.className = 'jz-ai-sp-auto';
          span.textContent = 'auto';
          cell.appendChild(span);
        }
      }
    });
  };

  panel.querySelectorAll('tr[data-role]').forEach((tr) => {
    const role = tr.getAttribute('data-role');
    tr.querySelector('.jz-ai-sp-font').addEventListener('change', (e) => {
      _aiSetRoleStyle(role, { font: e.target.value || '' });
      _aiApplyStyles(el);
    });
    tr.querySelector('.jz-ai-sp-size').addEventListener('change', (e) => {
      const v = parseInt(e.target.value, 10);
      _aiSetRoleStyle(role, { size: (isFinite(v) && v >= 10 && v <= 24) ? v : 0 });
      _aiApplyStyles(el);
    });
    tr.querySelector('.jz-ai-sp-color').addEventListener('input', (e) => {
      _aiSetRoleStyle(role, { color: e.target.value });
      _aiApplyStyles(el);
      refreshRow(role);
    });
    tr.querySelector('.jz-ai-sp-bg').addEventListener('input', (e) => {
      _aiSetRoleStyle(role, { bg: e.target.value });
      _aiApplyStyles(el);
      refreshRow(role);
    });
    tr.querySelectorAll('.jz-ai-sp-clear').forEach((btn) => {
      btn.addEventListener('click', () => {
        const field = btn.getAttribute('data-field');
        _aiSetRoleStyle(role, { [field]: '' });
        _aiApplyStyles(el);
        refreshRow(role);
      });
    });
    tr.querySelector('.jz-ai-sp-row-reset').addEventListener('click', () => {
      _aiResetRoleStyle(role);
      _aiApplyStyles(el);
      refreshRow(role);
    });
  });
  panel.querySelector('.jz-ai-sp-reset-all').addEventListener('click', () => {
    _aiResetAllStyles();
    _aiApplyStyles(el);
    AI_STYLE_ROLES.forEach((r) => refreshRow(r));
  });
  panel.querySelector('.jz-ai-sp-close').addEventListener('click', () => {
    panel.remove();
    document.removeEventListener('keydown', onKey, true);
  });
  // Drag belki nagłówka.
  const head = panel.querySelector('.jz-ai-sp-head');
  head.addEventListener('mousedown', (e) => {
    if (e.target && e.target.closest('button')) return;
    e.preventDefault();
    const r = panel.getBoundingClientRect();
    const offX = e.clientX - r.left, offY = e.clientY - r.top;
    const onMove = (ev) => {
      panel.style.left = (ev.clientX - offX) + 'px';
      panel.style.top = Math.max(0, ev.clientY - offY) + 'px';
    };
    const onUp = () => {
      document.removeEventListener('mousemove', onMove, true);
      document.removeEventListener('mouseup', onUp, true);
    };
    document.addEventListener('mousemove', onMove, true);
    document.addEventListener('mouseup', onUp, true);
  });
  const onKey = (ev) => {
    if (ev.key === 'Escape') {
      panel.remove();
      document.removeEventListener('keydown', onKey, true);
    }
  };
  document.addEventListener('keydown', onKey, true);
}
function _aiSetBusy(el, busy) {
  el.querySelector('.jz-ai-input').disabled = busy;
  // od v3.2.8 — Send chowamy, Stop pokazujemy gdy busy (i odwrotnie).
  const send = el.querySelector('.jz-ai-send');
  const stop = el.querySelector('.jz-ai-stop');
  if (send) send.hidden = busy;
  if (stop) stop.hidden = !busy;
}

function _aiSend(el) {
  if (_aiHelperBusy) return;
  const input = el.querySelector('.jz-ai-input');
  const text = (input.value || '').trim();
  if (!text) return;
  const provider = el.querySelector('.jz-ai-provider').value;
  const model = el.querySelector('.jz-ai-model').value;
  if (!provider) { _aiShowNotice(el, _aiT('aiHelper.win.noKeys')); return; }
  if (!model) { _aiShowNotice(el, _aiT('aiHelper.win.noModel')); return; }
  _aiStopRequested = false; // od v3.2.8 — każda nowa wiadomość usera resetuje stop.
  _aiMarkUserActivity(); // v3.2.45 — reset "current action" tokens + start timers
  const chat = _aiGetChat();
  chat.push({ role: 'user', content: text });
  _aiSetChat(chat);
  input.value = '';
  _aiRenderMessages(el);
  _aiRunAgent(el, provider, model);
}

// Pojedyncze zapytanie do modelu przez background.
function _aiChatOnce(provider, model) {
  // v3.2.43 — Build API messages with tool-history compaction.
  // The chat UI keeps full history for the user; the API payload only ships
  // full content for the latest N tool results. Older tool results are
  // replaced with one-line summaries (count / message / error flag). Without
  // this every tool round shipped 30–50 KB of stale list_tools / search_elements
  // JSON to the model, negating the whole lazy-discovery design.
  const messages = _aiBuildApiMessages();
  // od v3.2.28 — wstrzykujemy zbuforowane screeny jako attachments do OSTATNIEJ
  // wiadomości user. Bufor jest rolling (max 3). Wstrzykujemy tylko gdy bufor
  // niepusty i ostatnia wiadomość to user — żeby nie balastować historii.
  // Po wysłaniu CZYŚCIMY bufor — screen jest świeży z definicji, ponowne
  // wysyłanie tych samych pixeli przy każdej iteracji tool-loopa = waste tokens.
  if (_aiScreenshotBuffer.length > 0) {
    for (let i = messages.length - 1; i >= 0; i--) {
      if (messages[i].role === 'user') {
        messages[i].attachments = _aiScreenshotBuffer.map(s => ({
          mimeType: s.mimeType, base64: s.base64,
        }));
        break;
      }
    }
    _aiScreenshotBuffer = []; // wysłane → pozbywamy się z pamięci
  }
  // v3.2.45 — measure LLM call duration + propagate usage to the caller.
  const callStartedAt = Date.now();
  return new Promise((res) => {
    safeSendMessage({
      type: 'jz-ai-chat', provider, model,
      system: _aiBuildSystem(), messages,
    }, (resp) => {
      const durationMs = Date.now() - callStartedAt;
      if (chrome.runtime.lastError) res({ ok: false, error: chrome.runtime.lastError.message, durationMs });
      else {
        const r = resp || { ok: false, error: 'no response' };
        r.durationMs = durationMs;
        res(r);
      }
    });
  });
}

// v3.2.53 — Build the messages array for the API. Heuristic per-tool-result
// compaction REMOVED — tool results ship FULL. With client-side trim (one tool
// call per turn) + wide-output limits, the history grows slowly enough; the
// LLM auto-compaction (>85% context, _aiCompactConversation) remains as the
// safety net for very long conversations. UI chat is not mutated.
function _aiBuildApiMessages() {
  return _aiGetChat().map(m => ({ role: m.role, content: m.content }));
}

// od v3.2.28 — bufor screenshotów. Trzymamy w pamięci content scriptu (znika na F5).
// dataUrl tu nie wpada — rozdzielamy base64 (do API) i przechowujemy też dataUrl
// w mapce id → dataUrl (do miniaturki w toolchipie).
const SCREENSHOT_BUFFER_MAX = 3;
let _aiScreenshotBuffer = []; // [{ id, mimeType, base64 }] — do wysłania w kolejnym chat
const _aiScreenshotById = new Map(); // id → dataUrl (do miniaturki, persist do końca sesji)

function _aiPushScreenshot(dataUrl) {
  // dataUrl format: "data:image/png;base64,XXXX..."
  const m = /^data:([^;]+);base64,(.+)$/.exec(dataUrl || '');
  if (!m) return null;
  const id = 'sc_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 5);
  const entry = { id, mimeType: m[1], base64: m[2] };
  _aiScreenshotBuffer.push(entry);
  while (_aiScreenshotBuffer.length > SCREENSHOT_BUFFER_MAX) {
    _aiScreenshotBuffer.shift();
  }
  _aiScreenshotById.set(id, dataUrl);
  return id;
}

function _aiHideJustZixUi(hide) {
  if (document && document.documentElement) {
    document.documentElement.classList.toggle('jz-screenshot-hide', !!hide);
  }
}

// Lista wzorców vision-capable modeli per dostawca. Używana w _aiToolScreenshotPage,
// żeby ostrzec gdy user wywoła screenshot na modelu bez vision (API odrzuci request).
// Tool handler — ukryj UI, capture, restore, push do bufora, zwróć tekst dla modelu.
let _aiLastScreenshotAt = 0;
async function _aiToolScreenshotPage() {
  // 500 ms cooldown — chrome.tabs.captureVisibleTab ma quota ~2 calls/sec.
  const now = Date.now();
  if (now - _aiLastScreenshotAt < 500) {
    return 'Screenshot pominięty — za szybkie powtórzenie (cooldown 500 ms). Spróbuj ponownie za chwilę albo wykonaj inną akcję między screenami.';
  }
  _aiLastScreenshotAt = now;
  // v3.2.57 — usunięto hardcodowany pre-check vision capability (lista modeli per
  // provider). Łamał zasadę „no hardcode capability flags" i starzał się. Nie
  // blokowaliśmy i tak — jeśli model nie obsługuje obrazów, API zwróci błąd, który
  // trafia do modelu/usera normalną ścieżką.
  _aiHideJustZixUi(true);
  // Dwa rAF = czekamy aż browser zrobi pełny paint (po visibility change).
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  const resp = await new Promise((res) => {
    safeSendMessage({ type: 'jz-capture-screenshot', format: 'png' }, (r) => {
      res(chrome.runtime.lastError ? null : r);
    });
  });
  _aiHideJustZixUi(false);
  if (!resp || !resp.ok) {
    return 'Błąd screenshot: ' + ((resp && resp.error) || 'brak odpowiedzi background');
  }
  const id = _aiPushScreenshot(resp.dataUrl);
  if (!id) return 'Błąd screenshot: niepoprawny dataUrl z capture.';
  const vw = window.innerWidth, vh = window.innerHeight;
  return 'screenshot zapisany (' + id + ' — ' + vw + '×' + vh + '). '
       + _aiScreenshotBuffer.length + '/' + SCREENSHOT_BUFFER_MAX
       + ' w buforze. Obraz dostępny w kolejnym zapytaniu jako attachment.';
}

// v3.2.40 — element crop screenshot. Uses MAIN-world page tools to compute the
// rect (via the jz-page-tool-call bridge), then captures full viewport in the
// background and crops the dataUrl on a canvas in the content world. Pushes to
// the same single-use buffer as screenshot_page.
async function _aiToolScreenshotElement(args) {
  const selector = String((args && args.selector) || '').trim();
  if (!selector) {
    return { data: null, meta: { error: true }, warnings: ['selector is required'], suggestions: [] };
  }
  const padding = Math.max(0, Math.min(200, Number(args && args.padding) || 0));
  const now = Date.now();
  if (now - _aiLastScreenshotAt < 500) {
    return {
      data: null,
      meta: { error: true, cooldown: true },
      warnings: ['Screenshot cooldown 500 ms — slow down between captures.'],
      suggestions: ['Do something else (e.g. observe_dom_changes) between screenshots.'],
    };
  }
  // Step 1: rect via page-tools bridge in MAIN world.
  const rectResp = await new Promise((res) => {
    safeSendMessage(
      { type: 'jz-page-tool-call', tool: 'getElementRectForScreenshot', args: { selector, padding } },
      (r) => res(chrome.runtime.lastError ? null : r)
    );
  });
  if (!rectResp || !rectResp.ok) {
    return {
      data: null,
      meta: { error: true },
      warnings: ['Bridge error: ' + ((rectResp && rectResp.error) || 'no response from background')],
      suggestions: ['Retry, or use screenshot_page if the element is hard to locate.'],
    };
  }
  const rectData = rectResp.result && rectResp.result.data;
  if (!rectData || !rectData.rect) {
    return {
      data: null,
      meta: { error: true },
      warnings: (rectResp.result && rectResp.result.warnings) || ['Element not found or has zero size.'],
      suggestions: ['Call simulate_scroll first if the element is off-screen.'],
    };
  }
  const rect = rectData.rect;
  _aiLastScreenshotAt = now;
  // v3.2.57 — usunięto hardcodowany vision pre-check (patrz screenshot_page).
  // Step 2: full viewport capture (UI hidden).
  _aiHideJustZixUi(true);
  await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
  const capResp = await new Promise((res) => {
    safeSendMessage({ type: 'jz-capture-screenshot', format: 'png' }, (r) => {
      res(chrome.runtime.lastError ? null : r);
    });
  });
  _aiHideJustZixUi(false);
  if (!capResp || !capResp.ok) {
    return {
      data: null,
      meta: { error: true },
      warnings: ['Capture error: ' + ((capResp && capResp.error) || 'no response from background')],
      suggestions: [],
    };
  }
  // Step 3: crop dataUrl to rect on a canvas (DPR-aware).
  let croppedDataUrl = '';
  try {
    croppedDataUrl = await _aiCropDataUrlToRect(capResp.dataUrl, rect);
  } catch (e) {
    return {
      data: null,
      meta: { error: true },
      warnings: ['Crop error: ' + String((e && e.message) || e)],
      suggestions: [],
    };
  }
  const id = _aiPushScreenshot(croppedDataUrl);
  if (!id) {
    return { data: null, meta: { error: true }, warnings: ['Invalid dataUrl after crop.'], suggestions: [] };
  }
  return {
    data: { captured: true, screenshotId: id, rect, bufferSize: _aiScreenshotBuffer.length },
    meta: { bufferMax: SCREENSHOT_BUFFER_MAX, selector, padding },
    suggestions: [],
  };
}

// v3.2.40 — Serialize a tool result envelope ({data, meta, suggestions, warnings})
// for injection into the chat as @@JZ_RESULT@@. JSON for structured access by
// the model; truncate very large `data` to keep the chat manageable.
function _aiSerializeToolResult(result) {
  if (result == null) return 'null';
  // Legacy string passthrough (e.g. parse-error sentinels)
  if (typeof result === 'string') return result;
  try {
    let envelope = result;
    if (envelope && typeof envelope === 'object' && 'data' in envelope) {
      const str = JSON.stringify(envelope, null, 2);
      if (str.length <= 60000) return str;
      // Too large — truncate data, keep meta/suggestions/warnings intact.
      const truncated = {
        data: '[truncated: ' + (typeof envelope.data === 'string' ? envelope.data.length + ' chars' : 'object > 60 KB') + ']',
        meta: Object.assign({ truncated: true }, envelope.meta || {}),
        suggestions: envelope.suggestions || [],
        warnings: envelope.warnings || [],
      };
      return JSON.stringify(truncated, null, 2);
    }
    return JSON.stringify(envelope, null, 2);
  } catch (_) {
    return String(result);
  }
}

// Crop a PNG dataUrl to a viewport-space rect, accounting for devicePixelRatio
// (captureVisibleTab returns image at native pixel density on HiDPI screens).
function _aiCropDataUrlToRect(dataUrl, rect) {
  return new Promise((resolve, reject) => {
    const img = new Image();
    img.onload = () => {
      try {
        const dpr = (img.naturalWidth && window.innerWidth)
          ? (img.naturalWidth / window.innerWidth)
          : (window.devicePixelRatio || 1);
        const sx = Math.max(0, Math.floor(rect.x * dpr));
        const sy = Math.max(0, Math.floor(rect.y * dpr));
        const sw = Math.max(1, Math.min(img.naturalWidth - sx, Math.floor(rect.width * dpr)));
        const sh = Math.max(1, Math.min(img.naturalHeight - sy, Math.floor(rect.height * dpr)));
        const canvas = document.createElement('canvas');
        canvas.width = sw;
        canvas.height = sh;
        const ctx = canvas.getContext('2d');
        ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh);
        resolve(canvas.toDataURL('image/png'));
      } catch (e) {
        reject(e);
      }
    };
    img.onerror = () => reject(new Error('Image load failed'));
    img.src = dataUrl;
  });
}

// Pętla agentowa: zapytanie → ewentualne narzędzie → wynik → kolejne zapytanie.
// v3.2.40 — Round budget split:
//   • action rounds  (create_/update_/preview_/set_/simulate_/screenshot_/run_js_probe
//                     /open_temp_pane/request_user_input) — capped at MAX_ACTION_ROUNDS
//   • discovery rounds (read_/list_/get_/search_/inspect_/observe_/test_) — unlimited
//   • hard cap on total rounds prevents infinite loops in any mode
// request_user_input resets the action counter (interaction = natural pause).
async function _aiRunAgent(el, provider, model) {
  _aiHelperBusy = true;
  _aiSetBusy(el, true);
  const MAX_ACTION_ROUNDS = 10;
  const MAX_HARD_ROUNDS = 50;
  let lastToolName = '';
  let lastResult = null;
  let actionRound = 0;
  let totalRound = 0;
  try {
    while (actionRound < MAX_ACTION_ROUNDS && totalRound < MAX_HARD_ROUNDS) {
      // od v3.2.8 — soft-stop: sprawdzamy przed każdą turą.
      if (_aiStopRequested) { _aiAppendStoppedBubble(); return; }
      // od v3.2.38 — auto-compaction kontekstu: gdy wykorzystanie > 85% i
      // ostatnie kompaktowanie > 60s temu (zapobiega pętli przy bardzo długich
      // turach gdzie summary sam też jest długi).
      try {
        const limit = _aiGetContextLimit(provider, model);
        const used = _aiCurrentContextTokens();
        if (used / limit > 0.85 && (Date.now() - _aiCompactedAt) > 60000 && !_aiCompacting) {
          await _aiCompactConversation(el, { silent: true });
          if (_aiStopRequested) { _aiAppendStoppedBubble(); return; }
        }
      } catch (_) {}
      const box = el.querySelector('.jz-ai-msgs');
      let thinkingTimer = null;
      let pending = null;
      if (box) {
        pending = document.createElement('div');
        pending.className = 'jz-ai-msg jz-ai-msg-assistant jz-ai-pending';
        const baseText = _aiT('aiHelper.win.thinking');
        pending.textContent = baseText;
        box.appendChild(pending);
        box.scrollTop = box.scrollHeight;
        // od v3.2.38 — licznik czasu „Thinking… (Xs)", odświeżany co 1 s,
        // resetowany dla każdej nowej tury (każde wywołanie pętli = nowy pending).
        const startedAt = Date.now();
        thinkingTimer = setInterval(() => {
          if (!pending || !pending.isConnected) {
            clearInterval(thinkingTimer);
            return;
          }
          const sec = Math.floor((Date.now() - startedAt) / 1000);
          pending.textContent = baseText + ' (' + sec + 's)';
        }, 1000);
      }
      const resp = await _aiChatOnce(provider, model);
      if (thinkingTimer) { clearInterval(thinkingTimer); thinkingTimer = null; }
      // v3.2.45 — accumulate tokens + LLM call duration into session totals.
      if (resp.ok && resp.usage) {
        _aiAccumulateCall(resp.usage.input, resp.usage.output, resp.durationMs, resp.usage.cached);
      } else if (resp.durationMs) {
        // Even on failure we count the time the agent was waiting on the API.
        _aiAccumulateCall(0, 0, resp.durationMs);
      }
      if (_aiStopRequested) { _aiAppendStoppedBubble(); return; }
      const elNow = _aiWinEl();
      if (!elNow) return; // okno zamknięte w trakcie
      // v3.2.45 — refresh toolbar (timers + tokens) after each LLM call
      _aiUpdateTelemetryUI(elNow);
      const chat = _aiGetChat();
      if (!resp.ok) {
        // od v3.2.15 — logujemy błąd providera (auth, rate-limit, sieć, parse)
        _aiLogError('provider', {
          provider: provider, model: model, round: totalRound,
          error: { message: resp.error || 'unknown provider error' },
        });
        chat.push({ role: 'assistant', content: '⚠ ' + _aiT('aiHelper.win.error') + (resp.error ? ': ' + resp.error : '') });
        _aiSetChat(chat);
        _aiRenderMessages(elNow);
        return;
      }
      let answer = resp.content || '';
      // v3.2.52 — CLIENT-SIDE STOP. A model (esp. reasoning ones like gpt-5.5 that
      // reject the API `stop` param) can hallucinate a whole conversation in one
      // response — fake @@JZ_RESULT@@ blocks + further tool calls. When the FIRST
      // block is a real tool call we keep only that block; the rest never reaches
      // the UI, chat history (which would poison the next request) or the parser.
      // Works for every model, no hardcoded list.
      // v3.2.55 FIX — we now parse BEFORE trimming and trim ONLY when the first
      // block parses as a REAL tool call. Previously we cut after the first
      // @@END@@ unconditionally, which truncated answers that merely QUOTE a doc
      // example containing @@END@@ (the parser returns null for placeholder
      // blocks) — silently dropping the explanation written after the example.
      let tool = _aiParseToolCall(answer);
      if (tool) {
        const _endIdx = answer.indexOf('@@END@@');
        if (_endIdx >= 0) answer = answer.slice(0, _endIdx + 7); // keep through first @@END@@
      } else if (answer.indexOf('@@JZ_TOOL@@') >= 0 && answer.indexOf('@@END@@') < 0) {
        // Tool block truncated mid-output (no @@END@@) — close it and re-parse.
        const _closed = answer.replace(/\s+$/, '') + '\n@@END@@';
        const _t2 = _aiParseToolCall(_closed);
        if (_t2) { answer = _closed; tool = _t2; }
      }
      // v3.2.45 — attach per-call telemetry to the assistant message so the
      // renderer can show a footer (tokens this round + LLM response time),
      // persisted in chat history (sessionStorage).
      chat.push({
        role: 'assistant',
        content: answer,
        _usage: resp.usage || null,
        _durationMs: resp.durationMs || 0,
      });
      // v3.2.53 — per-result compaction usunięty; _aiBuildApiMessages zwraca pełną
      // historię. Jedyny mechanizm kompresji to LLM auto-compaction (>85% kontekstu).
      _aiSetChat(chat);
      _aiRenderMessages(elNow);
      if (!tool) return; // zwykła odpowiedź — koniec tury (tool sparsowany wyżej)
      lastToolName = tool.tool;
      // v3.2.45 — measure tool exec duration (used in per-toolchip stempel).
      const toolStartedAt = Date.now();
      const result = await _aiExecTool(tool, elNow);
      const toolDurationMs = Date.now() - toolStartedAt;
      lastResult = result;
      // Attach durationMs to the result envelope if missing — the renderer
      // uses meta.durationMs for the per-toolchip ⏱ stamp.
      if (result && typeof result === 'object' && result.meta && typeof result.meta.durationMs !== 'number') {
        result.meta.durationMs = toolDurationMs;
      }
      _aiMarkToolActivity(toolDurationMs); // resets "since tool" timer + accumulates agentTotalMs
      if (_aiStopRequested) { _aiAppendStoppedBubble(); return; }
      const elAfter = _aiWinEl();
      if (!elAfter) return;
      _aiUpdateTelemetryUI(elAfter); // refresh toolbar after tool exchange
      // v3.2.48 — empty-result no-context. When a discovery tool returns an
      // empty set (count:0 or empty data array) WITHOUT an error, inject only a
      // 1-line note so the agent remembers it tried this and does not repeat the
      // same call (instead of a full empty envelope).
      if (_aiIsEmptyResult(result)) {
        const c2e = _aiGetChat();
        c2e.push({
          role: 'user',
          kind: 'tool',
          toolName: tool.tool,
          content: '@@JZ_RESULT@@ ' + tool.tool + '\n[no results — ' + _aiEmptyResultNote(tool, result) + ']',
        });
        _aiSetChat(c2e);
        _aiRenderMessages(elAfter);
        totalRound++;
        if (!_aiIsDiscoveryTool(tool.tool)) actionRound++;
        continue;
      }
      const c2 = _aiGetChat();
      // v3.2.40 — tool result is always {data, meta, suggestions, warnings?} envelope.
      // Inject as JSON so the model can reliably parse meta/warnings/suggestions.
      const resultStr = _aiSerializeToolResult(result);
      c2.push({
        role: 'user',
        kind: 'tool',
        toolName: tool.tool,
        content: '@@JZ_RESULT@@ ' + tool.tool + '\n' + resultStr,
      });
      _aiSetChat(c2);
      _aiRenderMessages(elAfter);
      // v3.2.40 — request_user_input resets action counter (interaction = natural pause).
      // Otherwise increment action counter only for non-discovery tools.
      totalRound++;
      if (tool.tool === 'request_user_input') {
        actionRound = 0;
      } else if (!_aiIsDiscoveryTool(tool.tool)) {
        actionRound++;
      }
    }
    // od v3.2.8 — turn-limit hit: pokaż podsumowanie + przyciski Kontynuuj/Zatrzymaj.
    _aiAppendTurnLimitBubble(provider, model, lastToolName, lastResult);
  } finally {
    _aiHelperBusy = false;
    _aiMarkTurnEnded(); // v3.2.46 — freeze u:/t: timers until next user message
    const e2 = _aiWinEl();
    if (e2) {
      _aiSetBusy(e2, false);
      _aiUpdateTelemetryUI(e2); // final refresh with frozen timers
      _aiFocusInput(e2); // v3.2.70 — wróć kursorem do pola po turze (też po pytaniu agenta)
    }
  }
}

// v3.2.48 — detect an empty (but non-error) discovery result. These carry no
// recoverable information, so they skip history_steps and get a 1-line note.
function _aiIsEmptyResult(result) {
  if (!result || typeof result !== 'object') return false;
  const meta = result.meta || {};
  if (meta.error) return false; // errors keep full context + recovery
  // Explicit count:0 (search_elements, count_elements, get_matching_css_rules)
  if (typeof meta.count === 'number' && meta.count === 0) return true;
  if (meta.count === 0 && typeof meta.totalMatched === 'number' && meta.totalMatched === 0) return true;
  // Empty data array (timeline with 0 events, search with 0 matches)
  if (Array.isArray(result.data) && result.data.length === 0) return true;
  // count_elements / record_dom_timeline shapes
  if (result.data && typeof result.data === 'object') {
    if (result.data.count === 0) return true;
    if (typeof meta.totalObserved === 'number' && meta.totalObserved === 0) return true;
  }
  return false;
}
// Short human note describing why the result was empty (for the 1-line trace).
function _aiEmptyResultNote(tool, result) {
  const name = (tool && tool.tool) || 'tool';
  const args = (tool && tool.args) || {};
  const meta = (result && result.meta) || {};
  const parts = [];
  // Echo the discriminating filter so the agent does not repeat it blindly.
  if (args.text) parts.push('text:"' + String(args.text).slice(0, 40) + '"');
  if (args.selector) parts.push('selector:"' + String(args.selector).slice(0, 60) + '"');
  if (args.role) parts.push('role:"' + args.role + '"');
  if (args.tag) parts.push('tag:"' + args.tag + '"');
  if (args.root) parts.push('root:"' + args.root + '"');
  if (typeof meta.totalObserved === 'number') parts.push('0 events in ' + (meta.durationMs || '?') + 'ms');
  const filterStr = parts.length ? parts.join(', ') : 'no matches';
  return name + ': ' + filterStr;
}

// Bubble „Przerwano" gdy user kliknął Stop.
function _aiAppendStoppedBubble() {
  const el = _aiWinEl();
  if (!el) return;
  const c = _aiGetChat();
  c.push({ role: 'assistant', content: '⏹ ' + _aiT('aiHelper.win.stopped') });
  _aiSetChat(c);
  _aiRenderMessages(el);
}

// Bubble po wyczerpaniu MAX_ROUNDS — opis ostatniego narzędzia + akcje Tak/Nie.
// Tymczasowe inline-buttony; w batchu 3 podpięte zostaną do uniwersalnego inline-form.
function _aiAppendTurnLimitBubble(provider, model, lastToolName, lastResult) {
  const el = _aiWinEl();
  if (!el) return;
  const box = el.querySelector('.jz-ai-msgs');
  if (!box) return;
  const d = document.createElement('div');
  d.className = 'jz-ai-msg jz-ai-msg-assistant jz-ai-turnlimit';
  const r = document.createElement('div');
  r.className = 'jz-ai-msg-role';
  r.textContent = _aiT('aiHelper.win.assistant');
  d.appendChild(r);
  const body = document.createElement('div');
  body.className = 'jz-ai-msg-body';
  const lastInfo = lastToolName
    ? '\n\n' + _aiT('aiHelper.win.turnLimit.lastTool') + ': ' + lastToolName
      + (lastResult ? '\n' + _aiT('aiHelper.win.turnLimit.lastResult') + ': '
                      + _aiSerializeToolResult(lastResult).slice(0, 400) : '')
    : '';
  body.textContent = _aiT('aiHelper.win.turnLimit.body') + lastInfo;
  d.appendChild(body);
  const actions = document.createElement('div');
  actions.className = 'jz-ai-turnlimit-actions';
  const yes = document.createElement('button');
  yes.type = 'button'; yes.className = 'jz-ai-tl-yes';
  yes.textContent = _aiT('aiHelper.win.turnLimit.continue');
  const no = document.createElement('button');
  no.type = 'button'; no.className = 'jz-ai-tl-no';
  no.textContent = _aiT('aiHelper.win.turnLimit.stop');
  actions.appendChild(yes); actions.appendChild(no);
  d.appendChild(actions);
  box.appendChild(d);
  box.scrollTop = box.scrollHeight;
  yes.addEventListener('click', () => {
    d.remove();
    // Reset licznika realizujemy odpalając _aiRunAgent od nowa — pętla startuje od 0.
    _aiRunAgent(el, provider, model);
  });
  no.addEventListener('click', () => {
    actions.remove();
    body.textContent += '\n\n— ' + _aiT('aiHelper.win.turnLimit.stoppedByUser');
  });
}

// ============================================================
// v3.2.40 scaffolding (prep for meta-system + registry-driven dispatch)
// ============================================================
// Helpers below are defined but NOT YET wired into _aiRunAgent/_aiExecTool.
// Activation happens in main session after agents #2 (TOOL_REGISTRY content)
// and #3 (page tools installer) deliver their outputs. Plan:
// `extension/justzix-docs/plans/v3.2.40-meta-system.md`.

// Wraps a raw tool result in the standard `{data, meta, suggestions}` shape.
// Backwards-compatible: if a handler already returns the new shape, passthrough.
// Heuristics for legacy string returns (most existing tools return raw strings):
// - starts with 'Błąd' → error wrap
// - starts with 'Utworzono '/'Zaktualizowano ' → success wrap
// - else: wrap as {data: rawString}
// Used after each tool execution in the new registry-driven dispatcher.
function _aiNormalizeResponse(rawResult, _toolEntry) {
  if (rawResult && typeof rawResult === 'object' && 'data' in rawResult && 'meta' in rawResult) {
    return rawResult; // already normalized
  }
  if (typeof rawResult === 'string') {
    if (rawResult.startsWith('Błąd')) {
      return { data: null, meta: { error: true }, warnings: [rawResult], suggestions: [] };
    }
    if (rawResult.startsWith('Utworzono ') || rawResult.startsWith('Zaktualizowano ')) {
      return { data: { message: rawResult }, meta: {}, suggestions: [] };
    }
    return { data: rawResult, meta: {}, suggestions: [] };
  }
  return { data: rawResult, meta: {}, suggestions: [] };
}

// Round budget classification (v3.2.40): discovery rounds are unlimited,
// action rounds capped at MAX_ACTION_ROUNDS=10 (reset after request_user_input).
// Pattern matches tool names whose handlers have no side effects on storage
// or page state. _aiExecTool also honors per-entry `classification` from
// TOOL_REGISTRY where defined (registry beats pattern).
const _AI_DISCOVERY_TOOL_PATTERNS = /^(list_|get_|search_|inspect_|observe_|test_|read_)/;
function _aiIsDiscoveryTool(toolName) {
  if (!toolName || typeof toolName !== 'string') return false;
  const entry = (window.JZ_AI_TOOL_REGISTRY || {})[toolName];
  if (entry && entry.classification === 'action') return false;
  if (entry && entry.classification === 'discovery') return true;
  return _AI_DISCOVERY_TOOL_PATTERNS.test(toolName);
}

// ─────────────────────────────────────────────────────────────────────────────
// Meta-tools (v3.2.40) — documentation discovery on top of JZ_AI_TOOL_REGISTRY.
// All handlers return the standard `{data, meta, suggestions}` shape directly
// (no _aiNormalizeResponse wrap).
// ─────────────────────────────────────────────────────────────────────────────

const _AI_TOOL_GROUP_DEFS = {
  meta:        { name: 'Tool documentation',    description: 'Discovery of tool documentation (always available — use FIRST to orient yourself).' },
  dom:         { name: 'Page inspection',       description: 'Inspect the page without dumping full HTML — search, inspect, computed styles, matching rules, selector stability, DOM diffs.' },
  tree:        { name: 'Tree management',       description: 'Folders / groups / rules CRUD (the persistence hierarchy).' },
  bars:        { name: 'Action bars',           description: 'Action bars and actions CRUD plus layout/order management.' },
  temp:        { name: 'Temporary windows',     description: 'TEMP windows for user-visible test code (CSS preview / JS run).' },
  panes:       { name: 'Persistent panes',      description: 'Persistent CSS / JS panes (NOT consoles).' },
  consoles:    { name: 'Persistent consoles',   description: 'Persistent JS Console + Output Console — REPL and log viewer.' },
  interaction: { name: 'User input simulation', description: 'Mouse / keyboard / scroll synthetic events to test behaviors after a user action.' },
  ui:          { name: 'User communication',    description: 'Inline forms in the chat (request_user_input).' },
  vision:      { name: 'Vision (screenshot)',   description: 'Screenshot of page or element as image attachment for vision-capable models.' },
};

function _aiToolRegistry() { return window.JZ_AI_TOOL_REGISTRY || {}; }

function _aiToolListToolGroups() {
  const reg = _aiToolRegistry();
  const allNames = Object.keys(reg);
  const counts = {};
  allNames.forEach(name => {
    const g = reg[name] && reg[name].group;
    if (!g) return;
    counts[g] = (counts[g] || 0) + 1;
  });
  const data = Object.keys(_AI_TOOL_GROUP_DEFS).map(id => ({
    id,
    name: _AI_TOOL_GROUP_DEFS[id].name,
    description: _AI_TOOL_GROUP_DEFS[id].description,
    toolCount: counts[id] || 0,
  }));
  return {
    data,
    meta: { totalGroups: data.length, totalTools: allNames.length },
    suggestions: ['Call list_tools({group}) to see tools in a specific group.'],
  };
}

// v3.2.64 — one-line summary of a verbose `purpose` for the list_tools TOC.
// Takes the first sentence (≤140 chars); falls back to a hard char-cap.
function _aiBriefPurpose(p) {
  const s = String(p || '').replace(/\s+/g, ' ').trim();
  if (!s) return '';
  const m = s.match(/^(.{0,140}?[.!?])(?:\s|$)/);
  let brief = m ? m[1] : s;
  if (brief.length > 140) brief = brief.slice(0, 139).replace(/\s+\S*$/, '') + '…';
  return brief;
}

function _aiToolListTools(args) {
  const reg = _aiToolRegistry();
  const group = args && args.group ? String(args.group) : null;
  const names = Object.keys(reg).filter(n => !group || reg[n].group === group);
  // v3.2.64 — TRUE table of contents: name + ONE-LINE purpose + arg names only.
  // Previously this returned the full `purpose` paragraph + full output shape per
  // tool, so the "brief list" was nearly as heavy as the docs — defeating lazy
  // discovery. Full purpose / output / whenToUse / schema / examples live in
  // get_tool_doc (and get_tool_schema for args).
  const data = names.map(name => {
    const e = reg[name] || {};
    return {
      name,
      group: e.group,
      summary: _aiBriefPurpose(e.purpose),
      classification: e.classification,
      args: _aiSummarizeSchema(e.inputSchema),
    };
  });
  return {
    data,
    meta: { count: data.length, totalTools: Object.keys(reg).length, group: group || null },
    suggestions: data.length
      ? ['Brief list. Call get_tool_doc(name) for purpose/usage/examples, or get_tool_schema(name) for the full argument spec.']
      : [group ? ('Unknown group "' + group + '". Call list_tool_groups() to see valid ids.') : 'Registry empty.'],
  };
}

function _aiSummarizeSchema(schema) {
  if (!schema || typeof schema !== 'object') return '';
  const keys = Object.keys(schema);
  if (!keys.length) return '{}';
  return '{' + keys.map(k => {
    const s = schema[k] || {};
    return k + (s.required ? '' : '?');
  }).join(', ') + '}';
}

function _aiToolGetToolDoc(args) {
  const reg = _aiToolRegistry();
  const name = args && args.name ? String(args.name) : '';
  const level = Math.max(1, Math.min(3, Number(args && args.level) || 2));
  const e = reg[name];
  if (!e) {
    return {
      data: null,
      meta: { error: true, unknownTool: name },
      warnings: ['Unknown tool: ' + name],
      suggestions: ['Call list_tools() to see available tools, or search_tool_docs(query) to find by keyword.'],
    };
  }
  const base = { name, group: e.group, classification: e.classification, purpose: e.purpose };
  if (level >= 2) {
    base.whenToUse = e.whenToUse || [];
    base.whenNotToUse = e.whenNotToUse || [];
    base.relatedTools = e.relatedTools || [];
    base.exampleFirst = (e.examples && e.examples[0]) || null;
    base.requiresConfirmation = !!e.requiresConfirmation;
  }
  if (level >= 3) {
    base.inputSchema = e.inputSchema || {};
    base.outputSchema = e.outputSchema || {};
    base.examples = e.examples || [];
    if (e.styleFields) base.styleFields = e.styleFields;
  }
  return {
    data: base,
    meta: { level },
    suggestions: level < 3
      ? ['Pass level:3 for full input/output schema and all examples.']
      : [],
  };
}

function _aiToolGetToolSchema(args) {
  const reg = _aiToolRegistry();
  const name = args && args.name ? String(args.name) : '';
  const e = reg[name];
  if (!e) {
    return { data: null, meta: { error: true }, warnings: ['Unknown tool: ' + name], suggestions: ['Use list_tools() first.'] };
  }
  return {
    data: { name, inputSchema: e.inputSchema || {}, outputSchema: e.outputSchema || {} },
    meta: {},
    suggestions: [],
  };
}

function _aiToolGetToolExamples(args) {
  const reg = _aiToolRegistry();
  const name = args && args.name ? String(args.name) : '';
  const taskType = args && args.taskType ? String(args.taskType).toLowerCase() : '';
  const e = reg[name];
  if (!e) {
    return { data: [], meta: { error: true }, warnings: ['Unknown tool: ' + name], suggestions: [] };
  }
  let examples = e.examples || [];
  if (taskType && examples.length) {
    const filtered = examples.filter(ex => (ex.task || '').toLowerCase().includes(taskType));
    if (filtered.length) examples = filtered;
  }
  return {
    data: examples,
    meta: { count: examples.length, name, taskType: taskType || null },
    suggestions: examples.length ? [] : ['No examples on file for this tool.'],
  };
}

function _aiToolSearchToolDocs(args) {
  const reg = _aiToolRegistry();
  const query = String((args && args.query) || '').toLowerCase().trim();
  if (!query) {
    return { data: [], meta: { error: true }, warnings: ['Empty query.'], suggestions: ['Pass {query:"..."}.'] };
  }
  const tokens = query.split(/\s+/).filter(t => t.length > 2);
  if (!tokens.length) {
    return { data: [], meta: { error: true }, warnings: ['Query too short (need tokens > 2 chars).'], suggestions: [] };
  }
  const scored = Object.keys(reg).map(name => {
    const e = reg[name] || {};
    const corpus = (
      String(e.purpose || '') + ' ' +
      (e.whenToUse || []).join(' ') + ' ' +
      (e.relatedTools || []).join(' ') + ' ' +
      name
    ).toLowerCase();
    let score = 0;
    tokens.forEach(t => { if (corpus.includes(t)) score++; });
    const reason = (e.whenToUse || []).find(use => tokens.some(t => String(use).toLowerCase().includes(t))) || e.purpose || '';
    return { toolName: name, group: e.group, relevance: score / tokens.length, reason };
  }).filter(s => s.relevance > 0).sort((a, b) => b.relevance - a.relevance);
  return {
    data: scored.slice(0, 5),
    meta: { totalMatched: scored.length, totalTools: Object.keys(reg).length, query },
    suggestions: scored.length
      ? ['Call get_tool_doc(toolName) for full documentation.']
      : ['No matches. Try broader keywords or list_tool_groups() to browse.'],
  };
}

function _aiParseToolCall(text) {
  const s = String(text || '');
  // v3.2.47 — multi-block tolerance: bierzemy PIERWSZY blok i wykonujemy go.
  // v3.2.61 — usunięto ostrzeżenie multi-block. Client-side trim (v3.2.55) i tak
  // przycina odpowiedź po pierwszym @@END@@, więc nadmiarowe bloki nigdy nie trafiają
  // do wykonania/historii/UI — ostrzeżenie było przestarzałym szumem (odpalało się
  // co turę dla modeli typu gpt-5.5, które zawsze dopisują ciąg dalszy, i nie zmieniało
  // ich zachowania).
  const m = s.match(/@@JZ_TOOL@@([\s\S]*?)@@END@@/);
  if (!m) return null;
  const inner = m[1].trim();
  // v3.2.70 — JSON.parse jest JEDYNYM sędzią. Wcześniejsza heurystyka „placeholder"
  // (od v3.2.38) odrzucała blok zawierający `...`/`…`/`/*`/`NAZWA`. To kasowało
  // PRAWDZIWE tool calle, których args zawierały komentarz CSS/JS `/* */` albo `…`
  // w treści (np. set_temp_pane_code / preview_css z komentarzem) → tool po cichu
  // się nie wykonywał (puste okno TEMP, brak efektu). Heurystyka jest zbędna:
  // prawdziwe placeholdery (`...`, `NAZWA` bez cudzysłowów) i tak NIE są poprawnym
  // JSON-em → JSON.parse rzuci → zwracamy null (tekst). Poprawny JSON z polem
  // `tool` = realne wywołanie → wykonujemy, nawet jeśli w STRINGU jest `/*` czy `…`.
  try {
    const obj = JSON.parse(inner);
    if (obj && typeof obj.tool === 'string') return obj;
    return null;
  } catch (e) {
    // Niepoprawny JSON (w tym cytowane przykłady z `...`/`NAZWA`) → zwykły tekst,
    // nie tool call. Bez fałszywego `__parse_error__` (jak od v3.2.38).
    return null;
  }
}

// v3.2.54 — walidacja argumentów względem registry inputSchema. Zamyka pętlę
// „agent zgaduje nazwy argumentów": brak wymaganego argu → twardy błąd (handler
// się NIE wykona, model dostaje jasny komunikat); nieznany klucz / zła wartość
// enuma → miękkie ostrzeżenie doklejane do wyniku (handler i tak działa z tym, co
// rozumie). Schema-driven (zero hardcode list narzędzi) — czyta entry.inputSchema,
// które istnieje dla każdego narzędzia. Ostrzeżenie o nieznanym kluczu odpala się
// TYLKO dla niepustego schematu — puste/niekompletne schematy (np. handler czyta
// args, których schema nie deklaruje) nie generują fałszywych alarmów.
function _aiValidateArgs(entry, args) {
  const out = { errors: [], warnings: [] };
  const schema = entry && entry.inputSchema;
  if (!schema || typeof schema !== 'object') return out;
  const known = Object.keys(schema);
  const a = (args && typeof args === 'object') ? args : {};
  // Brak wymaganego argumentu → twardy błąd.
  known.forEach((k) => {
    const spec = schema[k];
    if (spec && spec.required && (a[k] === undefined || a[k] === null)) {
      out.errors.push('Missing required argument "' + k + '"' +
        (spec.description ? ' — ' + spec.description : '') + '.');
    }
  });
  if (known.length) {
    // Nieznany klucz → miękkie ostrzeżenie (tylko dla niepustego schematu).
    Object.keys(a).forEach((k) => {
      if (known.indexOf(k) === -1) {
        out.warnings.push('Unknown argument "' + k + '" was ignored. Valid arguments: ' +
          known.join(', ') + '.');
      }
    });
    // Wartość spoza enuma → miękkie ostrzeżenie.
    known.forEach((k) => {
      const spec = schema[k];
      if (spec && Array.isArray(spec.enum) && a[k] !== undefined && a[k] !== null &&
          spec.enum.indexOf(a[k]) === -1) {
        out.warnings.push('Argument "' + k + '"=' + JSON.stringify(a[k]) +
          ' is not one of: ' + spec.enum.join(', ') + '.');
      }
    });
  }
  return out;
}

// v3.2.40 — registry-driven dispatcher. Resolves entry.handler string to one of:
//   - 'window.__aiPageTools.<name>' → bridge to MAIN-world page tool via background
//   - '_aiToolReadEntity:<type>'    → _aiToolReadEntity(type, args[<typ>Id])
//   - '_aiToolUpdateEntity:<type>'  → _aiToolUpdateEntity(type, args[<typ>Id], args.patch, el)
//   - '_aiToolCreateEntity'         → _aiToolCreateEntity(toolName, args, el)
//   - '_aiToolUpdatePaneCode'       → _aiToolUpdatePaneCode(args, el)
//   - '_aiToolRequestUserInput'     → _aiToolRequestUserInput(args, el)
//   - any other '_aiTool*'/'_ai*'   → lookup in _AI_HANDLER_FNS table
// Result is wrapped via _aiNormalizeResponse to {data, meta, suggestions} shape.
// Special parse-error / multi-block sentinels handled inline (not in registry).
async function _aiExecTool(tool, el) {
  const a = tool && tool.args || {};
  const toolName = tool && tool.tool;
  if (toolName === '__parse_error__') {
    _aiLogError('parse', { error: { message: 'invalid JSON in @@JZ_TOOL@@ block: ' + ((tool && tool._err) || '?') } });
    return _aiNormalizeResponse('Błąd: niepoprawny JSON w wywołaniu narzędzia (' + ((tool && tool._err) || '?') + '). Popraw składnię i spróbuj ponownie.');
  }
  const reg = _aiToolRegistry();
  const entry = reg[toolName];
  if (!entry) {
    _aiLogError('tool', { tool: toolName, args: a, error: { message: 'unknown tool: ' + toolName } });
    return {
      data: null,
      meta: { error: true, unknownTool: true },
      warnings: ['Unknown tool: ' + String(toolName)],
      suggestions: ['Call list_tool_groups() then list_tools(group) to discover available tools, or search_tool_docs(query) to find by keyword.'],
    };
  }
  const _argCheck = _aiValidateArgs(entry, a);
  if (_argCheck.errors.length) {
    _aiLogError('tool', { tool: toolName, args: a, error: { message: 'invalid args: ' + _argCheck.errors.join(' ') } });
    return {
      data: null,
      meta: { error: true, invalidArgs: true },
      warnings: _argCheck.errors,
      suggestions: ['Call get_tool_schema("' + String(toolName) + '") to see the exact argument names and which are required.'],
    };
  }
  try {
    const handlerStr = String(entry.handler || '');
    let raw;
    if (handlerStr.indexOf('window.__aiPageTools.') === 0) {
      const pageToolName = handlerStr.slice('window.__aiPageTools.'.length);
      raw = await _aiBridgePageTool(pageToolName, a);
    } else if (handlerStr.indexOf('_aiToolReadEntity:') === 0) {
      const type = handlerStr.slice('_aiToolReadEntity:'.length);
      const idArg = _aiEntityIdArgName(type);
      raw = await _aiToolReadEntity(type, a[idArg]);
    } else if (handlerStr.indexOf('_aiToolUpdateEntity:') === 0) {
      const type = handlerStr.slice('_aiToolUpdateEntity:'.length);
      const idArg = _aiEntityIdArgName(type);
      raw = await _aiToolUpdateEntity(type, a[idArg], a.patch, el);
    } else if (handlerStr === '_aiToolCreateEntity') {
      raw = await _aiToolCreateEntity(toolName, a, el);
    } else if (handlerStr === '_aiToolUpdatePaneCode') {
      raw = await _aiToolUpdatePaneCode(a, el);
    } else if (handlerStr === '_aiToolRequestUserInput') {
      raw = await _aiToolRequestUserInput(a, el);
    } else {
      const fn = _AI_HANDLER_FNS[handlerStr];
      if (typeof fn !== 'function') {
        _aiLogError('tool', { tool: toolName, args: a, error: { message: 'unresolved handler: ' + handlerStr } });
        return {
          data: null,
          meta: { error: true, unresolvedHandler: true },
          warnings: ['Tool "' + toolName + '" registry handler "' + handlerStr + '" is not implemented.'],
          suggestions: [],
        };
      }
      raw = await fn(a, el);
    }
    const normalized = _aiNormalizeResponse(raw, entry);
    if (_argCheck.warnings.length) {
      normalized.warnings = (normalized.warnings || []).concat(_argCheck.warnings);
    }
    return normalized;
  } catch (e) {
    _aiLogError('tool', { tool: toolName, args: a, error: e });
    return {
      data: null,
      meta: { error: true, exception: true },
      warnings: ['Tool execution error: ' + String((e && e.message) || e)],
      suggestions: [],
    };
  }
}

// Entity ID argument name per type (registry handler is '_aiToolReadEntity:<type>'
// but the actual arg key in tool.args follows the legacy naming).
function _aiEntityIdArgName(type) {
  switch (type) {
    case 'folder': return 'folderId';
    case 'group':  return 'groupId';
    case 'rule':   return 'ruleId';
    case 'bar':    return 'barId';
    case 'action': return 'actionId';
    case 'cssPane':
    case 'jsPane':
    case 'jsConsole':
    case 'outputConsole':
      return 'paneId';
    default: return 'id';
  }
}

// Bridge to MAIN-world page tool via background script. Returns the page tool's
// normalized {data, meta, suggestions} envelope, or an error envelope on failure.
async function _aiBridgePageTool(toolName, args) {
  return await new Promise((resolve) => {
    safeSendMessage(
      { type: 'jz-page-tool-call', tool: toolName, args: args || {} },
      (r) => {
        if (chrome.runtime.lastError) {
          resolve({
            data: null,
            meta: { error: true, bridgeError: true },
            warnings: ['Page-tool bridge error: ' + chrome.runtime.lastError.message],
            suggestions: [],
          });
          return;
        }
        if (!r || !r.ok) {
          resolve({
            data: null,
            meta: { error: true, bridgeError: true },
            warnings: ['Page-tool bridge error: ' + ((r && r.error) || 'no response')],
            suggestions: [],
          });
          return;
        }
        // Page tools already return {data, meta, suggestions} via ok()/fail().
        resolve(r.result || { data: null, meta: {}, suggestions: [] });
      }
    );
  });
}

// Wrapper handlers — the registry references their string names; we keep the
// signature handler(args, el) for uniformity and adapt to existing functions.
function _aiOpenTempPane(args) {
  const type = (args && args.type) === 'js' ? 'js' : 'css';
  createTempPane(type);
  return {
    data: { type, opened: true },
    meta: {},
    suggestions: ['Use set_temp_pane_code({type, code}) to insert code, then read_temp_pane({type}) to verify.'],
  };
}

function _aiSetTempPaneCodeArgs(args) {
  const type = (args && args.type) === 'js' ? 'js' : 'css';
  const code = String((args && args.code) || '');
  _aiSetTempPaneCode(type, code);
  return {
    data: { type, codeLength: code.length, applied: true },
    meta: {},
    suggestions: ['Ask the user to verify the effect on the page, then use read_temp_pane({type}) for sync errors and read_console for async/runtime errors.'],
  };
}

async function _aiToolRequestUserInput(args, el) {
  const spec = {
    title: String((args && args.title) || _aiT('aiHelper.form.askTitle')),
    description: String((args && args.description) || ''),
    fields: Array.isArray(args && args.fields) ? args.fields : [],
    submitLabel: args && args.submitLabel ? String(args.submitLabel) : undefined,
    cancelLabel: args && args.cancelLabel ? String(args.cancelLabel) : undefined,
  };
  const result = await _aiShowInlineForm(el, spec);
  return {
    data: result,
    meta: {
      hasFreeText: !!(result && result.text),
      fieldsFilled: (result && result.values) ? Object.keys(result.values).length : 0,
      cancelled: !!(result && result.action === 'cancel'),
    },
    suggestions: [],
  };
}

// Lookup table for non-bridged, non-dispatcher handlers. Registry references
// handler strings; this maps each string to the actual function (avoids eval).
const _AI_HANDLER_FNS = {
  // legacy + existing handlers
  _aiToolListAll:          _aiToolListAll,
  _aiToolListStructure:    _aiToolListStructure,
  _aiToolListBars:         _aiToolListBars,
  _aiToolListActions:      _aiToolListActions,
  _aiToolListPanes:        _aiToolListPanes,
  _aiToolReadConsole:      _aiToolReadConsole,
  _aiToolReadTempPane:     _aiToolReadTempPane,
  _aiToolReadPaneCode:     _aiToolReadPaneCode,
  _aiToolScreenshotPage:   _aiToolScreenshotPage,
  _aiToolScreenshotElement:_aiToolScreenshotElement,
  // temp-pane wrappers (registry references the wrapper names)
  _aiOpenTempPane:         _aiOpenTempPane,
  _aiSetTempPaneCode:      _aiSetTempPaneCodeArgs,
  // meta-tools (v3.2.40)
  _aiToolListToolGroups:   _aiToolListToolGroups,
  _aiToolListTools:        _aiToolListTools,
  _aiToolGetToolDoc:       _aiToolGetToolDoc,
  _aiToolGetToolSchema:    _aiToolGetToolSchema,
  _aiToolGetToolExamples:  _aiToolGetToolExamples,
  _aiToolSearchToolDocs:   _aiToolSearchToolDocs,
};

async function _aiToolListStructure() {
  const d = await safeStorageGet(['folders', 'groups', 'rules', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles']);
  const folders = Array.isArray(d.folders) ? d.folders : [];
  const groups = Array.isArray(d.groups) ? d.groups : [];
  const rules = Array.isArray(d.rules) ? d.rules : [];
  if (!folders.length) return 'Brak folderów. Użyj create_folder, aby utworzyć pierwszy.';
  // od v3.2.25 — dorzucamy persistent okna pod każdym parentem, żeby model widział
  // co już istnieje i mógł zarządzać przez update_*/create_* zamiast tworzyć duplikat.
  const cssPanes = Array.isArray(d.cssPanes) ? d.cssPanes : [];
  const jsPanes = Array.isArray(d.jsPanes) ? d.jsPanes : [];
  const jsConsoles = Array.isArray(d.jsConsoles) ? d.jsConsoles : [];
  const outputConsoles = Array.isArray(d.outputConsoles) ? d.outputConsoles : [];
  const formatPanels = (parentType, parentId, indent) => {
    const out = [];
    const list = (arr, label) => arr
      .filter(x => x.parentType === parentType && x.parentId === parentId)
      .forEach(x => out.push(indent + label + ' "' + (x.name || '') + '" id=' + x.id));
    list(cssPanes, 'OKNO_CSS');
    list(jsPanes, 'OKNO_JS');
    list(jsConsoles, 'KONSOLA_JS');
    list(outputConsoles, 'OUTPUT_CONSOLE');
    return out;
  };
  const lines = [];
  folders.forEach(f => {
    lines.push('FOLDER "' + (f.name || '') + '" id=' + f.id + ' url=' + ((f.urlPatterns || []).join(', ') || '—'));
    formatPanels('folder', f.id, '  ').forEach(l => lines.push(l));
    groups.filter(g => g.folderId === f.id).forEach(g => {
      lines.push('  GRUPA "' + (g.name || '') + '" id=' + g.id);
      formatPanels('group', g.id, '    ').forEach(l => lines.push(l));
      rules.filter(r => r.groupId === g.id).forEach(r => {
        lines.push('    ZESTAW "' + (r.name || '') + '" id=' + r.id);
        formatPanels('rule', r.id, '      ').forEach(l => lines.push(l));
      });
    });
  });
  return lines.join('\n');
}

// od v3.2.27 — dedykowane listy poza list_structure. Pozwalają modelowi znaleźć
// paski/akcje/okna po parencie bez czytania całego drzewa. Każda zwraca JSON.
async function _aiToolListBars(args) {
  const d = await safeStorageGet(['actionBars']);
  const bars = Array.isArray(d.actionBars) ? d.actionBars : [];
  const filtered = (args && args.parentType && args.parentId)
    ? bars.filter(b => b.parentType === args.parentType && b.parentId === args.parentId)
    : bars;
  if (!filtered.length) {
    return 'Brak pasków akcji' + ((args && args.parentId) ? ` dla ${args.parentType} ${args.parentId}.` : '.');
  }
  return JSON.stringify(filtered.map(b => ({
    id: b.id, name: b.name || '', parentType: b.parentType, parentId: b.parentId,
    orientation: b.orientation || 'vertical', color: b.color || null,
    enabled: b.enabled !== false, hidden: b.hidden === true,
    layoutMode: b.layoutMode || 'flow',
    size: b.size || null,
    actionOrder: Array.isArray(b.actionOrder) ? b.actionOrder : null,
  })), null, 2);
}

async function _aiToolListActions(args) {
  const d = await safeStorageGet(['rules']);
  const rules = Array.isArray(d.rules) ? d.rules : [];
  const out = [];
  rules.forEach(r => {
    (r.actions || []).forEach(a => {
      if (args && args.ruleId && r.id !== args.ruleId) return;
      if (args && args.barId && a.barId !== args.barId) return;
      out.push({
        id: a.id, ruleId: r.id, type: a.type || 'button',
        label: a.label || '', barId: a.barId || null, color: a.color || null,
        layout: a.layout || null, size: a.size || null,
      });
    });
  });
  if (!out.length) {
    return 'Brak akcji'
      + ((args && args.ruleId) ? ` w zestawie ${args.ruleId}.`
       : (args && args.barId) ? ` w pasku ${args.barId}.` : '.');
  }
  return JSON.stringify(out, null, 2);
}

async function _aiToolListPanes(args) {
  const d = await safeStorageGet(['cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles']);
  const TYPES = {
    css:       { arr: Array.isArray(d.cssPanes)       ? d.cssPanes       : [], label: 'cssPane' },
    js:        { arr: Array.isArray(d.jsPanes)        ? d.jsPanes        : [], label: 'jsPane' },
    jsConsole: { arr: Array.isArray(d.jsConsoles)     ? d.jsConsoles     : [], label: 'jsConsole' },
    output:    { arr: Array.isArray(d.outputConsoles) ? d.outputConsoles : [], label: 'outputConsole' },
  };
  const want = (args && args.type && TYPES[args.type]) ? [args.type] : Object.keys(TYPES);
  const out = [];
  want.forEach(k => {
    TYPES[k].arr.forEach(p => {
      if (args && args.parentType && args.parentId) {
        if (p.parentType !== args.parentType || p.parentId !== args.parentId) return;
      }
      out.push({
        id: p.id, type: TYPES[k].label, name: p.name || '',
        parentType: p.parentType, parentId: p.parentId, color: p.color || null,
        enabled: p.enabled !== false,
        position: p.position || null, size: p.size || null,
      });
    });
  });
  if (!out.length) {
    return 'Brak okien'
      + ((args && args.type) ? ` typu ${args.type}.`
       : (args && args.parentId) ? ` dla ${args.parentType} ${args.parentId}.` : '.');
  }
  return JSON.stringify(out, null, 2);
}

async function _aiToolListAll() {
  const d = await safeStorageGet(['folders', 'groups', 'rules', 'actionBars', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles']);
  const rules = Array.isArray(d.rules) ? d.rules : [];
  const summary = {
    folders: (Array.isArray(d.folders) ? d.folders : []).length,
    groups: (Array.isArray(d.groups) ? d.groups : []).length,
    rules: rules.length,
    actions: rules.reduce((s, r) => s + ((Array.isArray(r.actions) ? r.actions : []).length), 0),
    actionBars: (Array.isArray(d.actionBars) ? d.actionBars : []).length,
    cssPanes: (Array.isArray(d.cssPanes) ? d.cssPanes : []).length,
    jsPanes: (Array.isArray(d.jsPanes) ? d.jsPanes : []).length,
    jsConsoles: (Array.isArray(d.jsConsoles) ? d.jsConsoles : []).length,
    outputConsoles: (Array.isArray(d.outputConsoles) ? d.outputConsoles : []).length,
  };
  return JSON.stringify({
    summary,
    note: 'Użyj list_structure (hierarchia), list_bars/list_actions/list_panes (filtrowane listy), read_<typ> (szczegóły konkretnej encji).',
  }, null, 2);
}

// od v3.2.27 — read/update kodu persistent CSS/JS pane. Kod żyje per-tab w
// sessionStorage (CSS_PANE_CONTENT_KEY). Live textarea jest źródłem prawdy gdy
// pane wyrenderowany, sessionStorage fallback. NIE persistuje cross-session/
// cross-tab — to ograniczenie wynika z designu persistent panes.
// v3.2.55 — zwraca strukturę { paneId, kind, code, length } zgodnie z outputSchema
// (wcześniej goły string — model nie dostawał kind/length, a komunikaty błędów
// wyglądały jak „kod"). Źródło prawdy: żywa textarea (shadow-aware), fallback
// sessionStorage.
async function _aiToolReadPaneCode(args) {
  const paneId = args && args.paneId;
  if (!paneId) {
    return { data: null, meta: { error: true }, warnings: ['paneId is required'], suggestions: ['Use list_panes to find pane ids.'] };
  }
  if (typeof paneId !== 'string' || (!paneId.startsWith('cp_') && !paneId.startsWith('jp_'))) {
    return { data: null, meta: { error: true }, warnings: ['read_pane_code works only for CSS panes (cp_*) or JS panes (jp_*).'], suggestions: ['Use list_panes to find pane ids.'] };
  }
  const kind = paneId.startsWith('cp_') ? 'cssPane' : 'jsPane';
  let code = null;
  // od v3.2.30 — getPanelElementById shadow-aware (wszystkie panes w shadow).
  const elp = getPanelElementById(paneId);
  if (elp) {
    const ta = elp.querySelector('.jz-pane-body');
    if (ta) code = ta.value || '';
  }
  if (code === null) {
    try { const v = sessionStorage.getItem('jz_pane_' + paneId + '_content'); if (v !== null) code = v; } catch (_) {}
  }
  if (code === null) {
    return {
      data: { paneId, kind, code: '', length: 0 },
      meta: { rendered: false },
      suggestions: ['Pane is not rendered in this tab and has no saved sessionStorage — open the pane or verify the id.'],
    };
  }
  return { data: { paneId, kind, code, length: code.length }, meta: { rendered: !!elp }, suggestions: [] };
}

function _aiSetPersistentPaneCode(paneId, code) {
  try { sessionStorage.setItem('jz_pane_' + paneId + '_content', code); } catch (_) {}
  if (!paneId.startsWith('cp_') && !paneId.startsWith('jp_')) return false;
  // od v3.2.30 — getPanelElementById shadow-aware.
  const elp = getPanelElementById(paneId);
  if (!elp) return false;
  const ta = elp.querySelector('.jz-pane-body');
  if (!ta) return false;
  ta.value = code;
  ta.dispatchEvent(new Event('input', { bubbles: true }));
  return true;
}

async function _aiToolUpdatePaneCode(args, el) {
  const paneId = args && args.paneId;
  const code = args && typeof args.code === 'string' ? args.code : '';
  if (!paneId) return 'Błąd: brak paneId.';
  if (typeof paneId !== 'string' || (!paneId.startsWith('cp_') && !paneId.startsWith('jp_'))) {
    return 'Błąd: update_pane_code działa tylko dla CSS pane (cp_*) lub JS pane (jp_*). JS Console / Output Console nie mają edytowalnego kodu.';
  }
  const isCss = paneId.startsWith('cp_');
  const d = await safeStorageGet([isCss ? 'cssPanes' : 'jsPanes']);
  const list = isCss ? (d.cssPanes || []) : (d.jsPanes || []);
  const pane = list.find(p => p.id === paneId);
  if (!pane) {
    return 'Błąd: ' + (isCss ? 'cssPane' : 'jsPane') + ' o id ' + paneId + ' nie istnieje.';
  }
  // Confirm — pokazujemy nazwę pane'a + długość kodu (do/od).
  // v3.2.55 zmieniło _aiToolReadPaneCode na zwracanie { data:{code,length} } —
  // ten wewnętrzny konsument musi czytać oldLen z data, nie ze stringa.
  const oldRead = await _aiToolReadPaneCode({ paneId });
  const oldLen = (oldRead && oldRead.data && typeof oldRead.data.length === 'number') ? oldRead.data.length : 0;
  const summary = _aiT('aiHelper.confirm.setPaneCode', {
    name: pane.name || paneId,
    id: paneId,
    old: oldLen,
    new: code.length,
  });
  const ok = await _aiInlineConfirm(el, summary, _aiT('aiHelper.form.updateConfirmTitle'));
  if (!ok) return _aiT('aiHelper.tool.rejected');
  const applied = _aiSetPersistentPaneCode(paneId, code);
  return applied
    ? 'Kod wstawiony do okna ' + (isCss ? 'CSS' : 'JS') + ' "' + (pane.name || paneId) + '". Efekt zastosowany w żywej textareie.'
    : 'Kod zapisany do sessionStorage, ale pane nie był wyrenderowany w DOM. Aplikuje się przy następnym otwarciu w tej karcie.';
}

function _aiSetTempPaneCode(type, code) {
  // createTempPane jest idempotentne — zwraca istniejące okno (odsłaniając je,
  // jeśli było zamknięte) albo tworzy nowe.
  const pane = createTempPane(type);
  if (!pane) return;
  try { sessionStorage.setItem('jz_pane_' + pane.id + '_content', code); } catch (_) {}
  const apply = () => {
    // od v3.2.30 — getPanelElementById shadow-aware.
    const elp = getPanelElementById(pane.id);
    if (!elp) return false;
    const ta = elp.querySelector('.jz-pane-body');
    if (!ta) return false;
    ta.value = code;
    ta.dispatchEvent(new Event('input', { bubbles: true }));
    return true;
  };
  if (apply()) return;
  // Pane może się jeszcze nie wyrenderować (applyAll async). Ponawiamy co 150 ms do ~3 s.
  let attempts = 0;
  const tick = () => {
    if (apply() || ++attempts >= 20) return;
    setTimeout(tick, 150);
  };
  setTimeout(tick, 150);
}

// od v3.2.13 — odczyt encji ze storage (rule/folder/group/bar/action).
// Zwraca pełen obiekt jako JSON, by model mógł zobaczyć aktualny stan przed update.
async function _aiToolReadEntity(entityType, id) {
  if (!id) return 'Błąd: brak id.';
  if (entityType === 'action') {
    const data = await safeStorageGet(['rules']);
    const rules = data.rules || [];
    for (const r of rules) {
      const a = (r.actions || []).find(x => x && x.id === id);
      if (a) {
        // Dorzucamy ruleId i barName (z actionBars) jako kontekst.
        const ab = await safeStorageGet(['actionBars']);
        const barName = ((ab.actionBars || []).find(b => b.id === a.barId) || {}).name || null;
        return JSON.stringify(Object.assign({ ruleId: r.id, barName: barName }, a));
      }
    }
    return 'Błąd: nie znaleziono akcji o id: ' + id;
  }
  const COLL = { folder: 'folders', group: 'groups', rule: 'rules', bar: 'actionBars',
                 // od v3.2.25 — persistent okna na froncie.
                 cssPane: 'cssPanes', jsPane: 'jsPanes', jsConsole: 'jsConsoles', outputConsole: 'outputConsoles' };
  const collection = COLL[entityType];
  if (!collection) return 'Błąd: nieznany typ encji: ' + entityType;
  const data = await safeStorageGet([collection]);
  const arr = Array.isArray(data[collection]) ? data[collection] : [];
  const item = arr.find(x => x && x.id === id);
  if (!item) return 'Błąd: nie znaleziono encji ' + entityType + ' o id: ' + id;
  return JSON.stringify(item);
}

// od v3.2.13 — aktualizacja encji. Wymaga potwierdzenia użytkownika (inline-form
// confirm) z podsumowaniem zmian. Wykonuje się przez background (_aiUpdateEntity)
// — atomiczny merge + zapis + enqueue sync.
async function _aiToolUpdateEntity(entityType, id, patch, el) {
  if (!id) return 'Błąd: brak id.';
  if (!patch || typeof patch !== 'object') return 'Błąd: brak patcha (object).';
  const keys = Object.keys(patch).filter(k => k !== 'id');
  if (!keys.length) return 'Patch pusty — nic do aktualizacji.';
  // Dane bieżące do pokazania w confirm.
  const currentJson = await _aiToolReadEntity(entityType, id);
  let current = null;
  try { current = JSON.parse(currentJson); } catch (_) {}
  if (!current) return currentJson; // czyli error message z readEntity
  const lines = keys.map(k => {
    const oldV = current[k];
    const newV = patch[k];
    const fmt = (v) => {
      if (v == null) return '(brak)';
      if (typeof v === 'string') return v.length > 60 ? '(' + v.length + ' zn.)' : JSON.stringify(v);
      if (Array.isArray(v)) return '[' + v.length + ' el.]';
      if (typeof v === 'object') return '{…}';
      return String(v);
    };
    return '• ' + k + ': ' + fmt(oldV) + ' → ' + fmt(newV);
  });
  const summary = _aiT('aiHelper.confirm.update.' + entityType)
    + '\n\nid: ' + id
    + '\n' + (current.name ? ('„' + current.name + '"\n') : '')
    + '\n' + lines.join('\n');
  const ok = await _aiInlineConfirm(el, summary, _aiT('aiHelper.form.updateConfirmTitle'));
  if (!ok) return _aiT('aiHelper.tool.rejected');
  const resp = await new Promise((res) => {
    safeSendMessage({ type: 'jz-ai-update-entity', entityType, id, patch }, (r) => {
      res(chrome.runtime.lastError ? null : r);
    });
  });
  if (!resp || !resp.ok) {
    const msg = (resp && resp.error) || 'brak odpowiedzi';
    _aiLogError('tool', {
      tool: 'update_' + entityType,
      args: { id: id, patch: patch },
      error: { message: msg },
    });
    return 'Błąd update: ' + msg;
  }
  return 'Zaktualizowano ' + entityType + ' (' + id + '). Zmienione pola: ' + keys.join(', ');
}

async function _aiToolCreateEntity(toolName, a, el) {
  const map = {
    create_folder: 'folder', create_group: 'group', create_rule: 'rule',
    create_bar: 'bar', create_action: 'action',
    // od v3.2.25 — persistent okna.
    create_css_pane: 'cssPane', create_js_pane: 'jsPane',
    create_js_console: 'jsConsole', create_output_console: 'outputConsole',
  };
  const entityType = map[toolName];
  let summary;
  if (entityType === 'folder') {
    summary = _aiT('aiHelper.confirm.folder') + '\n\n' + (a.name || '(bez nazwy)') +
      '\nURL: ' + ((a.urlPatterns || []).join(', ') || '—');
  } else if (entityType === 'group') {
    summary = _aiT('aiHelper.confirm.group') + '\n\n' + (a.name || '(bez nazwy)');
  } else if (entityType === 'bar') {
    summary = _aiT('aiHelper.confirm.bar') + '\n\n' + (a.name || '(bez nazwy)') +
      '\n' + (a.parentType || '?') + ': ' + (a.parentId || '?');
  } else if (entityType === 'action') {
    summary = _aiT('aiHelper.confirm.action') + '\n\n' +
      String(a.type || 'button').toUpperCase() + ' "' + (a.label || '') + '"';
  } else if (entityType === 'cssPane' || entityType === 'jsPane' ||
             entityType === 'jsConsole' || entityType === 'outputConsole') {
    summary = _aiT('aiHelper.confirm.create.' + entityType) + '\n\n"' + (a.name || '—') +
      '"\n' + (a.parentType || '?') + ': ' + (a.parentId || '?');
  } else {
    summary = _aiT('aiHelper.confirm.rule') + '\n\n' + (a.name || '(bez nazwy)') +
      (a.css ? '\nCSS: ' + String(a.css).length + ' zn.' : '') +
      (a.js ? '\nJS: ' + String(a.js).length + ' zn.' : '');
  }
  // od v3.2.11 — inline-form confirm zamiast window.confirm.
  const ok = await _aiInlineConfirm(el, summary, _aiT('aiHelper.form.createConfirmTitle'));
  if (!ok) return _aiT('aiHelper.tool.rejected');
  const resp = await new Promise((res) => {
    safeSendMessage({ type: 'jz-ai-create-entity', entityType, data: a }, (r) => {
      res(chrome.runtime.lastError ? null : r);
    });
  });
  if (!resp || !resp.ok) {
    const msg = (resp && resp.error) || 'brak odpowiedzi';
    _aiLogError('tool', { tool: toolName, args: a, error: { message: msg } });
    return 'Błąd tworzenia: ' + msg;
  }
  if (entityType === 'folder') return 'Utworzono folder. folderId=' + resp.folderId + '. Domyślna grupa JUŻ utworzona: groupId=' + resp.groupId + ' — UŻYJ tego groupId w create_rule. NIE wołaj create_group.';
  if (entityType === 'group') return 'Utworzono grupę. groupId=' + resp.groupId;
  if (entityType === 'bar') return 'Utworzono pasek akcji. barId=' + resp.barId;
  if (entityType === 'action') return 'Utworzono akcję. actionId=' + resp.actionId + ' — element jest aktywny na pasku.';
  if (entityType === 'cssPane') return 'Utworzono okno CSS. paneId=' + resp.paneId + ' — pojawi się na stronach pasujących do parenta.';
  if (entityType === 'jsPane') return 'Utworzono okno JS. paneId=' + resp.paneId + ' — pojawi się na stronach pasujących do parenta.';
  if (entityType === 'jsConsole') return 'Utworzono konsolę JS. paneId=' + resp.paneId + ' — pojawi się na stronach pasujących do parenta.';
  if (entityType === 'outputConsole') return 'Utworzono Output Console. paneId=' + resp.paneId + ' — pojawi się na stronach pasujących do parenta.';
  return 'Utworzono zestaw. ruleId=' + resp.ruleId + ' — kod jest już aktywny na pasujących stronach.';
}

// Odczyt ZAWARTOŚCI okna TEMP (kontrakt registry: { type, code, opened }).
// Źródłem prawdy jest ŻYWA textarea — user mógł ręcznie edytować kod po tym, jak
// ustawił go AI; sessionStorage to fallback, gdy okno jeszcze się nie wyrenderowało.
// getPanelElementById jest shadow-aware (pane'y żyją w shadow root od v3.2.30) —
// wcześniej ta funkcja czytała przez document.getElementById(prefix+id), co NIE
// przebijało shadow boundary, więc nigdy nie widziała żywej textarei i zamiast
// kodu zwracała komunikat statusu. Sanity-check składni trafia do `meta`, nie
// zastępuje kodu. Błędy runtime JS sprawdza się przez preview_js / read_console.
async function _aiToolReadTempPane(args) {
  const type = (args && args.type === 'css') ? 'css' : 'js';
  const pane = _tempPanes.find(p => p.type === type && !p.closed)
            || _tempPanes.find(p => p.type === type);
  if (!pane) {
    return {
      data: { type, code: '', opened: false },
      meta: { opened: false },
      suggestions: ['No TEMP ' + type.toUpperCase() + ' pane is open. Use open_temp_pane({type:"' + type + '"}) then set_temp_pane_code.'],
    };
  }
  let code = '';
  const elp = getPanelElementById(pane.id);
  const ta = elp && elp.querySelector('.jz-pane-body');
  if (ta) {
    code = ta.value || '';
  } else {
    try { code = sessionStorage.getItem('jz_pane_' + pane.id + '_content') || ''; } catch (_) {}
  }
  const opened = !pane.closed;
  const meta = { type, opened, codeLength: code.length };
  const suggestions = [];
  if (type === 'css') {
    const open = (code.match(/\{/g) || []).length;
    const close = (code.match(/\}/g) || []).length;
    meta.bracesBalanced = (open === close);
    if (open !== close) {
      suggestions.push('CSS braces look unbalanced ({=' + open + ', }=' + close + ') — review the snippet.');
    }
  } else if (!code.trim()) {
    suggestions.push('The TEMP JS pane is empty — use set_temp_pane_code to insert code.');
  } else {
    suggestions.push('This returns the code only. For runtime / async errors run preview_js or read_console.');
  }
  return { data: { type, code, opened }, meta, suggestions };
}

// Odczyt przechwyconych wpisów console strony przez okno TEMP Output Console.
async function _aiToolReadConsole() {
  let pane = _tempPanes.find(p => p.type === 'output' && !p.closed);
  if (!pane) {
    createTempPane('output');
    return 'Otwarto okno TEMP Output Console. Przechwytuje console.log/warn/error strony ' +
      'WYŁĄCZNIE od tego momentu — wpisy sprzed otwarcia okna NIE zostały złapane. ' +
      'Jeśli test (set_temp_pane_code) był już uruchomiony wcześniej, URUCHOM GO PONOWNIE ' +
      'teraz, a dopiero potem wywołaj read_console.';
  }
  // od v3.2.29 — getPanelElementById jest shadow-aware (TEMP Output w shadow).
  const el = getPanelElementById(pane.id);
  if (!el) return 'Okno Output Console jeszcze się nie wyrenderowało — spróbuj ponownie za chwilę.';
  const lines = Array.from(el.querySelectorAll('.jz-output-line'));
  if (!lines.length) {
    return 'Output Console jest pusta — brak przechwyconych wpisów. UWAGA: to NIE dowód ' +
      'braku błędów. Jeśli test był uruchomiony, zanim okno zaczęło przechwytywać, ' +
      'jego wpisy przepadły — uruchom test ponownie (set_temp_pane_code) i wywołaj read_console.';
  }
  const last = lines.slice(-30).map(r => {
    const kind = (r.dataset && r.dataset.kind) || 'log';
    return '[' + kind + '] ' + (r.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 300);
  });
  return 'Ostatnie wpisy konsoli (' + last.length + ' z ' + lines.length + '):\n' + last.join('\n');
}

// opts.skipSize=true → tylko sessionStorage (pozycja+size dla F5), bez nadpisywania
// cross-session jzAiHelperSize. Używane przy zwykłym drag headera i przy dblclick
// toggle — gdzie size SAM się nie zmienia z woli usera (tylko my w kodzie). Bez
// tego (v3.2.18) drugi dblclick nie wracał do remembered: każdy mousedown/up na
// headerze (też te z dblclick) wywoływał _aiSaveGeometry → nadpisywało remembered
// na current. Po pierwszym dblclick current=default, więc remembered=default, więc
// kolejny dblclick „nic nie robił" (próbował wrócić do default, na którym był).
function _aiSaveGeometry(el, opts) {
  if (!_aiHelperWin) return;
  opts = opts || {};
  const r = el.getBoundingClientRect();
  const w = Math.round(r.width), h = Math.round(r.height);
  _aiHelperWin.size = { w: w, h: h };
  _aiHelperWin.position = absToAnchorPosition(r.left, r.top, r.width, r.height);
  saveAiHelper();
  // od v3.2.19: saveAiHelperSize TYLKO przy faktycznym resize (resize-handle).
  // Zwykły drag/klik/dblclick na headerze → skipSize:true.
  if (!opts.skipSize) saveAiHelperSize(w, h);
}
function _aiAttachDragResize(el) {
  const header = el.querySelector('.jz-pane-header');
  // od v3.2.10 — prawy klik na belce: menu kontekstowe (nowa rozmowa / font / rozmiar).
  // Spójne z innymi oknami, które mają prawy-klik → menu.
  header.addEventListener('contextmenu', (e) => {
    if (e.target && e.target.closest('button')) return;
    e.preventDefault();
    e.stopPropagation();
    _aiShowContextMenu(el, e.clientX, e.clientY);
  });
  // od v3.2.8 — dblclick w belkę: toggle między domyślnym a zapamiętanym rozmiarem
  // (v3.2.10: pamięć z cross-session chrome.storage, _aiHelperSizeRemembered).
  header.addEventListener('dblclick', (e) => {
    if (e.target && e.target.closest('button')) return;
    e.preventDefault(); e.stopPropagation();
    if (!_aiHelperWin) return;
    const remembered = _aiHelperSizeRemembered;
    const def = AI_HELPER_DEFAULT_SIZE;
    const curW = Math.round(el.getBoundingClientRect().width);
    const curH = Math.round(el.getBoundingClientRect().height);
    const atDefault = (curW === def.w && curH === def.h);
    let nextW, nextH;
    if (atDefault) {
      if (!remembered || !remembered.w || !remembered.h) return; // brak zapamiętanego — no-op
      nextW = remembered.w; nextH = remembered.h;
    } else {
      nextW = def.w; nextH = def.h;
    }
    el.style.width = nextW + 'px';
    el.style.height = nextH + 'px';
    _aiHelperWin.size = { w: nextW, h: nextH };
    // NIE wywołujemy saveAiHelperSize() — toggle NIE nadpisuje cross-session pamięci.
    _aiHelperWin.position = absToAnchorPosition(
      el.getBoundingClientRect().left, el.getBoundingClientRect().top, nextW, nextH);
    saveAiHelper();
  });
  // od v3.2.21 — drag z pełnym group/snap (taki sam mechanizm jak panes/bary).
  // AI Helper żyje w snap-ID space pod stałą '__ai-helper' (getDraggablePanelsForSnap,
  // getPanelElementById, saveGroupPositions już go obsługują). Snap-edge visual klasy
  // `.jz-snap-edge-*` na elemencie + na otherPanel (CSS panes mają już style; AI Helper
  // dziedziczy generic CSS).
  header.addEventListener('mousedown', (e) => {
    if (e.button !== 0 || (e.target && e.target.closest('button'))) return;
    e.preventDefault();
    e.stopPropagation();
    el.dataset.jzDragging = '1';
    const startMouseX = e.clientX;
    const startMouseY = e.clientY;
    let dragStarted = false;
    const myId = '__ai-helper';
    const groupIds = getBarGroup(myId);
    if (!groupIds.includes(myId)) groupIds.push(myId);
    const groupStartPositions = {};
    let groupCurrentPositions = {};
    groupIds.forEach(id => {
      const p = getPanelElementById(id);
      if (p) {
        groupStartPositions[id] = { left: parseInt(p.style.left) || 0, top: parseInt(p.style.top) || 0, inDOM: true };
      } else {
        const stored = getStoredStartPos(id);
        if (stored) groupStartPositions[id] = { left: stored.left, top: stored.top, inDOM: false };
      }
    });
    let lastSnap = null;
    const onMove = (ev) => {
      const dx = ev.clientX - startMouseX;
      const dy = ev.clientY - startMouseY;
      if (Math.abs(dx) > 3 || Math.abs(dy) > 3) dragStarted = true;
      if (!dragStarted) return;
      groupCurrentPositions = {};
      groupIds.forEach(id => {
        const start = groupStartPositions[id];
        if (!start) return;
        const newLeft = start.left + dx;
        const newTop = Math.max(0, start.top + dy);
        groupCurrentPositions[id] = { left: newLeft, top: newTop };
        if (start.inDOM) {
          const p = getPanelElementById(id);
          if (p) {
            p.style.left = newLeft + 'px';
            p.style.top = newTop + 'px';
            p.style.right = '';
            p.style.bottom = '';
          }
        }
      });
      clearAllSnapEdges();
      const aRect = el.getBoundingClientRect();
      const candidates = getDraggablePanelsForSnap();
      const snap = findSnapCandidate(myId, aRect, candidates, groupIds);
      lastSnap = snap;
      if (snap) {
        groupIds.forEach(id => {
          const cur = groupCurrentPositions[id];
          if (!cur) return;
          cur.left += snap.snapDX;
          cur.top += snap.snapDY;
          const start = groupStartPositions[id];
          if (start && start.inDOM) {
            const p = getPanelElementById(id);
            if (p) { p.style.left = cur.left + 'px'; p.style.top = cur.top + 'px'; }
          }
        });
        el.classList.add('jz-snap-edge-' + snap.aSide);
        const otherPanel = getPanelElementById(snap.otherBarId);
        if (otherPanel) otherPanel.classList.add('jz-snap-edge-' + snap.bSide);
      }
    };
    const onUp = () => {
      document.removeEventListener('mousemove', onMove, true);
      document.removeEventListener('mouseup', onUp, true);
      delete el.dataset.jzDragging;
      if (!dragStarted) {
        // klik bez drag: zachowaj dotychczasowe zachowanie zapisu (skipSize:true)
        _aiSaveGeometry(el, { skipSize: true });
        return;
      }
      clearAllSnapEdges();
      if (lastSnap) {
        addBarConnection({ aId: myId, aSide: lastSnap.aSide, bId: lastSnap.otherBarId, bSide: lastSnap.bSide });
      }
      saveGroupPositions(groupIds, groupCurrentPositions);
    };
    document.addEventListener('mousemove', onMove, true);
    document.addEventListener('mouseup', onUp, true);
  });
  Object.keys(PANE_RESIZE_HANDLES).forEach(name => {
    const spec = PANE_RESIZE_HANDLES[name];
    const h = document.createElement('div');
    h.className = 'jz-pane-resize jz-pane-resize-' + name;
    el.appendChild(h);
    h.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      e.preventDefault();
      e.stopPropagation();
      const r = el.getBoundingClientRect();
      const startX = e.clientX, startY = e.clientY;
      const sL = r.left, sT = r.top, sW = r.width, sH = r.height;
      const fixedR = sL + sW, fixedB = sT + sH;
      const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
      el.dataset.jzDragging = '1';
      const onMove = (ev) => {
        const dx = ev.clientX - startX, dy = ev.clientY - startY;
        let w = sW, hh = sH, left = sL, top = sT;
        if (spec.ew === 'e') w = clamp(sW + dx, PANE_W_MIN, PANE_W_MAX);
        else if (spec.ew === 'w') { w = clamp(sW - dx, PANE_W_MIN, PANE_W_MAX); left = fixedR - w; }
        if (spec.ns === 's') hh = clamp(sH + dy, PANE_H_MIN, PANE_H_MAX);
        else if (spec.ns === 'n') { hh = clamp(sH - dy, PANE_H_MIN, PANE_H_MAX); top = fixedB - hh; }
        el.style.width = w + 'px';
        el.style.height = hh + 'px';
        el.style.left = left + 'px';
        el.style.top = top + 'px';
        el.style.right = ''; el.style.bottom = '';
      };
      const onUp = () => {
        document.removeEventListener('mousemove', onMove, true);
        document.removeEventListener('mouseup', onUp, true);
        delete el.dataset.jzDragging;
        _aiSaveGeometry(el);
      };
      document.addEventListener('mousemove', onMove, true);
      document.addEventListener('mouseup', onUp, true);
    });
  });
}

// Init — przywróć okno po F5, jeśli było otwarte w tej sesji karty.
loadAiHelper();
if (_aiHelperWin) {
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => renderAiHelper());
  } else {
    renderAiHelper();
  }
}

// Global Ctrl+Shift+{L,H} (od v2.13.76). Działa z dowolnego miejsca strony — capture
// phase żeby strona nie zjadła event'u. Od v3.0.0 listener rejestrowany przy starcie
// content scriptu (wcześniej dopiero przy pierwszym Output Console) — żeby pomoc
// Ctrl+Shift+H była dostępna globalnie, niezależnie od obecności Output Console.
//
//   Ctrl+Shift+L — wyczyść aktywną zakładkę każdej Output Console
//   Ctrl+Shift+H — popup pomocy (skróty + okna na froncie)
//
// L działa tylko gdy ≥1 Output Console rendered. H działa zawsze (popup pomocy).
let _outputGlobalKeydownInstalled = false;
function _ensureOutputGlobalKeydown() {
  if (_outputGlobalKeydownInstalled) return;
  _outputGlobalKeydownInstalled = true;
  try {
    document.addEventListener('keydown', (e) => {
      if (!(e.ctrlKey || e.metaKey) || !e.shiftKey) return;
      // Alt key dyskryminuje TEMP shortcuts (Ctrl+Shift+Alt+...) od Output shortcuts.
      if (e.altKey) return;
      const key = (e.key || '').toLowerCase();
      if (key !== 'l' && key !== 'h') return;
      // L działa na sinki — wymaga ≥1 Output Console na stronie. H = info popup, zawsze.
      if (key !== 'h' && _outputConsoleSinks.size === 0) return;
      e.preventDefault();
      e.stopPropagation();
      if (key === 'l') {
        _forEachLiveOutputSink(s => { s.clear && s.clear(); });
      } else if (key === 'h') {
        _toggleOutputApiHelp();
      }
    }, true);
  } catch (_) {}
}
// Od v3.0.0: instalacja przy starcie content scriptu — popup pomocy (Ctrl+Shift+H)
// globalnie dostępny. Idempotent (_outputGlobalKeydownInstalled) — injectOutputConsoleHook
// nadal woła to samo, bez podwójnego bindu.
_ensureOutputGlobalKeydown();

// API help popup — singleton overlay (toggle przez Ctrl+Shift+H).
const OUTPUT_API_HELP_ID = 'jz-output-api-help';
function _toggleOutputApiHelp() {
  const existing = document.getElementById(OUTPUT_API_HELP_ID);
  if (existing) { existing.remove(); return; }
  const t = (key) => JZ_I18N.t(key);
  const overlay = document.createElement('div');
  overlay.id = OUTPUT_API_HELP_ID;
  overlay.className = 'jz-output-api-help-overlay';
  const sc  = (combo, key) => `<div class="jz-output-api-help-shortcut"><kbd>${combo}</kbd><span>${escapeBarName(t(key))}</span></div>`;
  const it_ = (key) => `<div class="jz-output-api-help-item">${escapeBarName(t(key))}</div>`;
  const sec = (key) => `<div class="jz-output-api-help-section-title">${escapeBarName(t(key))}</div>`;
  overlay.innerHTML = `
    <div class="jz-output-api-help-modal">
      <div class="jz-output-api-help-header">
        <span class="jz-output-api-help-title">${escapeBarName(t('outputConsole.apiHelp.title'))}</span>
        <button class="jz-output-api-help-close" type="button" aria-label="Close">×</button>
      </div>
      <div class="jz-output-api-help-body">
        <div class="jz-output-api-help-col">
          <div class="jz-output-api-help-section">
            ${sec('outputConsole.apiHelp.sec.global')}
            ${sc('Ctrl+Alt+G', 'outputConsole.apiHelp.g.tempCss')}
            ${sc('Ctrl+Alt+H', 'outputConsole.apiHelp.g.tempJs')}
            ${sc('Ctrl+Alt+J', 'outputConsole.apiHelp.g.tempJsc')}
            ${sc('Ctrl+Alt+K', 'outputConsole.apiHelp.g.tempOut')}
            ${sc('Ctrl+Alt+\\', 'outputConsole.apiHelp.g.ai')}
            ${sc('Ctrl+Alt+S', 'outputConsole.apiHelp.g.edit')}
          </div>
          <div class="jz-output-api-help-section">
            ${sec('outputConsole.apiHelp.sec.window')}
            ${sc('Ctrl+Enter', 'outputConsole.apiHelp.w.run')}
            ${sc('↑ / ↓', 'outputConsole.apiHelp.w.history')}
            ${sc('Ctrl+L', 'outputConsole.apiHelp.w.clearConsole')}
            ${sc('Esc', 'outputConsole.apiHelp.w.escSearch')}
          </div>
          <div class="jz-output-api-help-section">
            ${sec('outputConsole.apiHelp.sec.editmode')}
            ${sc('Ctrl+Z', 'outputConsole.apiHelp.em.undo')}
            ${sc('Delete', 'outputConsole.apiHelp.em.delete')}
            ${sc('Esc', 'outputConsole.apiHelp.em.deselect')}
            ${it_('outputConsole.apiHelp.em.move')}
            ${it_('outputConsole.apiHelp.em.select')}
            ${it_('outputConsole.apiHelp.em.label')}
          </div>
        </div>
        <div class="jz-output-api-help-col">
          <div class="jz-output-api-help-section">
            ${sec('outputConsole.apiHelp.sec.mouse')}
            ${it_('outputConsole.apiHelp.m.drag')}
            ${it_('outputConsole.apiHelp.m.resize')}
            ${it_('outputConsole.apiHelp.m.snap')}
            ${it_('outputConsole.apiHelp.m.context')}
            ${it_('outputConsole.apiHelp.m.tempDbl')}
            ${it_('outputConsole.apiHelp.m.scroll')}
          </div>
          <div class="jz-output-api-help-section">
            ${sec('outputConsole.apiHelp.sec.output')}
            ${sc('Ctrl+Shift+L', 'outputConsole.apiHelp.o.clear')}
            ${sc('Ctrl+Shift+H', 'outputConsole.apiHelp.o.help')}
            ${it_('outputConsole.apiHelp.o.tabs')}
            ${it_('outputConsole.apiHelp.o.network')}
            ${it_('outputConsole.apiHelp.o.datalayer')}
          </div>
          <div class="jz-output-api-help-section">
            ${sec('outputConsole.apiHelp.sec.logging')}
            <div class="jz-output-api-help-sublabel">${escapeBarName(t('outputConsole.apiHelp.jzApi'))}</div>
<pre>window.JUSTZIX.log(...args)
window.JUSTZIX.warn(...args)
window.JUSTZIX.error(...args)
window.JUSTZIX.info(...args)
window.JUSTZIX.debug(...args)</pre>
            <div class="jz-output-api-help-note">${escapeBarName(t('outputConsole.apiHelp.aliases'))}</div>
            <div class="jz-output-api-help-sublabel">${escapeBarName(t('outputConsole.apiHelp.consoleApi'))}</div>
<pre>console.log/.warn/.error/.info/.debug(...args)</pre>
            <div class="jz-output-api-help-note">${escapeBarName(t('outputConsole.apiHelp.consoleNote'))}</div>
          </div>
        </div>
      </div>
      <div class="jz-output-api-help-footer">${escapeBarName(t('outputConsole.apiHelp.footer'))}</div>
    </div>
  `;
  document.documentElement.appendChild(overlay);
  const modal = overlay.querySelector('.jz-output-api-help-modal');
  const header = overlay.querySelector('.jz-output-api-help-header');
  // Center initial — po appendChild measuring rect. position: absolute na modalu (CSS),
  // overlay jest fixed full-vp ale przezroczysty dla flex (od v2.13.80 — drag friendly).
  if (modal) {
    const mw = modal.offsetWidth || 480;
    const mh = modal.offsetHeight || 320;
    const cx = Math.max(8, (window.innerWidth  - mw) / 2);
    const cy = Math.max(8, (window.innerHeight - mh) / 2);
    modal.style.left = cx + 'px';
    modal.style.top  = cy + 'px';
  }
  const closeBtn = overlay.querySelector('.jz-output-api-help-close');
  // Wspólne zamknięcie — usuwa overlay ORAZ escHandler. Bez tego zamknięcie przez
  // przycisk × / klik w tło zostawiało wiszący keydown listener (wyciek przy każdym
  // ponownym otwarciu helpa).
  const closeHelp = () => {
    overlay.remove();
    document.removeEventListener('keydown', escHandler, true);
  };
  if (closeBtn) closeBtn.addEventListener('click', closeHelp);
  overlay.addEventListener('click', (e) => { if (e.target === overlay) closeHelp(); });
  // Drag handler na header (poza close button). mousedown → mousemove document → reposition.
  if (header && modal) {
    header.addEventListener('mousedown', (e) => {
      if (e.button !== 0) return;
      // Skip click na close button (nie zaczynaj draga gdy user klika ×).
      if (closeBtn && (e.target === closeBtn || closeBtn.contains(e.target))) return;
      e.preventDefault();
      header.style.cursor = 'grabbing';
      const startX = e.clientX;
      const startY = e.clientY;
      const startL = parseInt(modal.style.left) || 0;
      const startT = parseInt(modal.style.top)  || 0;
      const onMove = (ev) => {
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;
        let nl = startL + dx;
        let nt = startT + dy;
        // Clamp do viewport — żeby header zawsze pozostawał klikalny.
        const mw = modal.offsetWidth, mh = modal.offsetHeight;
        nl = Math.max(8 - mw + 80, Math.min(window.innerWidth - 80, nl));
        nt = Math.max(8, Math.min(window.innerHeight - 40, nt));
        modal.style.left = nl + 'px';
        modal.style.top  = nt + 'px';
      };
      const onUp = () => {
        header.style.cursor = 'grab';
        document.removeEventListener('mousemove', onMove, true);
        document.removeEventListener('mouseup',   onUp,   true);
      };
      document.addEventListener('mousemove', onMove, true);
      document.addEventListener('mouseup',   onUp,   true);
    });
  }
  // Esc close — listener installed once, removes itself on close.
  function escHandler(e) {
    if (e.key === 'Escape') {
      e.preventDefault();
      e.stopPropagation();
      closeHelp();
    }
  }
  document.addEventListener('keydown', escHandler, true);
}

function discoverRelevantOutputConsoles(folders, groups, rules, outputConsoles, floatingHidden) {
  const matching = getMatchingFoldersForPage(folders);
  const matchingFolderIds = new Set(matching.map(f => f.id));
  const out = [];
  (outputConsoles || []).forEach(oc => {
    if (!oc || !oc.id || !oc.parentType || !oc.parentId) return;
    if (oc.enabled === false) return;
    if (oc.parentType === 'temp') { out.push(oc); return; }
    if (oc.parentType === 'folder') {
      if (!matchingFolderIds.has(oc.parentId)) return;
    } else if (oc.parentType === 'group') {
      const g = (groups || []).find(gx => gx.id === oc.parentId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const ok = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ok) return;
      }
    } else if (oc.parentType === 'rule') {
      const r = (rules || []).find(rx => rx.id === oc.parentId);
      if (!r) return;
      const g = (groups || []).find(gx => gx.id === r.groupId);
      if (!g || !matchingFolderIds.has(g.folderId)) return;
      if (Array.isArray(g.urlPatterns) && g.urlPatterns.length > 0) {
        const ok = g.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ok) return;
      }
      if (Array.isArray(r.urlPatterns) && r.urlPatterns.length > 0) {
        const ok = r.urlPatterns.some(p => urlMatchesPattern(p, location.href));
        if (!ok) return;
      }
    } else { return; }
    out.push(oc);
  });
  return out;
}

// Page-side hook (od v2.13.74) — przerzucone z inline <script>+textContent na
// chrome.scripting.executeScript({world: 'MAIN'}) via background. Inline injection
// silently failowała przy stricter CSP strony (script-src 'self' bez 'unsafe-inline'
// blokuje script.textContent execution mimo że appendChild się udaje). executeScript
// jest CSP-immune (extension privileges przeważają nad page CSP) — tak samo jak w
// execute-js-console od v2.13.66.
//
// Listener postMessage zostaje w content script (ISOLATED world). MAIN→ISOLATED
// postMessage działa cross-world (window.postMessage shared via DOM).
let _outputHookInjected = false;
// Bufor wpisów console/jz/error/datalayer odebranych zanim zamontowała się jakakolwiek
// Output Console — odtwarzany do sinka przy jego rejestracji (kompletność przechwytywania).
const _outputPreBuffer = [];

// Najnowsza migawka window.dataLayer (prawa kolumna zakładki DataLayer).
let _dataLayerSnapshot = null;
function _setDataLayerSnapshot(json) {
  try {
    const parsed = JSON.parse(json);
    _dataLayerSnapshot = Array.isArray(parsed) ? parsed : [parsed];
  } catch (_) { return; }
  _forEachLiveOutputSink(s => { s.refreshDL && s.refreshDL(); });
}

function injectOutputConsoleHook() {
  if (_outputHookInjected) return;
  _outputHookInjected = true;
  // 1) Listener pierwszy (synchroniczny) — żeby nie zgubić messages podczas roundtrip
  // do background.
  try {
    window.addEventListener('message', (ev) => {
      try {
        // Twarda granica: przyjmuj tylko wiadomości z tego samego okna (page-hook
        // w świecie MAIN tej ramki). Odrzuca sfałszowane postMessage z iframe'ów
        // i innych kontekstów podszywające się pod source:'jz-output-console'.
        if (ev.source !== window) return;
        const d = ev && ev.data;
        if (!d || d.source !== 'jz-output-console') return;
        if (d.kind === 'datalayer-state') { _setDataLayerSnapshot(d.json); return; }
        const entry = _normalizeOutputEntry(d);
        if (!entry) return;
        if (_outputConsoleSinks.size === 0) {
          // Brak okna — buforujemy, by odtworzyć po zamontowaniu pierwszej Output Console.
          if (_outputPreBuffer.length < 3000) _outputPreBuffer.push(entry);
          return;
        }
        _forEachLiveOutputSink(sink => sink.append(entry));
      } catch (_) {}
    }, false);
  } catch (_) {}
  // 2) Background wstrzykuje page hook (CSP-immune via chrome.scripting.executeScript).
  try {
    safeSendMessage({ type: 'inject-output-hook' });
  } catch (_) {}
  // 3) Global Ctrl+Shift+L → czyści wszystkie Output Console (od v2.13.76).
  _ensureOutputGlobalKeydown();
}

// Odtwarza bufor wczesnych wpisów do nowo zarejestrowanego sinka.
// od v3.2.32 — używa appendBatch (jeden render zamiast N) jeśli sink go obsługuje.
function _replayOutputPreBuffer(sink) {
  if (sink.appendBatch) {
    try { sink.appendBatch(_outputPreBuffer.slice()); } catch (_) {}
    return;
  }
  for (let i = 0; i < _outputPreBuffer.length; i++) {
    try { sink.append(_outputPreBuffer[i]); } catch (_) {}
  }
}

// Normalizuje surowy postMessage z page hooka na wpis logu Output Console.
// Zwraca null gdy komunikat nie jest rozpoznawalnym wpisem (np. faza 'end' network).
function _normalizeOutputEntry(d) {
  const kind = d && d.kind;
  if (kind === 'console' || kind === 'jz') {
    if (typeof d.level !== 'string') return null;
    return {
      kind: kind, type: d.level,
      text: typeof d.text === 'string' ? d.text : '',
      detail: typeof d.detail === 'string' ? d.detail : '',
      t: d.t || Date.now(),
    };
  }
  if (kind === 'error') {
    return {
      kind: 'error',
      errType: d.errType === 'promise' ? 'promise' : 'error',
      message: String(d.message || ''),
      loc: String(d.loc || ''),
      stack: String(d.stack || ''),
      t: d.t || Date.now(),
    };
  }
  if (kind === 'datalayer') {
    return {
      kind: 'datalayer',
      event: String(d.event || '(no event)'),
      data: typeof d.data === 'string' ? d.data : '',
      history: !!d.history,
      t: d.t || Date.now(),
    };
  }
  return null;
}

// Buduje wpis Network z komunikatu fazy 'start'.
function _netEntryFromStartMsg(msg) {
  let domain = '', path = '';
  try {
    const u = new URL(msg.url);
    domain = u.hostname;
    path = (u.pathname || '/') + (u.search || '');
  } catch (_) { domain = ''; path = String(msg.url || ''); }
  return {
    kind: 'network',
    netId: String(msg.netId || ''),
    netCat: String(msg.netCat || 'other'),
    method: String(msg.method || 'GET'),
    url: String(msg.url || ''),
    domain: domain,
    path: path || '/',
    status: 'pending',
    duration: 0,
    size: null,
    ip: '',
    initiator: String(msg.initiator || ''),
    error: '',
    t: msg.t || Date.now(),
  };
}

// Obsługa zdarzeń Network z background (chrome.webRequest → chrome.tabs.sendMessage).
// Faza 'start' tworzy wpis (pending), 'end' aktualizuje status/IP/rozmiar/czas po netId.
function _handleNetworkEvent(msg) {
  if (msg.phase === 'start') {
    const entry = _netEntryFromStartMsg(msg);
    _forEachLiveOutputSink(sink => sink.append(entry));
  } else if (msg.phase === 'end') {
    _forEachLiveOutputSink(sink => { sink.updateNet && sink.updateNet(msg.netId, msg); });
  }
}

// Odtwarza bufor sieci z background do nowo zarejestrowanego sinka — żądania sprzed
// zamontowania Output Console (dokument, wczesne CSS/JS). Dedup po netId w appendEntry.
function _requestNetBufferInto(sink) {
  try {
    chrome.runtime.sendMessage({ type: 'jz-get-net-buffer' }, (resp) => {
      void chrome.runtime.lastError;
      if (!resp || !Array.isArray(resp.events)) return;
      // od v3.2.32 — najpierw zbuduj wszystkie start-wpisy do batch, potem
      // append jednym wywołaniem + updateNet dla phase 'end'.
      if (sink.appendBatch) {
        const starts = [];
        const ends = [];
        resp.events.forEach(m => {
          if (m.phase === 'start') starts.push(_netEntryFromStartMsg(m));
          else if (m.phase === 'end') ends.push(m);
        });
        try { sink.appendBatch(starts); } catch (_) {}
        ends.forEach(m => { try { sink.updateNet(m.netId, m); } catch (_) {} });
        return;
      }
      resp.events.forEach(m => {
        try {
          if (m.phase === 'start') sink.append(_netEntryFromStartMsg(m));
          else if (m.phase === 'end' && sink.updateNet) sink.updateNet(m.netId, m);
        } catch (_) {}
      });
    });
  } catch (_) {}
}

const OUTPUT_CONSOLE_SEARCH_KEY = (id) => `jz_output_${id}_search`; // per-tab
const OUTPUT_CONSOLE_TAB_KEY    = (id) => `jz_output_${id}_tab`;    // aktywna zakładka, per-tab
const OUTPUT_CONSOLE_FILTER_KEY = (id) => `jz_output_${id}_filter`; // stan filtra kontekstowego
// od v3.2.31 — timestamp ostatniego Clear (per okno). appendEntry skipuje wpisy
// starsze niż clearedAt → pre-buffer (content) i net-buffer (background) NIE
// re-aplikują skasowanych historycznych wpisów po close+open. Bez ruszania
// globalnych buforów (inne Output Console zachowują swoje historie).
const OUTPUT_CONSOLE_CLEARED_AT_KEY = (id) => `jz_output_${id}_clearedAt`;
const OUTPUT_CONSOLE_MAX_LINES  = 2000;

// Zakładki Output Console (od v2.13.117). 'all' = wszystkie wpisy; pozostałe filtrują po kind.
const OUTPUT_TABS = ['all', 'jz', 'console', 'network', 'errors', 'datalayer'];
const OUTPUT_TAB_KIND = { all: null, jz: 'jz', console: 'console', network: 'network', errors: 'error', datalayer: 'datalayer' };
const OUTPUT_TAB_LABEL_KEY = {
  all: 'outputConsole.tab.all', jz: 'outputConsole.tab.jz', console: 'outputConsole.tab.console',
  network: 'outputConsole.tab.network', errors: 'outputConsole.tab.errors', datalayer: 'outputConsole.tab.datalayer',
};
const OUTPUT_ECOM_EVENTS = ['view_item', 'add_to_cart', 'purchase', 'view_cart', 'begin_checkout',
  'view_item_list', 'select_item', 'remove_from_cart', 'add_payment_info', 'add_shipping_info'];

// Filtr kontekstowy (od v2.13.118) — zawartość wiersza filtra zależna od aktywnej zakładki.
const OUTPUT_LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
const OUTPUT_NET_TYPES = ['xhr', 'doc', 'css', 'js', 'font', 'img', 'media', 'manifest', 'socket', 'wasm', 'other'];
const OUTPUT_NET_TYPE_LABEL = {
  xhr: 'Fetch/XHR', doc: 'Doc', css: 'CSS', js: 'JS', font: 'Font', img: 'Img',
  media: 'Media', manifest: 'Manifest', socket: 'Socket', wasm: 'Wasm', other: 'Other',
};
const OUTPUT_NET_STATUS = ['2xx', '3xx', '4xx', '5xx', 'err'];
const OUTPUT_SIZE_MAX_KB = 2048;    // 2 MB — górna pozycja suwaka = brak limitu
const OUTPUT_TIME_MAX_MS = 100000;  // górna pozycja suwaka = brak limitu

function _outputDefaultFilter() {
  const allTrue = (arr) => { const o = {}; arr.forEach(k => { o[k] = true; }); return o; };
  return {
    jzLevels:  allTrue(OUTPUT_LEVELS),
    conLevels: allTrue(OUTPUT_LEVELS),
    netTypes:  allTrue(OUTPUT_NET_TYPES),
    netStatus: allTrue(OUTPUT_NET_STATUS),
    sizeMin: 0, sizeMax: OUTPUT_SIZE_MAX_KB,
    timeMin: 0, timeMax: OUTPUT_TIME_MAX_MS,
    domain: '',
    domainExclude: '',
  };
}
function _outputLoadFilter(ss, id) {
  const def = _outputDefaultFilter();
  if (!ss) return def;
  try {
    const raw = ss.getItem(OUTPUT_CONSOLE_FILTER_KEY(id));
    if (!raw) return def;
    const p = JSON.parse(raw);
    if (!p || typeof p !== 'object') return def;
    return {
      jzLevels:  Object.assign(def.jzLevels,  p.jzLevels  || {}),
      conLevels: Object.assign(def.conLevels, p.conLevels || {}),
      netTypes:  Object.assign(def.netTypes,  p.netTypes  || {}),
      netStatus: Object.assign(def.netStatus, p.netStatus || {}),
      sizeMin: typeof p.sizeMin === 'number' ? p.sizeMin : 0,
      sizeMax: typeof p.sizeMax === 'number' ? p.sizeMax : OUTPUT_SIZE_MAX_KB,
      timeMin: typeof p.timeMin === 'number' ? p.timeMin : 0,
      timeMax: typeof p.timeMax === 'number' ? p.timeMax : OUTPUT_TIME_MAX_MS,
      domain: typeof p.domain === 'string' ? p.domain : '',
      domainExclude: typeof p.domainExclude === 'string' ? p.domainExclude : '',
    };
  } catch (_) { return def; }
}

function _outputEntryMatchesTab(entry, tab) {
  const k = OUTPUT_TAB_KIND[tab];
  return k == null || entry.kind === k;
}

// Kategoria statusu Network dla filtra: 2xx/3xx/4xx/5xx lub 'err' (pending, ERR, inne).
function _netStatusCat(status) {
  if (status === 'pending' || status === 'ERR') return 'err';
  const n = Number(status);
  if (isNaN(n)) return 'err';
  if (n >= 200 && n < 300) return '2xx';
  if (n >= 300 && n < 400) return '3xx';
  if (n >= 400 && n < 500) return '4xx';
  if (n >= 500 && n < 600) return '5xx';
  return 'err';
}

function _outputEntryMatchesFilter(entry, tab, f) {
  if (!f) return true;
  if (tab === 'jz')      return f.jzLevels[entry.type] !== false;
  if (tab === 'console') return f.conLevels[entry.type] !== false;
  if (tab === 'network') {
    if (f.netTypes[entry.netCat || 'other'] === false) return false;
    if (f.netStatus[_netStatusCat(entry.status)] === false) return false;
    // Domeny+ / Domeny- — listy domen oddzielone '|', dopasowanie podciągiem.
    // Domeny+: wpis musi pasować do co najmniej jednej. Domeny-: pasujący odpada.
    const _dom = (entry.domain || '').toLowerCase();
    const _splitDomains = (s) => (s || '').toLowerCase().split('|').map(x => x.trim()).filter(Boolean);
    const _inc = _splitDomains(f.domain);
    if (_inc.length && !_inc.some(d => _dom.indexOf(d) !== -1)) return false;
    const _exc = _splitDomains(f.domainExclude);
    if (_exc.length && _exc.some(d => _dom.indexOf(d) !== -1)) return false;
    // Filtr wielkości aktywny (suwak ruszony) → wpisy bez podanego rozmiaru
    // odpadają; inaczej pokazywałyby się zawsze, ignorując filtr.
    const sizeFilterActive = f.sizeMin > 0 || f.sizeMax < OUTPUT_SIZE_MAX_KB;
    if (sizeFilterActive) {
      if (typeof entry.size !== 'number') return false;
      const kb = entry.size / 1024;
      if (kb < f.sizeMin) return false;
      if (f.sizeMax < OUTPUT_SIZE_MAX_KB && kb > f.sizeMax) return false;
    }
    if (entry.status !== 'pending') {
      const ms = entry.duration || 0;
      if (ms < f.timeMin) return false;
      if (f.timeMax < OUTPUT_TIME_MAX_MS && ms > f.timeMax) return false;
    }
    return true;
  }
  return true;
}

function _outputEntrySearchText(entry) {
  if (entry.kind === 'console' || entry.kind === 'jz') return (entry.text || '') + ' ' + (entry.detail || '');
  if (entry.kind === 'error') return (entry.message || '') + ' ' + (entry.loc || '') + ' ' + (entry.stack || '');
  if (entry.kind === 'network') return (entry.domain || '') + ' ' + (entry.path || '');
  if (entry.kind === 'datalayer') return (entry.event || '') + ' ' + (entry.data || '');
  return '';
}
function _outputFmtTime(t) {
  try { return new Date(t).toTimeString().slice(0, 8); } catch (_) { return ''; }
}
function _outputFmtSize(bytes) {
  if (typeof bytes !== 'number' || bytes < 0) return '—';
  if (bytes < 1024) return bytes + ' B';
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' kB';
  return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
function _outputNetStatusClass(status) {
  if (status === 'pending') return 'jz-tag-pending';
  if (status === 'ERR') return 'jz-tag-err';
  const n = Number(status);
  if (!isNaN(n) && n >= 100) return 'jz-tag-' + Math.floor(n / 100) + '00';
  return 'jz-tag-err';
}

// Buduje wiersz DOM dla wpisu logu — kształt zależny od entry.kind. textContent na każdym
// fragmencie z danymi (bez innerHTML) — brak ryzyka XSS z treści strony.
function _renderOutputEntryRow(entry) {
  const row = document.createElement('div');
  const addTime = () => {
    const s = document.createElement('span');
    s.className = 'jz-time';
    s.textContent = _outputFmtTime(entry.t);
    row.appendChild(s);
  };
  const addTag = (cls, text) => {
    const s = document.createElement('span');
    s.className = 'jz-tag ' + cls;
    s.textContent = text;
    row.appendChild(s);
  };
  const addText = (cls, text) => {
    const s = document.createElement('span');
    s.className = cls;
    s.textContent = text;
    row.appendChild(s);
  };
  const addBlock = (cls, text) => {
    const d = document.createElement('div');
    d.className = cls;
    d.textContent = text;
    row.appendChild(d);
  };
  // Rozwijany blok szczegółów — lista par klucz/wartość. Puste pola pokazujemy jako „—",
  // żeby było widać, że dana kolumna istnieje (np. Remote address dla zasobu z cache).
  const makeDetail = (pairs) => {
    const det = document.createElement('div');
    det.className = 'jz-output-detail';
    pairs.forEach(([k, v, pre]) => {
      const line = document.createElement('div');
      line.className = 'jz-kv';
      const kEl = document.createElement('span');
      kEl.className = 'jz-kv-k';
      kEl.textContent = k;
      const vEl = document.createElement(pre ? 'pre' : 'span');
      vEl.className = pre ? 'jz-kv-pre' : 'jz-kv-v';
      vEl.textContent = (v == null || v === '') ? '—' : v;
      line.appendChild(kEl);
      line.appendChild(vEl);
      det.appendChild(line);
    });
    return det;
  };
  if (entry.kind === 'console' || entry.kind === 'jz') {
    const lvl = entry.type || 'log';
    row.className = 'jz-output-line jz-output-line-' + lvl;
    row.dataset.kind = entry.kind;
    row.dataset.level = lvl;
    if (entry.detail) {
      // Wpis z obiektem — zwinięty: jednoliniowy podgląd; rozwinięty: pełna postać.
      row.classList.add('jz-output-row-rich', 'jz-output-expandable');
      const head = document.createElement('div');
      head.className = 'jz-out-head';
      const tm = document.createElement('span'); tm.className = 'jz-time'; tm.textContent = _outputFmtTime(entry.t);
      const tg = document.createElement('span'); tg.className = 'jz-tag jz-tag-lvl-' + lvl; tg.textContent = String(lvl).toUpperCase();
      const tx = document.createElement('span'); tx.className = 'jz-out-text'; tx.textContent = entry.text || '';
      head.appendChild(tm); head.appendChild(tg); head.appendChild(tx);
      row.appendChild(head);
      addBlock('jz-stack', entry.detail);
    } else {
      addTime();
      addTag('jz-tag-lvl-' + lvl, String(lvl).toUpperCase());
      addText('jz-out-text', entry.text || '');
    }
    return row;
  }
  if (entry.kind === 'error') {
    row.className = 'jz-output-line jz-output-line-error jz-output-row-rich jz-output-expandable';
    row.dataset.kind = 'error';
    addTime();
    addTag('jz-tag-err', entry.errType === 'promise' ? 'PROMISE' : 'ERROR');
    addText('jz-out-text', entry.message || '');
    row.appendChild(makeDetail([
      [JZ_I18N.t('outputConsole.err.field.type'), entry.errType === 'promise' ? 'promise' : 'error'],
      [JZ_I18N.t('outputConsole.err.field.location'), entry.loc],
      [JZ_I18N.t('outputConsole.err.field.stack'), entry.stack, true],
    ]));
    return row;
  }
  if (entry.kind === 'network') {
    row.className = 'jz-output-line jz-output-row-rich jz-output-row-net jz-output-expandable';
    row.dataset.kind = 'network';
    if (entry.netId) row.dataset.netId = entry.netId;
    if (entry.domain) row.dataset.netDomain = entry.domain;
    const cat = entry.netCat || 'other';
    addTime();
    addTag(_outputNetStatusClass(entry.status), String(entry.status));
    addTag('jz-tag-type jz-tag-type-' + cat, OUTPUT_NET_TYPE_LABEL[cat] || 'Other');
    addText('jz-net-method', entry.method || 'GET');
    addText('jz-net-domain', entry.domain || '');
    // URL w zwiniętym wierszu skrócony do 255 znaków — długi query string potrafił
    // zająć całe okno. Pełny URL widoczny w szczegółach po rozwinięciu wiersza.
    const _netPath = entry.path || '';
    addText('jz-net-path', _netPath.length > 255 ? _netPath.slice(0, 255) + '…' : _netPath);
    addText('jz-net-dur', entry.status === 'pending' ? '…' : ((entry.duration || 0) + ' ms'));
    const _hasSize = typeof entry.size === 'number' && entry.size >= 0;
    addText('jz-net-size' + (_hasSize ? ' jz-net-size-val' : ''), _outputFmtSize(entry.size));
    row.appendChild(makeDetail([
      [JZ_I18N.t('outputConsole.net.field.callTime'),  _outputFmtTime(entry.t)],
      [JZ_I18N.t('outputConsole.net.field.status'),    String(entry.status)],
      [JZ_I18N.t('outputConsole.net.field.type'),      OUTPUT_NET_TYPE_LABEL[cat] || 'Other'],
      [JZ_I18N.t('outputConsole.net.field.method'),    entry.method || 'GET'],
      [JZ_I18N.t('outputConsole.net.field.domain'),    entry.domain],
      [JZ_I18N.t('outputConsole.net.field.path'),      entry.path],
      [JZ_I18N.t('outputConsole.net.field.remote'),    entry.ip],
      [JZ_I18N.t('outputConsole.net.field.initiator'), entry.initiator],
      [JZ_I18N.t('outputConsole.net.field.time'),      entry.status === 'pending' ? '…' : ((entry.duration || 0) + ' ms')],
      [JZ_I18N.t('outputConsole.net.field.size'),      _outputFmtSize(entry.size)],
      [JZ_I18N.t('outputConsole.net.field.url'),       entry.url, true],
      [JZ_I18N.t('outputConsole.net.field.error'),     entry.error],
    ]));
    return row;
  }
  if (entry.kind === 'datalayer') {
    row.className = 'jz-output-line jz-output-row-rich jz-output-row-dl';
    row.dataset.kind = 'datalayer';
    addTime();
    if (OUTPUT_ECOM_EVENTS.indexOf(entry.event) !== -1) addTag('jz-tag-ecom', 'ECOM');
    if (entry.history) addTag('jz-tag-hist', 'HIST');
    addText('jz-dl-event', entry.event || '');
    if (entry.data) {
      addBlock('jz-stack', entry.data);
      row.classList.add('jz-output-expandable');
    }
    return row;
  }
  row.className = 'jz-output-line';
  return row;
}

// === DataLayer — drzewo JSON (prawa kolumna zakładki DataLayer) ===
function _jsonValShort(v) {
  if (v === null) return 'null';
  if (Array.isArray(v)) return v.length ? 'Array(' + v.length + ')' : '[]';
  if (typeof v === 'object') return Object.keys(v).length ? '{…}' : '{}';
  if (typeof v === 'string') return "'" + v + "'";
  return String(v);
}
function _jsonNodeSummary(v) {
  let s;
  if (Array.isArray(v)) {
    s = '(' + v.length + ') [' + v.map(_jsonValShort).join(', ') + ']';
  } else {
    const keys = Object.keys(v);
    s = '{' + keys.map(k => k + ': ' + _jsonValShort(v[k])).join(', ') + '}';
  }
  return s.length > 220 ? s.slice(0, 220) + '…' : s;
}
// Rekurencyjny węzeł drzewa. Dzieci budowane leniwie przy pierwszym rozwinięciu.
function _renderJsonNode(key, value) {
  const node = document.createElement('div');
  node.className = 'jz-tree-node';
  const isObj = value !== null && typeof value === 'object';
  const head = document.createElement('div');
  head.className = 'jz-tree-row';
  const tog = document.createElement('span');
  tog.className = 'jz-tree-toggle' + (isObj ? '' : ' jz-tree-toggle-empty');
  tog.textContent = isObj ? '▶' : '';
  head.appendChild(tog);
  if (key !== null) {
    const k = document.createElement('span');
    k.className = 'jz-tree-key';
    k.textContent = key + ': ';
    head.appendChild(k);
  }
  const val = document.createElement('span');
  if (isObj) {
    val.className = 'jz-tree-preview';
    val.textContent = _jsonNodeSummary(value);
  } else {
    val.className = 'jz-tree-val jz-tree-val-' + (value === null ? 'null' : typeof value);
    val.textContent = (typeof value === 'string') ? "'" + value + "'" : String(value);
  }
  head.appendChild(val);
  node.appendChild(head);
  if (isObj) {
    head.classList.add('jz-tree-expandable');
    const children = document.createElement('div');
    children.className = 'jz-tree-children';
    node.appendChild(children);
    let built = false;
    const build = () => {
      if (built) return;
      built = true;
      const keys = Array.isArray(value) ? value.map((_, i) => String(i)) : Object.keys(value);
      keys.forEach(ck => children.appendChild(_renderJsonNode(ck, value[ck])));
    };
    head.addEventListener('click', (e) => {
      e.stopPropagation();
      build();
      node.classList.toggle('jz-tree-open');
    });
    // Rozwiń rekurencyjnie — używane przez „rozwiń wszystko".
    node._jzExpandAll = () => {
      build();
      node.classList.add('jz-tree-open');
      const kids = children.children;
      for (let i = 0; i < kids.length; i++) {
        if (kids[i]._jzExpandAll) kids[i]._jzExpandAll();
      }
    };
  }
  return node;
}

// od v3.2.30 — uniwersalny wrap pane'a JustZixa w shadow root dla pełnej izolacji
// stylów od strony. Host = `<div id=elId data-jz-shadow-host="1" style="display:contents">`
// w document; real el (z `position:fixed`) w `host.shadowRoot` razem z `@import` na
// content.css. `display:contents` na hoście = layout pane'a jak dotąd. id przeniesione
// na host → istniejące `document.getElementById(elId)` zwraca host (dla `remove()`,
// istnienia). `getPanelElementById` jest shadow-aware (rozwiązuje host → real pane).
function _jzWrapInShadow(el, elId) {
  const host = document.createElement('div');
  host.id = elId;
  host.dataset.jzShadowHost = '1';
  host.style.display = 'contents';
  el.removeAttribute('id'); // ID przejmuje host
  const shadow = host.attachShadow({ mode: 'open' });
  const styleEl = document.createElement('style');
  styleEl.textContent = '@import url(' + JSON.stringify(chrome.runtime.getURL('content.css')) + ');';
  shadow.appendChild(styleEl);
  shadow.appendChild(el);
  return host;
}
// Resolves host → real pane wewnątrz shadow. Akceptuje też legacy
// `dataset.jzTempShadow` (z v3.2.29) i nowy `dataset.jzShadowHost`.
function _jzResolveShadow(el, paneSelector) {
  if (!el) return el;
  if (el.dataset && (el.dataset.jzShadowHost === '1' || el.dataset.jzTempShadow === '1')) {
    return el.shadowRoot ? el.shadowRoot.querySelector(paneSelector) : null;
  }
  return el;
}

function renderOutputConsoleElement(oc) {
  const elId = OUTPUT_CONSOLE_PREFIX + oc.id;
  let el = document.getElementById(elId);
  // od v3.2.29/v3.2.30 — TEMP+persistent Output w shadow root. Rozwiąż host → real pane.
  el = _jzResolveShadow(el, '.jz-pane-output');
  let ss = null; try { ss = window.sessionStorage; } catch (_) {}

  if (!el) {
    el = document.createElement('div');
    el.id = elId;
    el.className = 'jz-pane jz-pane-output';
    const initLog = ss ? (ss.getItem(OUTPUT_CONSOLE_LOG_KEY(oc.id)) || '[]') : '[]';
    const initAutoscroll = ss ? (ss.getItem(OUTPUT_CONSOLE_AUTOSCROLL_KEY(oc.id)) || 'on') : 'on';
    const initSearch = ss ? (ss.getItem(OUTPUT_CONSOLE_SEARCH_KEY(oc.id)) || '') : '';
    let initTab = ss ? (ss.getItem(OUTPUT_CONSOLE_TAB_KEY(oc.id)) || 'all') : 'all';
    if (OUTPUT_TABS.indexOf(initTab) === -1) initTab = 'all';

    const tabsHtml = OUTPUT_TABS.map(tab =>
      `<button class="jz-output-tab" type="button" data-tab="${tab}">` +
      `<span class="jz-output-tab-label">${escapeBarName(JZ_I18N.t(OUTPUT_TAB_LABEL_KEY[tab]))}</span>` +
      `<span class="jz-output-tab-cnt" data-cnt="${tab}">0</span></button>`
    ).join('');

    el.innerHTML = `
      <div class="jz-pane-header">
        <span class="jz-pane-dot" style="background:${(oc.color || '#10B981').replace(/[<"']/g, '')};"></span>
        <span class="jz-pane-title"></span>
        <button class="jz-output-autoscroll" type="button" title="${JZ_I18N.t('outputConsole.autoscroll.title').replace(/"/g, '&quot;')}" aria-label="Autoscroll">⇲</button>
        <button class="jz-output-clear" type="button" title="${JZ_I18N.t('outputConsole.clear.title').replace(/"/g, '&quot;')}" aria-label="Clear">⌫</button>
      </div>
      <div class="jz-output-tabs">${tabsHtml}</div>
      <div class="jz-output-filterbar"></div>
      <div class="jz-output-search-row">
        <input class="jz-output-search-input" type="search" spellcheck="false" autocomplete="off" placeholder="${JZ_I18N.t('outputConsole.search.placeholder').replace(/"/g, '&quot;')}" />
      </div>
      <div class="jz-output-log" tabindex="0"></div>
    `;
    // od v3.2.30 — wszystkie Output Console (TEMP + persistent) w shadow root.
    document.documentElement.appendChild(_jzWrapInShadow(el, elId));

    const logEl     = el.querySelector('.jz-output-log');
    const clearBtn  = el.querySelector('.jz-output-clear');
    const autoBtn   = el.querySelector('.jz-output-autoscroll');
    const tabsEl    = el.querySelector('.jz-output-tabs');
    const filterbar = el.querySelector('.jz-output-filterbar');
    const searchRow = el.querySelector('.jz-output-search-row');
    const searchIn  = el.querySelector('.jz-output-search-input');
    if (initSearch) searchIn.value = initSearch;

    let log = [];
    try { log = JSON.parse(initLog); if (!Array.isArray(log)) log = []; } catch (_) { log = []; }
    // od v3.2.31 — cutoff dla backfill (zob. OUTPUT_CONSOLE_CLEARED_AT_KEY).
    let clearedAt = 0;
    if (ss) {
      const raw = ss.getItem(OUTPUT_CONSOLE_CLEARED_AT_KEY(oc.id));
      const n = parseInt(raw, 10);
      if (!isNaN(n) && n > 0) clearedAt = n;
    }
    let autoscroll = initAutoscroll !== 'off';
    if (autoscroll) autoBtn.classList.add('jz-active');
    let searchTerm = initSearch.toLowerCase();
    let activeTab = initTab;
    const filter = _outputLoadFilter(ss, oc.id);

    const matchesSearch = (entry) =>
      !searchTerm || _outputEntrySearchText(entry).toLowerCase().indexOf(searchTerm) !== -1;
    const entryVisible = (entry) =>
      _outputEntryMatchesTab(entry, activeTab)
      && _outputEntryMatchesFilter(entry, activeTab, filter)
      && matchesSearch(entry);
    const persistFilter = () => {
      if (!ss) return;
      try { ss.setItem(OUTPUT_CONSOLE_FILTER_KEY(oc.id), JSON.stringify(filter)); } catch (_) {}
    };
    // Toggle domeny w liście '|'-separowanej (Ctrl / Ctrl+Alt klik na wierszu Network).
    const toggleDomainInList = (listStr, domain) => {
      const parts = (listStr || '').split('|').map(s => s.trim()).filter(Boolean);
      const i = parts.indexOf(domain);
      if (i >= 0) parts.splice(i, 1);
      else parts.push(domain);
      return parts.join('|');
    };

    const updateCounts = () => {
      const counts = { all: log.length, jz: 0, console: 0, network: 0, errors: 0, datalayer: 0 };
      for (let i = 0; i < log.length; i++) {
        const k = log[i].kind;
        if (k === 'jz') counts.jz++;
        else if (k === 'console') counts.console++;
        else if (k === 'network') counts.network++;
        else if (k === 'error') counts.errors++;
        else if (k === 'datalayer') counts.datalayer++;
      }
      OUTPUT_TABS.forEach(tab => {
        const c = el.querySelector('.jz-output-tab-cnt[data-cnt="' + tab + '"]');
        if (c) c.textContent = counts[tab];
      });
    };

    const paintActiveTab = () => {
      tabsEl.querySelectorAll('.jz-output-tab').forEach(b => {
        b.classList.toggle('jz-active', b.dataset.tab === activeTab);
      });
    };

    // DataLayer — widok dzielony: lewa = strumień nowych pushy, prawa = żywa zawartość
    // obiektu window.dataLayer jako rozwijane drzewo (migawka z page hooka).
    let _dlDragging = false; // true podczas przeciągania dividera DataLayer
    const renderDataLayerSplit = () => {
      const fresh = log.filter(e => e.kind === 'datalayer' && !e.history && matchesSearch(e));
      const split = document.createElement('div');
      split.className = 'jz-dl-split';
      // Lewa kolumna — nowe pushe.
      const leftCol = document.createElement('div');
      leftCol.className = 'jz-dl-col';
      const leftTitle = document.createElement('div');
      leftTitle.className = 'jz-dl-col-title';
      leftTitle.textContent = JZ_I18N.t('outputConsole.dl.newPushes') + ' (' + fresh.length + ')';
      const leftBody = document.createElement('div');
      leftBody.className = 'jz-dl-col-body';
      if (fresh.length === 0) {
        const e = document.createElement('div');
        e.className = 'jz-output-empty';
        e.textContent = JZ_I18N.t('outputConsole.empty');
        leftBody.appendChild(e);
      } else {
        fresh.forEach(it => leftBody.appendChild(_renderOutputEntryRow(it)));
      }
      leftCol.appendChild(leftTitle);
      leftCol.appendChild(leftBody);
      // Prawa kolumna — aktualny stan window.dataLayer (drzewo) + rozwiń/zwiń wszystko.
      const snap = _dataLayerSnapshot;
      const rightCol = document.createElement('div');
      rightCol.className = 'jz-dl-col';
      const rightTitle = document.createElement('div');
      rightTitle.className = 'jz-dl-col-title';
      const rightLabel = document.createElement('span');
      rightLabel.textContent = JZ_I18N.t('outputConsole.dl.currentState')
        + ' (' + (snap ? snap.length : 0) + ')';
      const treeBtns = document.createElement('span');
      treeBtns.className = 'jz-dl-tree-btns';
      const expandBtn = document.createElement('button');
      expandBtn.type = 'button';
      expandBtn.className = 'jz-filter-bulk-btn';
      expandBtn.textContent = JZ_I18N.t('outputConsole.dl.expandAll');
      const collapseBtn = document.createElement('button');
      collapseBtn.type = 'button';
      collapseBtn.className = 'jz-filter-bulk-btn';
      collapseBtn.textContent = JZ_I18N.t('outputConsole.dl.collapseAll');
      treeBtns.appendChild(expandBtn);
      treeBtns.appendChild(collapseBtn);
      rightTitle.appendChild(rightLabel);
      rightTitle.appendChild(treeBtns);
      const rightBody = document.createElement('div');
      rightBody.className = 'jz-dl-col-body jz-dl-tree';
      if (!snap || snap.length === 0) {
        const e = document.createElement('div');
        e.className = 'jz-output-empty';
        e.textContent = JZ_I18N.t('outputConsole.empty');
        rightBody.appendChild(e);
      } else {
        snap.forEach((item, i) => rightBody.appendChild(_renderJsonNode(String(i), item)));
      }
      expandBtn.addEventListener('click', () => {
        const kids = rightBody.children;
        for (let i = 0; i < kids.length; i++) {
          if (kids[i]._jzExpandAll) kids[i]._jzExpandAll();
        }
      });
      collapseBtn.addEventListener('click', () => {
        rightBody.querySelectorAll('.jz-tree-node').forEach(n => n.classList.remove('jz-tree-open'));
      });
      rightCol.appendChild(rightTitle);
      rightCol.appendChild(rightBody);
      // Divider — przeciąganie zmienia szerokość kolumn DataLayer; ratio utrzymywane
      // tylko na czas sesji (sessionStorage per Output Console).
      const _ratioKey = 'jz-dl-ratio-' + oc.id;
      let _ratio = 0.5;
      try {
        const r = parseFloat(sessionStorage.getItem(_ratioKey));
        if (r >= 0.15 && r <= 0.85) _ratio = r;
      } catch (_) {}
      // setProperty z 'important' — reguła .jz-dl-col { flex:1 1 0 !important }
      // przebiłaby zwykły inline style i kolumna nie zmieniłaby szerokości.
      leftCol.style.setProperty('flex', '0 0 ' + (_ratio * 100) + '%', 'important');
      const divider = document.createElement('div');
      divider.className = 'jz-dl-divider';
      divider.addEventListener('mousedown', (ev) => {
        ev.preventDefault();
        ev.stopPropagation();
        // Wstrzymaj przebudowę widoku DataLayer na czas drag — inaczej odświeżenie
        // snapshotu zrekonstruowałoby kolumny i przerwało przeciąganie.
        _dlDragging = true;
        const rect = split.getBoundingClientRect();
        const onMove = (mv) => {
          let r = (mv.clientX - rect.left) / (rect.width || 1);
          r = Math.max(0.15, Math.min(0.85, r));
          _ratio = r;
          leftCol.style.setProperty('flex', '0 0 ' + (r * 100) + '%', 'important');
        };
        const onUp = () => {
          document.removeEventListener('mousemove', onMove, true);
          document.removeEventListener('mouseup', onUp, true);
          _dlDragging = false;
          try { sessionStorage.setItem(_ratioKey, String(_ratio)); } catch (_) {}
        };
        document.addEventListener('mousemove', onMove, true);
        document.addEventListener('mouseup', onUp, true);
      });
      split.appendChild(leftCol);
      split.appendChild(divider);
      split.appendChild(rightCol);
      logEl.appendChild(split);
    };

    const renderAll = () => {
      // Podczas przeciągania dividera DataLayer nie przebudowuj widoku — rebuild
      // odłączyłby kolumny i przerwał drag (snapshot DataLayer odświeża się często).
      if (_dlDragging && activeTab === 'datalayer') return;
      logEl.innerHTML = '';
      if (activeTab === 'datalayer') { renderDataLayerSplit(); return; }
      const visible = log.filter(entryVisible);
      if (visible.length === 0) {
        const empty = document.createElement('div');
        empty.className = 'jz-output-empty';
        empty.textContent = JZ_I18N.t('outputConsole.empty');
        logEl.appendChild(empty);
      } else {
        // od v3.2.32 — DocumentFragment redukuje N reflow → 1 (700 wpisów ~5s → ~50ms).
        const frag = document.createDocumentFragment();
        visible.forEach(e => frag.appendChild(_renderOutputEntryRow(e)));
        logEl.appendChild(frag);
      }
      if (autoscroll) logEl.scrollTop = logEl.scrollHeight;
    };

    // od v3.2.32 — debounce persistLog 200 ms. Wcześniej każdy appendEntry robił
    // sync JSON.stringify całego loga (rosnący O(N)) + sessionStorage.setItem.
    // Backfill 700 wpisów = 700× O(N) = O(N²). Debounce + flush trim daje O(N).
    let _persistTimer = null;
    const persistLog = () => {
      if (_persistTimer) return;
      _persistTimer = setTimeout(() => {
        _persistTimer = null;
        // Trim PRZED guardem sessionStorage — patrz persistLog konsoli JS.
        if (log.length > OUTPUT_CONSOLE_MAX_LINES) log = log.slice(-OUTPUT_CONSOLE_MAX_LINES);
        if (!ss) return;
        try { ss.setItem(OUTPUT_CONSOLE_LOG_KEY(oc.id), JSON.stringify(log)); } catch (_) {}
      }, 200);
    };

    // --- Filtr kontekstowy: budowa wiersza zależna od aktywnej zakładki ---
    const mkCheckbox = (label, checked, extraCls, onChange) => {
      const lab = document.createElement('label');
      lab.className = 'jz-filter-chk' + (extraCls ? ' ' + extraCls : '');
      const cb = document.createElement('input');
      cb.type = 'checkbox';
      cb.checked = checked;
      cb.addEventListener('change', () => onChange(cb.checked));
      const sp = document.createElement('span');
      sp.textContent = label;
      lab.appendChild(cb);
      lab.appendChild(sp);
      return lab;
    };
    const mkRange = (metric) => {
      const isSize = metric === 'size';
      const max = isSize ? OUTPUT_SIZE_MAX_KB : OUTPUT_TIME_MAX_MS;
      const POS = 1000;   // liniowe pozycje suwaka
      const K = 3;        // wykładnik krzywej — dolny zakres ma drobną granulację
      const GAP = 16;     // minimalny odstęp kciuków w pozycjach (≈1.6% toru)
      // Pozycja 0..POS ↔ wartość 0..max po krzywej wykładniczej. Minimalny ruch
      // suwaka z 0 daje wartość ≥ 1 — filtr staje się aktywny (wpisy bez/0 rozmiaru
      // znikają). Pozycja 0 = brak limitu od dołu.
      const posToVal = (p) => {
        if (p <= 0) return 0;
        if (p >= POS) return max;
        return Math.min(max, Math.max(1, Math.round(max * Math.pow(p / POS, K))));
      };
      const valToPos = (v) => {
        if (!(v > 0)) return 0;
        if (v >= max) return POS;
        return Math.round(POS * Math.pow(v / max, 1 / K));
      };
      let lo = isSize ? filter.sizeMin : filter.timeMin;
      let hi = isSize ? filter.sizeMax : filter.timeMax;
      if (!(lo >= 0 && lo <= max)) lo = 0;
      if (!(hi >= 0 && hi <= max)) hi = max;
      const wrap = document.createElement('div');
      wrap.className = 'jz-range';
      const label = document.createElement('span');
      label.className = 'jz-range-label';
      label.textContent = (isSize ? JZ_I18N.t('outputConsole.net.size') : JZ_I18N.t('outputConsole.net.time'))
        + ' (' + (isSize ? 'kB' : 'ms') + ')';
      const loNum = document.createElement('input');
      loNum.type = 'number'; loNum.className = 'jz-range-num jz-range-num-lo';
      loNum.min = '0'; loNum.max = String(max);
      const hiNum = document.createElement('input');
      hiNum.type = 'number'; hiNum.className = 'jz-range-num jz-range-num-hi';
      hiNum.min = '0'; hiNum.max = String(max);
      const track = document.createElement('div');
      track.className = 'jz-range-track';
      const loIn = document.createElement('input');
      loIn.type = 'range'; loIn.className = 'jz-range-lo';
      loIn.min = '0'; loIn.max = String(POS); loIn.step = '1';
      const hiIn = document.createElement('input');
      hiIn.type = 'range'; hiIn.className = 'jz-range-hi';
      hiIn.min = '0'; hiIn.max = String(POS); hiIn.step = '1';
      track.appendChild(loIn);
      track.appendChild(hiIn);
      const syncUI = () => {
        loIn.value = String(valToPos(lo));
        hiIn.value = String(valToPos(hi));
        loNum.value = String(lo);
        hiNum.value = String(hi);
      };
      const commit = () => {
        if (isSize) { filter.sizeMin = lo; filter.sizeMax = hi; }
        else { filter.timeMin = lo; filter.timeMax = hi; }
        persistFilter();
        renderAll();
      };
      loIn.addEventListener('input', () => {
        let p = Number(loIn.value);
        const hp = valToPos(hi);
        if (p > hp - GAP) p = Math.max(0, hp - GAP);
        lo = posToVal(p);
        if (lo > hi) hi = lo;
        syncUI(); commit();
      });
      hiIn.addEventListener('input', () => {
        let p = Number(hiIn.value);
        const lp = valToPos(lo);
        if (p < lp + GAP) p = Math.min(POS, lp + GAP);
        hi = posToVal(p);
        if (hi < lo) lo = hi;
        syncUI(); commit();
      });
      // Pola input — zatwierdzenie na change (Enter/blur).
      const commitNum = () => {
        let a = parseFloat(loNum.value);
        let b = parseFloat(hiNum.value);
        a = (a >= 0) ? Math.min(max, Math.round(a)) : 0;
        b = (b >= 0) ? Math.min(max, Math.round(b)) : max;
        if (a > b) a = b;
        lo = a; hi = b;
        syncUI(); commit();
      };
      loNum.addEventListener('change', commitNum);
      hiNum.addEventListener('change', commitNum);
      syncUI();
      wrap.appendChild(label);
      wrap.appendChild(loNum);
      wrap.appendChild(track);
      wrap.appendChild(hiNum);
      return wrap;
    };
    // Przyciski „wszystkie / żadne" dla grupy checkboxów.
    const mkBulk = (obj, keys) => {
      const box = document.createElement('span');
      box.className = 'jz-filter-bulk';
      const mk = (txt, val) => {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'jz-filter-bulk-btn';
        b.textContent = txt;
        b.addEventListener('click', () => {
          keys.forEach(k => { obj[k] = val; });
          persistFilter();
          renderFilterBar();
          renderAll();
        });
        return b;
      };
      box.appendChild(mk(JZ_I18N.t('outputConsole.filter.all'), true));
      box.appendChild(mk(JZ_I18N.t('outputConsole.filter.none'), false));
      return box;
    };
    const renderFilterBar = () => {
      filterbar.innerHTML = '';
      if (activeTab === 'jz' || activeTab === 'console') {
        filterbar.style.display = '';
        const which = activeTab === 'jz' ? 'jzLevels' : 'conLevels';
        const r = document.createElement('div');
        r.className = 'jz-filter-row';
        OUTPUT_LEVELS.forEach(lv => {
          r.appendChild(mkCheckbox(
            JZ_I18N.t('outputConsole.filters.level.' + lv),
            filter[which][lv] !== false,
            'jz-filter-lvl-' + lv,
            (c) => { filter[which][lv] = c; persistFilter(); renderAll(); }
          ));
        });
        filterbar.appendChild(r);
      } else if (activeTab === 'network') {
        filterbar.style.display = '';
        const r1 = document.createElement('div');
        r1.className = 'jz-filter-row';
        r1.appendChild(mkBulk(filter.netTypes, OUTPUT_NET_TYPES));
        OUTPUT_NET_TYPES.forEach(tp => {
          r1.appendChild(mkCheckbox(
            OUTPUT_NET_TYPE_LABEL[tp],
            filter.netTypes[tp] !== false,
            'jz-filter-type-' + tp,
            (c) => { filter.netTypes[tp] = c; persistFilter(); renderAll(); }
          ));
        });
        filterbar.appendChild(r1);
        const r2 = document.createElement('div');
        r2.className = 'jz-filter-row';
        r2.appendChild(mkBulk(filter.netStatus, OUTPUT_NET_STATUS));
        OUTPUT_NET_STATUS.forEach(st => {
          r2.appendChild(mkCheckbox(
            st === 'err' ? JZ_I18N.t('outputConsole.net.status.err') : st,
            filter.netStatus[st] !== false,
            'jz-filter-st-' + st,
            (c) => { filter.netStatus[st] = c; persistFilter(); renderAll(); }
          ));
        });
        // Domeny+ / Domeny- — wiele domen oddzielonych '|', dopasowanie podciągiem.
        // W rzędzie ze statusami (przeniesione z rzędu suwaków).
        const mkDomainInput = (key, labelText) => {
          const lab = document.createElement('label');
          lab.className = 'jz-filter-domain';
          const sp = document.createElement('span');
          sp.textContent = labelText;
          const inp = document.createElement('input');
          inp.type = 'text';
          inp.className = 'jz-filter-domain-input';
          inp.placeholder = 'wp.pl|onet.pl';
          inp.value = filter[key] || '';
          inp.addEventListener('input', () => {
            filter[key] = inp.value || '';
            persistFilter();
            renderAll();
          });
          lab.appendChild(sp);
          lab.appendChild(inp);
          return lab;
        };
        r2.appendChild(mkDomainInput('domain', JZ_I18N.t('outputConsole.net.domainsInclude')));
        r2.appendChild(mkDomainInput('domainExclude', JZ_I18N.t('outputConsole.net.domainsExclude')));
        filterbar.appendChild(r2);
        const r3 = document.createElement('div');
        r3.className = 'jz-filter-row jz-filter-row-ranges';
        r3.appendChild(mkRange('size'));
        r3.appendChild(mkRange('time'));
        filterbar.appendChild(r3);
      } else {
        filterbar.style.display = 'none';
      }
    };

    updateCounts();
    paintActiveTab();
    renderFilterBar();
    renderAll();

    const appendEntry = (entry) => {
      // od v3.2.31 — filtr cutoff: wpisy starsze niż ostatni Clear NIE wracają z
      // pre-buffera/net-buffera przy ponownym mount okna (close+open dawniej
      // przywracało skasowaną historię). entry.t jest UNIX ms z _normalizeOutputEntry/
      // _netEntryFromStartMsg; brak `t` (legacy) → przepuszczamy (no-op skipping).
      if (clearedAt && entry && typeof entry.t === 'number' && entry.t < clearedAt) return;
      // Dedup Network po netId — bufor z background bywa odtwarzany równolegle z eventami live.
      if (entry.kind === 'network' && entry.netId) {
        for (let i = log.length - 1; i >= 0; i--) {
          if (log[i].kind === 'network' && log[i].netId === entry.netId) return;
        }
      }
      log.push(entry);
      const trimmed = log.length > OUTPUT_CONSOLE_MAX_LINES;
      persistLog();
      updateCounts();
      if (trimmed) {
        // Trim mógł usunąć z DOM widoczne wpisy z początku — pełny re-render zakładki.
        renderAll();
        return;
      }
      // Zakładka DataLayer ma widok dzielony — przyrostowy append nie wystarcza.
      if (activeTab === 'datalayer') {
        if (entry.kind === 'datalayer') renderAll();
        return;
      }
      if (entryVisible(entry)) {
        const emptyEl = logEl.querySelector('.jz-output-empty');
        if (emptyEl) emptyEl.remove();
        logEl.appendChild(_renderOutputEntryRow(entry));
        if (autoscroll) logEl.scrollTop = logEl.scrollHeight;
      }
    };

    // Network 'end' — dopina status/IP/rozmiar/czas do wpisu po netId i odświeża wiersz.
    // Po aktualizacji wpis może zacząć/przestać pasować do filtra — stąd dodaj/usuń/podmień.
    const updateNet = (netId, d) => {
      if (!netId) return;
      let entry = null;
      for (let i = log.length - 1; i >= 0; i--) {
        if (log[i].kind === 'network' && log[i].netId === netId) { entry = log[i]; break; }
      }
      if (!entry) return;
      if (d.status != null) entry.status = d.status;
      if (typeof d.duration === 'number') entry.duration = d.duration;
      if (typeof d.size === 'number') entry.size = d.size;
      if (d.ip) entry.ip = String(d.ip);
      if (d.error) entry.error = String(d.error);
      persistLog();
      const sel = (window.CSS && CSS.escape) ? CSS.escape(netId) : netId;
      const oldRow = logEl.querySelector('.jz-output-row-net[data-net-id="' + sel + '"]');
      const visible = entryVisible(entry);
      if (oldRow) {
        if (visible) {
          oldRow.replaceWith(_renderOutputEntryRow(entry));
        } else {
          oldRow.remove();
        }
      } else if (visible) {
        const emptyEl = logEl.querySelector('.jz-output-empty');
        if (emptyEl) emptyEl.remove();
        logEl.appendChild(_renderOutputEntryRow(entry));
        if (autoscroll) logEl.scrollTop = logEl.scrollHeight;
      }
    };

    // Clear czyści CAŁY log (wszystkie zakładki), nie tylko widoczną — z przycisku i skrótu.
    // od v3.2.31 — zapisujemy clearedAt → backfill (pre-buffer + net-buffer z background)
    // po close+open NIE przywróci skasowanej historii. Globalne bufory pozostają nietknięte
    // (inne Output Console mogą z nich korzystać).
    const clearLog = () => {
      log = [];
      clearedAt = Date.now();
      if (ss) try { ss.setItem(OUTPUT_CONSOLE_CLEARED_AT_KEY(oc.id), String(clearedAt)); } catch (_) {}
      persistLog();
      updateCounts();
      renderAll();
    };

    const setActiveTab = (tab) => {
      if (OUTPUT_TABS.indexOf(tab) === -1 || tab === activeTab) return;
      activeTab = tab;
      if (ss) try { ss.setItem(OUTPUT_CONSOLE_TAB_KEY(oc.id), tab); } catch (_) {}
      paintActiveTab();
      renderFilterBar();
      renderAll();
    };

    // od v3.2.32 — bulk append dla backfill (replay pre-buffera + net-buffera).
    // Zamiast 700× appendEntry (każdy z DOM render + persistLog + updateCounts),
    // push wszystkich do log[] z cutoff/dedup, JEDEN renderAll + updateCounts +
    // persistLog na końcu. Backfill 700 wpisów: ~5s → ~50ms.
    const appendBatch = (entries) => {
      if (!entries || !entries.length) return;
      const seenNetIds = new Set();
      for (let i = log.length - 1; i >= 0 && seenNetIds.size < 1000; i--) {
        if (log[i].kind === 'network' && log[i].netId) seenNetIds.add(log[i].netId);
      }
      for (let i = 0; i < entries.length; i++) {
        const e = entries[i];
        if (!e) continue;
        if (clearedAt && typeof e.t === 'number' && e.t < clearedAt) continue;
        if (e.kind === 'network' && e.netId) {
          if (seenNetIds.has(e.netId)) continue;
          seenNetIds.add(e.netId);
        }
        log.push(e);
      }
      updateCounts();
      renderAll();
      persistLog();
    };
    const sink = {
      append: appendEntry,
      appendBatch: appendBatch,
      updateNet: updateNet,
      clear: clearLog,
      refreshDL: () => { if (activeTab === 'datalayer') renderAll(); },
    };
    _outputConsoleSinks.set(oc.id, sink);
    // Backfill — wpisy sprzed zamontowania okna: console/jz/error/datalayer z prebufora
    // oraz żądania Network (w tym dokument strony) z bufora background.
    _replayOutputPreBuffer(sink);
    _requestNetBufferInto(sink);

    clearBtn.addEventListener('click', (e) => { e.stopPropagation(); clearLog(); });
    autoBtn.addEventListener('click', (e) => {
      e.stopPropagation();
      autoscroll = !autoscroll;
      if (autoscroll) {
        autoBtn.classList.add('jz-active');
        logEl.scrollTop = logEl.scrollHeight;
      } else {
        autoBtn.classList.remove('jz-active');
      }
      if (ss) try { ss.setItem(OUTPUT_CONSOLE_AUTOSCROLL_KEY(oc.id), autoscroll ? 'on' : 'off'); } catch (_) {}
    });
    tabsEl.addEventListener('click', (e) => {
      const btn = e.target.closest('.jz-output-tab');
      if (!btn) return;
      e.stopPropagation();
      setActiveTab(btn.dataset.tab);
    });
    searchIn.addEventListener('input', () => {
      searchTerm = (searchIn.value || '').toLowerCase();
      if (ss) try { ss.setItem(OUTPUT_CONSOLE_SEARCH_KEY(oc.id), searchIn.value || ''); } catch (_) {}
      renderAll();
    });
    searchIn.addEventListener('keydown', (e) => {
      if (e.key === 'Escape') {
        e.preventDefault();
        searchIn.value = '';
        searchIn.dispatchEvent(new Event('input', { bubbles: true }));
      }
    });
    // Rozwijanie/zwijanie wzbogaconych wierszy (stack błędu / dane dataLayer).
    logEl.addEventListener('click', (e) => {
      // Alt+klik / Alt+Shift+klik na wierszu Network — szybkie dodanie/usunięcie
      // domeny wpisu z listy Domeny+ (Alt) lub Domeny- (Alt+Shift). Alt zamiast
      // Ctrl — Ctrl+klik na macOS jest systemowo przechwytywany jako menu
      // kontekstowe; Alt+klik działa identycznie na Windows/Linux/macOS.
      if (e.altKey) {
        const netRow = e.target.closest('.jz-output-row-net');
        if (netRow && logEl.contains(netRow)) {
          const dom = netRow.dataset.netDomain || '';
          if (dom) {
            const key = e.shiftKey ? 'domainExclude' : 'domain';
            filter[key] = toggleDomainInList(filter[key], dom);
            persistFilter();
            renderFilterBar();
            renderAll();
          }
          e.preventDefault();
          return;
        }
      }
      const row = e.target.closest('.jz-output-expandable');
      if (row && logEl.contains(row)) row.classList.toggle('expanded');
    });
    // Alt+Shift+klik na wierszu Network zaznacza tekst (Shift rozszerza selekcję) —
    // preventDefault na mousedown blokuje ten artefakt; sam skrót działa w handlerze click.
    logEl.addEventListener('mousedown', (e) => {
      if (e.altKey && e.target.closest('.jz-output-row-net')) e.preventDefault();
    });
    // mousedown.stopPropagation — drag z headera nie startuje na kontrolkach/zakładkach.
    clearBtn.addEventListener('mousedown', (e) => e.stopPropagation());
    autoBtn.addEventListener('mousedown', (e) => e.stopPropagation());
    tabsEl.addEventListener('mousedown', (e) => e.stopPropagation());
    filterbar.addEventListener('mousedown', (e) => e.stopPropagation());
    searchRow.addEventListener('mousedown', (e) => e.stopPropagation());
    attachPaneDrag(el, oc);
    attachPaneResize(el, oc);
  }
  const titleEl = el.querySelector('.jz-pane-title');
  if (titleEl) titleEl.textContent = oc.name || 'Output';
  const dotEl = el.querySelector('.jz-pane-dot');
  if (dotEl) dotEl.style.background = (oc.color || '#10B981').replace(/[<"']/g, '');
  applyPaneCustomStyles(el, oc);
  _ensureTempCloseBtn(el, oc);
  _ensurePersistentCloseBtn(el, oc); // od v3.2.34
  _ensurePaneCollapseBtn(el, oc);
  if (!el.dataset.jzDragging) applyCssPanePosition(el, oc);
}

function renderOutputConsoles(folders, groups, rules, outputConsoles, globalEnabled, floatingHidden) {
  if (!globalEnabled) {
    document.querySelectorAll('[id^="' + OUTPUT_CONSOLE_PREFIX + '"]').forEach(el => {
      const ocId = el.id.slice(OUTPUT_CONSOLE_PREFIX.length);
      _outputConsoleSinks.delete(ocId);
      el.remove();
    });
    return;
  }
  const relevant = discoverRelevantOutputConsoles(folders, groups, rules, outputConsoles, floatingHidden)
    .filter(o => paneCascadeEnabled(o, folders, groups, rules, floatingHidden));
  const relevantIds = new Set(relevant.map(o => o.id));
  document.querySelectorAll('[id^="' + OUTPUT_CONSOLE_PREFIX + '"]').forEach(el => {
    const ocId = el.id.slice(OUTPUT_CONSOLE_PREFIX.length);
    if (!relevantIds.has(ocId)) {
      _outputConsoleSinks.delete(ocId);
      el.remove();
    }
  });
  if (relevant.length > 0) injectOutputConsoleHook();
  relevant.forEach(o => jzUsageBump('win', 'oc:' + o.id));
  relevant.forEach(renderOutputConsoleElement);
}

let _outputConsoleEnabledChain = Promise.resolve();
function setOutputConsoleEnabled(ocId, enabled) {
  if (typeof ocId !== 'string') return Promise.resolve();
  _outputConsoleEnabledChain = _outputConsoleEnabledChain.then(async () => {
    const data = await safeStorageGet(['outputConsoles']);
    const ocs = Array.isArray(data.outputConsoles) ? data.outputConsoles : [];
    const oc = ocs.find(o => o.id === ocId);
    if (!oc) return;
    oc.enabled = !!enabled;
    oc.updatedAt = Date.now();
    await safeStorageSet({ outputConsoles: ocs });
  });
  return _outputConsoleEnabledChain;
}

let activeShortcutHandlers = []; // [{shortcut, code, actionId}]

function rebuildShortcutHandlers(folders, groups, rules, globalEnabled) {
  activeShortcutHandlers = [];
  if (!globalEnabled) return;
  // Skrót dziala dla aktywnych regul (folder.active && folder.enabled && group.active &&
  // group.enabled && rule.active && rule.enabled). Niezaleznie od action.visible —
  // user moze chciec skrót bez widocznego przycisku.
  const matchingFolders = (folders || []).filter(f =>
    f.active !== false && f.enabled && folderMatchesUrl(f, location.href)
  );
  matchingFolders.forEach(folder => {
    const folderGroupIds = new Set((groups || []).filter(g =>
      g.folderId === folder.id && g.active !== false && g.enabled !== false
    ).map(g => g.id));
    const folderRules = (rules || []).filter(r =>
      folderGroupIds.has(r.groupId) && r.active && r.enabled
    );
    folderRules.forEach(rule => {
      (rule.actions || []).forEach(action => {
        if (!action.shortcut || !action.code) return;
        activeShortcutHandlers.push({
          shortcut: action.shortcut,
          code: action.code,
          actionId: action.id,
        });
      });
    });
  });
}

document.addEventListener('keydown', (e) => {
  if (activeShortcutHandlers.length === 0) return;
  const sc = jzEventToShortcut(e);
  if (!sc) return;
  const handler = activeShortcutHandlers.find(h => h.shortcut === sc);
  if (!handler) return;
  e.preventDefault();
  e.stopPropagation();
  runActionCode(handler.code, handler.actionId);
}, true);

let _clickCountChain = Promise.resolve();
async function runActionCode(code, actionId, extraVars) {
  // extraVars (od v2.12.2): obiekt {key: serializableValue} wstrzykiwany do scope
  // user-code jako `const key = value;`. Używane przez SELECT (value, _jsVariant).
  // _jsVariant: true → nie inkrementuj clickCount (init render, nie user click).
  const skipCount = !!(extraVars && extraVars._jsVariant);
  if (!skipCount) {
    // Serializacja przez łańcuch — równoległe kliknięcia robiłyby read-modify-write
    // na całej tablicy `rules` i gubiły inkrementy clickCount (drugi zapis nadpisywał
    // pierwszy). Każde kliknięcie dokłada się na koniec łańcucha.
    _clickCountChain = _clickCountChain.then(async () => {
      try {
        const data = await safeStorageGet(['rules']);
        const rules = data.rules || [];
        let updated = false;
        for (const r of rules) {
          const a = (r.actions || []).find(a => a.id === actionId);
          if (a) {
            a.clickCount = (a.clickCount || 0) + 1;
            updated = true;
            break;
          }
        }
        // Tag write jako 'clickCount' — options.js listener wykrywa to i robi
        // tylko in-place merge zamiast pełnego re-renderu editora.
        if (updated) await safeStorageSet({ rules, _jzMutation: 'clickCount' });
      } catch (e) {}
    });
  }

  safeSendMessage({ type: 'execute-action', code, actionId, extraVars: extraVars || null });
}

function escapeHtml(s) {
  const div = document.createElement('div');
  div.textContent = s || '';
  return div.innerHTML;
}

// Init
// JZ_I18N.init() jest async (czyta storage.userLang) — wystartuj go i daj mu szansę
// rozwiązać się przed pierwszym renderem. Pierwsze applyAll() przed init może użyć
// fallback PL z dictionary, ale subsequent re-rendery (przy DOMContentLoaded i load)
// będą już zlokalizowane.
try { JZ_I18N.init(); } catch (e) {}
// Re-render gdy user zmieni język w opcjach.
try {
  if (typeof window !== 'undefined') {
    window.addEventListener('jz-lang-changed', () => { applyAll(); });
  }
} catch (e) {}

// Page hook instalujemy NAJWCZEŚNIEJ jak się da (nie czekając na render Output Console),
// żeby przechwycić console.*/błędy/dataLayer już od startu strony. Listener wiadomości
// buforuje wpisy do czasu zamontowania okna — patrz _outputPreBuffer.
injectOutputConsoleHook();

applyAll();

if (document.readyState === 'loading') {
  document.addEventListener('DOMContentLoaded', applyAll);
}
window.addEventListener('load', applyAll);

// === Wykrywanie nawigacji SPA (od v2.13.147) ===
// Strony typu ads.google.com zmieniają URL przez history.pushState / replaceState
// bez przeładowania dokumentu. Content script żyje w świecie izolowanym i nie dostaje
// natywnego eventu dla pushState/replaceState — dlatego dopasowanie wzorców URL
// (pokazanie/ukrycie pływającego przycisku, pasków, okien, CSS/JS) odświeżało się
// dopiero po F5. Wykrywamy zmianę URL dwoma kanałami, spiętymi w jeden guard po
// `location.href`, więc `applyAll()` rusza tylko przy realnej zmianie:
//  - popstate / hashchange — cofnij/dalej i zmiana hasha (natywne eventy okna),
//  - lekki polling co 450 ms — łapie pushState/replaceState. Wcześniej był tu
//    MutationObserver na całym subtree documentElement: callback (choć tani)
//    odpalał się przy każdej partii mutacji DOM — gęsto na dynamicznych stronach.
//    Pojedyncze porównanie stringa ~2×/s jest nieporównanie tańsze, a ~450 ms
//    opóźnienia w detekcji SPA-nawigacji jest dla użytkownika niezauważalne.
let _jzLastUrl = location.href;
function jzOnUrlMaybeChanged() {
  if (location.href === _jzLastUrl) return;
  _jzLastUrl = location.href;
  // Nawigacja SPA = logiczna zmiana strony. Reguły JS dopasowane do nowego
  // adresu muszą wstrzyknąć JS ponownie — tak jak przy pełnym przeładowaniu
  // (CSS jest re-aplikowane już teraz). Wyjątek: reguły z opcją „Wykonaj JS
  // tylko raz" (jsRunOnce) trafiają do JS_RUNONCE_DONE i NIE ruszają ponownie —
  // chroni globalne listenery / interwały / patche window przed dublowaniem.
  JS_EXECUTED.forEach(id => { if (!JS_RUNONCE_DONE.has(id)) JS_EXECUTED.delete(id); });
  applyAll().catch(() => {});
}
window.addEventListener('popstate', jzOnUrlMaybeChanged);
window.addEventListener('hashchange', jzOnUrlMaybeChanged);
setInterval(jzOnUrlMaybeChanged, 450);

// Pagehide safety net (od v2.13.5) — fire pending memory write SYNCHRONICZNIE przed unload,
// nawet jeśli debounce/keystroke spam nie zdążył wystrzelić. Bez tego F5 wciśnięty
// szybko po wpisaniu wartości mógł zgubić ostatnie writes (storage.set async).
window.addEventListener('pagehide', () => {
  try {
    if (actionMemoryCache && Object.keys(actionMemoryCache).length > 0) {
      // safeStorageSet jest async ale chrome.storage queues writes immediately;
      // navigation context daje chrome krótkie okno na flush.
      safeStorageSet({ actionMemory: actionMemoryCache });
    }
  } catch (_) {}
});

try {
  chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
    if (!isExtensionContextValid()) return;
    if (!msg || typeof msg !== 'object') return;
    if (msg.type === 'jz-network-event') { _handleNetworkEvent(msg); return; }
    if (msg.type === 'apply-rules') applyAll();
    if (msg.type === 'toggle-floating-button') toggleFloatingButtonForPage();
    // v2.13.22 — listę relevant bars + per-bar hidden state (dla popup widgetu + chrome.contextMenus).
    if (msg.type === 'get-relevant-bars') {
      (async () => {
        try {
          const bars = await discoverRelevantBars();
          sendResponse({ bars });
        } catch (e) {
          sendResponse({ bars: [], error: e.message });
        }
      })();
      return true; // async response
    }
    // CSS panes — analogiczna lista relevant dla URL aktywnej karty (dla chrome.contextMenus).
    // Zwraca panes z metadanymi (paneId, name, enabled), wliczając wyłączone żeby user mógł
    // je włączyć z menu kontekstowego.
    if (msg.type === 'get-relevant-css-panes') {
      (async () => {
        try {
          const data = await safeStorageGet(['folders', 'groups', 'rules', 'cssPanes', 'floatingHidden']);
          const all = Array.isArray(data.cssPanes) ? data.cssPanes : [];
          const patched = all.map(p => ({ ...p, enabled: true }));
          const relevant = discoverRelevantCssPanes(data.folders || [], data.groups || [], data.rules || [], patched, data.floatingHidden || {})
            .map(p => all.find(orig => orig.id === p.id))
            .filter(Boolean)
            .map(p => ({ paneId: p.id, name: p.name || '', enabled: p.enabled !== false }));
          sendResponse({ panes: relevant });
        } catch (e) {
          sendResponse({ panes: [], error: e.message });
        }
      })();
      return true;
    }
    if (msg.type === 'set-bar-visibility') {
      // visible=true / false → persistent toggle (actionBar.enabled lub
      // folder.showActionsPanel dla legacy). Storage onChanged rerendruje bary
      // i odświeży floating panel jeśli otwarty.
      setBarVisibility(msg.barId, !!msg.visible);
    }
    if (msg.type === 'set-css-pane-enabled') {
      // CSS pane on/off toggle z popup widgetu lub browser action popup'u.
      setCssPaneEnabled(msg.paneId, !!msg.enabled);
    }
    // JS panes (od v2.13.56) — analogiczne msg handlery.
    if (msg.type === 'get-relevant-js-panes') {
      (async () => {
        try {
          const data = await safeStorageGet(['folders', 'groups', 'rules', 'jsPanes', 'floatingHidden']);
          const all = Array.isArray(data.jsPanes) ? data.jsPanes : [];
          const patched = all.map(p => ({ ...p, enabled: true }));
          const relevant = discoverRelevantJsPanes(data.folders || [], data.groups || [], data.rules || [], patched, data.floatingHidden || {})
            .map(p => all.find(orig => orig.id === p.id))
            .filter(Boolean)
            .map(p => ({ paneId: p.id, name: p.name || '', enabled: p.enabled !== false }));
          sendResponse({ panes: relevant });
        } catch (e) {
          sendResponse({ panes: [], error: e.message });
        }
      })();
      return true;
    }
    if (msg.type === 'set-js-pane-enabled') {
      setJsPaneEnabled(msg.paneId, !!msg.enabled);
    }
    // JS Consoles (od v2.13.66) — analogiczne msg handlery.
    if (msg.type === 'get-relevant-js-consoles') {
      (async () => {
        try {
          const data = await safeStorageGet(['folders', 'groups', 'rules', 'jsConsoles', 'floatingHidden']);
          const all = Array.isArray(data.jsConsoles) ? data.jsConsoles : [];
          const patched = all.map(c => ({ ...c, enabled: true }));
          const relevant = discoverRelevantJsConsoles(data.folders || [], data.groups || [], data.rules || [], patched, data.floatingHidden || {})
            .map(c => all.find(orig => orig.id === c.id))
            .filter(Boolean)
            .map(c => ({ consoleId: c.id, name: c.name || '', enabled: c.enabled !== false }));
          sendResponse({ consoles: relevant });
        } catch (e) {
          sendResponse({ consoles: [], error: e.message });
        }
      })();
      return true;
    }
    if (msg.type === 'set-js-console-enabled') {
      setJsConsoleEnabled(msg.consoleId, !!msg.enabled);
    }
    // Output Consoles (od v2.13.72) — analogiczne msg handlery.
    if (msg.type === 'get-relevant-output-consoles') {
      (async () => {
        try {
          const data = await safeStorageGet(['folders', 'groups', 'rules', 'outputConsoles', 'floatingHidden']);
          const all = Array.isArray(data.outputConsoles) ? data.outputConsoles : [];
          const patched = all.map(o => ({ ...o, enabled: true }));
          const relevant = discoverRelevantOutputConsoles(data.folders || [], data.groups || [], data.rules || [], patched, data.floatingHidden || {})
            .map(o => all.find(orig => orig.id === o.id))
            .filter(Boolean)
            .map(o => ({ consoleId: o.id, name: o.name || '', enabled: o.enabled !== false }));
          sendResponse({ consoles: relevant });
        } catch (e) {
          sendResponse({ consoles: [], error: e.message });
        }
      })();
      return true;
    }
    if (msg.type === 'set-output-console-enabled') {
      setOutputConsoleEnabled(msg.consoleId, !!msg.enabled);
    }
    // TEMP panes (od v2.13.81) — z chrome contextMenu items. Od v2.13.84 toggle (max 1 per type).
    if (msg.type === 'create-temp-pane' && typeof msg.tempType === 'string') {
      toggleTempPane(msg.tempType);
    }
    // AI Helper (od v2.13.127) — toggle z popup widgetu / menu kontekstowego.
    if (msg.type === 'toggle-ai-helper') {
      toggleAiHelper();
      sendResponse({ open: !!_aiHelperWin });
      return true;
    }
    if (msg.type === 'get-ai-helper') {
      sendResponse({ open: !!_aiHelperWin });
      return true;
    }
    // Tryb edycji paska (od v2.13.97) — toggle/odczyt z popup widgetu i menu kontekstowego.
    if (msg.type === 'toggle-edit-mode') {
      toggleEditMode();
      sendResponse({ editMode: _editMode });
      return true;
    }
    if (msg.type === 'get-edit-mode') {
      sendResponse({ editMode: _editMode });
      return true;
    }
  });
} catch (e) {
  // Kontekst nieważny — listener pominięty.
}

async function toggleFloatingButtonForPage() {
  // Włącza/wyłącza pływający przycisk per katalog (per strona via floatingHidden)
  const { folders } = await getData();
  const matching = getMatchingFoldersForPage(folders);
  if (matching.length === 0) {
    showInfoToast(JZ_I18N.t('content.toast.noFoldersForPage'));
    return;
  }
  const data = await safeStorageGet(['floatingHidden']);
  const hidden = data.floatingHidden || {};
  // Czy WSZYSTKIE pasujące są ukryte?
  const allHidden = matching.every(f => hidden[f.id]);
  matching.forEach(f => {
    if (allHidden) {
      delete hidden[f.id]; // pokaż wszystkie
    } else {
      hidden[f.id] = true; // ukryj wszystkie
    }
  });
  await safeStorageSet({ floatingHidden: hidden });
  applyAll();
  showInfoToast(allHidden ? JZ_I18N.t('content.toast.floatVisible') : JZ_I18N.t('content.toast.floatHidden'));
}

// Krótki toast info
function showInfoToast(text) {
  const old = document.getElementById('justzix-toast');
  if (old) old.remove();
  const t = document.createElement('div');
  t.id = 'justzix-toast';
  t.textContent = text;
  document.documentElement.appendChild(t);
  setTimeout(() => t.classList.add('jz-show'), 10);
  setTimeout(() => {
    t.classList.remove('jz-show');
    setTimeout(() => t.remove(), 300);
  }, 2000);
}

try {
  chrome.storage.onChanged.addListener((changes, area) => {
    if (!isExtensionContextValid()) return;
    if (area !== 'local') return;
    if (changes.rules || changes.folders || changes.groups || changes.actionBars
        || changes.cssPanes || changes.jsPanes || changes.jsConsoles || changes.outputConsoles
        || changes.floatingHidden || changes.globalEnabled) {
      applyAll();
      const onlyPaneChange = (changes.cssPanes || changes.jsPanes || changes.jsConsoles || changes.outputConsoles)
        && !changes.rules && !changes.folders && !changes.groups && !changes.actionBars
        && !changes.floatingHidden && !changes.globalEnabled;
      if (!onlyPaneChange) {
        const panel = document.getElementById(PANEL_ID);
        if (panel) {
          removeFloatingPanel();
          togglePanel();
        }
      }
    }
    // actionMemory zmieniona z innej zakładki → odśwież cache (nie re-render, bo użytkownik mógł
    // właśnie wpisywać w INPUT na tej zakładce; tylko cache update).
    if (changes.actionMemory) {
      actionMemoryCache = changes.actionMemory.newValue || {};
    }
    // aiHelper (klucze AI) zmienione — np. dosynchronizowane z innego urządzenia.
    // Odśwież otwarte okno AI Helpera, by podchwyciło klucze bez przeładowania strony.
    if (changes.aiHelper && _aiHelperWin) {
      const aiEl = _aiWinEl();
      if (aiEl) _aiInitProviders(aiEl);
    }
  });
} catch (e) {
  // Kontekst rozszerzenia nieważny przy ładowaniu — listener pominięty (zwykle po reload).
}
