let folders = [];
let groups = [];
let rules = [];
let actionBars = []; // od v5 — wiele pasków akcji per folder/grupa/zestaw (Faza 1 modelu)
// Od v12 (Faza 1 "Okna na froncie"): 4 nowe typy okien tekstowych obok ActionBar.
// Faza 1 to wyłącznie data plumbing — UI dochodzi w kolejnych fazach. Sync (chmurowy)
// niesie metadane (name, color, parent, position, size, mode, filters); content (kod
// CSS/JS / input/output / logi) żyje w sessionStorage per tab i NIE syncuje się.
let cssPanes = [];        // entity_type='css_pane'
let jsPanes = [];         // entity_type='js_pane'
let jsConsoles = [];      // entity_type='js_console'
let outputConsoles = [];  // entity_type='output_console'
let selectedFolderId = null;
let selectedGroupId = null; // null = nic specyficznego, wartość = wybrana grupa (otwiera Group editor)
let selectedRuleId = null; // null = cały katalog, wartość = pojedynczy zestaw
let expandedFolders = {}; // { folderId: true } - rozwinięte w sidebarze
let expandedGroups = {};  // { groupId: true }
let editors = {}; // { ruleId: { css, js } }
let saveTimer = null;
let actionCodeEditor = null; // CodeMirror dla modala
let actionCodeEditorTimer = null; // pending setTimeout inicjalizacji CodeMirror
let currentActionContext = null; // { ruleId, actionId }

// nextActionSeq zachowany w storage dla kompatybilności (v9 migration), ale od v2.13.5
// NIE używany — sekwencyjne ID `a1, a2`... okazały się NIE sync-safe (każde urządzenie
// renumerowało niezależnie, cloud miał duplikaty stare+nowe). Powrót do random IDs.
let nextActionSeq = 1;

function genId(prefix = 'id') {
  return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
}

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

function escapeAttr(s) {
  return (s || '').replace(/"/g, '&quot;').replace(/</g, '&lt;');
}

// Buduje listę statystyk typu "X katalogów · Y grup · Z zestawów" z lokalizacją i pluralizacją.
// Pomija pola z 0 (lub gdy `keepZero` ustawione na true — pokazuje też 0).
function formatStats({ folders = 0, groups = 0, rules = 0 } = {}, sep = ' · ', keepZero = false) {
  const parts = [];
  if (folders || keepZero) parts.push(folders + ' ' + JZ_I18N.plural(folders, 'plural.folder'));
  if (groups  || keepZero) parts.push(groups  + ' ' + JZ_I18N.plural(groups,  'plural.group'));
  if (rules   || keepZero) parts.push(rules   + ' ' + JZ_I18N.plural(rules,   'plural.rule'));
  return parts.join(sep);
}

// Wsteczna zgodność dla typu eksportu — akceptujemy nowe `justzix-*` i stare `css-injector-*`.
function isExportType(value, suffix) {
  return value === 'justzix-' + suffix || value === 'css-injector-' + suffix;
}

// === Action bars — helpery (od v5) ===
// ActionBar może być przypisany do folderu, grupy lub zestawu (parentType + parentId).
// Widoczność dziedziczona w dół: pasek folderu pokazuje się dla wszystkich aktywnych
// rules tego folderu; pasek grupy — dla rules tej grupy; pasek zestawu — gdy zestaw
// aktywny. Pasek bez visible+enabled elementów w danym scope nie powinien się renderować.
function getBarsForFolder(folderId) {
  return (Array.isArray(actionBars) ? actionBars : [])
    .filter(b => b.parentType === 'folder' && b.parentId === folderId);
}
function getBarsForGroup(groupId) {
  return (Array.isArray(actionBars) ? actionBars : [])
    .filter(b => b.parentType === 'group' && b.parentId === groupId);
}
function getBarsForRule(ruleId) {
  return (Array.isArray(actionBars) ? actionBars : [])
    .filter(b => b.parentType === 'rule' && b.parentId === ruleId);
}
// Zwraca wszystkie paski widoczne dla danego zestawu — własne + dziedziczone z grupy + folderu.
function getInheritedBarsForRule(rule) {
  if (!rule || !rule.groupId) return [];
  const group = groups.find(g => g.id === rule.groupId);
  const folder = group ? folders.find(f => f.id === group.folderId) : null;
  return [
    ...(folder ? getBarsForFolder(folder.id) : []),
    ...(group  ? getBarsForGroup(group.id)   : []),
    ...getBarsForRule(rule.id),
  ];
}
function getActionsForBar(barId) {
  const out = [];
  for (const r of rules) {
    if (!Array.isArray(r.actions)) continue;
    for (const a of r.actions) if (a.barId === barId) out.push({ rule: r, action: a });
  }
  return out;
}

// Łączny licznik barów dla kontekstu — własne + odziedziczone z wyższych poziomów.
// Używany w przyciskach "Paski akcji (N)" w edytorach folder/grupa/zestaw, żeby N
// odzwierciedlało liczbę barów widocznych w popupie (a nie tylko tych przypisanych
// bezpośrednio do tego poziomu — to było mylące dla user'a).
function countAllBarsForContext(parentType, parentId) {
  let count = 0;
  if (parentType === 'folder') {
    count += getBarsForFolder(parentId).length;
  } else if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    count += getBarsForGroup(parentId).length;
    if (group) count += getBarsForFolder(group.folderId).length;
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    count += getBarsForRule(parentId).length;
    if (rule) {
      count += getBarsForGroup(rule.groupId).length;
      const group = groups.find(g => g.id === rule.groupId);
      if (group) count += getBarsForFolder(group.folderId).length;
    }
  }
  return count;
}

// === Mutatory ActionBar (od v5) ===
function createActionBar(parentType, parentId, opts = {}) {
  const bar = {
    id: genId('b'),
    parentType,
    parentId,
    name: typeof opts.name === 'string' ? opts.name.slice(0, 6) : '',
    enabled: opts.enabled !== false,
    orientation: opts.orientation === 'horizontal' ? 'horizontal' : 'vertical',
    color: typeof opts.color === 'string' ? opts.color : '',
  };
  actionBars.push(bar);
  return bar;
}

function deleteActionBar(barId) {
  const idx = actionBars.findIndex(b => b.id === barId);
  if (idx < 0) return false;
  actionBars.splice(idx, 1);
  // Cleanup orphan barId w actions — sanityzacja w load() i tak by to zrobiła, ale lepiej proaktywnie.
  rules.forEach(r => {
    if (!Array.isArray(r.actions)) return;
    r.actions.forEach(a => { if (a.barId === barId) delete a.barId; });
  });
  return true;
}

function updateActionBar(barId, patch) {
  const bar = actionBars.find(b => b.id === barId);
  if (!bar) return false;
  if (patch.name !== undefined) bar.name = String(patch.name).slice(0, 6);
  if (patch.enabled !== undefined) bar.enabled = !!patch.enabled;
  if (patch.orientation !== undefined) {
    bar.orientation = patch.orientation === 'horizontal' ? 'horizontal' : 'vertical';
  }
  if (patch.color !== undefined) bar.color = String(patch.color || '');
  if (patch.parentType !== undefined && patch.parentId !== undefined) {
    bar.parentType = patch.parentType;
    bar.parentId = patch.parentId;
  }
  return true;
}

// === CSS panes — helpery (od v12, Faza 2 "Okna na froncie") ===
// CSS pane to floating window wstrzykiwany w content.js — body to textarea, content
// → live `<style id="jz-pane-${id}">` w head strony. Metadane (name, color, parent,
// pos, size) żyją w sync + storage; content per tab w sessionStorage. Semantyka
// dziedziczenia analogicznie do ActionBar: pane folderu pokazuje się na wszystkich
// stronach z URL pasującym do folderu; group/rule dziedziczą w dół.
function getCssPanesForFolder(folderId) {
  return (Array.isArray(cssPanes) ? cssPanes : [])
    .filter(p => p.parentType === 'folder' && p.parentId === folderId);
}
function getCssPanesForGroup(groupId) {
  return (Array.isArray(cssPanes) ? cssPanes : [])
    .filter(p => p.parentType === 'group' && p.parentId === groupId);
}
function getCssPanesForRule(ruleId) {
  return (Array.isArray(cssPanes) ? cssPanes : [])
    .filter(p => p.parentType === 'rule' && p.parentId === ruleId);
}
function countAllCssPanesForContext(parentType, parentId) {
  let count = 0;
  if (parentType === 'folder') {
    count += getCssPanesForFolder(parentId).length;
  } else if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    count += getCssPanesForGroup(parentId).length;
    if (group) count += getCssPanesForFolder(group.folderId).length;
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    count += getCssPanesForRule(parentId).length;
    if (rule) {
      count += getCssPanesForGroup(rule.groupId).length;
      const group = groups.find(g => g.id === rule.groupId);
      if (group) count += getCssPanesForFolder(group.folderId).length;
    }
  }
  return count;
}

// JS pane helpers (analog CSS pane).
function getJsPanesForFolder(folderId) {
  return (Array.isArray(jsPanes) ? jsPanes : [])
    .filter(p => p.parentType === 'folder' && p.parentId === folderId);
}
function getJsPanesForGroup(groupId) {
  return (Array.isArray(jsPanes) ? jsPanes : [])
    .filter(p => p.parentType === 'group' && p.parentId === groupId);
}
function getJsPanesForRule(ruleId) {
  return (Array.isArray(jsPanes) ? jsPanes : [])
    .filter(p => p.parentType === 'rule' && p.parentId === ruleId);
}
function countAllJsPanesForContext(parentType, parentId) {
  let count = 0;
  if (parentType === 'folder') {
    count += getJsPanesForFolder(parentId).length;
  } else if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    count += getJsPanesForGroup(parentId).length;
    if (group) count += getJsPanesForFolder(group.folderId).length;
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    count += getJsPanesForRule(parentId).length;
    if (rule) {
      count += getJsPanesForGroup(rule.groupId).length;
      const group = groups.find(g => g.id === rule.groupId);
      if (group) count += getJsPanesForFolder(group.folderId).length;
    }
  }
  return count;
}

// Zwraca posegmentowaną listę CSS panes widocznych w danym kontekście — own + inherited.
function getInheritedCssPanesForContext(parentType, parentId) {
  const owned = cssPanes.filter(p => p.parentType === parentType && p.parentId === parentId);
  const inherited = [];
  if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const panes = getCssPanesForFolder(folder.id);
      if (panes.length) inherited.push({ from: 'folder', parentName: folder.name || '', panes });
    }
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    const group = rule ? groups.find(g => g.id === rule.groupId) : null;
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const panes = getCssPanesForFolder(folder.id);
      if (panes.length) inherited.push({ from: 'folder', parentName: folder.name || '', panes });
    }
    if (group) {
      const panes = getCssPanesForGroup(group.id);
      if (panes.length) inherited.push({ from: 'group', parentName: group.name || '', panes });
    }
  }
  return { owned, inherited };
}

// === Mutatory CSS pane ===
function createCssPane(parentType, parentId, opts = {}) {
  const pane = {
    id: genId('cp'),
    parentType,
    parentId,
    name: typeof opts.name === 'string' ? opts.name.slice(0, 32) : 'CSS',
    enabled: opts.enabled !== false,
    color: typeof opts.color === 'string' ? opts.color : '#2D9D53',
    // Position/size — anchor='top-right' bo to typowo "scratch pad" w rogu strony.
    // Front klampuje out-of-bounds renderując (sync nie zmienia wartości — inne urządzenia
    // mogą mieć inne rozdzielczości).
    position: { x: 20, y: 60, anchor: 'top-right' },
    size: { w: 360, h: 240 },
    createdAt: Date.now(),
    updatedAt: Date.now(),
  };
  cssPanes.push(pane);
  return pane;
}

function deleteCssPane(paneId) {
  const idx = cssPanes.findIndex(p => p.id === paneId);
  if (idx < 0) return false;
  cssPanes.splice(idx, 1);
  return true;
}

function updateCssPane(paneId, patch) {
  const pane = cssPanes.find(p => p.id === paneId);
  if (!pane) return false;
  if (patch.name !== undefined) pane.name = String(patch.name).slice(0, 32);
  if (patch.enabled !== undefined) pane.enabled = !!patch.enabled;
  if (patch.color !== undefined) pane.color = String(patch.color || '');
  // Custom styling (od v2.13.58): pusty string oznacza "użyj domyślnego z CSS".
  if (patch.headerBg !== undefined)   pane.headerBg   = String(patch.headerBg   || '');
  if (patch.headerText !== undefined) pane.headerText = String(patch.headerText || '');
  if (patch.bodyBg !== undefined)     pane.bodyBg     = String(patch.bodyBg     || '');
  if (patch.bodyText !== undefined)   pane.bodyText   = String(patch.bodyText   || '');
  // CSS pane nie ma runColor (tylko JS pane), ale akceptuję patch żeby kod współdzielony nie crashował.
  if (patch.position && typeof patch.position === 'object') {
    pane.position = { ...pane.position, ...patch.position };
  }
  if (patch.size && typeof patch.size === 'object') {
    pane.size = { ...pane.size, ...patch.size };
  }
  if (patch.parentType !== undefined && patch.parentId !== undefined) {
    pane.parentType = patch.parentType;
    pane.parentId = patch.parentId;
  }
  pane.updatedAt = Date.now();
  return true;
}

// === Mutatory JS pane (od v2.13.56, Faza 2 cont.) ===
// Analogiczne do CSS pane — ten sam shape, inny prefix ID (`jp_`).
function createJsPane(parentType, parentId, opts = {}) {
  const pane = {
    id: genId('jp'),
    parentType,
    parentId,
    name: typeof opts.name === 'string' ? opts.name.slice(0, 32) : 'JS',
    enabled: opts.enabled !== false,
    color: typeof opts.color === 'string' ? opts.color : '#D65D0E',  // amber accent, vs zielony dla CSS
    position: { x: 20, y: 60, anchor: 'top-right' },
    size: { w: 360, h: 240 },
    createdAt: Date.now(),
    updatedAt: Date.now(),
  };
  jsPanes.push(pane);
  return pane;
}

function deleteJsPane(paneId) {
  const idx = jsPanes.findIndex(p => p.id === paneId);
  if (idx < 0) return false;
  jsPanes.splice(idx, 1);
  return true;
}

function updateJsPane(paneId, patch) {
  const pane = jsPanes.find(p => p.id === paneId);
  if (!pane) return false;
  if (patch.name !== undefined) pane.name = String(patch.name).slice(0, 32);
  if (patch.enabled !== undefined) pane.enabled = !!patch.enabled;
  if (patch.color !== undefined) pane.color = String(patch.color || '');
  if (patch.headerBg !== undefined)   pane.headerBg   = String(patch.headerBg   || '');
  if (patch.headerText !== undefined) pane.headerText = String(patch.headerText || '');
  if (patch.bodyBg !== undefined)     pane.bodyBg     = String(patch.bodyBg     || '');
  if (patch.bodyText !== undefined)   pane.bodyText   = String(patch.bodyText   || '');
  if (patch.runColor !== undefined)   pane.runColor   = String(patch.runColor   || ''); // v2.13.64
  if (patch.position && typeof patch.position === 'object') {
    pane.position = { ...pane.position, ...patch.position };
  }
  if (patch.size && typeof patch.size === 'object') {
    pane.size = { ...pane.size, ...patch.size };
  }
  if (patch.parentType !== undefined && patch.parentId !== undefined) {
    pane.parentType = patch.parentType;
    pane.parentId = patch.parentId;
  }
  pane.updatedAt = Date.now();
  return true;
}

// Discovery dla modalu JS pane (analog CSS pane getInheritedCssPanesForContext).
function getInheritedJsPanesForContext(parentType, parentId) {
  const owned = jsPanes.filter(p => p.parentType === parentType && p.parentId === parentId);
  const inherited = [];
  if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const panes = jsPanes.filter(p => p.parentType === 'folder' && p.parentId === folder.id);
      if (panes.length) inherited.push({ from: 'folder', parentName: folder.name || '', panes });
    }
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    const group = rule ? groups.find(g => g.id === rule.groupId) : null;
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const panesFromFolder = jsPanes.filter(p => p.parentType === 'folder' && p.parentId === folder.id);
      if (panesFromFolder.length) inherited.push({ from: 'folder', parentName: folder.name || '', panes: panesFromFolder });
    }
    if (group) {
      const panes = jsPanes.filter(p => p.parentType === 'group' && p.parentId === group.id);
      if (panes.length) inherited.push({ from: 'group', parentName: group.name || '', panes });
    }
  }
  return { owned, inherited };
}

// === JS Console (od v2.13.66, Faza 2 cont. — 3. typ okna na froncie) ===
// W odróżnieniu od JS pane (jeden script, eval na save) — Console to REPL: każdy Ctrl+Enter
// odpala command, output appenduje do log area pod inputem. Persistent log per-tab w sessionStorage.
function getJsConsolesForFolder(folderId) {
  return (Array.isArray(jsConsoles) ? jsConsoles : [])
    .filter(p => p.parentType === 'folder' && p.parentId === folderId);
}
function getJsConsolesForGroup(groupId) {
  return (Array.isArray(jsConsoles) ? jsConsoles : [])
    .filter(p => p.parentType === 'group' && p.parentId === groupId);
}
function getJsConsolesForRule(ruleId) {
  return (Array.isArray(jsConsoles) ? jsConsoles : [])
    .filter(p => p.parentType === 'rule' && p.parentId === ruleId);
}
function countAllJsConsolesForContext(parentType, parentId) {
  let count = 0;
  if (parentType === 'folder') {
    count += getJsConsolesForFolder(parentId).length;
  } else if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    count += getJsConsolesForGroup(parentId).length;
    if (group) count += getJsConsolesForFolder(group.folderId).length;
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    count += getJsConsolesForRule(parentId).length;
    if (rule) {
      count += getJsConsolesForGroup(rule.groupId).length;
      const group = groups.find(g => g.id === rule.groupId);
      if (group) count += getJsConsolesForFolder(group.folderId).length;
    }
  }
  return count;
}

function createJsConsole(parentType, parentId, opts = {}) {
  const con = {
    id: genId('jc'),
    parentType,
    parentId,
    name: typeof opts.name === 'string' ? opts.name.slice(0, 32) : 'Console',
    enabled: opts.enabled !== false,
    color: typeof opts.color === 'string' ? opts.color : '#7C3AED',  // purple — odróżnia od CSS (zielony) i JS pane (amber)
    position: { x: 20, y: 60, anchor: 'top-right' },
    size: { w: 380, h: 280 },
    createdAt: Date.now(),
    updatedAt: Date.now(),
  };
  jsConsoles.push(con);
  return con;
}

function deleteJsConsole(conId) {
  const idx = jsConsoles.findIndex(c => c.id === conId);
  if (idx < 0) return false;
  jsConsoles.splice(idx, 1);
  return true;
}

function updateJsConsole(conId, patch) {
  const con = jsConsoles.find(c => c.id === conId);
  if (!con) return false;
  if (patch.name !== undefined) con.name = String(patch.name).slice(0, 32);
  if (patch.enabled !== undefined) con.enabled = !!patch.enabled;
  if (patch.color !== undefined) con.color = String(patch.color || '');
  if (patch.headerBg !== undefined)   con.headerBg   = String(patch.headerBg   || '');
  if (patch.headerText !== undefined) con.headerText = String(patch.headerText || '');
  if (patch.bodyBg !== undefined)     con.bodyBg     = String(patch.bodyBg     || '');
  if (patch.bodyText !== undefined)   con.bodyText   = String(patch.bodyText   || '');
  if (patch.runColor !== undefined)   con.runColor   = String(patch.runColor   || '');
  if (patch.position && typeof patch.position === 'object') {
    con.position = { ...con.position, ...patch.position };
  }
  if (patch.size && typeof patch.size === 'object') {
    con.size = { ...con.size, ...patch.size };
  }
  if (patch.parentType !== undefined && patch.parentId !== undefined) {
    con.parentType = patch.parentType;
    con.parentId = patch.parentId;
  }
  con.updatedAt = Date.now();
  return true;
}

function getInheritedJsConsolesForContext(parentType, parentId) {
  const owned = jsConsoles.filter(c => c.parentType === parentType && c.parentId === parentId);
  const inherited = [];
  if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const cons = jsConsoles.filter(c => c.parentType === 'folder' && c.parentId === folder.id);
      if (cons.length) inherited.push({ from: 'folder', parentName: folder.name || '', consoles: cons });
    }
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    const group = rule ? groups.find(g => g.id === rule.groupId) : null;
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const cons = jsConsoles.filter(c => c.parentType === 'folder' && c.parentId === folder.id);
      if (cons.length) inherited.push({ from: 'folder', parentName: folder.name || '', consoles: cons });
    }
    if (group) {
      const cons = jsConsoles.filter(c => c.parentType === 'group' && c.parentId === group.id);
      if (cons.length) inherited.push({ from: 'group', parentName: group.name || '', consoles: cons });
    }
  }
  return { owned, inherited };
}

// === 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
// linie. W odróżnieniu od JS Console (REPL — user wpisuje) Output Console tylko wyświetla.
function getOutputConsolesForFolder(folderId) {
  return (Array.isArray(outputConsoles) ? outputConsoles : [])
    .filter(o => o.parentType === 'folder' && o.parentId === folderId);
}
function getOutputConsolesForGroup(groupId) {
  return (Array.isArray(outputConsoles) ? outputConsoles : [])
    .filter(o => o.parentType === 'group' && o.parentId === groupId);
}
function getOutputConsolesForRule(ruleId) {
  return (Array.isArray(outputConsoles) ? outputConsoles : [])
    .filter(o => o.parentType === 'rule' && o.parentId === ruleId);
}
function countAllOutputConsolesForContext(parentType, parentId) {
  let count = 0;
  if (parentType === 'folder') {
    count += getOutputConsolesForFolder(parentId).length;
  } else if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    count += getOutputConsolesForGroup(parentId).length;
    if (group) count += getOutputConsolesForFolder(group.folderId).length;
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    count += getOutputConsolesForRule(parentId).length;
    if (rule) {
      count += getOutputConsolesForGroup(rule.groupId).length;
      const group = groups.find(g => g.id === rule.groupId);
      if (group) count += getOutputConsolesForFolder(group.folderId).length;
    }
  }
  return count;
}

function createOutputConsole(parentType, parentId, opts = {}) {
  const oc = {
    id: genId('oc'),
    parentType,
    parentId,
    name: typeof opts.name === 'string' ? opts.name.slice(0, 32) : 'Output',
    enabled: opts.enabled !== false,
    color: typeof opts.color === 'string' ? opts.color : '#10B981',  // emerald — odróżnia od CSS (zielony Tailwind), JS pane (amber), JS Console (purple)
    // viewMode (od v2.13.73, per spec backend/api-handoff/windows-feature.md sekcja 2.2):
    // 'standard' = tylko console.* z przeglądarki, 'jz' = tylko window.JZ.*, 'both' = oba.
    // Default 'both' bo to najszerszy view (user widzi wszystko, sam zawęża).
    viewMode: typeof opts.viewMode === 'string' ? opts.viewMode : 'both',
    position: { x: 20, y: 60, anchor: 'bottom-right' },
    size: { w: 380, h: 240 },
    createdAt: Date.now(),
    updatedAt: Date.now(),
  };
  outputConsoles.push(oc);
  return oc;
}

function deleteOutputConsole(ocId) {
  const idx = outputConsoles.findIndex(o => o.id === ocId);
  if (idx < 0) return false;
  outputConsoles.splice(idx, 1);
  return true;
}

function updateOutputConsole(ocId, patch) {
  const oc = outputConsoles.find(o => o.id === ocId);
  if (!oc) return false;
  if (patch.name !== undefined) oc.name = String(patch.name).slice(0, 32);
  if (patch.enabled !== undefined) oc.enabled = !!patch.enabled;
  if (patch.color !== undefined) oc.color = String(patch.color || '');
  if (patch.headerBg !== undefined)   oc.headerBg   = String(patch.headerBg   || '');
  if (patch.headerText !== undefined) oc.headerText = String(patch.headerText || '');
  if (patch.bodyBg !== undefined)     oc.bodyBg     = String(patch.bodyBg     || '');
  if (patch.bodyText !== undefined)   oc.bodyText   = String(patch.bodyText   || '');
  if (patch.runColor !== undefined)   oc.runColor   = String(patch.runColor   || '');
  // viewMode + filters (od v2.13.73, per spec). Walidacja: viewMode w {standard, jz, both};
  // filters tylko 5 dozwolonych keys (log/info/warn/error/debug), value bool.
  if (patch.viewMode !== undefined) {
    const v = String(patch.viewMode);
    if (v === 'standard' || v === 'jz' || v === 'both') oc.viewMode = v;
  }
  if (patch.filters !== undefined && patch.filters && typeof patch.filters === 'object') {
    const allowed = ['log', 'info', 'warn', 'error', 'debug'];
    const cur = (oc.filters && typeof oc.filters === 'object') ? { ...oc.filters } : {};
    allowed.forEach(k => {
      if (typeof patch.filters[k] === 'boolean') cur[k] = patch.filters[k];
    });
    oc.filters = cur;
  }
  if (patch.position && typeof patch.position === 'object') {
    oc.position = { ...oc.position, ...patch.position };
  }
  if (patch.size && typeof patch.size === 'object') {
    oc.size = { ...oc.size, ...patch.size };
  }
  if (patch.parentType !== undefined && patch.parentId !== undefined) {
    oc.parentType = patch.parentType;
    oc.parentId = patch.parentId;
  }
  oc.updatedAt = Date.now();
  return true;
}

function getInheritedOutputConsolesForContext(parentType, parentId) {
  const owned = outputConsoles.filter(o => o.parentType === parentType && o.parentId === parentId);
  const inherited = [];
  if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const ocs = outputConsoles.filter(o => o.parentType === 'folder' && o.parentId === folder.id);
      if (ocs.length) inherited.push({ from: 'folder', parentName: folder.name || '', consoles: ocs });
    }
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    const group = rule ? groups.find(g => g.id === rule.groupId) : null;
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const ocs = outputConsoles.filter(o => o.parentType === 'folder' && o.parentId === folder.id);
      if (ocs.length) inherited.push({ from: 'folder', parentName: folder.name || '', consoles: ocs });
    }
    if (group) {
      const ocs = outputConsoles.filter(o => o.parentType === 'group' && o.parentId === group.id);
      if (ocs.length) inherited.push({ from: 'group', parentName: group.name || '', consoles: ocs });
    }
  }
  return { owned, inherited };
}

// === Modal "Paski akcji" — uniwersalny dla folderu/grupy/zestawu ===
let actionBarsModalContext = null; // { parentType, parentId, parentName, refresh: callback po zamknięciu }

function openActionBarsModal({ parentType, parentId, parentName, onClose }) {
  if (!parentType || !parentId) return;
  actionBarsModalContext = { parentType, parentId, parentName: parentName || '', onClose: onClose || null };
  const labelEl = document.getElementById('actionBarsParentLabel');
  if (labelEl) {
    const typeLabel = parentType === 'folder' ? JZ_I18N.t('actionBars.parentType.folder')
                    : parentType === 'group'  ? JZ_I18N.t('actionBars.parentType.group')
                    : JZ_I18N.t('actionBars.parentType.rule');
    labelEl.textContent = `${typeLabel}: ${parentName || ''}`;
  }
  renderActionBarsList();
  document.getElementById('actionBarsModal').classList.add('show');
}

function closeActionBarsModal() {
  document.getElementById('actionBarsModal').classList.remove('show');
  const cb = actionBarsModalContext && actionBarsModalContext.onClose;
  actionBarsModalContext = null;
  if (typeof cb === 'function') cb();
}

// Zwraca posegmentowaną listę barów widocznych w danym kontekście:
//   { owned: ActionBar[], inherited: [{ from: 'folder'|'group', parentName, bars: ActionBar[] }] }
// "owned" = bary przypisane bezpośrednio do tego poziomu (full edit + add/delete tutaj).
// "inherited" = bary z poziomów wyżej (też edytowalne — propagują do właściciela).
function getInheritedBarsForContext(parentType, parentId) {
  const owned = actionBars.filter(b => b.parentType === parentType && b.parentId === parentId);
  const inherited = [];
  if (parentType === 'group') {
    const group = groups.find(g => g.id === parentId);
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const bars = getBarsForFolder(folder.id);
      if (bars.length) inherited.push({ from: 'folder', parentName: folder.name || '', bars });
    }
  } else if (parentType === 'rule') {
    const rule = rules.find(r => r.id === parentId);
    const group = rule ? groups.find(g => g.id === rule.groupId) : null;
    const folder = group ? folders.find(f => f.id === group.folderId) : null;
    if (folder) {
      const bars = getBarsForFolder(folder.id);
      if (bars.length) inherited.push({ from: 'folder', parentName: folder.name || '', bars });
    }
    if (group) {
      const bars = getBarsForGroup(group.id);
      if (bars.length) inherited.push({ from: 'group', parentName: group.name || '', bars });
    }
  }
  return { owned, inherited };
}

function renderActionBarsList() {
  const ctx = actionBarsModalContext;
  const list = document.getElementById('actionBarsList');
  if (!ctx || !list) return;
  const { owned, inherited } = getInheritedBarsForContext(ctx.parentType, ctx.parentId);

  list.innerHTML = '';

  // Sekcja "Bary tego poziomu" — pełen control (add/edit/delete tu).
  const ownedHeader = document.createElement('div');
  ownedHeader.className = 'action-bars-section-header';
  ownedHeader.textContent = JZ_I18N.t('actionBars.section.owned');
  list.appendChild(ownedHeader);

  if (owned.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:14px;text-align:center;color:#9CA3AF;font-size:12px;border:1px dashed #D1D5DB;border-radius:6px;';
    empty.textContent = JZ_I18N.t('actionBars.empty');
    list.appendChild(empty);
  } else {
    owned.forEach(bar => list.appendChild(renderBarRow(bar, null)));
  }

  // Sekcje odziedziczone (folder, grupa). Ikona z JZ_ICONS — emoji 📁/📂 z i18n
  // usunięte w v2.13.15 żeby UI używało spójnego zestawu ikon (consistent z resztą).
  inherited.forEach(group => {
    const header = document.createElement('div');
    header.className = 'action-bars-section-header inherited';
    const iconSvg = group.from === 'folder' ? JZ_ICONS.folder : JZ_ICONS.group;
    const labelText = group.from === 'folder'
      ? JZ_I18N.t('actionBars.section.fromFolder', { name: escapeHtml(group.parentName) || '?' })
      : JZ_I18N.t('actionBars.section.fromGroup',  { name: escapeHtml(group.parentName) || '?' });
    header.innerHTML = `<span style="display:inline-flex;vertical-align:middle;margin-right:4px;">${iconSvg}</span>${labelText}`;
    list.appendChild(header);
    group.bars.forEach(bar => list.appendChild(renderBarRow(bar, group.from)));
  });
}

// Buduje wiersz baru. inheritedFrom = null (own) | 'folder' | 'group'.
function renderBarRow(bar, inheritedFrom) {
  const elementsCount = getActionsForBar(bar.id).length;
  const isInherited = !!inheritedFrom;
  const row = document.createElement('div');
  row.className = 'action-bar-row' + (isInherited ? ' inherited' : '');
  const isVertical = bar.orientation !== 'horizontal';
  const orientLabel = isVertical ? JZ_I18N.t('folder.layout.vertical') : JZ_I18N.t('folder.layout.horizontal');
  const orientIcon  = isVertical ? '↕' : '↔';
  row.innerHTML = `
      <label style="display:flex;align-items:center;gap:4px;cursor:pointer;font-size:12px;">
        <input type="checkbox" class="bar-enabled" ${bar.enabled ? 'checked' : ''}>
        <span>${escapeHtml(JZ_I18N.t('actionBars.enabled'))}</span>
      </label>
      <input type="text" class="bar-name" value="${escapeAttr(bar.name || '')}" maxlength="6"
        placeholder="${escapeAttr(JZ_I18N.t('actionBars.name.placeholder'))}"
        title="${escapeAttr(JZ_I18N.t('actionBars.name.title'))}"
        style="width:80px;padding:4px 6px;font-size:12px;text-transform:uppercase;font-weight:bold;letter-spacing:0.5px;border:1px solid #D1D5DB;border-radius:4px;">
      <button class="bar-orientation-toggle" type="button" data-orientation="${isVertical ? 'vertical' : 'horizontal'}"
        title="${escapeAttr(JZ_I18N.t('actionBars.orientation.title'))}"
        style="padding:4px 8px;font-size:12px;border:1px solid #D1D5DB;border-radius:4px;background:#F3F4F6;cursor:pointer;width:108px;display:inline-flex;align-items:center;gap:4px;justify-content:center;flex-shrink:0;"><span style="font-size:14px;line-height:1;">${orientIcon}</span> <span>${escapeHtml(orientLabel)}</span></button>
      <input type="color" class="bar-color" value="${escapeAttr(bar.color || '#E5E7EB')}"
        title="${escapeAttr(JZ_I18N.t('actionBars.color.title'))}"
        style="width:32px;height:28px;padding:2px;border:1px solid #D1D5DB;border-radius:4px;cursor:pointer;">
      <button class="bar-color-clear" type="button" title="${escapeAttr(JZ_I18N.t('actionBars.color.clear'))}"
        style="padding:4px 6px;font-size:11px;background:#F3F4F6;border:1px solid #D1D5DB;border-radius:4px;cursor:pointer;">×</button>
      <span style="font-size:11px;color:#6B7280;white-space:nowrap;">${escapeHtml(JZ_I18N.t('actionBars.elementsCount', { count: elementsCount }))}</span>
      <span style="flex:1;min-width:8px;"></span>
      <label style="font-size:11px;color:#6B7280;white-space:nowrap;">${escapeHtml(JZ_I18N.t('actionBars.moveTo'))}</label>
      <span class="bar-move-slot"></span>
      <button class="bar-delete danger" type="button">${escapeHtml(JZ_I18N.t('actionBars.delete'))}</button>
    `;

    // Custom bar-move dropdown (od v2.12.3) — z SVG ikonami JZ_ICONS.
    const moveSlot = row.querySelector('.bar-move-slot');
    const moveItems = buildMoveItems(bar);
    const moveDropdown = createBarDropdown({
      items: moveItems,
      currentValue: `${bar.parentType}:${bar.parentId}`,
      placeholder: JZ_I18N.t('actionBars.moveTo'),
      onChange: async (val) => {
        if (!val || val === `${bar.parentType}:${bar.parentId}`) return;
        const [newType, newId] = val.split(':');
        updateActionBar(bar.id, { parentType: newType, parentId: newId });
        await saveAll(true);
        renderActionBarsList();
      },
    });
    moveSlot.replaceWith(moveDropdown);

    // Event handlers — live save + re-render gdy potrzeba
    row.querySelector('.bar-enabled').addEventListener('change', async (e) => {
      updateActionBar(bar.id, { enabled: e.target.checked });
      await saveAll(true);
    });
    row.querySelector('.bar-name').addEventListener('input', (e) => {
      const upper = e.target.value.toUpperCase().slice(0, 6);
      if (e.target.value !== upper) e.target.value = upper;
      updateActionBar(bar.id, { name: upper });
      debouncedSave();
    });
    row.querySelector('.bar-orientation-toggle').addEventListener('click', async (e) => {
      const btn = e.currentTarget;
      const next = btn.dataset.orientation === 'vertical' ? 'horizontal' : 'vertical';
      updateActionBar(bar.id, { orientation: next });
      await saveAll(true);
      // Re-render row żeby ikona/label/dataset się zaktualizowały spójnie.
      renderActionBarsList();
    });
    row.querySelector('.bar-color').addEventListener('input', (e) => {
      updateActionBar(bar.id, { color: e.target.value });
      debouncedSave();
    });
    row.querySelector('.bar-color-clear').addEventListener('click', async () => {
      updateActionBar(bar.id, { color: '' });
      await saveAll(true);
      renderActionBarsList();
    });
    // .bar-move event handler — wyniesiony do createBarDropdown.onChange (powyżej w innerHTML setup).
    row.querySelector('.bar-delete').addEventListener('click', async () => {
      const cnt = getActionsForBar(bar.id).length;
      // Bar bez elementów — proste potwierdzenie i delete.
      if (cnt === 0) {
        if (!confirm(JZ_I18N.t('actionBars.confirmDelete'))) return;
        deleteActionBar(bar.id);
        await saveAll(true);
        renderActionBarsList();
        return;
      }
      // Bar z elementami — wymagamy decyzji o ich przepisaniu PRZED usunięciem.
      // Pokazujemy inline UI w wierszu (zamiast nowego modala).
      showBarDeleteWithMigration(row, bar);
    });
    return row;
}

// Inline panel wyboru "Przenieś i usuń" lub "Anuluj" przy próbie usunięcia baru z elementami.
function showBarDeleteWithMigration(row, bar) {
  // Jeśli już otwarty — nic nie rób (re-klik na "Usuń" zwija nie wymaga się).
  if (row.querySelector('.bar-delete-confirm')) return;

  const cnt = getActionsForBar(bar.id).length;
  const otherBars = actionBars.filter(b => b.id !== bar.id);
  const targetItems = [{ value: '', name: JZ_I18N.t('actionBars.deleteMigrate.detachOption') }]
    .concat(otherBars.map(b => {
      const parentEntity = b.parentType === 'folder' ? folders.find(f => f.id === b.parentId)
                        : b.parentType === 'group'  ? groups.find(g => g.id === b.parentId)
                        : rules.find(r => r.id === b.parentId);
      const parentName = parentEntity ? (parentEntity.name || '(?)') : '(?)';
      const barName = b.name || JZ_I18N.t('action.bar.unnamed');
      return { value: b.id, name: barName, parentType: b.parentType, parentName };
    }));

  const panel = document.createElement('div');
  panel.className = 'bar-delete-confirm';
  panel.style.cssText = 'background:#FEF2F2;border:1px solid #FCA5A5;padding:10px;border-radius:4px;display:flex;flex-direction:column;gap:8px;margin-top:4px;';
  panel.innerHTML = `
    <div style="font-size:12px;color:#991B1B;">${escapeHtml(JZ_I18N.t('actionBars.deleteMigrate.message', { count: cnt }))}</div>
    <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
      <span class="bar-delete-target-slot" style="flex:1;min-width:180px;"></span>
      <button class="bar-delete-confirm-btn danger" type="button">${escapeHtml(JZ_I18N.t('actionBars.deleteMigrate.confirm'))}</button>
      <button class="bar-delete-cancel" type="button">${escapeHtml(JZ_I18N.t('actionBars.deleteMigrate.cancel'))}</button>
    </div>
  `;
  row.appendChild(panel);

  // Custom dropdown z SVG ikonami zamiast <select>.
  let chosenTargetId = '';
  const targetDropdown = createBarDropdown({
    items: targetItems,
    currentValue: '',
    placeholder: JZ_I18N.t('actionBars.deleteMigrate.detachOption'),
    onChange: (val) => { chosenTargetId = val || ''; },
  });
  panel.querySelector('.bar-delete-target-slot').replaceWith(targetDropdown);

  panel.querySelector('.bar-delete-cancel').addEventListener('click', () => panel.remove());
  panel.querySelector('.bar-delete-confirm-btn').addEventListener('click', async () => {
    const target = chosenTargetId;
    // Przepisz barId wszystkich elementów. Pusty target = usuń barId (legacy fallback).
    rules.forEach(r => {
      if (!Array.isArray(r.actions)) return;
      r.actions.forEach(a => {
        if (a.barId !== bar.id) return;
        if (target) a.barId = target;
        else delete a.barId;
      });
    });
    deleteActionBar(bar.id);
    await saveAll(true);
    renderActionBarsList();
  });
}

// Sprawdza, czy przeniesienie baru do nowego parent spowodowałoby utratę widoczności
// któregokolwiek z przypisanych do baru elementów. Element jest "utracony", jeśli jego
// rule nie należy do nowego scope (folder/group/rule).
function wouldBarLoseElements(bar, newParentType, newParentId) {
  const attached = getActionsForBar(bar.id);
  if (attached.length === 0) return false;
  for (const { rule } of attached) {
    if (newParentType === 'folder') {
      const group = groups.find(g => g.id === rule.groupId);
      if (!group || group.folderId !== newParentId) return true;
    } else if (newParentType === 'group') {
      if (rule.groupId !== newParentId) return true;
    } else if (newParentType === 'rule') {
      if (rule.id !== newParentId) return true;
    }
  }
  return false;
}

// Buduje listę opcji do "Przenieś do..." — wszystkie poziomy w drzewie folderu/grupy/zestawu.
// Opcje powodujące utratę widoczności elementów są disabled (🔒), z tooltipem wyjaśniającym.
// Obecna pozycja oznaczona jako selected i NIE jest disabled (już tam jest).
// Buduje listę items dla custom bar-move dropdown (od v2.12.3 zastąpił buildMoveOptions).
// Każdy item: { value: 'type:id', name, parentType, indent, disabled, lockHint, title }
function buildMoveItems(bar) {
  const out = [];
  const cur = `${bar.parentType}:${bar.parentId}`;
  const lockHint = JZ_I18N.t('actionBars.moveLocked');
  const make = (value, name, parentType, indent) => {
    const isCurrent = cur === value;
    const wouldLose = !isCurrent && wouldBarLoseElements(bar, ...value.split(':'));
    return {
      value,
      name: name || JZ_I18N.t('action.bar.unnamed'),
      parentType,
      indent,
      disabled: wouldLose,
      lockHint: wouldLose ? lockHint : null,
      title: wouldLose ? lockHint : null,
    };
  };
  folders.forEach(f => {
    out.push(make(`folder:${f.id}`, f.name, 'folder', 0));
    const folderGroups = groups.filter(g => g.folderId === f.id);
    folderGroups.forEach(g => {
      out.push(make(`group:${g.id}`, g.name, 'group', 1));
      const groupRules = rules.filter(r => r.groupId === g.id);
      groupRules.forEach(r => {
        out.push(make(`rule:${r.id}`, r.name, 'rule', 2));
      });
    });
  });
  return out;
}

// Inicjalizacja przycisków modalu (Add / Close) — globalna, bo modal jest singleton.
document.addEventListener('DOMContentLoaded', () => {
  const addBtn = document.getElementById('addActionBarBtn');
  const closeBtn = document.getElementById('closeActionBarsBtn');
  if (addBtn) addBtn.addEventListener('click', async () => {
    if (!actionBarsModalContext) return;
    createActionBar(actionBarsModalContext.parentType, actionBarsModalContext.parentId);
    await saveAll(true);
    renderActionBarsList();
  });
  if (closeBtn) closeBtn.addEventListener('click', () => {
    closeActionBarsModal();
    // Re-render parent UI żeby zaktualizować licznik na przycisku "Paski akcji (N)".
    if (selectedFolderId) {
      const f = folders.find(ff => ff.id === selectedFolderId);
      if (f) {
        if (selectedRuleId) renderRuleEditor();
        else if (selectedGroupId) renderGroupEditor(groups.find(g => g.id === selectedGroupId), f);
        else renderFolderEditor();
      }
    }
  });

  // CSS panes modal (Faza 2 "Okna na froncie", v12) — analogiczne wiązanie.
  const addCpBtn = document.getElementById('addCssPaneBtn');
  const closeCpBtn = document.getElementById('closeCssPanesBtn');
  if (addCpBtn) addCpBtn.addEventListener('click', async () => {
    if (!cssPanesModalContext) return;
    createCssPane(cssPanesModalContext.parentType, cssPanesModalContext.parentId);
    await saveAll(true);
    renderCssPanesList();
  });
  if (closeCpBtn) closeCpBtn.addEventListener('click', () => {
    closeCssPanesModal();
    // Refresh parent UI żeby licznik na przycisku "Okna CSS (N)" się odświeżył.
    if (selectedFolderId) {
      const f = folders.find(ff => ff.id === selectedFolderId);
      if (f) {
        if (selectedRuleId) renderRuleEditor();
        else if (selectedGroupId) renderGroupEditor(groups.find(g => g.id === selectedGroupId), f);
        else renderFolderEditor();
      }
    }
  });

  // JS panes modal (Faza 2 cont., v2.13.56) — analogiczne wiązanie do CSS panes.
  const addJpBtn = document.getElementById('addJsPaneBtn');
  const closeJpBtn = document.getElementById('closeJsPanesBtn');
  if (addJpBtn) addJpBtn.addEventListener('click', async () => {
    if (!jsPanesModalContext) return;
    createJsPane(jsPanesModalContext.parentType, jsPanesModalContext.parentId);
    await saveAll(true);
    renderJsPanesList();
  });
  if (closeJpBtn) closeJpBtn.addEventListener('click', () => {
    closeJsPanesModal();
    if (selectedFolderId) {
      const f = folders.find(ff => ff.id === selectedFolderId);
      if (f) {
        if (selectedRuleId) renderRuleEditor();
        else if (selectedGroupId) renderGroupEditor(groups.find(g => g.id === selectedGroupId), f);
        else renderFolderEditor();
      }
    }
  });

  // JS Consoles modal (od v2.13.66, Faza 2 cont.) — analog do JS panes.
  const addJcBtn = document.getElementById('addJsConsoleBtn');
  const closeJcBtn = document.getElementById('closeJsConsolesBtn');
  if (addJcBtn) addJcBtn.addEventListener('click', async () => {
    if (!jsConsolesModalContext) return;
    createJsConsole(jsConsolesModalContext.parentType, jsConsolesModalContext.parentId);
    await saveAll(true);
    renderJsConsolesList();
  });
  if (closeJcBtn) closeJcBtn.addEventListener('click', () => {
    closeJsConsolesModal();
    if (selectedFolderId) {
      const f = folders.find(ff => ff.id === selectedFolderId);
      if (f) {
        if (selectedRuleId) renderRuleEditor();
        else if (selectedGroupId) renderGroupEditor(groups.find(g => g.id === selectedGroupId), f);
        else renderFolderEditor();
      }
    }
  });

  // Output Consoles modal (od v2.13.72, Faza 2 finish) — analog do JS Console.
  const addOcBtn = document.getElementById('addOutputConsoleBtn');
  const closeOcBtn = document.getElementById('closeOutputConsolesBtn');
  if (addOcBtn) addOcBtn.addEventListener('click', async () => {
    if (!outputConsolesModalContext) return;
    createOutputConsole(outputConsolesModalContext.parentType, outputConsolesModalContext.parentId);
    await saveAll(true);
    renderOutputConsolesList();
  });
  if (closeOcBtn) closeOcBtn.addEventListener('click', () => {
    closeOutputConsolesModal();
    if (selectedFolderId) {
      const f = folders.find(ff => ff.id === selectedFolderId);
      if (f) {
        if (selectedRuleId) renderRuleEditor();
        else if (selectedGroupId) renderGroupEditor(groups.find(g => g.id === selectedGroupId), f);
        else renderFolderEditor();
      }
    }
  });
});

// === "Okna" dropdown (od v2.13.41) — uniwersalne wejście do wszystkich 4 typów okien ===
// Wcześniej (v2.13.40) każdy typ miał osobny button "Okna CSS (N)". User reportował że
// rozszerzanie się o kolejne typy zaspamuje rząd przycisków — od v2.13.41 jeden button
// "Okna (N) ▾" z dropdownem listujący typy. Tylko CSS panes jest aktywny w v2.13.41;
// pozostałe (JS / JS console / Output console) są wymienione jako "wkrótce" — placeholder
// dla kolejnych faz.
function showWindowsMenu(parentType, parentId, parentName, anchorBtn, onAfterModal) {
  // Reuse #moveMenu singleton — ten sam pattern co dla move/copy menu.
  const menu = document.getElementById('moveMenu');
  if (!menu) return;
  const cssCount = countAllCssPanesForContext(parentType, parentId);
  const jsCount = countAllJsPanesForContext(parentType, parentId);
  const consoleCount = countAllJsConsolesForContext(parentType, parentId);
  const outputCount = countAllOutputConsolesForContext(parentType, parentId);

  let html = `<div class="move-menu-section">${escapeHtml(JZ_I18N.t('windows.menu.title'))}</div>`;
  html += `<div class="move-menu-item" data-window-type="css_pane">${escapeHtml(JZ_I18N.t('cssPanes.btn.label'))} <span style="color:#9ca3af;">(${cssCount})</span></div>`;
  html += `<div class="move-menu-item" data-window-type="js_pane">${escapeHtml(JZ_I18N.t('jsPanes.btn.label'))} <span style="color:#9ca3af;">(${jsCount})</span></div>`;
  html += `<div class="move-menu-item" data-window-type="js_console">${escapeHtml(JZ_I18N.t('jsConsoles.btn.label'))} <span style="color:#9ca3af;">(${consoleCount})</span></div>`;
  html += `<div class="move-menu-item" data-window-type="output_console">${escapeHtml(JZ_I18N.t('outputConsoles.btn.label'))} <span style="color:#9ca3af;">(${outputCount})</span></div>`;

  menu.innerHTML = html;
  const rect = anchorBtn.getBoundingClientRect();
  menu.style.left = rect.left + 'px';
  menu.style.top = (rect.bottom + 4) + 'px';
  menu.classList.add('show');

  menu.querySelectorAll('.move-menu-item[data-window-type]').forEach(item => {
    item.addEventListener('click', () => {
      const wt = item.dataset.windowType;
      menu.classList.remove('show');
      if (wt === 'css_pane') {
        openCssPanesModal({ parentType, parentId, parentName, onClose: onAfterModal });
      } else if (wt === 'js_pane') {
        openJsPanesModal({ parentType, parentId, parentName, onClose: onAfterModal });
      } else if (wt === 'js_console') {
        openJsConsolesModal({ parentType, parentId, parentName, onClose: onAfterModal });
      } else if (wt === 'output_console') {
        openOutputConsolesModal({ parentType, parentId, parentName, onClose: onAfterModal });
      }
    });
  });
  setTimeout(() => {
    const closer = (e) => {
      if (!menu.contains(e.target) && e.target !== anchorBtn && !anchorBtn.contains(e.target)) {
        menu.classList.remove('show');
        document.removeEventListener('click', closer, true);
      }
    };
    document.addEventListener('click', closer, true);
  }, 100);
}

// === Modal "Okna CSS" — analogiczny do Pasków akcji ===
let cssPanesModalContext = null;

function openCssPanesModal({ parentType, parentId, parentName, onClose }) {
  if (!parentType || !parentId) return;
  cssPanesModalContext = { parentType, parentId, parentName: parentName || '', onClose: onClose || null };
  const labelEl = document.getElementById('cssPanesParentLabel');
  if (labelEl) {
    const typeLabel = parentType === 'folder' ? JZ_I18N.t('actionBars.parentType.folder')
                    : parentType === 'group'  ? JZ_I18N.t('actionBars.parentType.group')
                    : JZ_I18N.t('actionBars.parentType.rule');
    labelEl.textContent = `${typeLabel}: ${parentName || ''}`;
  }
  renderCssPanesList();
  document.getElementById('cssPanesModal').classList.add('show');
}

function closeCssPanesModal() {
  document.getElementById('cssPanesModal').classList.remove('show');
  const cb = cssPanesModalContext && cssPanesModalContext.onClose;
  cssPanesModalContext = null;
  if (typeof cb === 'function') cb();
}

function renderCssPanesList() {
  const ctx = cssPanesModalContext;
  const list = document.getElementById('cssPanesList');
  if (!ctx || !list) return;
  const { owned, inherited } = getInheritedCssPanesForContext(ctx.parentType, ctx.parentId);

  list.innerHTML = '';

  // Sekcja "Okna tego poziomu"
  const ownedHeader = document.createElement('div');
  ownedHeader.className = 'action-bars-section-header';
  ownedHeader.textContent = JZ_I18N.t('cssPanes.section.owned');
  list.appendChild(ownedHeader);

  if (owned.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:14px;text-align:center;color:#9CA3AF;font-size:12px;border:1px dashed #D1D5DB;border-radius:6px;';
    empty.textContent = JZ_I18N.t('cssPanes.empty');
    list.appendChild(empty);
  } else {
    owned.forEach(pane => list.appendChild(renderCssPaneRow(pane, false)));
  }

  // Sekcje "dziedziczone" — read-only listed only żeby user wiedział że są.
  inherited.forEach(group => {
    const header = document.createElement('div');
    header.className = 'action-bars-section-header';
    const fromLabel = group.from === 'folder'
      ? JZ_I18N.t('actionBars.section.fromFolder', { name: escapeHtml(group.parentName) || '?' })
      : JZ_I18N.t('actionBars.section.fromGroup',  { name: escapeHtml(group.parentName) || '?' });
    header.innerHTML = fromLabel;
    list.appendChild(header);
    group.panes.forEach(pane => list.appendChild(renderCssPaneRow(pane, true)));
  });
}

// Kontrolki kolorów pane'a (od v2.13.59) — używają jednego multi-color pickera zamiast
// kilku natywnych <input type="color">. Pole `color` (kropka w nagłówku) + 4 customowe
// (headerBg/headerText/bodyBg/bodyText) → 5 pasków we flagze. Od v2.13.64 opcjonalne extraDefs
// (np. `runColor` dla JS pane).
function appendPaneStyleControls(row, pane, isInherited, updateFn, saveFn, _ignored, dotDefault, extraDefs) {
  const defs = [
    { key: 'color',      label: JZ_I18N.t('panes.style.dot'),        defaultColor: dotDefault || '#2D9D53' },
    { key: 'headerBg',   label: JZ_I18N.t('panes.style.headerBg'),   defaultColor: '#1F2937' },
    { key: 'headerText', label: JZ_I18N.t('panes.style.headerText'), defaultColor: '#E5E7EB' },
    { key: 'bodyBg',     label: JZ_I18N.t('panes.style.bodyBg'),     defaultColor: '#0F172A' },
    { key: 'bodyText',   label: JZ_I18N.t('panes.style.bodyText'),   defaultColor: '#E5E7EB' },
  ];
  if (Array.isArray(extraDefs)) defs.push(...extraDefs);
  const picker = createMultiColorPicker({
    defs,
    getValue: (key) => pane[key] || '',
    setValue: async (key, hex) => {
      updateFn(pane.id, { [key]: hex });
      await saveFn(true);
    },
    disabled: isInherited,
  });
  row.appendChild(picker);
}

function renderCssPaneRow(pane, isInherited) {
  const row = document.createElement('div');
  row.className = 'action-bar-row';
  row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #E5E7EB;border-radius:6px;background:#FFF;';
  if (isInherited) row.style.opacity = '0.85';

  // Color dot — wizualny preview (sam picker zintegrowany w multi-color trigger niżej).
  const dot = document.createElement('span');
  dot.style.cssText = `width:14px;height:14px;border-radius:50%;background:${pane.color || '#2D9D53'};flex:0 0 14px;border:1px solid rgba(0,0,0,0.1);`;
  row.appendChild(dot);

  // Name input
  const nameIn = document.createElement('input');
  nameIn.type = 'text';
  nameIn.value = pane.name || '';
  nameIn.placeholder = JZ_I18N.t('cssPanes.name.placeholder');
  nameIn.maxLength = 32;
  nameIn.disabled = isInherited;
  nameIn.style.cssText = 'flex:1;padding:5px 8px;font-size:12px;border:1px solid #D1D5DB;border-radius:4px;';
  if (!isInherited) {
    nameIn.addEventListener('input', async () => {
      updateCssPane(pane.id, { name: nameIn.value });
      await saveAll(true);
    });
  }
  row.appendChild(nameIn);

  // Enabled checkbox (active by default, can disable to hide on pages)
  const enabledWrap = document.createElement('label');
  enabledWrap.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;color:#555;cursor:pointer;';
  enabledWrap.title = JZ_I18N.t('cssPanes.enabled.title');
  const enabledCb = document.createElement('input');
  enabledCb.type = 'checkbox';
  enabledCb.checked = pane.enabled !== false;
  enabledCb.disabled = isInherited;
  if (!isInherited) {
    enabledCb.addEventListener('change', async () => {
      updateCssPane(pane.id, { enabled: enabledCb.checked });
      await saveAll(true);
    });
  }
  enabledWrap.appendChild(enabledCb);
  enabledWrap.appendChild(document.createTextNode(JZ_I18N.t('cssPanes.enabled.label')));
  row.appendChild(enabledWrap);

  // Multi-color picker (od v2.13.59) — dot + headerBg/Text + bodyBg/Text w jednym triggerze.
  appendPaneStyleControls(row, pane, isInherited, updateCssPane, saveAll, renderCssPanesList, '#2D9D53');

  // Delete (only for owned)
  if (!isInherited) {
    const delBtn = document.createElement('button');
    delBtn.type = 'button';
    delBtn.innerHTML = JZ_ICONS.close;
    delBtn.title = JZ_I18N.t('cssPanes.delete.title');
    delBtn.style.cssText = 'background:#fee;color:#c00;border:1px solid #fcc;padding:4px 6px;border-radius:4px;cursor:pointer;display:flex;align-items:center;';
    delBtn.addEventListener('click', async () => {
      if (!confirm(JZ_I18N.t('cssPanes.delete.confirm', { name: pane.name || '' }))) return;
      deleteCssPane(pane.id);
      await saveAll(true);
      renderCssPanesList();
    });
    row.appendChild(delBtn);
  }

  return row;
}

// === Modal "Okna JS" — analogiczny do "Okna CSS" (v2.13.56) ===
let jsPanesModalContext = null;

function openJsPanesModal({ parentType, parentId, parentName, onClose }) {
  if (!parentType || !parentId) return;
  jsPanesModalContext = { parentType, parentId, parentName: parentName || '', onClose: onClose || null };
  const labelEl = document.getElementById('jsPanesParentLabel');
  if (labelEl) {
    const typeLabel = parentType === 'folder' ? JZ_I18N.t('actionBars.parentType.folder')
                    : parentType === 'group'  ? JZ_I18N.t('actionBars.parentType.group')
                    : JZ_I18N.t('actionBars.parentType.rule');
    labelEl.textContent = `${typeLabel}: ${parentName || ''}`;
  }
  renderJsPanesList();
  document.getElementById('jsPanesModal').classList.add('show');
}

function closeJsPanesModal() {
  document.getElementById('jsPanesModal').classList.remove('show');
  const cb = jsPanesModalContext && jsPanesModalContext.onClose;
  jsPanesModalContext = null;
  if (typeof cb === 'function') cb();
}

function renderJsPanesList() {
  const ctx = jsPanesModalContext;
  const list = document.getElementById('jsPanesList');
  if (!ctx || !list) return;
  const { owned, inherited } = getInheritedJsPanesForContext(ctx.parentType, ctx.parentId);

  list.innerHTML = '';

  const ownedHeader = document.createElement('div');
  ownedHeader.className = 'action-bars-section-header';
  ownedHeader.textContent = JZ_I18N.t('jsPanes.section.owned');
  list.appendChild(ownedHeader);

  if (owned.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:14px;text-align:center;color:#9CA3AF;font-size:12px;border:1px dashed #D1D5DB;border-radius:6px;';
    empty.textContent = JZ_I18N.t('jsPanes.empty');
    list.appendChild(empty);
  } else {
    owned.forEach(pane => list.appendChild(renderJsPaneRow(pane, false)));
  }

  inherited.forEach(group => {
    const header = document.createElement('div');
    header.className = 'action-bars-section-header';
    const fromLabel = group.from === 'folder'
      ? JZ_I18N.t('actionBars.section.fromFolder', { name: escapeHtml(group.parentName) || '?' })
      : JZ_I18N.t('actionBars.section.fromGroup',  { name: escapeHtml(group.parentName) || '?' });
    header.innerHTML = fromLabel;
    list.appendChild(header);
    group.panes.forEach(pane => list.appendChild(renderJsPaneRow(pane, true)));
  });
}

function renderJsPaneRow(pane, isInherited) {
  const row = document.createElement('div');
  row.className = 'action-bar-row';
  row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #E5E7EB;border-radius:6px;background:#FFF;';
  if (isInherited) row.style.opacity = '0.85';

  const dot = document.createElement('span');
  dot.style.cssText = `width:14px;height:14px;border-radius:50%;background:${pane.color || '#D65D0E'};flex:0 0 14px;border:1px solid rgba(0,0,0,0.1);`;
  row.appendChild(dot);

  const nameIn = document.createElement('input');
  nameIn.type = 'text';
  nameIn.value = pane.name || '';
  nameIn.placeholder = JZ_I18N.t('jsPanes.name.placeholder');
  nameIn.maxLength = 32;
  nameIn.disabled = isInherited;
  nameIn.style.cssText = 'flex:1;padding:5px 8px;font-size:12px;border:1px solid #D1D5DB;border-radius:4px;';
  if (!isInherited) {
    nameIn.addEventListener('input', async () => {
      updateJsPane(pane.id, { name: nameIn.value });
      await saveAll(true);
    });
  }
  row.appendChild(nameIn);

  const enabledWrap = document.createElement('label');
  enabledWrap.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;color:#555;cursor:pointer;';
  enabledWrap.title = JZ_I18N.t('jsPanes.enabled.title');
  const enabledCb = document.createElement('input');
  enabledCb.type = 'checkbox';
  enabledCb.checked = pane.enabled !== false;
  enabledCb.disabled = isInherited;
  if (!isInherited) {
    enabledCb.addEventListener('change', async () => {
      updateJsPane(pane.id, { enabled: enabledCb.checked });
      await saveAll(true);
    });
  }
  enabledWrap.appendChild(enabledCb);
  enabledWrap.appendChild(document.createTextNode(JZ_I18N.t('jsPanes.enabled.label')));
  row.appendChild(enabledWrap);

  // Multi-color picker — dot + headerBg/Text + bodyBg/Text + runColor (v2.13.64, JS-only).
  appendPaneStyleControls(row, pane, isInherited, updateJsPane, saveAll, renderJsPanesList, '#D65D0E', [
    { key: 'runColor', label: JZ_I18N.t('panes.style.runBtn'), defaultColor: '#FCD34D' },
  ]);

  if (!isInherited) {
    const delBtn = document.createElement('button');
    delBtn.type = 'button';
    delBtn.innerHTML = JZ_ICONS.close;
    delBtn.title = JZ_I18N.t('jsPanes.delete.title');
    delBtn.style.cssText = 'background:#fee;color:#c00;border:1px solid #fcc;padding:4px 6px;border-radius:4px;cursor:pointer;display:flex;align-items:center;';
    delBtn.addEventListener('click', async () => {
      if (!confirm(JZ_I18N.t('jsPanes.delete.confirm', { name: pane.name || '' }))) return;
      deleteJsPane(pane.id);
      await saveAll(true);
      renderJsPanesList();
    });
    row.appendChild(delBtn);
  }

  return row;
}

// === Modal "Konsole JS" — analogiczny do "Okna JS" (v2.13.66) ===
let jsConsolesModalContext = null;

function openJsConsolesModal({ parentType, parentId, parentName, onClose }) {
  if (!parentType || !parentId) return;
  jsConsolesModalContext = { parentType, parentId, parentName: parentName || '', onClose: onClose || null };
  const labelEl = document.getElementById('jsConsolesParentLabel');
  if (labelEl) {
    const typeLabel = parentType === 'folder' ? JZ_I18N.t('actionBars.parentType.folder')
                    : parentType === 'group'  ? JZ_I18N.t('actionBars.parentType.group')
                    : JZ_I18N.t('actionBars.parentType.rule');
    labelEl.textContent = `${typeLabel}: ${parentName || ''}`;
  }
  renderJsConsolesList();
  document.getElementById('jsConsolesModal').classList.add('show');
}

function closeJsConsolesModal() {
  document.getElementById('jsConsolesModal').classList.remove('show');
  const cb = jsConsolesModalContext && jsConsolesModalContext.onClose;
  jsConsolesModalContext = null;
  if (typeof cb === 'function') cb();
}

function renderJsConsolesList() {
  const ctx = jsConsolesModalContext;
  const list = document.getElementById('jsConsolesList');
  if (!ctx || !list) return;
  const { owned, inherited } = getInheritedJsConsolesForContext(ctx.parentType, ctx.parentId);

  list.innerHTML = '';

  const ownedHeader = document.createElement('div');
  ownedHeader.className = 'action-bars-section-header';
  ownedHeader.textContent = JZ_I18N.t('jsConsoles.section.owned');
  list.appendChild(ownedHeader);

  if (owned.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:14px;text-align:center;color:#9CA3AF;font-size:12px;border:1px dashed #D1D5DB;border-radius:6px;';
    empty.textContent = JZ_I18N.t('jsConsoles.empty');
    list.appendChild(empty);
  } else {
    owned.forEach(con => list.appendChild(renderJsConsoleRow(con, false)));
  }

  inherited.forEach(group => {
    const header = document.createElement('div');
    header.className = 'action-bars-section-header';
    const fromLabel = group.from === 'folder'
      ? JZ_I18N.t('actionBars.section.fromFolder', { name: escapeHtml(group.parentName) || '?' })
      : JZ_I18N.t('actionBars.section.fromGroup',  { name: escapeHtml(group.parentName) || '?' });
    header.innerHTML = fromLabel;
    list.appendChild(header);
    group.consoles.forEach(con => list.appendChild(renderJsConsoleRow(con, true)));
  });
}

function renderJsConsoleRow(con, isInherited) {
  const row = document.createElement('div');
  row.className = 'action-bar-row';
  row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #E5E7EB;border-radius:6px;background:#FFF;';
  if (isInherited) row.style.opacity = '0.85';

  const dot = document.createElement('span');
  dot.style.cssText = `width:14px;height:14px;border-radius:50%;background:${con.color || '#7C3AED'};flex:0 0 14px;border:1px solid rgba(0,0,0,0.1);`;
  row.appendChild(dot);

  const nameIn = document.createElement('input');
  nameIn.type = 'text';
  nameIn.value = con.name || '';
  nameIn.placeholder = JZ_I18N.t('jsConsoles.name.placeholder');
  nameIn.maxLength = 32;
  nameIn.disabled = isInherited;
  nameIn.style.cssText = 'flex:1;padding:5px 8px;font-size:12px;border:1px solid #D1D5DB;border-radius:4px;';
  if (!isInherited) {
    nameIn.addEventListener('input', async () => {
      updateJsConsole(con.id, { name: nameIn.value });
      await saveAll(true);
    });
  }
  row.appendChild(nameIn);

  const enabledWrap = document.createElement('label');
  enabledWrap.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;color:#555;cursor:pointer;';
  enabledWrap.title = JZ_I18N.t('jsConsoles.enabled.title');
  const enabledCb = document.createElement('input');
  enabledCb.type = 'checkbox';
  enabledCb.checked = con.enabled !== false;
  enabledCb.disabled = isInherited;
  if (!isInherited) {
    enabledCb.addEventListener('change', async () => {
      updateJsConsole(con.id, { enabled: enabledCb.checked });
      await saveAll(true);
    });
  }
  enabledWrap.appendChild(enabledCb);
  enabledWrap.appendChild(document.createTextNode(JZ_I18N.t('jsConsoles.enabled.label')));
  row.appendChild(enabledWrap);

  // Multi-color picker — jak JS pane (dot + headerBg/Text + bodyBg/Text + runColor).
  appendPaneStyleControls(row, con, isInherited, updateJsConsole, saveAll, renderJsConsolesList, '#7C3AED', [
    { key: 'runColor', label: JZ_I18N.t('panes.style.runBtn'), defaultColor: '#FCD34D' },
  ]);

  if (!isInherited) {
    const delBtn = document.createElement('button');
    delBtn.type = 'button';
    delBtn.innerHTML = JZ_ICONS.close;
    delBtn.title = JZ_I18N.t('jsConsoles.delete.title');
    delBtn.style.cssText = 'background:#fee;color:#c00;border:1px solid #fcc;padding:4px 6px;border-radius:4px;cursor:pointer;display:flex;align-items:center;';
    delBtn.addEventListener('click', async () => {
      if (!confirm(JZ_I18N.t('jsConsoles.delete.confirm', { name: con.name || '' }))) return;
      deleteJsConsole(con.id);
      await saveAll(true);
      renderJsConsolesList();
    });
    row.appendChild(delBtn);
  }

  return row;
}

// === Output Console modal (od v2.13.72) — analog do JS Console modal. ===
let outputConsolesModalContext = null;

function openOutputConsolesModal({ parentType, parentId, parentName, onClose }) {
  if (!parentType || !parentId) return;
  outputConsolesModalContext = { parentType, parentId, parentName: parentName || '', onClose: onClose || null };
  const labelEl = document.getElementById('outputConsolesParentLabel');
  if (labelEl) {
    const typeLabel = parentType === 'folder' ? JZ_I18N.t('actionBars.parentType.folder')
                    : parentType === 'group'  ? JZ_I18N.t('actionBars.parentType.group')
                    : JZ_I18N.t('actionBars.parentType.rule');
    labelEl.textContent = `${typeLabel}: ${parentName || ''}`;
  }
  renderOutputConsolesList();
  document.getElementById('outputConsolesModal').classList.add('show');
}

function closeOutputConsolesModal() {
  document.getElementById('outputConsolesModal').classList.remove('show');
  const cb = outputConsolesModalContext && outputConsolesModalContext.onClose;
  outputConsolesModalContext = null;
  if (typeof cb === 'function') cb();
}

function renderOutputConsolesList() {
  const ctx = outputConsolesModalContext;
  const list = document.getElementById('outputConsolesList');
  if (!ctx || !list) return;
  const { owned, inherited } = getInheritedOutputConsolesForContext(ctx.parentType, ctx.parentId);

  list.innerHTML = '';

  const ownedHeader = document.createElement('div');
  ownedHeader.className = 'action-bars-section-header';
  ownedHeader.textContent = JZ_I18N.t('outputConsoles.section.owned');
  list.appendChild(ownedHeader);

  if (owned.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:14px;text-align:center;color:#9CA3AF;font-size:12px;border:1px dashed #D1D5DB;border-radius:6px;';
    empty.textContent = JZ_I18N.t('outputConsoles.empty');
    list.appendChild(empty);
  } else {
    owned.forEach(oc => list.appendChild(renderOutputConsoleRow(oc, false)));
  }

  inherited.forEach(group => {
    const header = document.createElement('div');
    header.className = 'action-bars-section-header';
    const fromLabel = group.from === 'folder'
      ? JZ_I18N.t('actionBars.section.fromFolder', { name: escapeHtml(group.parentName) || '?' })
      : JZ_I18N.t('actionBars.section.fromGroup',  { name: escapeHtml(group.parentName) || '?' });
    header.innerHTML = fromLabel;
    list.appendChild(header);
    group.consoles.forEach(oc => list.appendChild(renderOutputConsoleRow(oc, true)));
  });
}

function renderOutputConsoleRow(oc, isInherited) {
  const row = document.createElement('div');
  row.className = 'action-bar-row';
  row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #E5E7EB;border-radius:6px;background:#FFF;';
  if (isInherited) row.style.opacity = '0.85';

  const dot = document.createElement('span');
  dot.style.cssText = `width:14px;height:14px;border-radius:50%;background:${oc.color || '#10B981'};flex:0 0 14px;border:1px solid rgba(0,0,0,0.1);`;
  row.appendChild(dot);

  const nameIn = document.createElement('input');
  nameIn.type = 'text';
  nameIn.value = oc.name || '';
  nameIn.placeholder = JZ_I18N.t('outputConsoles.name.placeholder');
  nameIn.maxLength = 32;
  nameIn.disabled = isInherited;
  nameIn.style.cssText = 'flex:1;padding:5px 8px;font-size:12px;border:1px solid #D1D5DB;border-radius:4px;';
  if (!isInherited) {
    nameIn.addEventListener('input', async () => {
      updateOutputConsole(oc.id, { name: nameIn.value });
      await saveAll(true);
    });
  }
  row.appendChild(nameIn);

  const enabledWrap = document.createElement('label');
  enabledWrap.style.cssText = 'display:flex;align-items:center;gap:4px;font-size:11px;color:#555;cursor:pointer;';
  enabledWrap.title = JZ_I18N.t('outputConsoles.enabled.title');
  const enabledCb = document.createElement('input');
  enabledCb.type = 'checkbox';
  enabledCb.checked = oc.enabled !== false;
  enabledCb.disabled = isInherited;
  if (!isInherited) {
    enabledCb.addEventListener('change', async () => {
      updateOutputConsole(oc.id, { enabled: enabledCb.checked });
      await saveAll(true);
    });
  }
  enabledWrap.appendChild(enabledCb);
  enabledWrap.appendChild(document.createTextNode(JZ_I18N.t('outputConsoles.enabled.label')));
  row.appendChild(enabledWrap);

  // Multi-color picker — output console nie ma Run buttona, ale Clear button może mieć
  // własny kolor (analog runColor). Zachowujemy ten sam set żeby UX był spójny.
  appendPaneStyleControls(row, oc, isInherited, updateOutputConsole, saveAll, renderOutputConsolesList, '#10B981', [
    { key: 'runColor', label: JZ_I18N.t('panes.style.actionBtns'), defaultColor: '#FCD34D' },
  ]);

  if (!isInherited) {
    const delBtn = document.createElement('button');
    delBtn.type = 'button';
    delBtn.innerHTML = JZ_ICONS.close;
    delBtn.title = JZ_I18N.t('outputConsoles.delete.title');
    delBtn.style.cssText = 'background:#fee;color:#c00;border:1px solid #fcc;padding:4px 6px;border-radius:4px;cursor:pointer;display:flex;align-items:center;';
    delBtn.addEventListener('click', async () => {
      if (!confirm(JZ_I18N.t('outputConsoles.delete.confirm', { name: oc.name || '' }))) return;
      deleteOutputConsole(oc.id);
      await saveAll(true);
      renderOutputConsolesList();
    });
    row.appendChild(delBtn);
  }

  return row;
}

// === Wersjonowanie schematu ===
const STORAGE_VERSION = 12;

// Normalizuje wzorce URL katalogu do tablicy. Akceptuje legacy `urlPattern` (string)
// LUB nowy `urlPatterns` (string[]). Zwraca posortowaną tablicę bez duplikatów,
// pomijając puste wpisy. Używane też przez content.js i popup.js (zduplikowane,
// świadomie — nie chcemy ciągnąć modułu wspólnego do content scriptów).
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] : [];
}

// Migruje stary format do bieżącego. Zwraca zmigrowane dane.
function migrateData(data) {
  const fromVersion = data.schemaVersion || 1;
  if (fromVersion > STORAGE_VERSION) {
    console.warn('[JustZix] Schemat danych jest nowszy niż obsługiwany przez tę wersję wtyczki. Brak migracji wstecznej.');
    return data;
  }

  let migrated = data;

  // v1 → v2: dodaje pola color, icon, tags, history, actionStats; dodaje globalEnabled, theme
  if (fromVersion < 2) {
    if (!Array.isArray(migrated.folders)) migrated.folders = [];
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    migrated.folders.forEach(f => {
      if (!f.color) f.color = '';
      if (!f.icon) f.icon = '';
      if (typeof f.showActionsPanel !== 'boolean') f.showActionsPanel = false;
      if (!f.actionsOrientation) f.actionsOrientation = 'vertical';
    });
    migrated.rules.forEach(r => {
      if (!Array.isArray(r.actions)) r.actions = [];
      if (!Array.isArray(r.tags)) r.tags = [];
      if (!Array.isArray(r.history)) r.history = [];
      if (typeof r.updatedAt !== 'number') r.updatedAt = Date.now();
      r.actions.forEach(a => {
        if (typeof a.clickCount !== 'number') a.clickCount = 0;
      });
    });
    if (typeof migrated.globalEnabled !== 'boolean') migrated.globalEnabled = true;
    if (!migrated.theme) migrated.theme = 'light';
    if (!Array.isArray(migrated.filters)) migrated.filters = [];
    migrated.schemaVersion = 2;
  }

  // v2 → v3: dodaje warstwę grup pomiędzy folderami a zestawami,
  //          dodaje rule.urlPatterns: string[], rule.groupId zamiast rule.folderId.
  if (fromVersion < 3) {
    if (!Array.isArray(migrated.folders)) migrated.folders = [];
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    if (!Array.isArray(migrated.groups)) migrated.groups = [];

    // Dla każdego folderu utwórz "Domyślną" grupę (jeśli jeszcze nie ma żadnej w danym folderze).
    migrated.folders.forEach(f => {
      const existing = migrated.groups.find(g => g.folderId === f.id);
      if (!existing) {
        migrated.groups.push({
          id: 'g_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
          folderId: f.id,
          name: JZ_I18N.t('default.groupName'),
          enabled: true
        });
      }
    });

    // Przypisz zestawy bez groupId do domyślnej grupy ich folderu.
    migrated.rules.forEach(r => {
      if (!r.groupId) {
        const defaultGroup = migrated.groups.find(g => g.folderId === r.folderId);
        if (defaultGroup) {
          r.groupId = defaultGroup.id;
        }
      }
      if (!Array.isArray(r.urlPatterns)) r.urlPatterns = [];
      // folderId zostaje (denormalizacja) — wciąż przydatny dla szybszych zapytań i kompatybilności,
      // ale autorytatywny jest groupId → group.folderId.
    });

    // Sanitize groups
    migrated.groups.forEach(g => {
      if (typeof g.enabled !== 'boolean') g.enabled = true;
      if (typeof g.name !== 'string') g.name = 'Grupa';
      if (!Array.isArray(g.urlPatterns)) g.urlPatterns = [];
    });

    migrated.schemaVersion = 3;
  }

  // v3 → v4: folder.urlPattern (string) → folder.urlPatterns (string[]). Zachowujemy też
  // pole legacy `urlPattern` jako shadow pierwszego wzorca — żeby starsze urządzenia
  // z wersją 2.6.x widziały co najmniej jeden URL po pullu z syncu.
  if (fromVersion < 4) {
    if (!Array.isArray(migrated.folders)) migrated.folders = [];
    migrated.folders.forEach(f => {
      if (!Array.isArray(f.urlPatterns)) {
        const legacy = (f.urlPattern || '').trim();
        f.urlPatterns = legacy ? [legacy] : [];
      }
      // Backward-compat shadow.
      f.urlPattern = f.urlPatterns[0] || '';
    });
    migrated.schemaVersion = 4;
  }

  // v4 → v5: wprowadzenie ActionBar (wiele pasków akcji per folder/grupa/zestaw).
  // Stary model: folder.showActionsPanel + folder.actionsOrientation + actions w rule.actions.
  // Nowy model: actionBars[] (każdy z parentType/parentId), action.barId wskazuje pasek.
  // Migracja: każdy folder z showActionsPanel=true dostaje 1 ActionBar przypisany do tego
  // folderu z dotychczasową orientacją; wszystkie actions w rules tego folderu dostają
  // barId tego paska. Foldery bez showActionsPanel zostają bez baru (actions zachowują się
  // jak dotychczas — backward compat dopóki content.js nie przejdzie na nowy renderer).
  if (fromVersion < 5) {
    if (!Array.isArray(migrated.folders)) migrated.folders = [];
    if (!Array.isArray(migrated.groups)) migrated.groups = [];
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    if (!Array.isArray(migrated.actionBars)) migrated.actionBars = [];

    migrated.folders.forEach(f => {
      if (f.showActionsPanel !== true) return;
      const folderGroupIds = new Set(migrated.groups.filter(g => g.folderId === f.id).map(g => g.id));
      const folderRules = migrated.rules.filter(r => folderGroupIds.has(r.groupId));
      const hasActions = folderRules.some(r => Array.isArray(r.actions) && r.actions.length > 0);
      if (!hasActions) return; // pusty panel — pomiń
      const bar = {
        id: 'b_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
        parentType: 'folder',
        parentId: f.id,
        name: '',
        enabled: true,
        orientation: f.actionsOrientation === 'horizontal' ? 'horizontal' : 'vertical',
        color: '',
      };
      migrated.actionBars.push(bar);
      folderRules.forEach(r => {
        if (Array.isArray(r.actions)) r.actions.forEach(a => { if (!a.barId) a.barId = bar.id; });
      });
    });

    migrated.schemaVersion = 5;
  }

  // v6: typed actions (Faza 5 scaffold) — każda akcja dostaje jawny `type`.
  // Dotychczasowe akcje to wszystko przyciski → `type: 'button'`. Przyszłe typy
  // (select, input, slider, toggle3, textarea) wprowadzą kolejne minor releasy.
  if (fromVersion < 6) {
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    migrated.rules.forEach(r => {
      if (Array.isArray(r.actions)) {
        r.actions.forEach(a => {
          if (!a.type) a.type = 'button';
        });
      }
    });
    migrated.schemaVersion = 6;
  }

  // v7: SELECT redesign (v2.12.2) — stary model {options:[{id,label,code}]} → nowy:
  // {variant:'static', options:[{id,name,value}], code:''}. Konwersja: label → name,
  // value pusty (user uzupełni), per-option code odrzucony (nowy model ma JEDEN wspólny code).
  // Tylko pierwsza wersja SELECT (v2.12.1) miała stary model — dane lokalne devów.
  if (fromVersion < 7) {
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    migrated.rules.forEach(r => {
      if (!Array.isArray(r.actions)) return;
      r.actions.forEach(a => {
        if (a.type !== 'select') return;
        if (!a.variant) a.variant = 'static';
        if (typeof a.code !== 'string') a.code = '';
        if (Array.isArray(a.options)) {
          a.options = a.options.map(o => {
            if (!o || typeof o !== 'object') return null;
            // Nowy shape ma name+value zamiast label+code. Konwersja:
            const conv = {
              id: o.id || ('opt_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6)),
              name: o.name != null ? o.name : (o.label || ''),
              value: o.value != null ? o.value : (o.label || ''),
            };
            return conv;
          }).filter(Boolean);
        } else {
          a.options = [];
        }
      });
    });
    migrated.schemaVersion = 7;
  }

  // v8: INPUT type + memory infra (v2.13.0). SELECT actions dostają domyślnie
  // memoryScope='page' (zapamiętują wybór per URL między reloadami). Button bez memory.
  // Storage `actionMemory` (per-device, nie synced) inicjalizowany pusty.
  if (fromVersion < 8) {
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    migrated.rules.forEach(r => {
      if (!Array.isArray(r.actions)) return;
      r.actions.forEach(a => {
        if (a.type === 'select' && !a.memoryScope) a.memoryScope = 'page';
      });
    });
    migrated.schemaVersion = 8;
  }

  // v9: Stabilne sekwencyjne ID akcji (v2.13.1) — wszystkie action.id renumerowane
  // na 'a1', 'a2', 'a3'... per workspace. Stare random IDs (`a_lxg5_abc`) tracone,
  // ale referencje cross-entity są lokalne do action (barId wskazuje na bar, nie odwrotnie).
  // actionMemory keys także renumerowane (zachowanie wartości user'a).
  // counter `nextActionSeq` zapisany w storage.
  if (fromVersion < 9) {
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    let counter = 0;
    const idMap = {};  // oldId -> newId
    migrated.rules.forEach(r => {
      if (!Array.isArray(r.actions)) return;
      r.actions.forEach(a => {
        if (!a.id) return;
        counter++;
        const newId = 'a' + counter;
        idMap[a.id] = newId;
        a.id = newId;
      });
    });
    migrated.nextActionSeq = counter + 1;
    // Rebuild actionMemory keys.
    if (migrated.actionMemory && typeof migrated.actionMemory === 'object') {
      const newMem = {};
      Object.keys(migrated.actionMemory).forEach(oldId => {
        const newId = idMap[oldId];
        if (newId) newMem[newId] = migrated.actionMemory[oldId];
      });
      migrated.actionMemory = newMem;
    }
    migrated.schemaVersion = 9;
  }

  // v10 (v2.13.4): nowy scope 'ruleSet' = pamięć dzielona przez wszystkie URL gdzie
  // zestaw zawierający akcję jest aktywny (czyli pasujące do URL patterns folderu+grupy+zestawu).
  // Storage: actionMemory[actionId].ruleSet = singleValue. Funkcjonalnie odpowiada
  // staremu 'global' (jeden value per akcja, niezależny od URL), ale nazwa jasniej oddaje
  // semantykę: action żyje tylko w obrębie swojego zestawu, więc 'ruleSet' = "wszystkie URL
  // gdzie ta akcja istnieje".
  //
  // Migracja:
  // - SELECT/INPUT z memoryScope='page' → 'ruleSet' (default v8 był mylący per-URL)
  // - SELECT/INPUT z memoryScope='global' → 'ruleSet' (nazwa była niejasna, semantyka ta sama)
  // - actionMemory[id].page → .ruleSet (pickup latest value)
  // - actionMemory[id].global → .ruleSet (przeniesienie)
  // - 'host' i 'none' niezmienione — user explicit choice
  if (fromVersion < 10) {
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    migrated.rules.forEach(r => {
      if (!Array.isArray(r.actions)) return;
      r.actions.forEach(a => {
        if (a.type !== 'select' && a.type !== 'input') return;
        if (a.memoryScope === 'page' || a.memoryScope === 'global') {
          a.memoryScope = 'ruleSet';
        }
      });
    });
    if (migrated.actionMemory && typeof migrated.actionMemory === 'object') {
      Object.keys(migrated.actionMemory).forEach(actionId => {
        const mem = migrated.actionMemory[actionId];
        if (!mem || typeof mem !== 'object') return;
        // global → ruleSet
        if (mem.global !== undefined && mem.ruleSet === undefined) {
          mem.ruleSet = mem.global;
        }
        // page → ruleSet (pickup last entry — JS object iteration order = insertion order)
        if (mem.page && typeof mem.page === 'object') {
          const entries = Object.values(mem.page);
          if (entries.length > 0 && mem.ruleSet === undefined) {
            mem.ruleSet = entries[entries.length - 1];
          }
        }
        // Stare .page / .global NIE usuwane — gdyby user zmienił scope z powrotem.
      });
    }
    migrated.schemaVersion = 10;
  }

  // v11 (v2.13.7): usunięcie `memoryScope` jako konfiguracji usera. Memory jest teraz
  // ZAWSZE singleton per action.id (wartość zachowana wszędzie gdzie akcja widoczna —
  // czyli gdzie URL patterns rule setu pasują). Konfiguracja scope była myląca, user
  // chciał tylko "zapamiętaj wartość, kropka". Sync-wise: action.id jest unique-per-account,
  // więc memory[id] = singleValue wystarcza.
  //
  // Migracja:
  // - Każda akcja: usuwamy `memoryScope` (ignorowane od teraz)
  // - actionMemory flatten: było `{[id]: {ruleSet|page|host|global: ...}}`, teraz `{[id]: value}`
  // - Pickup wartości: ruleSet > global > first host entry > first page entry
  if (fromVersion < 11) {
    if (!Array.isArray(migrated.rules)) migrated.rules = [];
    migrated.rules.forEach(r => {
      if (!Array.isArray(r.actions)) return;
      r.actions.forEach(a => {
        if (a.memoryScope !== undefined) delete a.memoryScope;
      });
    });
    if (migrated.actionMemory && typeof migrated.actionMemory === 'object') {
      const flat = {};
      Object.keys(migrated.actionMemory).forEach(actionId => {
        const mem = migrated.actionMemory[actionId];
        if (mem === null || mem === undefined) return;
        // Już flat (string/number) — zachowaj jak jest.
        if (typeof mem !== 'object') { flat[actionId] = mem; return; }
        // Nested — pickup priority.
        if (mem.ruleSet !== undefined) { flat[actionId] = mem.ruleSet; return; }
        if (mem.global !== undefined)  { flat[actionId] = mem.global; return; }
        if (mem.host && typeof mem.host === 'object') {
          const hostEntries = Object.values(mem.host);
          if (hostEntries.length > 0) { flat[actionId] = hostEntries[hostEntries.length - 1]; return; }
        }
        if (mem.page && typeof mem.page === 'object') {
          const pageEntries = Object.values(mem.page);
          if (pageEntries.length > 0) { flat[actionId] = pageEntries[pageEntries.length - 1]; return; }
        }
      });
      migrated.actionMemory = flat;
    }
    migrated.schemaVersion = 11;
  }

  // v12 (v2.13.39, Faza 1 "Okna na froncie"): inicjalizacja 4 nowych kolekcji typów okien
  // tekstowych. Kolekcje są puste — Faza 1 to wyłącznie data plumbing (storage + sync types),
  // UI dochodzi w kolejnych fazach. Backend's migracja `004_panes.sql` rozszerza ENUM o nowe
  // typy, **zachowuje** istniejące `'bar'` dla ActionBar — żadnego rename'u.
  if (fromVersion < 12) {
    if (!Array.isArray(migrated.cssPanes))       migrated.cssPanes = [];
    if (!Array.isArray(migrated.jsPanes))        migrated.jsPanes = [];
    if (!Array.isArray(migrated.jsConsoles))     migrated.jsConsoles = [];
    if (!Array.isArray(migrated.outputConsoles)) migrated.outputConsoles = [];
    migrated.schemaVersion = 12;
  }

  return migrated;
}

// Sanityzacja encji w pamięci — idempotentna. Ustawia brakujące pola domyślne,
// normalizuje wzorce URL, kaskaduje active→enabled i usuwa encje osierocone
// (bary/okna z nieistniejącym parentem, orphan barId w akcjach). Wołana z load()
// oraz po applyFresh w storage.onChanged — zewnętrzny zapis / sync pull mógł
// wnieść encje z niepełnymi polami albo osierocone (np. grupa po pulled tombstone
// folderu na innej stronie paginacji).
function sanitizeEntitiesInMemory() {
  folders.forEach(f => {
    if (!f.color) f.color = '';
    if (!f.icon) f.icon = '';
    if (typeof f.enabled !== 'boolean') f.enabled = true;
    if (typeof f.active !== 'boolean') f.active = true;
    if (typeof f.showActionsPanel !== 'boolean') f.showActionsPanel = false;
    if (!f.actionsOrientation) f.actionsOrientation = 'vertical';
    // Wzorce URL — normalizuj do tablicy (idempotent). Zachowaj shadow w `urlPattern`
    // dla wstecznej kompatybilności ze starszymi wersjami wtyczki na innych urządzeniach.
    if (!Array.isArray(f.urlPatterns)) {
      const legacy = (f.urlPattern || '').trim();
      f.urlPatterns = legacy ? [legacy] : [];
    }
    f.urlPattern = f.urlPatterns[0] || '';
    // Cascade: nieaktywny → wyłączony
    if (f.active === false) f.enabled = false;
  });
  groups.forEach(g => {
    if (typeof g.enabled !== 'boolean') g.enabled = true;
    if (typeof g.active !== 'boolean') g.active = true;
    if (typeof g.name !== 'string') g.name = 'Grupa';
    if (!Array.isArray(g.urlPatterns)) g.urlPatterns = [];
    if (g.active === false) g.enabled = false;
  });
  rules.forEach(r => {
    if (!Array.isArray(r.actions)) r.actions = [];
    if (!Array.isArray(r.tags)) r.tags = [];
    if (!Array.isArray(r.history)) r.history = [];
    if (!Array.isArray(r.urlPatterns)) r.urlPatterns = [];
    if (typeof r.enabled !== 'boolean') r.enabled = true;
    if (typeof r.active !== 'boolean') r.active = true;
    // Cascade: nieaktywny zestaw musi być wyłączony.
    if (r.active === false) r.enabled = false;
    r.actions.forEach(a => {
      if (typeof a.clickCount !== 'number') a.clickCount = 0;
    });
  });

  // Bezpiecznik: zestawy bez groupId (np. import z v2) — przypisz do pierwszej grupy ich folderu, jeśli istnieje.
  rules.forEach(r => {
    if (!r.groupId && r.folderId) {
      const g = groups.find(gg => gg.folderId === r.folderId);
      if (g) r.groupId = g.id;
    }
  });

  // Sanityzacja actionBars — idempotent, bezpieczna dla wpisów z syncu starszych klientów
  // (które mogą wysłać niepełne pola). Drop barów z nieistniejącym parentem.
  const validFolderIds = new Set(folders.map(f => f.id));
  const validGroupIds = new Set(groups.map(g => g.id));
  const validRuleIds = new Set(rules.map(r => r.id));
  actionBars = (Array.isArray(actionBars) ? actionBars : []).filter(b => {
    if (!b || !b.id || !b.parentType || !b.parentId) return false;
    if (b.parentType === 'folder' && !validFolderIds.has(b.parentId)) return false;
    if (b.parentType === 'group' && !validGroupIds.has(b.parentId)) return false;
    if (b.parentType === 'rule' && !validRuleIds.has(b.parentId)) return false;
    return true;
  });
  actionBars.forEach(b => {
    if (typeof b.name !== 'string') b.name = '';
    if (typeof b.enabled !== 'boolean') b.enabled = true;
    if (b.orientation !== 'horizontal' && b.orientation !== 'vertical') b.orientation = 'vertical';
    if (typeof b.color !== 'string') b.color = '';
  });
  // Sprzątanie orphan barId — gdy bar znika, czyszczę barId w actions tej referencji.
  const validBarIds = new Set(actionBars.map(b => b.id));
  rules.forEach(r => {
    if (!Array.isArray(r.actions)) return;
    r.actions.forEach(a => { if (a.barId && !validBarIds.has(a.barId)) delete a.barId; });
  });

  // Sanityzacja cssPanes (Faza 2 "Okna na froncie", v12) — analogicznie do actionBars.
  // Drop panes z nieistniejącym parentem. Idempotent.
  cssPanes = (Array.isArray(cssPanes) ? cssPanes : []).filter(p => {
    if (!p || !p.id || !p.parentType || !p.parentId) return false;
    if (p.parentType === 'folder' && !validFolderIds.has(p.parentId)) return false;
    if (p.parentType === 'group'  && !validGroupIds.has(p.parentId))  return false;
    if (p.parentType === 'rule'   && !validRuleIds.has(p.parentId))   return false;
    return true;
  });
  cssPanes.forEach(p => {
    if (typeof p.name !== 'string') p.name = 'CSS';
    if (typeof p.enabled !== 'boolean') p.enabled = true;
    if (typeof p.color !== 'string') p.color = '#2D9D53';
    if (!p.position || typeof p.position !== 'object') p.position = { x: 20, y: 60, anchor: 'top-right' };
    if (!p.size || typeof p.size !== 'object') p.size = { w: 360, h: 240 };
  });

  // Sanityzacja jsPanes / jsConsoles / outputConsoles — analogicznie do cssPanes.
  // Drop wpisów z nieistniejącym parentem. Idempotent.
  const pruneByParent = (arr) => (Array.isArray(arr) ? arr : []).filter(p => {
    if (!p || !p.id || !p.parentType || !p.parentId) return false;
    if (p.parentType === 'folder' && !validFolderIds.has(p.parentId)) return false;
    if (p.parentType === 'group'  && !validGroupIds.has(p.parentId))  return false;
    if (p.parentType === 'rule'   && !validRuleIds.has(p.parentId))   return false;
    return true;
  });
  jsPanes = pruneByParent(jsPanes);
  jsConsoles = pruneByParent(jsConsoles);
  outputConsoles = pruneByParent(outputConsoles);
}

async function load() {
  const data = await chrome.storage.local.get([
    'folders', 'groups', 'rules', 'actionBars',
    'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles',
    'expandedFolders', 'expandedGroups',
    'schemaVersion', 'globalEnabled', 'theme', 'nextActionSeq'
  ]);

  // Migracja jeśli potrzeba
  if ((data.schemaVersion || 1) < STORAGE_VERSION) {
    const allData = await chrome.storage.local.get(null);
    const migrated = migrateData(allData);
    await chrome.storage.local.set(migrated);
    folders = migrated.folders || [];
    groups = migrated.groups || [];
    rules = migrated.rules || [];
    actionBars = migrated.actionBars || [];
    cssPanes = migrated.cssPanes || [];
    jsPanes = migrated.jsPanes || [];
    jsConsoles = migrated.jsConsoles || [];
    outputConsoles = migrated.outputConsoles || [];
    if (typeof migrated.nextActionSeq === 'number') nextActionSeq = migrated.nextActionSeq;
  } else {
    folders = data.folders || [];
    groups = data.groups || [];
    rules = data.rules || [];
    actionBars = data.actionBars || [];
    cssPanes = data.cssPanes || [];
    jsPanes = data.jsPanes || [];
    jsConsoles = data.jsConsoles || [];
    outputConsoles = data.outputConsoles || [];
    if (typeof data.nextActionSeq === 'number') nextActionSeq = data.nextActionSeq;
  }

  // Stała sanityzacja w runtime'ie (idempotentna).
  sanitizeEntitiesInMemory();

  expandedFolders = data.expandedFolders || {};
  expandedGroups = data.expandedGroups || {};

  // Tema
  // od v3.2.78 — domyślnie 'auto' dla nowych userów (idzie za systemem); explicit
  // 'light'/'dark' z poprzednich wersji szanujemy bez migracji.
  applyTheme(data.theme || 'auto');

  // Globalny włącznik (do popupu - tu tylko czytamy)
  globalEnabled = data.globalEnabled !== false;

  // Sync — załaduj stan; UI rozpozna czy user zalogowany.
  if (typeof JZ_SYNC !== 'undefined') {
    try { await JZ_SYNC.init(); } catch (e) { console.warn('[JustZix sync] init error:', e); }
    // Powiadom syncUi że state jest gotowy — IIFE syncUi wczytała się przed nami
    // i ustawiła kropkę na podstawie pustego state (gray). Odśwież.
    window.dispatchEvent(new CustomEvent('jz-sync-state-changed'));
  }
  // Baseline dla sync diff — bez tego pierwszy saveAll() po starcie nie wykrywa zmian.
  _syncSnapshot = _snapshotEntities();

  renderFolders();
  if (selectedRuleId && rules.find(r => r.id === selectedRuleId)) {
    renderRuleEditor();
  } else if (selectedGroupId && groups.find(g => g.id === selectedGroupId)) {
    renderGroupEditor();
  } else if (selectedFolderId && folders.find(f => f.id === selectedFolderId)) {
    renderFolderEditor();
  } else {
    // Strona główna panelu edycji — pulpit zamiast auto-zaznaczenia folders[0].
    renderDashboard();
  }

  updateStorageQuota();
  updateVersionDisplay();
  // Update check — non-blocking. Cache 30 min po stronie sync.js, więc spam-safe.
  checkForUpdate().catch(() => {});
  // Pending edit-nav z Ctrl+Alt+{I,O,P} (od v3.2.4) — gdy panel otwiera się na
  // żądanie skrótu, background zapisuje cel; tu konsumujemy go po pierwszym
  // renderze. Storage.onChanged obsługuje kolejne wywołania (panel już otwarty).
  try {
    const data = await chrome.storage.local.get(['jzPendingEditNav']);
    if (data && data.jzPendingEditNav) applyPendingEditNav(data.jzPendingEditNav);
  } catch (_) {}
}

let globalEnabled = true;

// === Theme ===
// od v3.2.78 — 3 stany: 'light' / 'dark' / 'auto' (domyślny dla nowych userów).
// 'auto' (oraz undefined/null/'') resolvuje się do effective light/dark przez
// matchMedia('(prefers-color-scheme: dark)'). Toggle cykliczny: light→dark→auto→light.
// Etykieta/ikona przycisku pokazują OBECNY stan (zmiana z v3.2.78 — wcześniej docelowy);
// tooltip opisuje co kliknięcie zrobi (następny w cyklu).
let _themeMediaQuery = null;
let _themeMediaListener = null;
function _resolveEffectiveTheme(theme) {
  if (theme === 'dark') return 'dark';
  if (theme === 'light') return 'light';
  // 'auto' lub brak → system
  if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) return 'dark';
  return 'light';
}
function _wireThemeMediaListener(currentTheme) {
  if (!window.matchMedia) return;
  if (!_themeMediaQuery) _themeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  if (_themeMediaListener) {
    try { _themeMediaQuery.removeEventListener('change', _themeMediaListener); } catch (_) {}
    _themeMediaListener = null;
  }
  const isAuto = (currentTheme === 'auto' || !currentTheme);
  if (!isAuto) return; // listener TYLKO gdy auto — explicit choice nie reaguje na system
  _themeMediaListener = () => {
    chrome.storage.local.get(['theme']).then((d) => {
      const t = d.theme;
      if (t === 'auto' || !t) applyTheme(t); // re-apply jeśli wciąż auto
    });
  };
  try { _themeMediaQuery.addEventListener('change', _themeMediaListener); } catch (_) {}
}
function applyTheme(theme) {
  const effective = _resolveEffectiveTheme(theme);
  document.body.classList.toggle('theme-dark', effective === 'dark');
  const cur = (theme === 'auto' || !theme) ? 'auto' : theme;
  // od v3.2.79 — segmented control: aktywny segment dostaje .active, pozostałe go gubią.
  const group = document.getElementById('themeToggle3');
  if (group) {
    group.querySelectorAll('.theme-toggle3-seg').forEach((seg) => {
      seg.classList.toggle('active', seg.dataset.theme === cur);
    });
  }
  const cmTheme = effective === 'dark' ? 'dracula' : 'default';
  Object.values(editors).forEach(({ css, js }) => {
    try { css?.setOption('theme', cmTheme); } catch (e) {}
    try { js?.setOption('theme', cmTheme); } catch (e) {}
  });
  if (actionCodeEditor) {
    try { actionCodeEditor.setOption('theme', cmTheme); } catch (e) {}
  }
  _wireThemeMediaListener(cur);
}

// === Search & filtry ===
let searchQuery = '';
let filterActiveOnly = false;

function searchMatches(folder, folderRules) {
  if (!searchQuery) return { matches: true, matchedRuleIds: null };
  const q = searchQuery.toLowerCase();

  // Match po nazwie katalogu - cały katalog się dopasowuje
  if ((folder.name || '').toLowerCase().includes(q)) {
    return { matches: true, matchedRuleIds: null };
  }
  const folderPatterns = getFolderPatterns(folder);
  if (folderPatterns.some(p => p.toLowerCase().includes(q))) {
    return { matches: true, matchedRuleIds: null };
  }

  // Match po zestawach
  const matchedRuleIds = new Set();
  folderRules.forEach(r => {
    if ((r.name || '').toLowerCase().includes(q)) matchedRuleIds.add(r.id);
    else if ((r.tags || []).some(t => (t || '').toLowerCase().includes(q))) matchedRuleIds.add(r.id);
    else if ((r.css || '').toLowerCase().includes(q)) matchedRuleIds.add(r.id);
    else if ((r.js || '').toLowerCase().includes(q)) matchedRuleIds.add(r.id);
  });

  if (matchedRuleIds.size > 0) {
    return { matches: true, matchedRuleIds };
  }
  return { matches: false, matchedRuleIds: null };
}

// Snapshot poprzedniego stanu — używany przez diff sync. JSON-based shallow,
// porównujemy stringified payload by wykryć modyfikacje. _syncSnapshot inicjuje się
// w load() po pierwszym pobraniu z storage.
let _syncSnapshot = null;

// Reguła do payloadu sync — BEZ `actions`. Działania synchronizują się jako
// osobne encje 'action'; trzymanie ich kopii w payload reguły powodowało, że
// /api/sync/snapshot (pomija tombstone'y) wskrzeszał skasowane akcje.
function _ruleSansActions(rule) {
  const { actions, ...rest } = rule;
  return rest;
}

function _snapshotEntities() {
  const f = {}, g = {}, r = {}, a = {}, b = {};
  // Faza 1 "Okna na froncie" (v12): 4 nowe kolekcje — cssPanes, jsPanes, jsConsoles,
  // outputConsoles. Każda dostaje własny słownik analogiczny do `b` (actionBars).
  const cp = {}, jp = {}, jc = {}, oc = {};
  for (const x of folders) f[x.id] = JSON.stringify(x);
  for (const x of groups)  g[x.id] = JSON.stringify(x);
  for (const x of rules) {
    // Działania zapisujemy osobno — nie wliczamy ich do hash rule, żeby zmiana action
    // nie nadpisywała rule entity.
    const ruleSansActions = { ...x }; delete ruleSansActions.actions;
    r[x.id] = JSON.stringify(ruleSansActions);
    if (Array.isArray(x.actions)) {
      for (const ac of x.actions) {
        a[ac.id] = { parentRuleId: x.id, hash: JSON.stringify(ac) };
      }
    }
  }
  for (const x of actionBars)     b[x.id]  = JSON.stringify(x);
  for (const x of cssPanes)       cp[x.id] = JSON.stringify(x);
  for (const x of jsPanes)        jp[x.id] = JSON.stringify(x);
  for (const x of jsConsoles)     jc[x.id] = JSON.stringify(x);
  for (const x of outputConsoles) oc[x.id] = JSON.stringify(x);
  return { f, g, r, a, b, cp, jp, jc, oc };
}

// Nazwa encji z migawki JSON (`_snapshotEntities`) — dla historii synchronizacji,
// by tombstone niósł czytelną nazwę kasowanej pozycji.
function _nameFromSnap(json) {
  try { const o = JSON.parse(json); return o.name || o.label || null; }
  catch (e) { return null; }
}

function _diffAndEnqueue(before, after) {
  if (typeof JZ_SYNC === 'undefined' || !JZ_SYNC.isAuthenticated()) return;

  // Folders
  for (const id in after.f) {
    if (before.f[id] !== after.f[id]) {
      const entity = folders.find(x => x.id === id);
      if (entity) JZ_SYNC.enqueueChange({ type: 'folder', id, parentId: null, payload: entity });
    }
  }
  for (const id in before.f) {
    if (!(id in after.f)) JZ_SYNC.enqueueDelete({ type: 'folder', id, name: _nameFromSnap(before.f[id]) });
  }

  // Groups
  for (const id in after.g) {
    if (before.g[id] !== after.g[id]) {
      const entity = groups.find(x => x.id === id);
      if (entity) JZ_SYNC.enqueueChange({ type: 'group', id, parentId: entity.folderId, payload: entity });
    }
  }
  for (const id in before.g) {
    if (!(id in after.g)) JZ_SYNC.enqueueDelete({ type: 'group', id, name: _nameFromSnap(before.g[id]) });
  }

  // Rules (bez actions — patrz _ruleSansActions)
  for (const id in after.r) {
    if (before.r[id] !== after.r[id]) {
      const entity = rules.find(x => x.id === id);
      if (entity) JZ_SYNC.enqueueChange({ type: 'rule', id, parentId: entity.groupId, payload: _ruleSansActions(entity) });
    }
  }
  for (const id in before.r) {
    if (!(id in after.r)) JZ_SYNC.enqueueDelete({ type: 'rule', id, name: _nameFromSnap(before.r[id]) });
  }

  // Actions — porównuj per id
  for (const id in after.a) {
    const ah = after.a[id];
    const bh = before.a[id];
    if (!bh || bh.hash !== ah.hash) {
      const rule = rules.find(rx => rx.id === ah.parentRuleId);
      const action = rule && Array.isArray(rule.actions) ? rule.actions.find(ax => ax.id === id) : null;
      if (action) JZ_SYNC.enqueueChange({ type: 'action', id, parentId: ah.parentRuleId, payload: action });
    }
  }
  for (const id in before.a) {
    if (!(id in after.a)) {
      const parentRuleId = before.a[id].parentRuleId;
      // parentId tombstone'a akcji jest KONIECZNY — applyEntities na innym
      // urządzeniu lokalizuje regułę po nim, by usunąć akcję z rule.actions[].
      // Bez tego skasowana akcja zostawała „duchem" w rule.actions[].
      JZ_SYNC.enqueueDelete({ type: 'action', id, parentId: parentRuleId,
        name: _nameFromSnap(before.a[id] && before.a[id].hash) });
      // Re-push reguły-rodzica (bez actions) — skasowana akcja mogła wisieć w
      // payload.actions reguły na serwerze; /api/sync/snapshot (bez tombstone'ów)
      // wskrzeszał ją. Re-push nadpisuje payload reguły wersją bez tej akcji.
      const rule = parentRuleId ? rules.find(rx => rx.id === parentRuleId) : null;
      if (rule) JZ_SYNC.enqueueChange({ type: 'rule', id: rule.id, parentId: rule.groupId, payload: _ruleSansActions(rule) });
    }
  }

  // ActionBars — od v5. parentId = parent entity id (folder/group/rule), parentType
  // jest w payload. Sync backend traktuje payload opaque, więc nie trzeba zmian po stronie API.
  // entity_type='bar' (backend ENUM od 004_panes.sql — nazwa zachowana).
  for (const id in after.b) {
    if (before.b[id] !== after.b[id]) {
      const entity = actionBars.find(x => x.id === id);
      if (entity) JZ_SYNC.enqueueChange({ type: 'bar', id, parentId: entity.parentId, payload: entity });
    }
  }
  for (const id in before.b) {
    if (!(id in after.b)) JZ_SYNC.enqueueDelete({ type: 'bar', id, name: _nameFromSnap(before.b[id]) });
  }

  // 4 nowe typy okien tekstowych (Faza 1 "Okna na froncie", v12). parentId = folder/group/rule id;
  // parentType żyje w payload (analogicznie do ActionBar). Faza 1 obsługuje wyłącznie sync metadanych —
  // UI dochodzi w kolejnych fazach. Symetryczna struktura per typ (DRY przez helper).
  const _syncPaneCollection = (key, type, collection) => {
    for (const id in after[key]) {
      if (before[key][id] !== after[key][id]) {
        const entity = collection.find(x => x.id === id);
        if (entity) JZ_SYNC.enqueueChange({ type, id, parentId: entity.parentId, payload: entity });
      }
    }
    for (const id in before[key]) {
      if (!(id in after[key])) JZ_SYNC.enqueueDelete({ type, id, name: _nameFromSnap(before[key][id]) });
    }
  };
  _syncPaneCollection('cp', 'css_pane',       cssPanes);
  _syncPaneCollection('jp', 'js_pane',        jsPanes);
  _syncPaneCollection('jc', 'js_console',     jsConsoles);
  _syncPaneCollection('oc', 'output_console', outputConsoles);
}

// Write tag (od v2.13.27) — UUID-ish token wstawiany do każdego save'a z options.js.
// storage.onChanged listener porównuje newValue tagu z _lastWriteTag i jeśli match → wie
// że to nasz write (skip replacement + re-render, żeby DOM closures pozostały valid).
// JSON.stringify equality z v2.13.26 było zawodne (potencjalne edge cases w property
// ordering po structured-clone storage round-trip) — tag jest deterministyczny.
let _lastWriteTag = null;

async function saveAll(silent = false) {
  // Shadow legacy single-string pola — żeby starsze klienty w syncu widziały pierwszy wzorzec.
  folders.forEach(f => {
    if (Array.isArray(f.urlPatterns)) f.urlPattern = f.urlPatterns[0] || '';
  });
  _lastWriteTag = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
  await chrome.storage.local.set({
    folders, groups, rules, actionBars,
    cssPanes, jsPanes, jsConsoles, outputConsoles,
    schemaVersion: STORAGE_VERSION, nextActionSeq,
    _jzWriteTag: _lastWriteTag,
  });
  if (!silent) showToast(JZ_I18N.t('toast.saved'));
  notifyTabs();
  updateStorageQuota();
  // Sync diff: porównaj poprzedni snapshot z bieżącym i enqueue ops do JZ_SYNC.
  if (_syncSnapshot) {
    const after = _snapshotEntities();
    _diffAndEnqueue(_syncSnapshot, after);
    _syncSnapshot = after;
    // Trigger background tick — przez maybeTriggerSync (5s dedup), żeby przy
    // ciągłej edycji nie spamować requestami. Background dla 'after-save' robi
    // tylko push (nie pull) — alarm 60s zajmie się pullowaniem.
    if (typeof JZ_SYNC !== 'undefined' && JZ_SYNC.isAuthenticated() && JZ_SYNC.pendingCount() > 0) {
      maybeTriggerSync('after-save');
    }
  } else {
    // Pierwszy save od load — zapamiętaj baseline
    _syncSnapshot = _snapshotEntities();
  }
}

function updateVersionDisplay() {
  const el = document.getElementById('versionLine');
  if (!el) return;
  try {
    const v = chrome.runtime.getManifest().version;
    el.textContent = (typeof JZ_I18N !== 'undefined' ? JZ_I18N.t('settings.version.label') : 'v') + ' ' + v;
    el.style.cursor = 'pointer';
    el.title = JZ_I18N.t('settings.version.checkTitle');
    if (!el._jzClickBound) {
      el._jzClickBound = true;
      el.addEventListener('click', async () => {
        const orig = el.textContent;
        el.textContent = JZ_I18N.t('settings.version.checking');
        try { await checkForUpdate({ force: true }); }
        catch (_) {}
        el.textContent = orig;
      });
    }
  } catch (e) {}
}

// Sprawdza wersję wystawioną przez backend i jeśli nowsza od zainstalowanej —
// pokazuje banner z linkiem do ZIP-a w menu Ustawień. Cache 30 min jest po
// stronie sync.js (chrome.storage.local), invalidowany przy zmianie installedVersion.
async function checkForUpdate({ force = false } = {}) {
  const el = document.getElementById('versionUpdate');
  if (!el || typeof JZ_SYNC === 'undefined') return;
  let installed;
  try { installed = chrome.runtime.getManifest().version; } catch (e) { return; }
  let latest;
  try {
    latest = await JZ_SYNC.fetchExtensionVersion({ force });
  } catch (e) {
    console.warn('[JustZix updateCheck] fetch failed:', e.code || e.message);
    return;
  }
  if (!latest || !latest.version) {
    console.log('[JustZix updateCheck] backend nie zwrócił wersji (downloadUrl null = ZIP nie wystawiony)');
    return;
  }
  const cmp = JZ_SYNC.compareVersions(latest.version, installed);
  console.log('[JustZix updateCheck]', {
    installed,
    latest: latest.version,
    downloadUrl: latest.downloadUrl,
    fromCache: !!latest.fromCache,
    comparison: cmp === 1 ? 'newer available' : cmp === 0 ? 'up to date' : 'installed is ahead',
  });
  if (cmp <= 0) {
    el.classList.remove('visible');
    el.innerHTML = '';
    return;
  }
  // Nowsza wersja istnieje. Pokaż banner.
  const sizeKb = latest.downloadSize ? Math.round(latest.downloadSize / 1024) : null;
  const labelText = JZ_I18N.t('settings.update.available', { version: latest.version });
  let html = '';
  if (latest.downloadUrl) {
    const sizeStr = sizeKb ? ` <span class="vu-size">(${sizeKb} KB)</span>` : '';
    html = `<a href="${escapeAttr(latest.downloadUrl)}" target="_blank" rel="noopener">${escapeHtml(labelText)}</a>${sizeStr}`;
  } else {
    html = `${escapeHtml(labelText)} <span class="vu-size">${escapeHtml(JZ_I18N.t('settings.update.notReady'))}</span>`;
  }
  el.innerHTML = html;
  el.classList.add('visible');
}

async function updateStorageQuota() {
  try {
    const bytes = await chrome.storage.local.getBytesInUse(null);
    const quota = chrome.storage.local.QUOTA_BYTES || 10485760;
    const percent = (bytes / quota * 100);
    const el = document.getElementById('storageQuota');
    if (el) {
      const mb = (bytes / 1024 / 1024).toFixed(2);
      const mbMax = (quota / 1024 / 1024).toFixed(0);
      const text = `${mb} MB / ${mbMax} MB (${percent.toFixed(1)}%)`;
      const icon = (typeof JZ_ICONS !== 'undefined') ? JZ_ICONS.storage : '';
      el.innerHTML = `<span class="storage-icon">${icon}</span><span>${text}</span>`;
      el.style.color = percent > 80 ? '#CC3333' : (percent > 50 ? '#D65D0E' : '#888888');
    }
  } catch (e) {
    // ignore
  }
}

function debouncedSave() {
  if (saveTimer) clearTimeout(saveTimer);
  saveTimer = setTimeout(() => saveAll(true), 500);
}

let renderFoldersTimer = null;
function debouncedRenderFolders() {
  if (renderFoldersTimer) clearTimeout(renderFoldersTimer);
  renderFoldersTimer = setTimeout(() => renderFolders(), 300);
}

// Po toggle z sidebara zsynchronizuj DOM widocznego edytora z aktualnym state — bez pełnego
// re-rendera (CodeMirror by się reinicjował). Dotyka tylko checkboxów Aktywny/Włączony.
function syncOpenEditorAfterToggle() {
  // Karty zestawów (we wszystkich widokach: folder, grupa, pojedynczy zestaw)
  document.querySelectorAll('.rule-card[data-rule-id]').forEach(card => {
    const rule = rules.find(r => r.id === card.dataset.ruleId);
    if (!rule) return;
    const activeCb = card.querySelector('.rule-active');
    const enabledCb = card.querySelector('.rule-enabled');
    const enabledLabel = enabledCb && enabledCb.closest('.toggle-label');
    if (activeCb) activeCb.checked = rule.active === true;
    if (enabledCb) {
      enabledCb.checked = rule.enabled === true;
      enabledCb.disabled = rule.active === false;
    }
    if (enabledLabel) {
      enabledLabel.classList.toggle('locked', rule.active === false);
      enabledLabel.title = rule.active === false
        ? JZ_I18N.t('rule.editor.toggleLockedHint')
        : JZ_I18N.t('rule.editor.toggleHint');
    }
  });
  // Folder editor — checkboxy nagłówka katalogu
  if (selectedFolderId && !selectedGroupId && !selectedRuleId) {
    const folder = folders.find(f => f.id === selectedFolderId);
    if (folder) {
      const fActive = document.getElementById('folderActive');
      const fEnabled = document.getElementById('folderEnabled');
      if (fActive) fActive.checked = folder.active !== false;
      if (fEnabled) {
        fEnabled.checked = folder.active !== false && folder.enabled === true;
        fEnabled.disabled = folder.active === false;
        fEnabled.parentElement.classList.toggle('locked', folder.active === false);
      }
    }
  }
  // Group editor — checkboxy nagłówka grupy
  if (selectedGroupId && !selectedRuleId) {
    const group = groups.find(g => g.id === selectedGroupId);
    if (group) {
      const gActive = document.getElementById('groupActive');
      const gEnabled = document.getElementById('groupEnabled');
      if (gActive) gActive.checked = group.active !== false;
      if (gEnabled) {
        gEnabled.checked = group.active !== false && group.enabled === true;
        gEnabled.disabled = group.active === false;
        gEnabled.parentElement.classList.toggle('locked', group.active === false);
      }
    }
  }
}

async function notifyTabs() {
  const tabs = await chrome.tabs.query({});
  tabs.forEach(t => {
    chrome.tabs.sendMessage(t.id, { type: 'apply-rules' }).catch(() => {});
  });
}

function showToast(msg, isError = false) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.className = 'toast show' + (isError ? ' error' : '');
  setTimeout(() => t.classList.remove('show'), 1800);
}

// === Sidebar ===
function saveExpanded() {
  chrome.storage.local.set({ expandedFolders, expandedGroups });
}

// === Helpery hierarchii ===
function getGroupsForFolder(folderId) {
  return groups.filter(g => g.folderId === folderId);
}
function getRulesForGroup(groupId) {
  return rules.filter(r => r.groupId === groupId);
}
function getRulesForFolder(folderId) {
  const groupIds = new Set(getGroupsForFolder(folderId).map(g => g.id));
  return rules.filter(r => groupIds.has(r.groupId));
}
function getFolderForGroup(groupId) {
  const g = groups.find(gg => gg.id === groupId);
  return g ? folders.find(f => f.id === g.folderId) : null;
}
function getGroupForRule(ruleId) {
  const r = rules.find(rr => rr.id === ruleId);
  return r ? groups.find(g => g.id === r.groupId) : null;
}

// Drag & drop state
let dragData = null; // { type: 'folder'|'group'|'rule', id }

function getInsertPosition(e, element) {
  // Zwraca 'before' lub 'after' w zależności od pozycji kursora względem elementu
  const rect = element.getBoundingClientRect();
  const midY = rect.top + rect.height / 2;
  return e.clientY < midY ? 'before' : 'after';
}

function clearDropIndicators() {
  document.querySelectorAll('.drop-indicator-before, .drop-indicator-after').forEach(el => {
    el.classList.remove('drop-indicator-before', 'drop-indicator-after');
  });
}

function renderFolders() {
  const list = document.getElementById('foldersList');
  list.innerHTML = '';

  if (folders.length === 0) {
    list.innerHTML = `<div style="color:#9ca3af;font-size:12px;text-align:center;padding:20px;">${escapeHtml(JZ_I18N.t('sidebar.empty.folders'))}</div>`;
    return;
  }

  let visibleCount = 0;

  folders.forEach(folder => {
    const folderGroups = getGroupsForFolder(folder.id);
    const folderRules = getRulesForFolder(folder.id);

    // Filtr "tylko aktywne"
    if (filterActiveOnly) {
      const anyActiveRule = folderRules.some(r => r.active === true);
      if (!anyActiveRule) return;
    }

    // Search
    const search = searchMatches(folder, folderRules);
    if (!search.matches) return;
    visibleCount++;

    const isExpanded = !!expandedFolders[folder.id] || (searchQuery && search.matchedRuleIds && search.matchedRuleIds.size > 0);
    const isFolderSelected = folder.id === selectedFolderId && !selectedGroupId && !selectedRuleId;

    const folderItem = document.createElement('div');
    folderItem.className = 'folder-item' + (isFolderSelected ? ' selected' : '');
    folderItem.dataset.folderId = folder.id;
    folderItem.dataset.dropType = 'folder';
    folderItem.draggable = true;

    if (folder.color) {
      folderItem.style.boxShadow = `inset 3px 0 0 ${escapeAttr(folder.color)}`;
    }

    const folderActive = folder.active !== false;
    const hasChildren = folderRules.length > 0 || folderGroups.length > 0;
    const arrowSvg = !hasChildren ? JZ_ICONS.emptyDot : (isExpanded ? JZ_ICONS.expandDown : JZ_ICONS.expandRight);
    folderItem.innerHTML = `
      <span class="drag-handle" title="${escapeAttr(JZ_I18N.t('sidebar.dragHandle.title'))}">${JZ_ICONS.dragHandle}</span>
      <span class="folder-arrow ${!hasChildren ? 'empty' : ''}" title="${escapeAttr(JZ_I18N.t('sidebar.expandCollapse.title'))}">${arrowSvg}</span>
      <input type="checkbox" class="folder-toggle" ${folder.enabled ? 'checked' : ''} ${folder.active === false ? 'disabled' : ''} title="${escapeAttr(folder.active === false ? JZ_I18N.t('sidebar.folder.inactiveHint') : JZ_I18N.t('sidebar.folder.toggleHint'))}">
      <span class="folder-name" title="${escapeAttr(folder.name)}">${highlightSearch(folder.name || JZ_I18N.t('sidebar.fallback.unnamed'))}</span>
      <span class="folder-count">${folderRules.length}</span>
      <span class="level-active-badge ${folderActive ? 'on' : 'off'}" title="${escapeAttr(folderActive ? JZ_I18N.t('sidebar.folderActive.title') : JZ_I18N.t('sidebar.folderInactive.title'))}">${folderActive ? JZ_ICONS.active : JZ_ICONS.inactive}</span>
    `;

    attachFolderDnd(folderItem, folder);

    folderItem.querySelector('.folder-arrow').addEventListener('click', (e) => {
      e.stopPropagation();
      if (folderRules.length === 0 && folderGroups.length === 0) return;
      expandedFolders[folder.id] = !isExpanded;
      saveExpanded();
      renderFolders();
    });

    folderItem.addEventListener('click', (e) => {
      if (e.target.classList.contains('folder-toggle')) return;
      if (e.target.classList.contains('folder-arrow')) return;
      if (e.target.classList.contains('drag-handle')) return;
      const wasSelected = selectedFolderId === folder.id && !selectedGroupId && !selectedRuleId;
      selectedFolderId = folder.id;
      selectedGroupId = null;
      selectedRuleId = null;
      // Toggle rozwinięcia tylko gdy ten katalog był już zaznaczony — pierwszy klik
      // tylko zaznacza, drugi rozwija/zwija. Strzałka (.folder-arrow) zawsze toggluje.
      if (wasSelected && (folderRules.length > 0 || folderGroups.length > 0)) {
        expandedFolders[folder.id] = !isExpanded;
        saveExpanded();
      }
      // Czyść stan single-rule
      const ruleListWrap = document.getElementById('ruleEditorList');
      if (ruleListWrap) ruleListWrap.parentElement.classList.remove('single-rule');
      renderFolders();
      renderFolderEditor();
    });

    folderItem.querySelector('.folder-toggle').addEventListener('change', async (e) => {
      e.stopPropagation();
      const enable = e.target.checked;
      folder.enabled = enable;
      // Cascade DOWN tylko przy WYŁĄCZANIU (od v2.13.53) — spójne z popup widget'em i
      // browser action popup'em. Przy WŁĄCZANIU folderu zostawiamy groups/rules takie jakie były.
      if (!enable) {
        folderGroups.filter(g => g.active !== false).forEach(g => { g.enabled = false; });
        const fGroupIds = new Set(folderGroups.map(g => g.id));
        rules.filter(r => fGroupIds.has(r.groupId) && r.active).forEach(r => { r.enabled = false; });
      }
      await saveAll(true);
      renderFolders();
      syncOpenEditorAfterToggle();
    });

    list.appendChild(folderItem);

    if (!isExpanded) return;

    // Renderuj grupy
    folderGroups.forEach(group => {
      const groupRules = getRulesForGroup(group.id).filter(r => {
        if (filterActiveOnly && r.active !== true) return false;
        if (searchQuery && search.matchedRuleIds) {
          return search.matchedRuleIds.has(r.id);
        }
        return true;
      });
      // Filtr "tylko aktywne": pokaż grupę jeśli jest aktywna (nawet pustą), schowaj nieaktywne.
      // Brak rules po filtrze, ale grupa aktywna → wciąż widoczna (user może dodać zestaw).
      if (filterActiveOnly && group.active === false) return;
      // Search: ukryj grupę gdy szukamy w zestawach i żaden nie pasuje.
      if (searchQuery && search.matchedRuleIds && groupRules.length === 0) return;

      const isGroupExpanded = !!expandedGroups[group.id]
        || (searchQuery && groupRules.length > 0);
      const isGroupSelected = selectedGroupId === group.id && !selectedRuleId;

      // Każda grupa ma własny węzeł w drzewie — także pojedyncza grupa domyślna folderu.
      const groupItem = document.createElement('div');
      groupItem.className = 'group-item' + (isGroupSelected ? ' selected' : '');
      groupItem.dataset.groupId = group.id;
      groupItem.dataset.dropType = 'group';
      groupItem.draggable = true;

      const groupHasUrls = Array.isArray(group.urlPatterns) && group.urlPatterns.filter(p => p && p.trim()).length > 0;
      const groupActive = group.active !== false;
      const groupArrowSvg = groupRules.length === 0 ? JZ_ICONS.emptyDot : (isGroupExpanded ? JZ_ICONS.expandDown : JZ_ICONS.expandRight);
      groupItem.innerHTML = `
        <span class="drag-handle" title="${escapeAttr(JZ_I18N.t('sidebar.dragHandle.title'))}">${JZ_ICONS.dragHandle}</span>
        <span class="group-arrow ${groupRules.length === 0 ? 'empty' : ''}" title="${escapeAttr(JZ_I18N.t('sidebar.expandCollapse.title'))}">${groupArrowSvg}</span>
        <input type="checkbox" class="group-toggle" ${group.enabled !== false ? 'checked' : ''} ${group.active === false ? 'disabled' : ''} title="${escapeAttr(group.active === false ? JZ_I18N.t('sidebar.group.inactiveHint') : JZ_I18N.t('sidebar.group.toggleHint'))}">
        <span class="group-name" title="${escapeAttr(group.name)}">${highlightSearch(group.name || JZ_I18N.t('sidebar.fallback.unnamed'))}</span>
        ${groupHasUrls ? `<span class="rule-flag flag-url" title="${escapeAttr(JZ_I18N.t('sidebar.flag.groupUrls'))}">${JZ_ICONS.flagUrl}</span>` : ''}
        <span class="group-count">${groupRules.length}</span>
        <span class="level-active-badge ${groupActive ? 'on' : 'off'}" title="${escapeAttr(groupActive ? JZ_I18N.t('sidebar.groupActive.title') : JZ_I18N.t('sidebar.groupInactive.title'))}">${groupActive ? JZ_ICONS.active : JZ_ICONS.inactive}</span>
      `;

      attachGroupDnd(groupItem, group);

      groupItem.querySelector('.group-arrow').addEventListener('click', (e) => {
        e.stopPropagation();
        if (groupRules.length === 0) return;
        expandedGroups[group.id] = !isGroupExpanded;
        saveExpanded();
        renderFolders();
      });

      groupItem.addEventListener('click', (e) => {
        if (e.target.classList.contains('group-toggle')) return;
        if (e.target.classList.contains('group-arrow')) return;
        if (e.target.classList.contains('drag-handle')) return;
        const wasSelected = selectedGroupId === group.id && !selectedRuleId;
        selectedFolderId = folder.id;
        selectedGroupId = group.id;
        selectedRuleId = null;
        // Toggle rozwinięcia tylko gdy ta grupa była już zaznaczona — pierwszy klik
        // tylko zaznacza, drugi rozwija/zwija. Strzałka (.group-arrow) zawsze toggluje.
        if (wasSelected && groupRules.length > 0) {
          expandedGroups[group.id] = !isGroupExpanded;
          saveExpanded();
        }
        renderFolders();
        renderGroupEditor();
      });

      groupItem.querySelector('.group-toggle').addEventListener('change', async (e) => {
        e.stopPropagation();
        const enable = e.target.checked;
        group.enabled = enable;
        if (enable) {
          // Cascade UP (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: rules grupy off.
          rules.filter(r => r.groupId === group.id && r.active).forEach(r => { r.enabled = false; });
        }
        await saveAll(true);
        renderFolders();
        syncOpenEditorAfterToggle();
      });

      list.appendChild(groupItem);

      if (!isGroupExpanded) return;

      // Renderuj zestawy w grupie
      groupRules.forEach(rule => {
        const isRuleSelected = selectedRuleId === rule.id;
        const ruleItem = document.createElement('div');
        ruleItem.className = 'rule-item' + (isRuleSelected ? ' selected' : '');
        ruleItem.dataset.ruleId = rule.id;
        ruleItem.dataset.groupId = group.id;
        ruleItem.dataset.dropType = 'rule';
        ruleItem.draggable = true;

        const tagsHtml = (rule.tags || []).map(t =>
          `<span class="rule-tag">${escapeHtml(t)}</span>`
        ).join('');

        const hasCss = !!(rule.css && rule.css.trim());
        const hasJs = !!(rule.js && rule.js.trim());
        const hasActions = Array.isArray(rule.actions) && rule.actions.length > 0;
        const hasUrls = Array.isArray(rule.urlPatterns) && rule.urlPatterns.filter(p => p && p.trim()).length > 0;
        const flagsHtml =
          (hasCss ? `<span class="rule-flag flag-css" title="${escapeAttr(JZ_I18N.t('sidebar.flag.css'))}">${JZ_ICONS.flagCss}</span>` : '') +
          (hasJs ? `<span class="rule-flag flag-js" title="${escapeAttr(JZ_I18N.t('sidebar.flag.js'))}">${JZ_ICONS.flagJs}</span>` : '') +
          (hasActions ? `<span class="rule-flag flag-actions" title="${escapeAttr(JZ_I18N.t('sidebar.flag.actions'))}">${JZ_ICONS.flagActions}</span>` : '') +
          (hasUrls ? `<span class="rule-flag flag-url" title="${escapeAttr(JZ_I18N.t('sidebar.rule.urlHint'))}">${JZ_ICONS.flagUrl}</span>` : '');

        ruleItem.innerHTML = `
          <span class="drag-handle" title="${escapeAttr(JZ_I18N.t('sidebar.dragHandle.title'))}">${JZ_ICONS.dragHandle}</span>
          <input type="checkbox" class="rule-toggle" ${rule.enabled ? 'checked' : ''} title="${escapeAttr(JZ_I18N.t('rule.editor.toggleHint'))}">
          <span class="rule-item-name" title="${escapeAttr(rule.name)}">${highlightSearch(rule.name || JZ_I18N.t('sidebar.fallback.unnamed'))}${tagsHtml}</span>
          <span class="rule-item-flags">${flagsHtml}</span>
          <span class="rule-item-badge ${rule.active ? 'on' : 'off'}" title="${escapeAttr(rule.active ? JZ_I18N.t('sidebar.rule.activeInFolder') : JZ_I18N.t('sidebar.rule.inactiveInFolder'))}">${rule.active ? JZ_ICONS.active : JZ_ICONS.inactive}</span>
        `;

        attachRuleDnd(ruleItem, rule);

        ruleItem.addEventListener('click', (e) => {
          if (e.target.classList.contains('rule-toggle')) return;
          if (e.target.classList.contains('drag-handle')) return;
          selectedFolderId = folder.id;
          selectedGroupId = group.id;
          selectedRuleId = rule.id;
          renderFolders();
          renderRuleEditor();
        });

        ruleItem.querySelector('.rule-toggle').addEventListener('change', async (e) => {
          e.stopPropagation();
          const enable = e.target.checked;
          rule.enabled = enable;
          if (enable) {
            // Cascade UP (od v2.13.53): włącz parent group + 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 saveAll(true);
          renderFolders();
          syncOpenEditorAfterToggle();
        });

        list.appendChild(ruleItem);
      });
    });
  });

  // Komunikat gdy nic nie znaleziono
  if (visibleCount === 0 && (searchQuery || filterActiveOnly)) {
    const empty = document.createElement('div');
    empty.style.cssText = 'color:#9ca3af;font-size:12px;text-align:center;padding:20px;';
    empty.textContent = searchQuery
      ? JZ_I18N.t('sidebar.searchNoResults', { query: searchQuery })
      : JZ_I18N.t('sidebar.empty.activeFolders');
    list.appendChild(empty);
  }
}

// === DnD: handlery dla każdego typu elementu ===

function attachFolderDnd(folderItem, folder) {
  folderItem.addEventListener('dragstart', (e) => {
    dragData = { type: 'folder', id: folder.id };
    folderItem.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', folder.id);
  });
  folderItem.addEventListener('dragend', () => {
    folderItem.classList.remove('dragging');
    clearDropIndicators();
    dragData = null;
  });
  folderItem.addEventListener('dragover', (e) => {
    if (!dragData) return;
    e.preventDefault();
    e.dataTransfer.dropEffect = 'move';
    clearDropIndicators();
    if (dragData.type === 'folder' && dragData.id !== folder.id) {
      const pos = getInsertPosition(e, folderItem);
      folderItem.classList.add(pos === 'before' ? 'drop-indicator-before' : 'drop-indicator-after');
    } else if (dragData.type === 'group' || dragData.type === 'rule') {
      // Drop grupy lub zestawu na folder = przeniesienie do tego folderu (do domyślnej grupy / na koniec)
      folderItem.classList.add('drop-folder-target');
    }
  });
  folderItem.addEventListener('dragleave', () => {
    folderItem.classList.remove('drop-folder-target');
  });
  folderItem.addEventListener('drop', (e) => {
    if (!dragData) return;
    e.preventDefault();
    e.stopPropagation();
    folderItem.classList.remove('drop-folder-target');
    if (dragData.type === 'folder' && dragData.id !== folder.id) {
      const pos = getInsertPosition(e, folderItem);
      moveFolderTo(dragData.id, folder.id, pos);
    } else if (dragData.type === 'group') {
      moveGroupToFolder(dragData.id, folder.id);
    } else if (dragData.type === 'rule') {
      moveRuleToFolder(dragData.id, folder.id);
    }
    clearDropIndicators();
    dragData = null;
  });
}

function attachGroupDnd(groupItem, group) {
  groupItem.addEventListener('dragstart', (e) => {
    e.stopPropagation();
    dragData = { type: 'group', id: group.id };
    groupItem.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', group.id);
  });
  groupItem.addEventListener('dragend', () => {
    groupItem.classList.remove('dragging');
    clearDropIndicators();
    dragData = null;
  });
  groupItem.addEventListener('dragover', (e) => {
    if (!dragData) return;
    if (dragData.type === 'group' && dragData.id === group.id) return;
    e.preventDefault();
    e.stopPropagation();
    e.dataTransfer.dropEffect = 'move';
    clearDropIndicators();
    if (dragData.type === 'group') {
      const pos = getInsertPosition(e, groupItem);
      groupItem.classList.add(pos === 'before' ? 'drop-indicator-before' : 'drop-indicator-after');
    } else if (dragData.type === 'rule') {
      groupItem.classList.add('drop-folder-target'); // zielona obwódka = drop na grupę
    }
  });
  groupItem.addEventListener('dragleave', () => {
    groupItem.classList.remove('drop-folder-target');
  });
  groupItem.addEventListener('drop', (e) => {
    if (!dragData) return;
    e.preventDefault();
    e.stopPropagation();
    groupItem.classList.remove('drop-folder-target');
    if (dragData.type === 'group' && dragData.id !== group.id) {
      const pos = getInsertPosition(e, groupItem);
      moveGroupNextToGroup(dragData.id, group.id, pos);
    } else if (dragData.type === 'rule') {
      moveRuleToGroup(dragData.id, group.id);
    }
    clearDropIndicators();
    dragData = null;
  });
}

function attachRuleDnd(ruleItem, rule) {
  ruleItem.addEventListener('dragstart', (e) => {
    e.stopPropagation();
    dragData = { type: 'rule', id: rule.id };
    ruleItem.classList.add('dragging');
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', rule.id);
  });
  ruleItem.addEventListener('dragend', () => {
    ruleItem.classList.remove('dragging');
    clearDropIndicators();
    dragData = null;
  });
  ruleItem.addEventListener('dragover', (e) => {
    if (!dragData || dragData.type !== 'rule') return;
    if (dragData.id === rule.id) return;
    e.preventDefault();
    e.stopPropagation();
    e.dataTransfer.dropEffect = 'move';
    clearDropIndicators();
    const pos = getInsertPosition(e, ruleItem);
    ruleItem.classList.add(pos === 'before' ? 'drop-indicator-before' : 'drop-indicator-after');
  });
  ruleItem.addEventListener('drop', (e) => {
    if (!dragData || dragData.type !== 'rule') return;
    if (dragData.id === rule.id) return;
    e.preventDefault();
    e.stopPropagation();
    const pos = getInsertPosition(e, ruleItem);
    moveRuleNextToRule(dragData.id, rule.id, pos);
    clearDropIndicators();
    dragData = null;
  });
}

function highlightSearch(text) {
  if (!searchQuery) return escapeHtml(text);
  const escaped = escapeHtml(text);
  const q = searchQuery.toLowerCase();
  const idx = escaped.toLowerCase().indexOf(q);
  if (idx === -1) return escaped;
  return escaped.slice(0, idx) +
    '<mark class="search-match">' + escaped.slice(idx, idx + searchQuery.length) + '</mark>' +
    escaped.slice(idx + searchQuery.length);
}

// === Funkcje przesuwania ===
function moveFolderTo(sourceFolderId, targetFolderId, position) {
  const sourceIdx = folders.findIndex(f => f.id === sourceFolderId);
  if (sourceIdx === -1) return;
  const [item] = folders.splice(sourceIdx, 1);

  // Po splice indeksy mogły się przesunąć - znajdź target ponownie
  let targetIdx = folders.findIndex(f => f.id === targetFolderId);
  if (targetIdx === -1) {
    folders.push(item); // fallback
  } else {
    if (position === 'after') targetIdx++;
    folders.splice(targetIdx, 0, item);
  }
  saveAll(true);
  renderFolders();
}

function moveRuleNextToRule(sourceRuleId, targetRuleId, position) {
  const sourceIdx = rules.findIndex(r => r.id === sourceRuleId);
  const target = rules.find(r => r.id === targetRuleId);
  if (sourceIdx === -1 || !target) return;

  const [item] = rules.splice(sourceIdx, 1);
  // Przenieś do grupy docelowego zestawu
  item.groupId = target.groupId;

  let targetIdx = rules.findIndex(r => r.id === targetRuleId);
  if (targetIdx === -1) {
    rules.push(item);
  } else {
    if (position === 'after') targetIdx++;
    rules.splice(targetIdx, 0, item);
  }

  if (selectedRuleId === sourceRuleId) {
    const targetGroup = groups.find(g => g.id === target.groupId);
    if (targetGroup) {
      selectedFolderId = targetGroup.folderId;
      expandedFolders[targetGroup.folderId] = true;
      expandedGroups[targetGroup.id] = true;
      saveExpanded();
    }
  }

  saveAll(true);
  renderFolders();
  renderFolderEditor();
}

function moveRuleToGroup(sourceRuleId, targetGroupId) {
  const sourceIdx = rules.findIndex(r => r.id === sourceRuleId);
  if (sourceIdx === -1) return;
  const item = rules[sourceIdx];
  if (item.groupId === targetGroupId) return;

  rules.splice(sourceIdx, 1);
  item.groupId = targetGroupId;

  // Wstaw na końcu listy zestawów docelowej grupy
  let lastIdxInTarget = -1;
  for (let i = rules.length - 1; i >= 0; i--) {
    if (rules[i].groupId === targetGroupId) { lastIdxInTarget = i; break; }
  }
  if (lastIdxInTarget === -1) {
    rules.push(item);
  } else {
    rules.splice(lastIdxInTarget + 1, 0, item);
  }

  const targetGroup = groups.find(g => g.id === targetGroupId);
  if (targetGroup) {
    if (selectedRuleId === sourceRuleId) selectedFolderId = targetGroup.folderId;
    expandedFolders[targetGroup.folderId] = true;
    expandedGroups[targetGroup.id] = true;
    saveExpanded();
  }
  saveAll(true);
  renderFolders();
  renderFolderEditor();
}

function moveRuleToFolder(sourceRuleId, targetFolderId) {
  // Drop zestawu na folder = przeniesienie do pierwszej grupy folderu (lub utworzenie domyślnej, jeśli brak).
  let firstGroup = groups.find(g => g.folderId === targetFolderId);
  if (!firstGroup) {
    firstGroup = { id: genId('g'), folderId: targetFolderId, name: JZ_I18N.t('default.groupName'), enabled: true };
    groups.push(firstGroup);
  }
  moveRuleToGroup(sourceRuleId, firstGroup.id);
}

function moveGroupNextToGroup(sourceGroupId, targetGroupId, position) {
  const sourceIdx = groups.findIndex(g => g.id === sourceGroupId);
  const target = groups.find(g => g.id === targetGroupId);
  if (sourceIdx === -1 || !target) return;

  const [item] = groups.splice(sourceIdx, 1);
  // Drop na grupę z innego folderu = zmiana folderu źródłowej grupy
  item.folderId = target.folderId;

  let targetIdx = groups.findIndex(g => g.id === targetGroupId);
  if (targetIdx === -1) {
    groups.push(item);
  } else {
    if (position === 'after') targetIdx++;
    groups.splice(targetIdx, 0, item);
  }

  if (selectedGroupId === sourceGroupId) selectedFolderId = item.folderId;
  expandedFolders[item.folderId] = true;
  saveExpanded();
  saveAll(true);
  renderFolders();
  if (selectedGroupId === item.id) renderGroupEditor();
  else if (selectedFolderId === item.folderId && !selectedGroupId && !selectedRuleId) renderFolderEditor();
}

function moveGroupToFolder(sourceGroupId, targetFolderId) {
  const item = groups.find(g => g.id === sourceGroupId);
  if (!item) return;
  if (item.folderId === targetFolderId) return;
  item.folderId = targetFolderId;

  // Wstaw na końcu listy grup docelowego folderu
  const sourceIdx = groups.findIndex(g => g.id === sourceGroupId);
  groups.splice(sourceIdx, 1);
  let lastIdxInTarget = -1;
  for (let i = groups.length - 1; i >= 0; i--) {
    if (groups[i].folderId === targetFolderId) { lastIdxInTarget = i; break; }
  }
  if (lastIdxInTarget === -1) {
    groups.push(item);
  } else {
    groups.splice(lastIdxInTarget + 1, 0, item);
  }

  if (selectedGroupId === sourceGroupId) selectedFolderId = targetFolderId;
  expandedFolders[targetFolderId] = true;
  saveExpanded();
  saveAll(true);
  renderFolders();
  if (selectedGroupId === item.id) renderGroupEditor();
}

function showEmptyState() {
  document.getElementById('folderEditor').style.display = 'none';
  document.getElementById('groupEditor').style.display = 'none';
  document.getElementById('ruleEditor').style.display = 'none';
  document.getElementById('dashboardView').style.display = 'none';
  document.getElementById('emptyState').style.display = 'block';
}

// === Dashboard — strona główna panelu edycji (od v2.13.148) ===
// Zastępuje domyślne auto-zaznaczenie folders[0]: po wejściu w panel edycji
// (oraz po kliknięciu logo) pokazuje pulpit z podsumowaniem biblioteki, stanem
// wersji, zużyciem pamięci, ostatnim backupem, ostatnio edytowanymi zestawami,
// szybkimi akcjami i losową wskazówką z instrukcji.
const JZ_DASH_TIP_COUNT = 60;
let _dashTipIndex = Math.floor(Math.random() * JZ_DASH_TIP_COUNT);
let _dashBackupCache; // undefined = jeszcze nie pobrano; null = brak/błąd; obj = najnowszy backup

function jzComputeDashStats() {
  let actions = 0, clicks = 0, withCss = 0, withJs = 0;
  const byType = { button: 0, select: 0, input: 0, slider: 0, textarea: 0, toggle3: 0 };
  // Liczymy tylko encje OSIĄGALNE w drzewie: grupa musi mieć istniejący folder,
  // zestaw — istniejącą grupę. Bez tego encja-sierota (np. groupId zestawu
  // wskazuje skasowaną grupę) była liczona na pulpicie, choć drzewo jej nie
  // pokazuje — pulpit pokazywał inną liczbę zestawów niż drzewo.
  const folderIds = new Set(folders.map(f => f.id));
  const groupById = new Map(groups.map(g => [g.id, g]));
  const reachableGroups = groups.filter(g => folderIds.has(g.folderId));
  const reachableRules = rules.filter(r => {
    const g = groupById.get(r.groupId);
    return g && folderIds.has(g.folderId);
  });
  reachableRules.forEach(r => {
    if ((r.css || '').trim()) withCss++;
    if ((r.js || '').trim()) withJs++;
    (r.actions || []).forEach(a => {
      actions++;
      const ty = a.type || 'button';
      if (byType[ty] !== undefined) byType[ty]++;
      if (typeof a.clickCount === 'number' && a.clickCount > 0) clicks += a.clickCount;
    });
  });
  // Paski/okna — osiągalne, gdy ich rodzic (parentType/parentId) jest osiągalny.
  // Spójnie z fetchServerCounts (Cloud library) i _appendSyncHistory — bez tego
  // pulpit liczył paski/okna-sieroty, a chmura nie, i liczby się rozjeżdżały.
  const reachGroupIds = new Set(reachableGroups.map(g => g.id));
  const reachRuleIds = new Set(reachableRules.map(r => r.id));
  const reachParent = (pType, pId) => {
    if (!pId) return false;
    if (pType === 'folder') return folderIds.has(pId);
    if (pType === 'group') return reachGroupIds.has(pId);
    if (pType === 'rule') return reachRuleIds.has(pId);
    return false;
  };
  const reachWin = (x) => reachParent(x && x.parentType, x && x.parentId);
  return {
    folders: folders.length, groups: reachableGroups.length, rules: reachableRules.length,
    actions, byType, clicks, withCss, withJs,
    bars: actionBars.filter(reachWin).length,
    windows: cssPanes.filter(reachWin).length + jsPanes.filter(reachWin).length
           + jsConsoles.filter(reachWin).length + outputConsoles.filter(reachWin).length,
  };
}

function renderDashboard() {
  ['folderEditor', 'groupEditor', 'ruleEditor', 'emptyState'].forEach(id => {
    const el = document.getElementById(id);
    if (el) el.style.display = 'none';
  });
  const view = document.getElementById('dashboardView');
  if (!view) return;
  view.style.display = 'block';

  const t = (k, v) => JZ_I18N.t(k, v);
  const s = jzComputeDashStats();

  const statTile = (val, label) =>
    `<div class="jz-dash-stat"><span class="jz-dash-stat-v">${val}</span>` +
    `<span class="jz-dash-stat-l">${escapeHtml(label)}</span></div>`;

  // Bez updatedAt traktujemy jako 0 (zestawy sprzed wprowadzenia śledzenia
  // updatedAt). Wcześniejszy filtr je całkowicie pomijał — nie pokazywały się
  // na pulpicie nawet, gdy były jedyną zawartością biblioteki.
  const recent = rules.slice()
    .sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0))
    .slice(0, 6);
  let recentHtml;
  if (recent.length === 0) {
    recentHtml = `<div class="jz-dash-muted">${escapeHtml(t('dashboard.recent.empty'))}</div>`;
  } else {
    recentHtml = recent.map(r => {
      const g = groups.find(gg => gg.id === r.groupId);
      const f = g ? folders.find(ff => ff.id === g.folderId) : null;
      const path = (f && g) ? escapeHtml(f.name) + ' / ' + escapeHtml(g.name) : '';
      return `<button type="button" class="jz-dash-recent" data-jz-dash-rule="${escapeAttr(r.id)}">` +
        `<span class="jz-dash-recent-name">${escapeHtml(r.name || '—')}</span>` +
        `<span class="jz-dash-recent-path">${path}</span></button>`;
    }).join('');
  }

  view.innerHTML = `
    <div class="jz-dash">
      <div class="jz-dash-head">
        <button type="button" class="jz-dash-home" title="${escapeAttr(t('dashboard.home.title'))}">${JZ_ICONS.home}</button>
        <h2>${escapeHtml(t('dashboard.title'))}</h2>
        <p class="jz-dash-sub">${escapeHtml(t('dashboard.subtitle'))}</p>
      </div>

      <div class="jz-dash-row">
        <div class="jz-dash-card" style="flex:2;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.stats.title'))}</div>
          <div class="jz-dash-stats">
            ${statTile(s.folders, t('dashboard.stats.folders'))}
            ${statTile(s.groups, t('dashboard.stats.groups'))}
            ${statTile(s.rules, t('dashboard.stats.rules'))}
            ${statTile(s.actions, t('dashboard.stats.actions'))}
            ${statTile(s.bars, t('dashboard.stats.bars'))}
            ${statTile(s.windows, t('dashboard.stats.windows'))}
            ${statTile(s.withCss, t('dashboard.stats.withCss'))}
            ${statTile(s.withJs, t('dashboard.stats.withJs'))}
          </div>
        </div>
        <div class="jz-dash-card" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.usage.title'))}</div>
          <div id="jzDashUsage" class="jz-dash-muted">…</div>
        </div>
      </div>

      <div class="jz-dash-row">
        <div class="jz-dash-card" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.recent.title'))}</div>
          <div class="jz-dash-recent-list">${recentHtml}</div>
        </div>
      </div>

      <div class="jz-dash-row">
        <div class="jz-dash-card" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.tip.title'))}</div>
          <div class="jz-dash-tip-body">
            <div id="jzDashTip" class="jz-dash-tip-text">${escapeHtml(t('tip.dash.' + (_dashTipIndex + 1)))}</div>
            <div class="jz-dash-tip-actions">
              <button type="button" class="jz-dash-tip-btn" data-jz-dash-tip-prev>${escapeHtml(t('dashboard.tip.prev'))}</button>
              <button type="button" class="jz-dash-tip-btn" data-jz-dash-tip-next>${escapeHtml(t('dashboard.tip.next'))}</button>
              <a href="#" data-jz-dash-help>${escapeHtml(t('dashboard.tip.manual'))}</a>
            </div>
          </div>
        </div>
      </div>

      <div class="jz-dash-row">
        <div class="jz-dash-card" id="jzDashExamplesCard" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.examples.title'))}</div>
          <div id="jzDashExamples" class="jz-dash-feed-list jz-dash-muted">${escapeHtml(t('dashboard.feed.loading'))}</div>
        </div>
      </div>

      <div class="jz-dash-row">
        <div class="jz-dash-card" id="jzDashPostsCard" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.posts.title'))}</div>
          <div id="jzDashPosts" class="jz-dash-feed-list jz-dash-muted">${escapeHtml(t('dashboard.feed.loading'))}</div>
        </div>
      </div>

      <div class="jz-dash-row">
        <div class="jz-dash-card" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.version.title'))}</div>
          <div id="jzDashVersion" class="jz-dash-muted">${escapeHtml(t('dashboard.version.checking'))}</div>
        </div>
        <div class="jz-dash-card" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.storage.title'))}</div>
          <div id="jzDashStorage" class="jz-dash-muted">…</div>
        </div>
        <div class="jz-dash-card" style="flex:1;">
          <div class="jz-dash-card-t">${escapeHtml(t('dashboard.backup.title'))}</div>
          <div id="jzDashBackup" class="jz-dash-muted">…</div>
        </div>
      </div>
    </div>`;
  // Ikona „dom" w nagłówku pulpitu — odświeża dane (przeładowanie ze storage
  // + ponowne renderowanie pulpitu; czyści zaznaczenie, by wylądować na pulpicie).
  const dashHomeBtn = view.querySelector('.jz-dash-home');
  if (dashHomeBtn) dashHomeBtn.addEventListener('click', () => {
    selectedFolderId = null;
    selectedGroupId = null;
    selectedRuleId = null;
    load();
  });
  view.querySelectorAll('[data-jz-dash-rule]').forEach(btn => {
    btn.addEventListener('click', () => {
      const r = rules.find(x => x.id === btn.getAttribute('data-jz-dash-rule'));
      if (!r) return;
      const g = groups.find(gg => gg.id === r.groupId);
      if (!g) return;
      selectedFolderId = g.folderId;
      selectedGroupId = g.id;
      selectedRuleId = r.id;
      renderFolders();
      renderRuleEditor();
    });
  });
  const setTip = () => {
    const el = document.getElementById('jzDashTip');
    if (el) el.textContent = JZ_I18N.t('tip.dash.' + (_dashTipIndex + 1));
  };
  const tipNext = view.querySelector('[data-jz-dash-tip-next]');
  if (tipNext) tipNext.addEventListener('click', () => {
    _dashTipIndex = (_dashTipIndex + 1) % JZ_DASH_TIP_COUNT;
    setTip();
  });
  const tipPrev = view.querySelector('[data-jz-dash-tip-prev]');
  if (tipPrev) tipPrev.addEventListener('click', () => {
    _dashTipIndex = (_dashTipIndex - 1 + JZ_DASH_TIP_COUNT) % JZ_DASH_TIP_COUNT;
    setTip();
  });
  const helpLink = view.querySelector('[data-jz-dash-help]');
  if (helpLink) helpLink.addEventListener('click', (e) => {
    e.preventDefault();
    document.getElementById('helpBtn')?.click();
  });

  jzDashFillVersion();
  jzDashFillStorage();
  jzDashFillBackup();
  jzDashFillFeed();
  jzDashFillUsage();
}

async function jzDashFillUsage() {
  const el = document.getElementById('jzDashUsage');
  if (!el) return;
  let local = {}, remote = {};
  try {
    const data = await chrome.storage.local.get(['jzUsageStats', 'jzUsageStatsRemote']);
    local = data.jzUsageStats || {};
    remote = data.jzUsageStatsRemote || {};
  } catch (e) {}
  if (!document.getElementById('jzDashUsage')) return;
  let ownUuid = null;
  try { ownUuid = (JZ_SYNC.getState() || {}).deviceUuid || null; } catch (e) {}
  const clicks = jzComputeDashStats().clicks;
  // cssApplied / jsExecuted / windowsShown — suma wszystkich urządzeń: lokalne
  // jzUsageStats + zdalne jzUsageStatsRemote (z pominięciem własnego deviceUuid,
  // bo lokalny licznik jest świeższy niż ostatnio wypchnięta encja usage_stats).
  let css = local.cssApplied || 0;
  let js = local.jsExecuted || 0;
  let win = local.windowsShown || 0;
  for (const [uuid, rs] of Object.entries(remote)) {
    if (uuid === ownUuid || !rs || typeof rs !== 'object') continue;
    css += rs.cssApplied || 0;
    js += rs.jsExecuted || 0;
    win += rs.windowsShown || 0;
  }
  if (!clicks && !css && !js && !win) {
    el.textContent = JZ_I18N.t('dashboard.usage.empty');
    return;
  }
  const tile = (v, l) =>
    `<div class="jz-dash-stat"><span class="jz-dash-stat-v">${v}</span>` +
    `<span class="jz-dash-stat-l">${escapeHtml(l)}</span></div>`;
  el.classList.remove('jz-dash-muted');
  el.innerHTML =
    `<div class="jz-dash-stats">` +
    tile(clicks, JZ_I18N.t('dashboard.stats.clicks')) +
    tile(css, JZ_I18N.t('dashboard.usage.cssApplied')) +
    tile(js, JZ_I18N.t('dashboard.usage.jsExecuted')) +
    tile(win, JZ_I18N.t('dashboard.usage.windowsShown')) +
    `</div>`;
}

async function jzDashFillFeed() {
  const exCard = document.getElementById('jzDashExamplesCard');
  const poCard = document.getElementById('jzDashPostsCard');
  if (!exCard && !poCard) return;
  let feed = null;
  try {
    if (typeof JZ_SYNC !== 'undefined') {
      const lang = (typeof JZ_I18N !== 'undefined' && JZ_I18N.lang) || 'en';
      feed = await JZ_SYNC.fetchHomeFeed({ lang, limit: 5 });
    }
  } catch (e) {}
  const exCard2 = document.getElementById('jzDashExamplesCard');
  const poCard2 = document.getElementById('jzDashPostsCard');
  if (!exCard2 && !poCard2) return;
  if (!feed) {
    if (exCard2) exCard2.style.display = 'none';
    if (poCard2) poCard2.style.display = 'none';
    return;
  }
  const fmtDate = (iso) => {
    try { return new Date(iso).toLocaleDateString(); } catch (e) { return ''; }
  };
  const renderList = (items, term, metaFn) => {
    if (!Array.isArray(items) || items.length === 0) {
      return `<div class="jz-dash-muted">${escapeHtml(JZ_I18N.t('dashboard.feed.empty'))}</div>`;
    }
    return items.map(it => {
      const url = jzAppendUtm(it.url || '', term);
      const meta = metaFn(it);
      return `<a class="jz-dash-feed-item" href="${escapeAttr(url)}" target="_blank" rel="noopener">` +
        `<span class="jz-dash-feed-title">${escapeHtml(it.title || '—')}</span>` +
        (it.excerpt ? `<span class="jz-dash-feed-excerpt">${escapeHtml(it.excerpt)}</span>` : '') +
        (meta ? `<span class="jz-dash-feed-meta">${escapeHtml(meta)}</span>` : '') +
        `</a>`;
    }).join('');
  };
  const exEl = document.getElementById('jzDashExamples');
  if (exEl) {
    exEl.classList.remove('jz-dash-muted');
    exEl.innerHTML = renderList(feed.examples, 'examples', it => {
      const parts = [];
      if (it.category) parts.push(it.category);
      const d = fmtDate(it.publishedAt);
      if (d) parts.push(d);
      return parts.join(' · ');
    });
  }
  const poEl = document.getElementById('jzDashPosts');
  if (poEl) {
    poEl.classList.remove('jz-dash-muted');
    poEl.innerHTML = renderList(feed.posts, 'blog', it => fmtDate(it.publishedAt));
  }
}

async function jzDashFillVersion() {
  const el = document.getElementById('jzDashVersion');
  if (!el) return;
  let installed = '';
  try { installed = chrome.runtime.getManifest().version; } catch (e) {}
  let latest = null;
  // Wejście na pulpit ma sprawdzić wersję na świeżo (force) — bez tego kafelek
  // pokazywałby zbuforowaną odpowiedź aż do wygaśnięcia TTL. Gdy świeży fetch
  // padnie (offline), spadamy na cache, żeby nie kłamać „aktualna".
  try {
    if (typeof JZ_SYNC !== 'undefined') latest = await JZ_SYNC.fetchExtensionVersion({ force: true });
  } catch (e) {
    try {
      if (typeof JZ_SYNC !== 'undefined') latest = await JZ_SYNC.fetchExtensionVersion({});
    } catch (e2) {}
  }
  if (!document.getElementById('jzDashVersion')) return; // dashboard już zniknął
  const cur = JZ_I18N.t('dashboard.version.current', { version: installed });
  if (latest && latest.version && typeof JZ_SYNC !== 'undefined' &&
      JZ_SYNC.compareVersions(latest.version, installed) === 1) {
    const label = JZ_I18N.t('dashboard.version.outdated', { version: latest.version });
    const inner = latest.downloadUrl
      ? `<a href="${escapeAttr(latest.downloadUrl)}" target="_blank" rel="noopener">${escapeHtml(label)}</a>`
      : escapeHtml(label);
    el.innerHTML = `<div>${escapeHtml(cur)}</div><div class="jz-dash-warn">${inner}</div>`;
  } else {
    el.innerHTML = `<div>${escapeHtml(cur)}</div><div class="jz-dash-ok">${escapeHtml(JZ_I18N.t('dashboard.version.upToDate'))}</div>`;
  }
}

async function jzDashFillStorage() {
  const el = document.getElementById('jzDashStorage');
  if (!el) return;
  try {
    const bytes = await chrome.storage.local.getBytesInUse(null);
    const quota = chrome.storage.local.QUOTA_BYTES || 10485760;
    const pct = Math.min(100, bytes / quota * 100);
    if (!document.getElementById('jzDashStorage')) return;
    const mb = (bytes / 1048576).toFixed(2);
    const mbMax = (quota / 1048576).toFixed(0);
    const barColor = pct > 80 ? '#CC3333' : (pct > 50 ? '#D65D0E' : '#2D9D53');
    el.innerHTML =
      `<div class="jz-dash-bar"><div class="jz-dash-bar-fill" style="width:${pct.toFixed(1)}%;background:${barColor};"></div></div>` +
      `<div class="jz-dash-muted">${mb} MB / ${mbMax} MB · ${pct.toFixed(1)}%</div>`;
  } catch (e) {
    el.textContent = '—';
  }
}

async function jzDashFillBackup() {
  const el = document.getElementById('jzDashBackup');
  if (!el) return;
  if (typeof JZ_SYNC === 'undefined' || !JZ_SYNC.isAuthenticated()) {
    el.textContent = JZ_I18N.t('dashboard.backup.signIn');
    return;
  }
  if (_dashBackupCache === undefined) {
    try {
      const data = await JZ_SYNC.listBackups();
      const list = (data && Array.isArray(data.backups)) ? data.backups : [];
      _dashBackupCache = list.length
        ? list.reduce((a, b) => (b.createdAt > a.createdAt ? b : a))
        : null;
    } catch (e) {
      _dashBackupCache = null;
    }
  }
  if (!document.getElementById('jzDashBackup')) return;
  if (_dashBackupCache) {
    const b = _dashBackupCache;
    let when = '';
    try {
      // createdAt z backendu bywa uniksowym timestampem w SEKUNDACH — Date() oczekuje
      // milisekund, więc wartość < 1e12 (czyli sprzed 2001 r. w ms) traktujemy jako sekundy.
      const raw = b.createdAt;
      const ms = (typeof raw === 'number' && raw < 1e12) ? raw * 1000 : raw;
      when = new Date(ms).toLocaleString();
    } catch (e) {}
    const lbl = b.label ? escapeHtml(b.label) : escapeHtml(JZ_I18N.t('dashboard.backup.noLabel'));
    el.innerHTML = `<div>${escapeHtml(JZ_I18N.t('dashboard.backup.last', { date: when }))}</div>` +
      `<div class="jz-dash-muted">${lbl}</div>`;
  } else {
    el.textContent = JZ_I18N.t('dashboard.backup.none');
  }
}

// === Group editor ===
function renderGroupEditor() {
  const group = groups.find(g => g.id === selectedGroupId);
  if (!group) { renderFolderEditor(); return; }
  const folder = folders.find(f => f.id === group.folderId);
  if (!folder) { showEmptyState(); return; }

  selectedFolderId = folder.id;
  document.getElementById('emptyState').style.display = 'none';
  document.getElementById('dashboardView').style.display = 'none';
  document.getElementById('folderEditor').style.display = 'none';
  document.getElementById('ruleEditor').style.display = 'none';
  document.getElementById('groupEditor').style.display = 'block';

  // Breadcrumb na poziomie grupy (od v2.12.5 — spójność z rule level): [folder-link] / [group-bold]
  const breadcrumb = document.getElementById('groupBreadcrumb');
  breadcrumb.innerHTML = '';
  const folderLink = document.createElement('a');
  folderLink.href = '#';
  folderLink.className = 'breadcrumb-link';
  folderLink.textContent = folder.name || JZ_I18N.t('sidebar.fallback.folderName');
  folderLink.addEventListener('click', (e) => {
    e.preventDefault();
    selectedRuleId = null;
    selectedGroupId = null;
    renderFolders();
    renderFolderEditor();
  });
  breadcrumb.appendChild(folderLink);
  breadcrumb.appendChild(document.createTextNode('  /  '));
  const groupSpan = document.createElement('strong');
  groupSpan.className = 'breadcrumb-current';
  groupSpan.textContent = group.name || JZ_I18N.t('sidebar.fallback.groupName');
  breadcrumb.appendChild(groupSpan);
  const nameInput = document.getElementById('groupName');
  const activeCb = document.getElementById('groupActive');
  const enabledCb = document.getElementById('groupEnabled');
  nameInput.value = group.name || '';
  activeCb.checked = group.active !== false;
  enabledCb.checked = group.active !== false && group.enabled === true;
  enabledCb.disabled = group.active === false;
  enabledCb.parentElement.classList.toggle('locked', group.active === false);

  nameInput.oninput = () => {
    group.name = nameInput.value;
    // Live-update breadcrumb (current group span jest ostatni .breadcrumb-current).
    const cur = document.querySelector('#groupBreadcrumb .breadcrumb-current');
    if (cur) cur.textContent = group.name || JZ_I18N.t('sidebar.fallback.groupName');
    debouncedSave();
    debouncedRenderFolders();
  };
  activeCb.onchange = async () => {
    group.active = activeCb.checked;
    // Pełna kaskada active+enabled na zestawy w grupie. Reaktywacja przywraca wszystkim
    // active+enabled (zamierzona semantyka — symetryczna do deaktywacji).
    const newState = activeCb.checked;
    group.enabled = newState;
    rules.filter(r => r.groupId === group.id).forEach(r => { r.active = newState; r.enabled = newState; });
    enabledCb.checked = group.active !== false && group.enabled === true;
    enabledCb.disabled = group.active === false;
    enabledCb.parentElement.classList.toggle('locked', group.active === false);
    await saveAll(true);
    renderFolders();
    syncOpenEditorAfterToggle();
  };
  enabledCb.onchange = async () => {
    if (group.active === false) return;
    const enable = enabledCb.checked;
    group.enabled = enable;
    if (enable) {
      // Cascade UP (od v2.13.53): włącz parent folder.
      const parentFolder = folders.find(f => f.id === group.folderId);
      if (parentFolder) parentFolder.enabled = true;
    } else {
      rules.filter(r => r.groupId === group.id && r.active).forEach(r => { r.enabled = false; });
    }
    await saveAll(true);
    renderFolders();
    syncOpenEditorAfterToggle();
  };

  // Wzorce URL grupy
  if (!Array.isArray(group.urlPatterns)) group.urlPatterns = [];
  const gChips = document.getElementById('groupUrlsChips');
  const gInput = document.getElementById('groupUrlsInput');
  const renderGroupUrlChips = () => {
    gChips.innerHTML = group.urlPatterns.map((p, i) =>
      `<span class="rule-url-chip"><code>${escapeHtml(p)}</code><button data-idx="${i}" title="${escapeAttr(JZ_I18N.t('btn.removeUrlChip'))}">${JZ_ICONS.close}</button></span>`
    ).join('');
    gChips.querySelectorAll('button[data-idx]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.preventDefault();
        const idx = parseInt(btn.dataset.idx);
        group.urlPatterns.splice(idx, 1);
        renderGroupUrlChips();
        debouncedSave();
        debouncedRenderFolders();
      });
    });
  };
  renderGroupUrlChips();
  // Klonujemy listenery przez przypisanie .onkeydown — żeby nie kumulować ich przy każdym renderGroupEditor.
  gInput.onkeydown = (e) => {
    if (e.key === 'Tab') {
      // Autouzupełnienie z katalogu — wstaw pierwszy wzorzec katalogu z kursorem przed `*`.
      const folderPatterns = getFolderPatterns(folder);
      const base = folderPatterns[0] || '';
      if (!base) return;
      e.preventDefault();
      gInput.value = base;
      const star = base.lastIndexOf('*');
      const pos = star > -1 ? star : base.length;
      gInput.setSelectionRange(pos, pos);
      return;
    }
    if (e.key === 'Enter') {
      e.preventDefault();
      const v = gInput.value.trim();
      if (!v) return;
      if (!group.urlPatterns.includes(v)) {
        group.urlPatterns.push(v);
        renderGroupUrlChips();
        saveAll(true);
        debouncedRenderFolders();
      }
      gInput.value = '';
    }
  };
  gInput.onblur = () => {
    const v = gInput.value.trim();
    if (!v) return;
    if (!group.urlPatterns.includes(v)) {
      group.urlPatterns.push(v);
      renderGroupUrlChips();
      saveAll(true);
      debouncedRenderFolders();
    }
    gInput.value = '';
  };

  document.getElementById('deleteGroupBtn').onclick = () => {
    const groupRules = rules.filter(r => r.groupId === group.id);
    const folderGroups = groups.filter(g => g.folderId === folder.id);
    if (folderGroups.length === 1) {
      showToast(JZ_I18N.t('toast.cantDeleteLastGroup'), true);
      return;
    }
    const msg = groupRules.length > 0
      ? JZ_I18N.t('confirm.deleteGroupWithRules', {
          name: group.name,
          count: groupRules.length,
          word: JZ_I18N.plural(groupRules.length, 'plural.rule'),
        })
      : JZ_I18N.t('confirm.deleteGroup', { name: group.name });
    if (!confirm(msg)) return;

    const deletedIds = new Set([group.id, ...groupRules.map(r => r.id)]);
    rules = rules.filter(r => r.groupId !== group.id);
    groups = groups.filter(g => g.id !== group.id);
    pruneOrphanWindows(deletedIds);
    selectedGroupId = null;
    selectedRuleId = null;
    saveAll();
    renderFolders();
    renderFolderEditor();
  };

  document.getElementById('duplicateGroupBtn').onclick = () => {
    // Deep-duplikacja całego poddrzewa grupy: grupa (wszystkie pola, w tym
    // urlPatterns/active), jej zestawy, paski akcji oraz okna (CSS/JS pane,
    // JS/Output Console) przypięte do grupy LUB jej zestawów. Mapy starych→nowych
    // id pozwalają przemapować parentId pasków/okien i action.barId akcji.
    const copy = JSON.parse(JSON.stringify(group));
    copy.id = genId('g');
    copy.name = (group.name || 'Grupa') + ' (kopia)';
    const idx = groups.findIndex(g => g.id === group.id);
    groups.splice(idx + 1, 0, copy);

    const groupRules = rules.filter(r => r.groupId === group.id);
    const ruleIdMap = new Map();  // stary ruleId → nowy ruleId
    const barIdMap = new Map();   // stary barId  → nowy barId

    // Zestawy — nowe id, nowe id akcji, wyczyszczona historia.
    const newRules = groupRules.map(r => {
      const rcopy = JSON.parse(JSON.stringify(r));
      rcopy.id = genId('r');
      rcopy.groupId = copy.id;
      rcopy.history = [];
      ruleIdMap.set(r.id, rcopy.id);
      if (Array.isArray(rcopy.actions)) rcopy.actions.forEach(a => { a.id = genId('a'); a.clickCount = 0; });
      return rcopy;
    });

    // Przemapowanie parentId encji z poddrzewa; null = encja spoza poddrzewa.
    const remapParent = (item) => {
      if (item.parentType === 'group' && item.parentId === group.id) return copy.id;
      if (item.parentType === 'rule' && ruleIdMap.has(item.parentId)) return ruleIdMap.get(item.parentId);
      return null;
    };

    // Paski akcji przypięte do grupy lub jej zestawów.
    const newBars = [];
    actionBars.forEach(bar => {
      const newParent = remapParent(bar);
      if (newParent === null) return;
      const bcopy = JSON.parse(JSON.stringify(bar));
      bcopy.id = genId('b');
      bcopy.parentId = newParent;
      if (Array.isArray(bcopy.labels)) bcopy.labels.forEach(l => { l.id = genId('lbl'); });
      barIdMap.set(bar.id, bcopy.id);
      newBars.push(bcopy);
    });

    // action.barId w skopiowanych akcjach → nowe paski. Paski spoza poddrzewa
    // (np. paski folderu) zostają bez zmian — kopia zestawu jest w tym samym folderze.
    newRules.forEach(r => {
      if (Array.isArray(r.actions)) r.actions.forEach(a => {
        if (a.barId && barIdMap.has(a.barId)) a.barId = barIdMap.get(a.barId);
      });
    });

    // Okna — 4 typy, ta sama logika remap parentId.
    const dupPanes = (coll, prefix) => {
      const added = [];
      coll.forEach(p => {
        const newParent = remapParent(p);
        if (newParent === null) return;
        const pcopy = JSON.parse(JSON.stringify(p));
        pcopy.id = genId(prefix);
        pcopy.parentId = newParent;
        added.push(pcopy);
      });
      added.forEach(p => coll.push(p));
    };

    newRules.forEach(r => rules.push(r));
    newBars.forEach(b => actionBars.push(b));
    dupPanes(cssPanes, 'cp');
    dupPanes(jsPanes, 'jp');
    dupPanes(jsConsoles, 'jc');
    dupPanes(outputConsoles, 'oc');

    selectedGroupId = copy.id;
    expandedGroups[copy.id] = true;
    saveExpanded();
    saveAll();
    renderFolders();
    renderGroupEditor();
  };

  document.getElementById('moveGroupBtn').onclick = (e) => {
    e.stopPropagation();
    showGroupMoveMenu(group, e.target);
  };

  document.getElementById('addRuleInGroupBtn').onclick = () => {
    const newRule = {
      id: genId('r'),
      groupId: group.id,
      name: JZ_I18N.t('default.ruleName'),
      css: '',
      js: '',
      jsRunOnce: false,
      active: true,
      enabled: true,
      tags: [],
      urlPatterns: [],
      history: [],
      actions: []
    };
    rules.push(newRule);
    expandedFolders[folder.id] = true;
    expandedGroups[group.id] = true;
    saveExpanded();
    saveAll();
    renderFolders();
    renderGroupEditor();
  };

  document.getElementById('exportGroupBtn').onclick = () => {
    exportGroupToFile(group);
  };
  document.getElementById('importGroupBtn').onclick = () => {
    openGroupImport(group);
  };

  // Przycisk "Paski akcji (N)" — od v2.9. N = own + inherited (z folderu).
  const groupBarsBtn = document.getElementById('groupActionBarsBtn');
  if (groupBarsBtn) {
    const cnt = countAllBarsForContext('group', group.id);
    const cntEl = groupBarsBtn.querySelector('.action-bars-count');
    if (cntEl) cntEl.textContent = `(${cnt})`;
    groupBarsBtn.onclick = () => {
      openActionBarsModal({
        parentType: 'group',
        parentId: group.id,
        parentName: group.name || JZ_I18N.t('default.groupName'),
        // Tylko refresh licznika — pełen rerender resetowałby stan kart zestawów (fix v2.10.4).
        onClose: () => {
          if (cntEl) cntEl.textContent = `(${countAllBarsForContext('group', group.id)})`;
        },
      });
    };
  }

  // Przycisk "Okna (N) ▾" (od v2.13.41) — dropdown. Suma CSS + JS panes (od v2.13.56).
  const groupWinBtn = document.getElementById('groupWindowsBtn');
  if (groupWinBtn) {
    const wCnt = countAllCssPanesForContext('group', group.id)
               + countAllJsPanesForContext('group', group.id)
               + countAllJsConsolesForContext('group', group.id)
               + countAllOutputConsolesForContext('group', group.id);
    const wCntEl = groupWinBtn.querySelector('.windows-count');
    if (wCntEl) wCntEl.textContent = `(${wCnt})`;
    groupWinBtn.onclick = (e) => {
      e.stopPropagation();
      showWindowsMenu('group', group.id, group.name || JZ_I18N.t('default.groupName'), groupWinBtn, () => {
        if (wCntEl) wCntEl.textContent = `(${countAllCssPanesForContext('group', group.id) + countAllJsPanesForContext('group', group.id) + countAllJsConsolesForContext('group', group.id) + countAllOutputConsolesForContext('group', group.id)})`;
      });
    };
  }

  renderGroupRules(group);
}

function renderGroupRules(group) {
  const list = document.getElementById('groupRulesList');
  // Cleanup CodeMirror
  Object.values(editors).forEach(({ css, js }) => {
    try { css?.toTextArea(); } catch(e) {}
    try { js?.toTextArea(); } catch(e) {}
  });
  editors = {};
  const folderRulesList = document.getElementById('rulesList');
  if (folderRulesList) folderRulesList.innerHTML = '';
  const ruleEditorList = document.getElementById('ruleEditorList');
  if (ruleEditorList) ruleEditorList.innerHTML = '';
  list.innerHTML = '';

  const groupRules = rules.filter(r => r.groupId === group.id);
  if (groupRules.length === 0) {
    list.innerHTML = `<div style="text-align:center;color:#9ca3af;padding:30px;font-size:13px;">${escapeHtml(JZ_I18N.t('rules.empty.group'))}</div>`;
    return;
  }
  groupRules.forEach(rule => {
    const card = createRuleCard(rule, { compact: true });
    list.appendChild(card);
  });
  groupRules.forEach(rule => initEditors(rule));

  setTimeout(() => {
    Object.values(editors).forEach(({ css, js }) => {
      try { css?.refresh(); } catch (e) {}
      try { js?.refresh(); } catch (e) {}
    });
  }, 0);
}

function showGroupMoveMenu(group, anchorBtn) {
  const menu = document.getElementById('moveMenu');
  const otherFolders = folders.filter(f => f.id !== group.folderId);
  let html = `<div class="move-menu-section">${escapeHtml(JZ_I18N.t('moveMenu.section.moveGroup'))}</div>`;
  if (otherFolders.length === 0) {
    html += `<div class="move-menu-item" style="color:#9ca3af;">${escapeHtml(JZ_I18N.t('moveMenu.empty.folders'))}</div>`;
  } else {
    otherFolders.forEach(f => {
      html += `<div class="move-menu-item" data-action="moveGroup" data-target="${f.id}">${escapeHtml(f.name || '(bez nazwy)')}</div>`;
    });
  }
  menu.innerHTML = html;
  const rect = anchorBtn.getBoundingClientRect();
  menu.style.left = rect.left + 'px';
  menu.style.top = (rect.bottom + 4) + 'px';
  menu.classList.add('show');

  menu.querySelectorAll('.move-menu-item[data-action]').forEach(item => {
    item.addEventListener('click', () => {
      const target = item.dataset.target;
      moveGroupToFolder(group.id, target);
      menu.classList.remove('show');
    });
  });
  setTimeout(() => {
    const closer = (e) => {
      if (!menu.contains(e.target)) {
        menu.classList.remove('show');
        document.removeEventListener('click', closer, true);
      }
    };
    document.addEventListener('click', closer, true);
  }, 100);
}

// === Rule editor (single rule full view) ===
// Zapamiętana aktywna zakładka edytora zestawu (CSS/JS/actions). Bez tego każdy
// re-render edytora — w tym wymuszony przez sync-pull (storage.onChanged) —
// wracał na CSS, wyrzucając użytkownika z zakładki, na której pracował.
let _ruleEditorTab = { ruleId: null, tab: 'css' };

// === Pending edit-nav (od v3.2.4) ===========================================
// Background zapisuje { folderId, groupId, ruleId, tab, ts } do storage gdy user
// wciska Ctrl+Alt+{I,O,P} na stronie. Konsumujemy: rozwijamy drzewo, zaznaczamy
// zestaw, ustawiamy zakładkę (CSS/JS/Akcje). Wywoływane na końcu load() oraz
// z storage.onChanged (gdy okno panelu jest już otwarte).
async function applyPendingEditNav(pending) {
  if (!pending || typeof pending !== 'object') return;
  const tab = (pending.tab === 'js' || pending.tab === 'actions') ? pending.tab : 'css';
  try {
    const rule = pending.ruleId ? rules.find(r => r.id === pending.ruleId) : null;
    if (!rule) return; // brak ruleId lub zestaw skasowany → tylko otwarcie okna, bez nawigacji
    const group = groups.find(g => g.id === (pending.groupId || rule.groupId));
    const folder = folders.find(f => f.id === (pending.folderId || (group && group.folderId)));
    if (group) expandedGroups[group.id] = true;
    if (folder) expandedFolders[folder.id] = true;
    selectedFolderId = folder ? folder.id : null;
    selectedGroupId = group ? group.id : null;
    selectedRuleId = rule.id;
    _ruleEditorTab = { ruleId: rule.id, tab: tab };
    renderFolders();
    renderRuleEditor();
    try { chrome.storage.local.set({ expandedFolders, expandedGroups }); } catch (_) {}
  } finally {
    // Konsumujemy — żeby następne otwarcie panelu (bez skrótu) nie wracało tu.
    try { chrome.storage.local.remove('jzPendingEditNav'); } catch (_) {}
  }
}

chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== 'local') return;
  if (changes.jzPendingEditNav && changes.jzPendingEditNav.newValue) {
    applyPendingEditNav(changes.jzPendingEditNav.newValue);
  }
});

// === Ctrl+Alt+{I,O,P} w panelu edycji (od v3.2.6) ===========================
// Te same skróty, co na froncie (otwieranie panelu z wybraną zakładką), tu
// przełączają zakładkę zestawu — działa tylko gdy faktycznie jesteśmy na
// poziomie zestawu (selectedRuleId, karta wyrenderowana). Dopasowanie po
// e.code — odporne na układ klawiatury i AltGr (PL Programmers': AltGr+O = ó).
document.addEventListener('keydown', (e) => {
  if (!(e.ctrlKey || e.metaKey) || !e.altKey || e.shiftKey) return;
  let tabName = null;
  if (e.code === 'KeyI') tabName = 'css';
  else if (e.code === 'KeyO') tabName = 'js';
  else if (e.code === 'KeyP') tabName = 'actions';
  if (!tabName) return;
  if (!selectedRuleId) return;
  let card = null;
  try {
    card = document.querySelector('.rule-card[data-rule-id="' + CSS.escape(selectedRuleId) + '"]');
  } catch (_) {}
  if (!card) return;
  const tabEl = card.querySelector('.editor-tab[data-tab="' + tabName + '"]');
  if (!tabEl) return;
  e.preventDefault();
  e.stopPropagation();
  tabEl.click();
}, true);

function renderRuleEditor() {
  const rule = rules.find(r => r.id === selectedRuleId);
  if (!rule) {
    selectedRuleId = null;
    if (selectedGroupId) renderGroupEditor();
    else renderFolderEditor();
    return;
  }
  const group = groups.find(g => g.id === rule.groupId);
  const folder = group ? folders.find(f => f.id === group.folderId) : null;
  if (!group || !folder) { showEmptyState(); return; }

  selectedFolderId = folder.id;
  selectedGroupId = group.id;

  document.getElementById('emptyState').style.display = 'none';
  document.getElementById('dashboardView').style.display = 'none';
  document.getElementById('folderEditor').style.display = 'none';
  document.getElementById('groupEditor').style.display = 'none';
  document.getElementById('ruleEditor').style.display = 'block';

  // Breadcrumb klikalny (od v2.12.3) — folder/group są linkami nawigującymi do tych poziomów,
  // bieżący zestaw jest pogrubiony bez linku.
  const breadcrumb = document.getElementById('ruleBreadcrumb');
  breadcrumb.innerHTML = '';

  const folderLink = document.createElement('a');
  folderLink.href = '#';
  folderLink.className = 'breadcrumb-link';
  folderLink.textContent = folder.name || JZ_I18N.t('sidebar.fallback.folderName');
  folderLink.addEventListener('click', (e) => {
    e.preventDefault();
    selectedRuleId = null;
    selectedGroupId = null;
    renderFolders();
    renderFolderEditor();
  });
  breadcrumb.appendChild(folderLink);

  breadcrumb.appendChild(document.createTextNode('  /  '));

  const groupLink = document.createElement('a');
  groupLink.href = '#';
  groupLink.className = 'breadcrumb-link';
  groupLink.textContent = group.name || JZ_I18N.t('sidebar.fallback.groupName');
  groupLink.addEventListener('click', (e) => {
    e.preventDefault();
    selectedRuleId = null;
    renderFolders();
    renderGroupEditor();
  });
  breadcrumb.appendChild(groupLink);

  breadcrumb.appendChild(document.createTextNode('  /  '));

  const ruleSpan = document.createElement('strong');
  ruleSpan.className = 'breadcrumb-current';
  ruleSpan.textContent = rule.name || JZ_I18N.t('sidebar.fallback.ruleName');
  breadcrumb.appendChild(ruleSpan);

  document.getElementById('ruleBackLink').onclick = (e) => {
    e.preventDefault();
    selectedRuleId = null;
    // Wracamy do widoku grupy
    renderFolders();
    renderGroupEditor();
  };

  document.getElementById('exportRuleBtn').onclick = () => exportRuleToFile(rule);
  document.getElementById('importRuleBtn').onclick = () => openRuleImport(rule);
  document.getElementById('shareRuleBtn').onclick = () => openShareModalForRule(rule);

  // Render karty zestawu w pełnym trybie
  const list = document.getElementById('ruleEditorList');
  Object.values(editors).forEach(({ css, js }) => {
    try { css?.toTextArea(); } catch (e) {}
    try { js?.toTextArea(); } catch (e) {}
  });
  editors = {};
  // Wyczyść inne listy żeby querySelector trafiał tutaj
  const folderRulesList = document.getElementById('rulesList');
  if (folderRulesList) folderRulesList.innerHTML = '';
  const groupRulesList = document.getElementById('groupRulesList');
  if (groupRulesList) groupRulesList.innerHTML = '';
  list.innerHTML = '';

  const card = createRuleCard(rule, { compact: false });
  list.appendChild(card);
  initEditors(rule);

  // Przywróć zakładkę aktywną sprzed re-renderu (sync-pull przerysowuje edytor
  // i bez tego wracał na CSS). Klik reużywa istniejącego handlera zakładek.
  const restoreTab = (_ruleEditorTab.ruleId === rule.id && _ruleEditorTab.tab)
    ? _ruleEditorTab.tab : 'css';
  if (restoreTab !== 'css') {
    const tabEl = card.querySelector(`.editor-tab[data-tab="${restoreTab}"]`);
    if (tabEl) tabEl.click();
  }

  // Tryb pełnowysoki dla pojedynczego zestawu
  list.parentElement.classList.add('single-rule');
  setTimeout(() => {
    Object.values(editors).forEach(({ css, js }) => {
      try { css?.refresh(); } catch (e) {}
      try { js?.refresh(); } catch (e) {}
    });
  }, 0);
}

// === Folder editor ===
function renderFolderEditor() {
  const folder = folders.find(f => f.id === selectedFolderId);
  if (!folder) { showEmptyState(); return; }

  document.getElementById('emptyState').style.display = 'none';
  document.getElementById('dashboardView').style.display = 'none';
  document.getElementById('groupEditor').style.display = 'none';
  document.getElementById('ruleEditor').style.display = 'none';
  document.getElementById('folderEditor').style.display = 'block';

  // Breadcrumb na poziomie katalogu (od v2.12.5 — spójność z group + rule level): [folder-bold]
  const breadcrumb = document.getElementById('folderBreadcrumb');
  if (breadcrumb) {
    breadcrumb.innerHTML = '';
    const folderSpan = document.createElement('strong');
    folderSpan.className = 'breadcrumb-current';
    folderSpan.textContent = folder.name || JZ_I18N.t('sidebar.fallback.folderName');
    breadcrumb.appendChild(folderSpan);
  }

  const nameInput = document.getElementById('folderName');
  const urlChipsEl = document.getElementById('folderUrlsChips');
  const urlInput = document.getElementById('folderUrlsInput');
  const iconInput = document.getElementById('folderIcon');
  const colorInput = document.getElementById('folderColor');
  const colorClearBtn = document.getElementById('folderColorClear');
  const insertUrlBtn = document.getElementById('insertCurrentUrlBtn');
  // Stare pola showActionsPanel + actionsOrientation usunięte z UI w v2.10.1
  // (panel akcji jest teraz w pełni zarządzany przez "Paski akcji" — Fazę 2/3).
  // Pola zostają w storage jako historyczne (legacy fallback w content.js).
  const activeCb = document.getElementById('folderActive');
  const enabledCb = document.getElementById('folderEnabled');

  nameInput.value = folder.name || '';
  if (!Array.isArray(folder.urlPatterns)) {
    const legacy = (folder.urlPattern || '').trim();
    folder.urlPatterns = legacy ? [legacy] : [];
  }
  iconInput.value = folder.icon || '';
  colorInput.value = folder.color || '#006870';

  // Active + enabled z kaskadą
  activeCb.checked = folder.active !== false;
  enabledCb.checked = folder.active !== false && folder.enabled === true;
  enabledCb.disabled = folder.active === false;
  enabledCb.parentElement.classList.toggle('locked', folder.active === false);

  activeCb.onchange = async () => {
    folder.active = activeCb.checked;
    // Pełna kaskada active+enabled na całe poddrzewo: groups i rules dziedziczą stan
    // aktywności po katalogu. Reaktywacja przywraca active+enabled wszystkim podelementom
    // (świadoma utrata indywidualnych dezaktywacji — taka jest zamierzona semantyka).
    const newState = activeCb.checked;
    const folderGroups = groups.filter(g => g.folderId === folder.id);
    const folderGroupIds = new Set(folderGroups.map(g => g.id));
    folder.enabled = newState;
    folderGroups.forEach(g => { g.active = newState; g.enabled = newState; });
    rules.filter(r => folderGroupIds.has(r.groupId)).forEach(r => { r.active = newState; r.enabled = newState; });
    enabledCb.checked = folder.active !== false && folder.enabled === true;
    enabledCb.disabled = folder.active === false;
    enabledCb.parentElement.classList.toggle('locked', folder.active === false);
    await saveAll(true);
    renderFolders();
    syncOpenEditorAfterToggle();
  };

  enabledCb.onchange = async () => {
    if (folder.active === false) return;
    const enable = enabledCb.checked;
    folder.enabled = enable;
    // Cascade DOWN tylko przy WYŁĄCZANIU (od v2.13.53) — spójne z popup widget'em.
    if (!enable) {
      const folderGroupsLocal = groups.filter(g => g.folderId === folder.id);
      folderGroupsLocal.filter(g => g.active !== false).forEach(g => { g.enabled = false; });
      const folderGroupIds = new Set(folderGroupsLocal.map(g => g.id));
      rules.filter(r => folderGroupIds.has(r.groupId) && r.active).forEach(r => { r.enabled = false; });
    }
    await saveAll(true);
    renderFolders();
    syncOpenEditorAfterToggle();
  };

  nameInput.oninput = () => {
    folder.name = nameInput.value;
    const cur = document.querySelector('#folderBreadcrumb .breadcrumb-current');
    if (cur) cur.textContent = folder.name || JZ_I18N.t('sidebar.fallback.folderName');
    debouncedSave();
    renderFolders();
  };
  const renderFolderUrlChips = () => {
    urlChipsEl.innerHTML = folder.urlPatterns.map((p, i) =>
      `<span class="rule-url-chip"><code>${escapeHtml(p)}</code><button data-idx="${i}" title="${escapeAttr(JZ_I18N.t('btn.removeUrlChip'))}">${JZ_ICONS.close}</button></span>`
    ).join('');
    urlChipsEl.querySelectorAll('button[data-idx]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.preventDefault();
        const idx = parseInt(btn.dataset.idx);
        folder.urlPatterns.splice(idx, 1);
        folder.urlPattern = folder.urlPatterns[0] || '';
        renderFolderUrlChips();
        saveAll(true);
        debouncedRenderFolders();
      });
    });
  };
  renderFolderUrlChips();
  const commitUrlPattern = (rawValue) => {
    const v = (rawValue || '').trim();
    if (!v) return false;
    if (folder.urlPatterns.includes(v)) return false;
    folder.urlPatterns.push(v);
    folder.urlPattern = folder.urlPatterns[0] || '';
    renderFolderUrlChips();
    saveAll(true);
    debouncedRenderFolders();
    return true;
  };
  urlInput.onkeydown = (e) => {
    if (e.key !== 'Enter') return;
    e.preventDefault();
    if (commitUrlPattern(urlInput.value)) urlInput.value = '';
  };
  urlInput.onblur = () => {
    if (commitUrlPattern(urlInput.value)) urlInput.value = '';
  };
  iconInput.oninput = () => {
    const upper = iconInput.value.toUpperCase().slice(0, 3);
    if (iconInput.value !== upper) iconInput.value = upper;
    folder.icon = upper.trim();
    debouncedSave();
  };
  colorInput.oninput = () => {
    folder.color = colorInput.value;
    debouncedSave();
    debouncedRenderFolders();
  };
  colorClearBtn.onclick = () => {
    folder.color = '';
    colorInput.value = '#006870';
    saveAll(true);
    renderFolders();
  };
  insertUrlBtn.onclick = async () => {
    try {
      // Pomijamy zakładki naszego rozszerzenia (options/help) i wewnętrzne strony Chrome.
      const ownPrefix = 'chrome-extension://' + chrome.runtime.id;
      const isExternal = (u) =>
        !!u && !u.startsWith('chrome://') && !u.startsWith('chrome-extension://')
        && !u.startsWith('edge://') && !u.startsWith('about:');

      // 1) aktywne karty we wszystkich oknach (najbardziej naturalny kandydat)
      let candidates = await chrome.tabs.query({ active: true });
      let tab = candidates.find(t => isExternal(t.url));

      // 2) jeśli nic — przejrzyj wszystkie karty i weź najświeższą zewnętrzną
      if (!tab) {
        const all = await chrome.tabs.query({});
        tab = all
          .filter(t => isExternal(t.url))
          .sort((a, b) => (b.lastAccessed || 0) - (a.lastAccessed || 0))[0];
      }

      if (!tab || !tab.url) {
        showToast(JZ_I18N.t('toast.noOtherTab'), true);
        return;
      }
      // Generuj wzorzec: origin + /*
      const u = new URL(tab.url);
      const pattern = `${u.protocol}//${u.host}/*`;
      if (folder.urlPatterns.includes(pattern)) {
        showToast(JZ_I18N.t('toast.urlAlreadyAdded', { pattern }), true);
        return;
      }
      folder.urlPatterns.push(pattern);
      folder.urlPattern = folder.urlPatterns[0] || '';
      renderFolderUrlChips();
      saveAll(true);
      debouncedRenderFolders();
      showToast(JZ_I18N.t('toast.urlInserted'));
    } catch (e) {
      showToast(JZ_I18N.t('toast.error', { msg: e.message }), true);
    }
  };

  // Przycisk "Paski akcji (N)" — od v2.9 (Faza 2 wielu pasków akcji).
  // N = łączna liczba barów widocznych w popupie (own + inherited).
  const folderBarsBtn = document.getElementById('folderActionBarsBtn');
  if (folderBarsBtn) {
    const cnt = countAllBarsForContext('folder', folder.id);
    const cntEl = folderBarsBtn.querySelector('.action-bars-count');
    if (cntEl) cntEl.textContent = `(${cnt})`;
    folderBarsBtn.onclick = () => {
      openActionBarsModal({
        parentType: 'folder',
        parentId: folder.id,
        parentName: folder.name || JZ_I18N.t('default.folderName'),
        // Tylko refresh licznika — pełen rerender edytora resetowałby stan kart
        // (CodeMirror cleanup, zwijanie compact, etc.) co user widział jako "zmianę widoku".
        onClose: () => {
          if (cntEl) cntEl.textContent = `(${countAllBarsForContext('folder', folder.id)})`;
        },
      });
    };
  }

  // Przycisk "Okna (N) ▾" (od v2.13.41) — dropdown do wszystkich 4 typów okien
  // (CSS / JS / JS console / Output console). Od v2.13.56 — CSS + JS panes aktywne.
  // N = suma CSS + JS panes + JS consoles (od v2.13.66; Output console wkrótce).
  const folderWinBtn = document.getElementById('folderWindowsBtn');
  if (folderWinBtn) {
    const wCnt = countAllCssPanesForContext('folder', folder.id)
               + countAllJsPanesForContext('folder', folder.id)
               + countAllJsConsolesForContext('folder', folder.id)
               + countAllOutputConsolesForContext('folder', folder.id);
    const wCntEl = folderWinBtn.querySelector('.windows-count');
    if (wCntEl) wCntEl.textContent = `(${wCnt})`;
    folderWinBtn.onclick = (e) => {
      e.stopPropagation();
      showWindowsMenu('folder', folder.id, folder.name || JZ_I18N.t('default.folderName'), folderWinBtn, () => {
        if (wCntEl) wCntEl.textContent = `(${countAllCssPanesForContext('folder', folder.id) + countAllJsPanesForContext('folder', folder.id) + countAllJsConsolesForContext('folder', folder.id) + countAllOutputConsolesForContext('folder', folder.id)})`;
      });
    };
  }

  renderRules();
}

// (validateUrlPattern + komunikaty "Pasuje do X kart" zostały usunięte na życzenie użytkownika)

function urlPatternMatches(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);
    }
    try {
      const a = new URL(trimmed); const b = new URL(url);
      return a.origin === b.origin && a.pathname === b.pathname;
    } catch (_) {
      return url === trimmed;
    }
  } catch (e) {
    return false;
  }
}

function renderRules() {
  const list = document.getElementById('rulesList');
  // Cleanup CodeMirror
  Object.values(editors).forEach(({ css, js }) => {
    try { css?.toTextArea(); } catch(e) {}
    try { js?.toTextArea(); } catch(e) {}
  });
  editors = {};
  // Wyczyść też listy z innych widoków — żeby initEditors nie trafił na stare karty.
  const groupRulesList = document.getElementById('groupRulesList');
  if (groupRulesList) groupRulesList.innerHTML = '';
  const ruleEditorList = document.getElementById('ruleEditorList');
  if (ruleEditorList) ruleEditorList.innerHTML = '';
  list.innerHTML = '';

  const folderRules = getRulesForFolder(selectedFolderId);
  if (folderRules.length === 0) {
    list.innerHTML = `<div style="text-align:center;color:#9ca3af;padding:30px;font-size:13px;">${escapeHtml(JZ_I18N.t('rules.empty.folder'))}</div>`;
    return;
  }
  // Karty są zminimalizowane — w widoku katalogu / grupy edytory CSS/JS startują collapsed.
  folderRules.forEach(rule => {
    const card = createRuleCard(rule, { compact: true });
    list.appendChild(card);
  });
  folderRules.forEach(rule => initEditors(rule));
}

function createRuleCard(rule, opts = {}) {
  const compact = !!opts.compact;
  const card = document.createElement('div');
  card.className = 'rule-card' + (compact ? ' compact' : '');
  card.dataset.ruleId = rule.id;
  if (compact) card.dataset.compact = 'true';

  const actionsCount = (rule.actions || []).length;
  const historyCount = (rule.history || []).length;

  // Dynamiczny placeholder dla wzorców URL — bierze wzorzec katalogu + wzmianka o Tab.
  const ruleGroup = groups.find(g => g.id === rule.groupId);
  const ruleFolder = ruleGroup ? folders.find(f => f.id === ruleGroup.folderId) : null;
  const folderPatterns = getFolderPatterns(ruleFolder);
  const folderPattern = folderPatterns[0] || '';
  const urlPlaceholder = folderPattern
    ? JZ_I18N.t('rule.editor.urlsPlaceholderWithPattern', { pattern: folderPattern })
    : JZ_I18N.t('rule.editor.urlsPlaceholder');

  const enabledLocked = rule.active === false;

  card.innerHTML = `
    <div class="rule-header">
      <input type="text" class="rule-name-input" placeholder="${escapeAttr(JZ_I18N.t('rule.namePlaceholder'))}" value="${escapeAttr(rule.name)}">
      <input type="text" class="rule-tags-input" placeholder="tag1, tag2" value="${escapeAttr((rule.tags || []).join(', '))}" style="flex:0 0 140px;font-size:11px;padding:6px 8px;border:1px solid #d1d5db;border-radius:4px;" title="${escapeAttr(JZ_I18N.t('rule.card.tagsTitle'))}">
      <div class="toggles">
        <label class="toggle-label" title="${escapeAttr(JZ_I18N.t('rule.activeTooltip'))}">
          <input type="checkbox" class="rule-active" ${rule.active ? 'checked' : ''}>
          ${escapeHtml(JZ_I18N.t('rule.card.activeLabel'))}
        </label>
        <label class="toggle-label${enabledLocked ? ' locked' : ''}" title="${escapeAttr(enabledLocked ? JZ_I18N.t('rule.editor.toggleLockedHint') : JZ_I18N.t('rule.editor.toggleHint'))}">
          <input type="checkbox" class="rule-enabled" ${rule.enabled ? 'checked' : ''} ${enabledLocked ? 'disabled' : ''}>
          ${escapeHtml(JZ_I18N.t('rule.card.enabledLabel'))}
        </label>
      </div>
      <div class="rule-actions">
        <button class="btn-action-bars" title="${escapeAttr(JZ_I18N.t('actionBars.btn.titleRule'))}">${escapeHtml(JZ_I18N.t('actionBars.btn.label'))} <span class="action-bars-count">(${countAllBarsForContext('rule', rule.id)})</span></button>
        <button class="btn-windows" title="${escapeAttr(JZ_I18N.t('windows.btn.titleRule'))}">${escapeHtml(JZ_I18N.t('windows.btn.label'))} <span class="windows-count">(${countAllCssPanesForContext('rule', rule.id) + countAllJsPanesForContext('rule', rule.id) + countAllJsConsolesForContext('rule', rule.id) + countAllOutputConsolesForContext('rule', rule.id)})</span> ▾</button>
        <button class="btn-history" title="${escapeAttr(JZ_I18N.t('rule.card.history.title'))}">${JZ_ICONS.undo} ${escapeHtml(JZ_I18N.t('rule.card.history.label'))}<span class="history-count">${historyCount ? ` (${historyCount})` : ''}</span></button>
        <button class="btn-move" title="${escapeAttr(JZ_I18N.t('rule.card.move.btnTitle'))}">${JZ_ICONS.move} ${escapeHtml(JZ_I18N.t('rule.card.move.label'))}</button>
        <button class="btn-copy" title="${escapeAttr(JZ_I18N.t('rule.card.copy.btnTitle'))}">${JZ_ICONS.copy} ${escapeHtml(JZ_I18N.t('rule.card.copy.label'))}</button>
        <button class="btn-duplicate">${JZ_ICONS.duplicate} ${escapeHtml(JZ_I18N.t('rule.card.duplicate.label'))}</button>
        <button class="btn-delete danger">${escapeHtml(JZ_I18N.t('rule.card.delete.label'))}</button>
      </div>
    </div>
    <div class="rule-urls" title="${escapeAttr(JZ_I18N.t('rule.urls.tooltip'))}">
      <span class="rule-urls-label">${JZ_ICONS.tabUrl} ${escapeHtml(JZ_I18N.t('rule.card.urlsLabel'))}</span>
      <div class="rule-urls-chips"></div>
      <input type="text" class="rule-urls-input" placeholder="${escapeAttr(urlPlaceholder)}">
    </div>
    <div class="editor-tabs">
      <div class="editor-tab${compact ? '' : ' active'}" data-tab="css">${jzIcon('flagCss', 'tab-flag flag-css')} CSS${compact ? ` <span class="tab-hint">${JZ_ICONS.caretDown}</span>` : ''}</div>
      <div class="editor-tab" data-tab="js">${jzIcon('flagJs', 'tab-flag flag-js')} JavaScript${compact ? ` <span class="tab-hint">${JZ_ICONS.caretDown}</span>` : ''}</div>
      <div class="editor-tab" data-tab="actions">${jzIcon('flagActions', 'tab-flag flag-actions')} ${escapeHtml(JZ_I18N.t('rule.card.tab.actions'))}${actionsCount ? ` <span style="background:#006870;color:white;padding:1px 5px;border-radius:8px;font-size:10px;margin-left:4px;">${actionsCount}</span>` : ''}</div>
    </div>
    <div class="editor-pane${compact ? '' : ' active'}" data-pane="css">
      <textarea class="editor-css">${escapeHtml(rule.css || '')}</textarea>
      <div class="css-validation" style="display:none;padding:6px 12px;font-size:11px;background:#fef3c7;border-top:1px solid #fde68a;color:#92400e;"></div>
      <div class="editor-resize-handle" data-target="css"></div>
    </div>
    <div class="editor-pane" data-pane="js">
      <textarea class="editor-js">${escapeHtml(rule.js || '')}</textarea>
      <div class="js-validation" style="display:none;padding:6px 12px;font-size:11px;background:#fef3c7;border-top:1px solid #fde68a;color:#92400e;"></div>
      <label class="js-runonce-label" title="${escapeAttr(JZ_I18N.t('rule.card.js.runOnce.title'))}" style="display:flex;align-items:center;gap:6px;padding:6px 12px;font-size:11px;color:#475569;border-top:1px solid #e2e8f0;cursor:pointer;">
        <input type="checkbox" class="rule-js-runonce" ${rule.jsRunOnce ? 'checked' : ''}>
        ${escapeHtml(JZ_I18N.t('rule.card.js.runOnce.label'))}
      </label>
      <div class="editor-resize-handle" data-target="js"></div>
    </div>
    <div class="editor-pane actions-pane" data-pane="actions">
      <div class="actions-info">
        ${escapeHtml(JZ_I18N.t('rule.card.actions.info'))}
      </div>
      <div class="action-buttons-list"></div>
      <button class="add-action-btn">${escapeHtml(JZ_I18N.t('rule.card.actions.add'))}</button>
    </div>
  `;

  // Eventy header
  card.querySelector('.rule-name-input').addEventListener('input', (e) => {
    rule.name = e.target.value;
    rule.updatedAt = Date.now();
    debouncedSave();
    debouncedRenderFolders();
  });
  card.querySelector('.rule-tags-input').addEventListener('input', (e) => {
    rule.tags = e.target.value.split(',').map(t => t.trim()).filter(Boolean);
    debouncedSave();
    debouncedRenderFolders();
  });

  // === Wzorce URL zestawu (chip input) ===
  if (!Array.isArray(rule.urlPatterns)) rule.urlPatterns = [];
  const chipsEl = card.querySelector('.rule-urls-chips');
  const urlInput = card.querySelector('.rule-urls-input');
  const renderUrlChips = () => {
    chipsEl.innerHTML = rule.urlPatterns.map((p, i) =>
      `<span class="rule-url-chip"><code>${escapeHtml(p)}</code><button data-idx="${i}" title="${escapeAttr(JZ_I18N.t('btn.removeUrlChip'))}">${JZ_ICONS.close}</button></span>`
    ).join('');
    chipsEl.querySelectorAll('button[data-idx]').forEach(btn => {
      btn.addEventListener('click', (e) => {
        e.preventDefault();
        const idx = parseInt(btn.dataset.idx);
        rule.urlPatterns.splice(idx, 1);
        renderUrlChips();
        debouncedSave();
        debouncedRenderFolders();
      });
    });
  };
  renderUrlChips();
  urlInput.addEventListener('keydown', (e) => {
    if (e.key === 'Tab') {
      // Autouzupełnienie: bierz wzorzec z grupy (jeśli ma urlPatterns) lub katalogu.
      const group = groups.find(g => g.id === rule.groupId);
      const folder = group ? folders.find(f => f.id === group.folderId) : null;
      const groupPatterns = (group && Array.isArray(group.urlPatterns)) ? group.urlPatterns.filter(p => p && p.trim()) : [];
      const folderPatterns = getFolderPatterns(folder);
      const base = (groupPatterns[0] || folderPatterns[0] || '').trim();
      if (!base) return;
      e.preventDefault();
      urlInput.value = base;
      const star = base.lastIndexOf('*');
      const pos = star > -1 ? star : base.length;
      urlInput.setSelectionRange(pos, pos);
      return;
    }
    if (e.key !== 'Enter') return;
    e.preventDefault();
    const v = urlInput.value.trim();
    if (!v) return;
    if (!rule.urlPatterns.includes(v)) {
      rule.urlPatterns.push(v);
      renderUrlChips();
      saveAll(true);
      debouncedRenderFolders();
    }
    urlInput.value = '';
  });
  urlInput.addEventListener('blur', () => {
    const v = urlInput.value.trim();
    if (!v) return;
    if (!rule.urlPatterns.includes(v)) {
      rule.urlPatterns.push(v);
      renderUrlChips();
      saveAll(true);
      debouncedRenderFolders();
    }
    urlInput.value = '';
  });
  card.querySelector('.rule-active').addEventListener('change', async (e) => {
    rule.active = e.target.checked;
    const enabledCb = card.querySelector('.rule-enabled');
    const enabledLabel = enabledCb && enabledCb.closest('.toggle-label');
    if (!e.target.checked) {
      // Cascade: nieaktywny → wymuszamy enabled=false i blokujemy checkbox.
      rule.enabled = false;
      if (enabledCb) {
        enabledCb.checked = false;
        enabledCb.disabled = true;
      }
      if (enabledLabel) {
        enabledLabel.classList.add('locked');
        enabledLabel.title = JZ_I18N.t('rule.editor.toggleLockedHint');
      }
    } else if (enabledCb) {
      enabledCb.disabled = false;
      if (enabledLabel) {
        enabledLabel.classList.remove('locked');
        enabledLabel.title = JZ_I18N.t('rule.editor.toggleHint');
      }
    }
    await saveAll(true);
    renderFolders();
  });
  card.querySelector('.rule-enabled').addEventListener('change', async (e) => {
    if (rule.active === false) { e.target.checked = false; return; }
    const enable = e.target.checked;
    rule.enabled = enable;
    if (enable) {
      // Cascade UP (od v2.13.53): włącz parent group + 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 saveAll(true);
    renderFolders();
  });
  card.querySelector('.btn-delete').addEventListener('click', () => {
    if (confirm(JZ_I18N.t('confirm.deleteRule', { name: rule.name || JZ_I18N.t('popup.fallback.unnamed') }))) {
      const idx = rules.findIndex(r => r.id === rule.id);
      if (idx > -1) rules.splice(idx, 1);
      pruneOrphanWindows(new Set([rule.id]));
      if (selectedRuleId === rule.id) selectedRuleId = null;
      saveAll();
      renderRules();
      renderFolders();
    }
  });
  card.querySelector('.btn-duplicate').addEventListener('click', () => {
    // Deep-duplikacja zestawu wraz z paskami akcji i oknami przypiętymi do tego
    // zestawu (parentType:'rule'). action.barId przemapowany na nowe paski; paski
    // i okna folderu/grupy zostają wspólne — kopia jest w tej samej grupie.
    const copy = JSON.parse(JSON.stringify(rule));
    copy.id = genId('r');
    copy.name = (rule.name || JZ_I18N.t('rule.card.fallbackName')) + ' ' + JZ_I18N.t('rule.card.duplicate.suffix');
    copy.history = [];
    copy.groupId = rule.groupId; // ta sama grupa
    if (Array.isArray(copy.actions)) {
      copy.actions.forEach(a => { a.id = genId('a'); a.clickCount = 0; });
    }
    const idx = rules.findIndex(r => r.id === rule.id);
    rules.splice(idx + 1, 0, copy);

    // Paski akcji przypięte do zestawu.
    const barIdMap = new Map();
    const newBars = [];
    actionBars.forEach(bar => {
      if (bar.parentType !== 'rule' || bar.parentId !== rule.id) return;
      const bcopy = JSON.parse(JSON.stringify(bar));
      bcopy.id = genId('b');
      bcopy.parentId = copy.id;
      if (Array.isArray(bcopy.labels)) bcopy.labels.forEach(l => { l.id = genId('lbl'); });
      barIdMap.set(bar.id, bcopy.id);
      newBars.push(bcopy);
    });
    newBars.forEach(b => actionBars.push(b));

    // action.barId w skopiowanych akcjach → nowe paski (paski folderu/grupy bez zmian).
    if (Array.isArray(copy.actions)) {
      copy.actions.forEach(a => {
        if (a.barId && barIdMap.has(a.barId)) a.barId = barIdMap.get(a.barId);
      });
    }

    // Okna przypięte do zestawu — 4 typy.
    const dupPanes = (coll, prefix) => {
      const added = [];
      coll.forEach(p => {
        if (p.parentType !== 'rule' || p.parentId !== rule.id) return;
        const pcopy = JSON.parse(JSON.stringify(p));
        pcopy.id = genId(prefix);
        pcopy.parentId = copy.id;
        added.push(pcopy);
      });
      added.forEach(p => coll.push(p));
    };
    dupPanes(cssPanes, 'cp');
    dupPanes(jsPanes, 'jp');
    dupPanes(jsConsoles, 'jc');
    dupPanes(outputConsoles, 'oc');

    saveAll();
    if (selectedGroupId) renderGroupEditor(); else renderRules();
    renderFolders();
  });
  card.querySelector('.btn-move').addEventListener('click', (e) => {
    e.stopPropagation();
    showMoveMenu(rule, e.currentTarget, 'move');
  });
  card.querySelector('.btn-copy').addEventListener('click', (e) => {
    e.stopPropagation();
    showMoveMenu(rule, e.currentTarget, 'copy');
  });
  card.querySelector('.btn-history').addEventListener('click', () => {
    showHistoryMenu(rule);
  });
  card.querySelector('.btn-action-bars').addEventListener('click', () => {
    openActionBarsModal({
      parentType: 'rule',
      parentId: rule.id,
      parentName: rule.name || JZ_I18N.t('default.ruleName'),
      onClose: () => {
        // Aktualizuj licznik na przycisku karty bez pełnego rerendera (zachowuje stan zakładek/edytorów).
        const cntEl = card.querySelector('.btn-action-bars .action-bars-count');
        if (cntEl) cntEl.textContent = `(${countAllBarsForContext('rule', rule.id)})`;
      },
    });
  });
  card.querySelector('.btn-windows').addEventListener('click', (e) => {
    e.stopPropagation();
    const btn = card.querySelector('.btn-windows');
    showWindowsMenu('rule', rule.id, rule.name || JZ_I18N.t('default.ruleName'), btn, () => {
      const cntEl = btn.querySelector('.windows-count');
      if (cntEl) cntEl.textContent = `(${countAllCssPanesForContext('rule', rule.id) + countAllJsPanesForContext('rule', rule.id) + countAllJsConsolesForContext('rule', rule.id) + countAllOutputConsolesForContext('rule', rule.id)})`;
    });
  });

  // Tabs — w trybie compact klik aktywnej zakładki zwija (toggle)
  card.querySelectorAll('.editor-tab').forEach(tab => {
    tab.addEventListener('click', () => {
      const tabName = tab.dataset.tab;
      const wasActive = tab.classList.contains('active');
      if (compact && wasActive) {
        // Toggle off
        card.querySelectorAll('.editor-tab').forEach(t => t.classList.remove('active'));
        card.querySelectorAll('.editor-pane').forEach(p => p.classList.remove('active'));
        return;
      }
      card.querySelectorAll('.editor-tab').forEach(t => t.classList.toggle('active', t === tab));
      card.querySelectorAll('.editor-pane').forEach(p => p.classList.toggle('active', p.dataset.pane === tabName));
      if (!compact) _ruleEditorTab = { ruleId: rule.id, tab: tabName };
      setTimeout(() => {
        if (tabName === 'css' && editors[rule.id]?.css) editors[rule.id].css.refresh();
        if (tabName === 'js' && editors[rule.id]?.js) editors[rule.id].js.refresh();
      }, 0);
    });
  });

  renderActionsList(card, rule);

  return card;
}

// === Walidacja CSS ===
function validateCSS(css) {
  // Prosta walidacja: balans nawiasów {}, niezamknięte komentarze
  if (!css || !css.trim()) return null;

  const issues = [];

  // Usuń komentarze /* ... */ by nie liczyć ich klamr
  let cleaned = '';
  let inComment = false;
  let inString = null; // ' lub "
  let unclosedComment = false;
  for (let i = 0; i < css.length; i++) {
    const c = css[i];
    const next = css[i + 1];
    if (inComment) {
      if (c === '*' && next === '/') { inComment = false; i++; }
      continue;
    }
    if (inString) {
      if (c === inString && css[i - 1] !== '\\') inString = null;
      cleaned += c;
      continue;
    }
    if (c === '/' && next === '*') { inComment = true; i++; continue; }
    if (c === '"' || c === "'") inString = c;
    cleaned += c;
  }
  if (inComment) issues.push(JZ_I18N.t('cssValidator.unclosedComment'));

  // Balans klamr
  let depth = 0;
  let lineCount = 1;
  let unmatchedLine = -1;
  for (let i = 0; i < cleaned.length; i++) {
    const c = cleaned[i];
    if (c === '\n') lineCount++;
    if (c === '{') depth++;
    if (c === '}') {
      depth--;
      if (depth < 0) {
        if (unmatchedLine === -1) unmatchedLine = lineCount;
        depth = 0;
      }
    }
  }
  if (depth > 0) issues.push(JZ_I18N.t('cssValidator.missingCloseBraces', { count: depth, word: JZ_I18N.plural(depth, 'plural.brace') }));
  if (unmatchedLine > 0) issues.push(JZ_I18N.t('cssValidator.extraCloseBrace', { line: unmatchedLine }));

  return issues.length > 0 ? issues : null;
}

// Walidacja składni JavaScript. MV3 CSP strony opcji blokuje new Function/eval, więc
// to lekka analiza heurystyczna (jak validateCSS): balans nawiasów {} () [],
// niezamknięty komentarz blokowy, niezamknięty literał tekstowy. Skaner pomija
// komentarze, stringi, template literale i literały regex — by nie liczyć nawiasów
// w ich wnętrzu. Regex rozpoznawany heurystyką pozycji (wartość vs dzielenie).
function validateJS(js) {
  if (!js || !js.trim()) return null;
  const issues = [];
  const stack = [];                       // { ch, line } — otwarte nawiasy
  const openOf = { '}': '{', ')': '(', ']': '[' };
  // Słowa kluczowe, po których '/' rozpoczyna regex, nie dzielenie.
  const VALUE_KW = /^(return|typeof|instanceof|in|of|new|delete|void|do|else|yield|await|case)$/;
  let line = 1, i = 0, prev = '';          // prev — marker poprzedniego tokenu
  let unclosedComment = false, unterminatedStr = -1, extraClose = null;
  const n = js.length;

  while (i < n) {
    const c = js[i], next = js[i + 1];
    if (c === '\n') { line++; i++; continue; }
    if (c === ' ' || c === '\t' || c === '\r') { i++; continue; }

    if (c === '/' && next === '/') {                    // komentarz liniowy
      i += 2; while (i < n && js[i] !== '\n') i++; continue;
    }
    if (c === '/' && next === '*') {                    // komentarz blokowy
      i += 2; let closed = false;
      while (i < n) {
        if (js[i] === '\n') line++;
        else if (js[i] === '*' && js[i + 1] === '/') { i += 2; closed = true; break; }
        i++;
      }
      if (!closed) unclosedComment = true;
      continue;
    }
    if (c === '"' || c === "'") {                       // literał ' "
      const sl = line; i++; let closed = false;
      while (i < n) {
        if (js[i] === '\\') { i += 2; continue; }
        if (js[i] === '\n' || js[i] === c) { closed = js[i] === c; break; }
        i++;
      }
      if (closed) i++;
      else if (unterminatedStr === -1) unterminatedStr = sl;
      prev = 'v'; continue;
    }
    if (c === '`') {                                    // template literal
      const sl = line; i++; let closed = false, depth = 0;
      while (i < n) {
        const t = js[i];
        if (t === '\n') line++;
        if (t === '\\') { i += 2; continue; }
        if (depth === 0 && t === '`') { i++; closed = true; break; }
        if (depth === 0 && t === '$' && js[i + 1] === '{') { depth = 1; i += 2; continue; }
        if (depth > 0 && t === '{') { depth++; i++; continue; }
        if (depth > 0 && t === '}') { depth--; i++; continue; }
        i++;
      }
      if (!closed && unterminatedStr === -1) unterminatedStr = sl;
      prev = 'v'; continue;
    }
    if (c === '/' && (prev === '' || prev === 'op')) {  // literał regex
      i++; let closed = false, inClass = false;
      while (i < n && js[i] !== '\n') {
        if (js[i] === '\\') { i += 2; continue; }
        if (js[i] === '[') inClass = true;
        else if (js[i] === ']') inClass = false;
        else if (js[i] === '/' && !inClass) { i++; closed = true; break; }
        i++;
      }
      while (i < n && /[a-z]/i.test(js[i])) i++;         // flagi
      prev = 'v'; continue;
    }
    if (/[A-Za-z_$]/.test(c)) {                         // identyfikator / słowo kluczowe
      let w = '';
      while (i < n && /[A-Za-z0-9_$]/.test(js[i])) { w += js[i]; i++; }
      prev = VALUE_KW.test(w) ? 'op' : 'v';
      continue;
    }
    if (c === '{' || c === '(' || c === '[') { stack.push({ ch: c, line }); prev = 'op'; i++; continue; }
    if (c === '}' || c === ')' || c === ']') {
      if (stack.length && stack[stack.length - 1].ch === openOf[c]) stack.pop();
      else if (!extraClose) extraClose = { ch: c, line };
      prev = 'v'; i++; continue;
    }
    // operator / interpunkcja — po nim '/' jest regexem; ) ] domknięte wyżej
    prev = (c === ')' || c === ']') ? 'v' : 'op';
    i++;
  }

  if (unclosedComment) issues.push(JZ_I18N.t('cssValidator.unclosedComment'));
  if (unterminatedStr > 0) issues.push(JZ_I18N.t('jsValidator.unterminatedString', { line: unterminatedStr }));
  if (extraClose) issues.push(JZ_I18N.t('jsValidator.extraBracket', { bracket: extraClose.ch, line: extraClose.line }));
  if (stack.length) {
    const o = stack[0];
    issues.push(JZ_I18N.t('jsValidator.unclosedBracket', { bracket: o.ch, line: o.line }));
  }
  return issues.length > 0 ? issues : null;
}

// Skróty klawiszowe — helper konwersji e.code → znormalizowany string.
// Spójna z content.js (duplikat — content.js nie ma access do options.js).
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('+');
}
function jzShortcutDisplay(s) {
  if (!s) return JZ_I18N.t('action.shortcut.empty');
  return s.split('+').map(p => {
    if (p === 'ctrl') return 'Ctrl';
    if (p === 'alt') return 'Alt';
    if (p === 'shift') return 'Shift';
    if (p === 'meta') return 'Meta';
    if (/^numpad/.test(p)) return 'Num' + p.slice(6).toUpperCase();
    if (/^f\d+$/.test(p)) return p.toUpperCase();
    const punct = { comma: ',', period: '.', slash: '/', semicolon: ';', quote: "'", bracketleft: '[', bracketright: ']', minus: '-', equal: '=', backquote: '`' };
    if (punct[p]) return punct[p];
    return p.length === 1 ? p.toUpperCase() : (p.charAt(0).toUpperCase() + p.slice(1));
  }).join('+');
}

// Skróty zarezerwowane przez Chrome — większości NIE da się przechwycić w content
// scripcie (przeglądarka łapie je wcześniej). Lista nie jest wyczerpująca; warn
// tylko gdy są pewne kolizje.
const JZ_CHROME_SHORTCUTS = new Set([
  'ctrl+t', 'ctrl+w', 'ctrl+n', 'ctrl+shift+t', 'ctrl+shift+n', 'ctrl+shift+w',
  'ctrl+tab', 'ctrl+shift+tab',
  'ctrl+1', 'ctrl+2', 'ctrl+3', 'ctrl+4', 'ctrl+5', 'ctrl+6', 'ctrl+7', 'ctrl+8', 'ctrl+9',
  'ctrl+l', 'ctrl+d', 'ctrl+shift+d', 'ctrl+shift+b',
  'ctrl+r', 'ctrl+shift+r', 'f5', 'ctrl+shift+i', 'f12', 'f11',
  'ctrl+u', 'ctrl+j', 'ctrl+h', 'ctrl+shift+j',
  'ctrl+plus', 'ctrl+minus', 'ctrl+0',
  'alt+f4', 'alt+home', 'alt+arrowleft', 'alt+arrowright',
]);
function jzShortcutConflictsChrome(s) {
  return JZ_CHROME_SHORTCUTS.has(s);
}

// Bezpieczna pula skrótów akcji — wyłącznie strefa Ctrl+Alt+Shift+<znak>.
// Cross-platform: brak kolizji ze skrótami przeglądarek (Chrome/Edge/Opera/Firefox)
// ani systemu na Windows/Linux/macOS. Shift neutralizuje AltGr (układy EU), Ctrl
// neutralizuje macOS Option-char — kombinacja nie wstawia znaku do pola tekstowego.
// Wykluczone litery g/h/j/k/l (skróty systemowe JustZix) oraz backslash (AI Helper
// + niestabilny e.code na klawiaturach ISO/JIS). 41 wpisów.
const JZ_SAFE_SHORTCUTS = new Set([
  'ctrl+alt+shift+f', 'ctrl+alt+shift+d', 'ctrl+alt+shift+s', 'ctrl+alt+shift+a',
  'ctrl+alt+shift+r', 'ctrl+alt+shift+e', 'ctrl+alt+shift+w', 'ctrl+alt+shift+v',
  'ctrl+alt+shift+c', 'ctrl+alt+shift+x', 'ctrl+alt+shift+t', 'ctrl+alt+shift+b',
  'ctrl+alt+shift+u', 'ctrl+alt+shift+i', 'ctrl+alt+shift+o', 'ctrl+alt+shift+m',
  'ctrl+alt+shift+n', 'ctrl+alt+shift+p', 'ctrl+alt+shift+q', 'ctrl+alt+shift+y',
  'ctrl+alt+shift+z',
  'ctrl+alt+shift+0', 'ctrl+alt+shift+1', 'ctrl+alt+shift+2', 'ctrl+alt+shift+3',
  'ctrl+alt+shift+4', 'ctrl+alt+shift+5', 'ctrl+alt+shift+6', 'ctrl+alt+shift+7',
  'ctrl+alt+shift+8', 'ctrl+alt+shift+9',
  'ctrl+alt+shift+comma', 'ctrl+alt+shift+period', 'ctrl+alt+shift+slash',
  'ctrl+alt+shift+semicolon', 'ctrl+alt+shift+quote', 'ctrl+alt+shift+bracketleft',
  'ctrl+alt+shift+bracketright', 'ctrl+alt+shift+minus', 'ctrl+alt+shift+equal',
  'ctrl+alt+shift+backquote',
]);

// Wewnętrzny konflikt — czy inna akcja w bibliotece ma ten sam skrót.
// Zwraca tablicę {actionLabel, ruleName, folderName} dla wszystkich kolizji
// (z wyjątkiem samej `excludeActionId`).
function jzFindShortcutConflicts(shortcut, excludeActionId) {
  if (!shortcut) return [];
  const conflicts = [];
  for (const r of rules) {
    if (!Array.isArray(r.actions)) continue;
    for (const a of r.actions) {
      if (a.shortcut !== shortcut) continue;
      if (a.id === excludeActionId) continue;
      const group = groups.find(g => g.id === r.groupId);
      const folder = group ? folders.find(f => f.id === group.folderId) : null;
      conflicts.push({
        actionLabel: a.label || JZ_I18N.t('rule.card.action.fallback.unlabeled'),
        ruleName: r.name || JZ_I18N.t('sidebar.fallback.ruleUnnamed'),
        folderName: folder ? (folder.name || JZ_I18N.t('sidebar.fallback.folderName')) : '(?)',
      });
    }
  }
  return conflicts;
}

// Defs kolorów dla pickera akcji — type-aware. Od v2.13.60. Wcześniej tylko `color`+`colorText`
// w wierszu, pozostałe kolory były ukryte w sub-modalach (select-options/slider/toggle3/input/textarea
// editor). Teraz wszystkie kolory danej akcji w jednym pickerze na liście.
function actionColorDefs(action) {
  const defs = [
    { key: 'color',     label: JZ_I18N.t('action.colors.bg'),   defaultColor: '#006870' },
    { key: 'colorText', label: JZ_I18N.t('action.colors.text'), defaultColor: '#ffffff' },
  ];
  if (action.type === 'select') {
    defs.push({ key: 'colorDropdownBg', label: JZ_I18N.t('action.colors.dropdownBg'), defaultColor: '#ffffff' });
    defs.push({ key: 'colorOption',     label: JZ_I18N.t('action.colors.option'),     defaultColor: '#1a1a1a' });
    defs.push({ key: 'colorSelected',   label: JZ_I18N.t('action.colors.selected'),   defaultColor: '#006870' });
    defs.push({ key: 'colorHover',      label: JZ_I18N.t('action.colors.hover'),      defaultColor: '#e0f7fa' });
  } else if (action.type === 'slider') {
    defs.push({ key: 'colorLabel', label: JZ_I18N.t('action.colors.label'), defaultColor: '#ffffff' });
    defs.push({ key: 'colorValue', label: JZ_I18N.t('action.colors.value'), defaultColor: '#ffffff' });
  } else if (action.type === 'input' || action.type === 'textarea') {
    defs.push({ key: 'colorPlaceholder', label: JZ_I18N.t('action.colors.placeholder'), defaultColor: '#9ca3af' });
  } else if (action.type === 'toggle3') {
    defs.push({ key: 'colorBg',           label: JZ_I18N.t('action.colors.bgInactive'),   defaultColor: '#e5e7eb' });
    defs.push({ key: 'colorHover',        label: JZ_I18N.t('action.colors.hover'),        defaultColor: '#cbd5e1' });
    defs.push({ key: 'colorInactiveText', label: JZ_I18N.t('action.colors.inactiveText'), defaultColor: '#374151' });
  }
  return defs;
}

function renderActionsList(card, rule) {
  const list = card.querySelector('.action-buttons-list');
  const addBtn = card.querySelector('.add-action-btn');
  list.innerHTML = '';

  if (!Array.isArray(rule.actions)) rule.actions = [];

  rule.actions.forEach((action, idx) => {
    const row = document.createElement('div');
    row.className = 'action-btn-row';
    if (!action.visible) row.classList.add('action-disabled');

    const actionType = action.type || 'button';

    // Per-type "middle button" — przycisk otwierający edytor specyficzny dla typu:
    // - button: edytor kodu (single JS snippet)
    // - select: edytor SELECT (variant static/js + opcje + kod + memory)
    // - input:  edytor INPUT (placeholder + default + memory + kod)
    // - slider: edytor SLIDER (min/max/step/default/unit + kod)
    let middleBtnHtml;
    if (actionType === 'select') {
      const variant = action.variant || 'static';
      const hasCode = !!(action.code && action.code.trim());
      let label;
      if (variant === 'static') {
        const optsCount = Array.isArray(action.options) ? action.options.length : 0;
        label = optsCount === 0
          ? JZ_I18N.t('action.select.middleBtn.staticEmpty')
          : JZ_I18N.t('action.select.middleBtn.static', { count: optsCount, code: hasCode ? '✓' : '✗' });
      } else {
        label = JZ_I18N.t('action.select.middleBtn.js', { code: hasCode ? '✓' : '✗' });
      }
      middleBtnHtml = `<button class="ab-options-edit ${hasCode ? 'has-code' : ''}">${escapeHtml(label)}</button>`;
    } else if (actionType === 'input') {
      const hasCode = !!(action.code && action.code.trim());
      const label = JZ_I18N.t('action.input.middleBtn', { code: hasCode ? '✓' : '✗' });
      middleBtnHtml = `<button class="ab-options-edit ${hasCode ? 'has-code' : ''}">${escapeHtml(label)}</button>`;
    } else if (actionType === 'slider') {
      const hasCode = !!(action.code && action.code.trim());
      const min = action.min ?? 0;
      const max = action.max ?? 100;
      const step = action.step ?? 1;
      const unit = action.unit || '';
      const label = JZ_I18N.t('action.slider.middleBtn', {
        min, max, step,
        unit: unit ? unit : '',
        code: hasCode ? '✓' : '✗',
      });
      middleBtnHtml = `<button class="ab-options-edit ${hasCode ? 'has-code' : ''}">${escapeHtml(label)}</button>`;
    } else if (actionType === 'textarea') {
      const hasCode = !!(action.code && action.code.trim());
      const rows = action.rows ?? 3;
      const label = JZ_I18N.t('action.textarea.middleBtn', { rows, code: hasCode ? '✓' : '✗' });
      middleBtnHtml = `<button class="ab-options-edit ${hasCode ? 'has-code' : ''}">${escapeHtml(label)}</button>`;
    } else if (actionType === 'toggle3') {
      const hasCode = !!(action.code && action.code.trim());
      const states = Array.isArray(action.states) ? action.states : [];
      const filled = states.filter(s => s && s.label).length;
      const label = JZ_I18N.t('action.toggle3.middleBtn', { filled, code: hasCode ? '✓' : '✗' });
      middleBtnHtml = `<button class="ab-options-edit ${hasCode ? 'has-code' : ''}">${escapeHtml(label)}</button>`;
    } else {
      const codeText = (action.code || '').trim();
      const codeLines = codeText ? codeText.split('\n').length : 0;
      const firstLine = codeText ? codeText.split('\n')[0].slice(0, 40) + (codeText.split('\n')[0].length > 40 ? '…' : '') : '';
      const codePreview = codeText
        ? JZ_I18N.t('rule.card.action.codePreview', { count: codeLines, word: JZ_I18N.plural(codeLines, 'plural.jsLine'), first: firstLine })
        : JZ_I18N.t('action.code.empty');
      middleBtnHtml = `<button class="ab-code-edit ${codeText ? 'has-code' : ''}">${escapeHtml(codePreview)}</button>`;
    }

    const clickCount = action.clickCount || 0;
    const statsText = clickCount > 0
      ? `<span class="ab-stats" title="${escapeAttr(JZ_I18N.t('action.stats.title'))}">${JZ_ICONS.stats} ${clickCount}</span>`
      : '';

    // Selektor paska dla tej akcji — bary dziedziczone przez zestaw (folder + group + rule).
    // Custom dropdown (od v2.12.3) z SVG ikonami JZ_ICONS.folder/group/rule.
    const inheritedBars = getInheritedBarsForRule(rule);
    const barSelectTitle = inheritedBars.length > 0
      ? JZ_I18N.t('action.bar.selectTitle')
      : JZ_I18N.t('action.bar.noBarsHint');

    // Type dropdown (od v2.12.1) — wybór typu elementu.
    // v2.13.0 dodaje INPUT, v2.13.17 SLIDER, v2.13.21 TEXTAREA, v2.13.25 TOGGLE3 (Faza 5 complete).
    const typeOptions = [
      { v: 'button',   l: JZ_I18N.t('action.type.button') },
      { v: 'select',   l: JZ_I18N.t('action.type.select') },
      { v: 'input',    l: JZ_I18N.t('action.type.input')  },
      { v: 'slider',   l: JZ_I18N.t('action.type.slider') },
      { v: 'textarea', l: JZ_I18N.t('action.type.textarea') },
      { v: 'toggle3',  l: JZ_I18N.t('action.type.toggle3') },
    ].map(t => `<option value="${t.v}" ${actionType === t.v ? 'selected' : ''}>${escapeHtml(t.l)}</option>`).join('');

    // Badge (od v2.13.7) — wyświetla numer rzędu (1-based), tooltip = pełne action.id,
    // klik → copy ID do schowka. Number nie jest stabilny (zmienia się przy reorder),
    // ID jest stabilne — to ono trafia do user JS code przez copy.
    // Drugi kolor (od v2.13.18) — kolor tekstu (BUTTON/SELECT/INPUT) lub kolor paska+tekstu (SLIDER).
    // Default white (gdy `colorText` undefined). Każda color-group ma picker + hex text input
    // (sync 2-way) — natywne ctrl+C/ctrl+V w hex text input rozwiązuje copy/paste.
    const colorBgHex = (action.color || '#006870');
    const colorTextHex = (action.colorText || '#ffffff');

    row.innerHTML = `
      <button type="button" class="ab-id-badge" data-action-id="${escapeAttr(action.id)}" title="${escapeAttr(action.id)}" draggable="true">${idx + 1}</button>
      <input type="text" class="ab-label" placeholder="ABCDEFGH" maxlength="8" value="${escapeAttr(action.label || '')}" style="background:${escapeAttr(colorBgHex)};color:${escapeAttr(colorTextHex)};">
      <span class="ab-color-slot"></span>
      <select class="ab-type" title="${escapeAttr(JZ_I18N.t('action.type.title'))}" style="padding:4px 6px;font-size:11px;border:1px solid #d1d5db;border-radius:4px;">${typeOptions}</select>
      <span class="ab-bar-slot"></span>
      <label class="ab-visible-wrap" title="${escapeAttr(JZ_I18N.t('action.visible.title'))}">
        <input type="checkbox" class="ab-visible" ${action.visible ? 'checked' : ''}>
        ${escapeHtml(JZ_I18N.t('rule.card.action.visibleLabel'))}
      </label>
      <button class="ab-shortcut ${action.shortcut ? 'has-shortcut' : ''}" title="${escapeAttr(JZ_I18N.t('action.shortcut.title'))}">⌨ ${escapeHtml(jzShortcutDisplay(action.shortcut))}</button>
      <button class="ab-shortcut-clear" title="${escapeAttr(JZ_I18N.t('action.shortcut.removeBtn'))}">${JZ_ICONS.close}</button>
      ${middleBtnHtml}
      ${statsText}
      <button class="ab-action-dup ab-action-icon" title="${escapeAttr(JZ_I18N.t('action.menu.btn.duplicate'))}">${JZ_ICONS.duplicate}</button>
      <button class="ab-action-move ab-action-icon" title="${escapeAttr(JZ_I18N.t('action.menu.btn.move'))}">${JZ_ICONS.move}</button>
      <button class="ab-action-copy ab-action-icon" title="${escapeAttr(JZ_I18N.t('action.menu.btn.copy'))}">${JZ_ICONS.copy}</button>
      <button class="ab-delete" title="${escapeAttr(JZ_I18N.t('action.delete.btn'))}">${JZ_ICONS.close}</button>
    `;

    // Wstaw custom bar dropdown w slot (zamiast <select>).
    const barSlot = row.querySelector('.ab-bar-slot');
    const barItems = [{ value: '', name: JZ_I18N.t('action.bar.noneOption') }]
      .concat(inheritedBars.map(b => ({
        value: b.id,
        name: b.name || JZ_I18N.t('action.bar.unnamed'),
        parentType: b.parentType,
      })));
    const barDropdown = createBarDropdown({
      items: barItems,
      currentValue: action.barId || '',
      placeholder: JZ_I18N.t('action.bar.noneOption'),
      disabled: inheritedBars.length === 0,
      className: 'ab-bar',
      onChange: (val) => {
        if (val) action.barId = val;
        else delete action.barId;
        saveAll(true);
      },
    });
    barDropdown.title = barSelectTitle;
    barSlot.replaceWith(barDropdown);

    const idBadge = row.querySelector('.ab-id-badge');
    if (idBadge) {
      idBadge.addEventListener('click', (e) => {
        // Klik (bez drag) = copy ID do schowka. dragend nie wystrzeli click — wystarczy.
        e.preventDefault();
        const id = idBadge.dataset.actionId || '';
        copyToClipboard(id, idBadge);
      });
      // Drag-and-drop reorder (od v2.13.61) — chwyt za badge.
      idBadge.addEventListener('dragstart', (e) => {
        e.dataTransfer.effectAllowed = 'move';
        e.dataTransfer.setData('text/x-jz-action-id', action.id);
        // Też plain text/plain — Chrome wymaga, inaczej dragstart cancel w niektórych warunkach.
        e.dataTransfer.setData('text/plain', action.id);
        row.classList.add('ab-row-dragging');
      });
      idBadge.addEventListener('dragend', () => {
        row.classList.remove('ab-row-dragging');
        card.querySelectorAll('.ab-row-drop-above, .ab-row-drop-below')
          .forEach(el => el.classList.remove('ab-row-drop-above', 'ab-row-drop-below'));
      });
    }
    // Drop target — cały wiersz; podział na "powyżej / poniżej" zależnie od y kursora.
    row.addEventListener('dragover', (e) => {
      // Sprawdź czy to nasz dragstart (typ JZ-action-id). Inaczej ignoruj (np. drag tekstu z textarea).
      if (!Array.from(e.dataTransfer.types || []).includes('text/x-jz-action-id')) return;
      const draggedId = e.dataTransfer.getData('text/x-jz-action-id');
      if (draggedId === action.id) return; // nie nad sobą samym
      e.preventDefault();
      e.dataTransfer.dropEffect = 'move';
      const rect = row.getBoundingClientRect();
      const above = (e.clientY - rect.top) < rect.height / 2;
      row.classList.toggle('ab-row-drop-above', above);
      row.classList.toggle('ab-row-drop-below', !above);
    });
    row.addEventListener('dragleave', () => {
      row.classList.remove('ab-row-drop-above', 'ab-row-drop-below');
    });
    row.addEventListener('drop', async (e) => {
      const draggedId = e.dataTransfer.getData('text/x-jz-action-id');
      if (!draggedId || draggedId === action.id) return;
      e.preventDefault();
      const rect = row.getBoundingClientRect();
      const above = (e.clientY - rect.top) < rect.height / 2;
      const fromIdx = rule.actions.findIndex(a => a.id === draggedId);
      const toIdx   = rule.actions.findIndex(a => a.id === action.id);
      if (fromIdx < 0 || toIdx < 0) return;
      const [moved] = rule.actions.splice(fromIdx, 1);
      // toIdx mógł się przesunąć po splice — wylicz na nowo.
      let insertAt = rule.actions.findIndex(a => a.id === action.id);
      if (!above) insertAt += 1;
      rule.actions.splice(insertAt, 0, moved);
      row.classList.remove('ab-row-drop-above', 'ab-row-drop-below');
      await saveAll(true);
      renderActionsList(card, rule);
    });

    const labelInput = row.querySelector('.ab-label');
    const typeSelect = row.querySelector('.ab-type');
    // bar dropdown (custom component) — onChange podpięty w createBarDropdown wyżej.
    const visCb = row.querySelector('.ab-visible');
    const codeBtn = row.querySelector('.ab-code-edit');         // tylko gdy type='button'
    const optsBtn = row.querySelector('.ab-options-edit');      // tylko gdy type='select'
    const shortcutBtn = row.querySelector('.ab-shortcut');
    const shortcutClearBtn = row.querySelector('.ab-shortcut-clear');
    const dupBtn = row.querySelector('.ab-action-dup');
    const moveBtn = row.querySelector('.ab-action-move');
    const copyBtn = row.querySelector('.ab-action-copy');
    const delBtn = row.querySelector('.ab-delete');

    typeSelect.addEventListener('change', async (e) => {
      const newType = e.target.value;
      if (newType === action.type) return;
      // Zmiana typu: zachowujemy label/color/bar/visible/shortcut. Pola specyficzne
      // dla typu zostają w danych — gdyby user wrócił do poprzedniego typu, dane
      // są przywrócone bez utraty.
      action.type = newType;
      if (newType === 'select') {
        if (!action.variant) action.variant = 'static';
        if (!Array.isArray(action.options)) action.options = [];
        if (typeof action.code !== 'string') action.code = '';
      } else if (newType === 'input') {
        if (typeof action.code !== 'string') action.code = '';
        if (typeof action.placeholder !== 'string') action.placeholder = '';
        if (typeof action.defaultValue !== 'string') action.defaultValue = '';
      } else if (newType === 'slider') {
        if (typeof action.code !== 'string') action.code = '';
        if (typeof action.min !== 'number') action.min = 0;
        if (typeof action.max !== 'number') action.max = 100;
        if (typeof action.step !== 'number') action.step = 1;
        if (typeof action.defaultValue !== 'number') {
          // Migracja: gdy zmieniamy z input/select (gdzie defaultValue było stringiem) — odrzucić
          // żeby NOT zhuetelle do zewn. typu. Inaczej initial = (min+max)/2.
          action.defaultValue = Math.round((action.min + action.max) / 2);
        }
        if (typeof action.unit !== 'string') action.unit = '';
      } else if (newType === 'textarea') {
        if (typeof action.code !== 'string') action.code = '';
        if (typeof action.placeholder !== 'string') action.placeholder = '';
        // defaultValue: jeśli przyszło z SLIDER (number), wymuś empty string żeby textarea
        // nie pokazało liczby. Inaczej zachowaj (np. przy input → textarea string przepuszczamy).
        if (typeof action.defaultValue !== 'string') action.defaultValue = '';
        if (typeof action.rows !== 'number') action.rows = 3;
      } else if (newType === 'toggle3') {
        if (typeof action.code !== 'string') action.code = '';
        // Init 3 stanów jeśli brak (lub zła długość). Default labels: OFF/MID/ON,
        // values takie same — user może je zmienić w modalu.
        if (!Array.isArray(action.states) || action.states.length !== 3) {
          action.states = [
            { id: 'st_' + Math.random().toString(36).slice(2, 8), label: JZ_I18N.t('action.toggle3.defaultState1'), value: '0' },
            { id: 'st_' + Math.random().toString(36).slice(2, 8), label: JZ_I18N.t('action.toggle3.defaultState2'), value: '1' },
            { id: 'st_' + Math.random().toString(36).slice(2, 8), label: JZ_I18N.t('action.toggle3.defaultState3'), value: '2' },
          ];
        }
        if (typeof action.defaultStateIdx !== 'number' || action.defaultStateIdx < 0 || action.defaultStateIdx > 2) {
          action.defaultStateIdx = 0;
        }
      }
      await saveAll();
      renderActionsList(card, rule); // re-render row żeby pokazać per-type middle
    });

    labelInput.addEventListener('input', (e) => {
      // v2.13.60: label = preview (jedno pole). v2.13.61: maxlength 6 → 8.
      action.label = e.target.value.slice(0, 8);
      debouncedSave();
    });
    // === Multi-color picker (od v2.13.59, rozszerzony o type-aware kolory v2.13.60).
    // Defs zależne od action.type — wszystkie kolory edytowalne w jednym dropdownie zamiast
    // schowane w sub-modalach edytora.
    const colorSlot = row.querySelector('.ab-color-slot');
    const colorDefs = actionColorDefs(action);
    const fallbackByKey = (key) => {
      const d = colorDefs.find(x => x.key === key);
      return d ? d.defaultColor : '';
    };
    const colorPicker = createMultiColorPicker({
      defs: colorDefs,
      getValue: (key) => action[key] || '',
      setValue: (key, hex) => {
        // color/colorText dodatkowo synchronizują tło/text label-button. Dla pozostałych
        // kolorów (sub-elementy: dropdown, slider, toggle3, placeholder) — zapis i tyle,
        // wpływ widoczny na froncie po render-rule.
        if (key === 'color') {
          action.color = hex || fallbackByKey('color');
          labelInput.style.background = action.color;
        } else if (key === 'colorText') {
          action.colorText = hex || fallbackByKey('colorText');
          labelInput.style.color = action.colorText;
        } else {
          // Pozostałe pola — pusty string DOZWOLONY (oznacza "użyj domyślnej heurystyki frontu").
          action[key] = hex || '';
        }
        debouncedSave();
      },
    });
    colorSlot.replaceWith(colorPicker);
    visCb.addEventListener('change', (e) => {
      action.visible = e.target.checked;
      row.classList.toggle('action-disabled', !action.visible);
      debouncedSave();
    });
    if (codeBtn) codeBtn.addEventListener('click', () => {
      openActionCodeModal(rule, action);
    });
    if (optsBtn) optsBtn.addEventListener('click', () => {
      if (action.type === 'input') {
        openInputEditorModal(rule, action);
      } else if (action.type === 'slider') {
        openSliderEditorModal(rule, action);
      } else if (action.type === 'textarea') {
        openTextareaEditorModal(rule, action);
      } else if (action.type === 'toggle3') {
        openToggle3EditorModal(rule, action);
      } else {
        openSelectOptionsModal(rule, action);
      }
    });

    // === Skrót klawiszowy ===
    let capturing = false;
    function refreshShortcutDisplay() {
      shortcutBtn.textContent = '⌨ ' + jzShortcutDisplay(action.shortcut);
      shortcutBtn.classList.toggle('has-shortcut', !!action.shortcut);
      // Wewnętrzny konflikt — inna akcja używa tego samego skrótu.
      const conflicts = jzFindShortcutConflicts(action.shortcut, action.id);
      shortcutBtn.classList.toggle('has-conflict', conflicts.length > 0);
      if (conflicts.length > 0) {
        const list = conflicts.map(c => JZ_I18N.t('action.shortcut.conflictItem', {
          action: c.actionLabel,
          rule: c.ruleName,
          folder: c.folderName,
        })).join('\n');
        shortcutBtn.title = JZ_I18N.t('action.shortcut.conflictTitle', {
          count: conflicts.length,
          word: JZ_I18N.plural(conflicts.length, 'plural.actionAlt'),
          list,
        });
      } else if (action.shortcut) {
        shortcutBtn.title = JZ_I18N.t('action.shortcut.titleAfterClick');
      } else {
        shortcutBtn.title = JZ_I18N.t('action.shortcut.title');
      }
    }
    refreshShortcutDisplay();
    shortcutBtn.addEventListener('click', () => {
      capturing = true;
      shortcutBtn.classList.add('capturing');
      shortcutBtn.classList.remove('has-shortcut');
      shortcutBtn.textContent = JZ_I18N.t('btn.shortcutPress');
      shortcutBtn.focus();
    });
    shortcutBtn.addEventListener('keydown', (e) => {
      if (!capturing) return;
      e.preventDefault();
      e.stopPropagation();
      if (e.key === 'Escape') {
        capturing = false;
        shortcutBtn.classList.remove('capturing');
        refreshShortcutDisplay();
        shortcutBtn.blur();
        return;
      }
      const sc = jzEventToShortcut(e);
      if (!sc) return; // tylko modyfikator — czekamy na key
      // Walidacja: skrót musi należeć do bezpiecznej puli (Ctrl+Alt+Shift + znak).
      // Pula gwarantuje brak kolizji z przeglądarką, systemem i edycją tekstu na
      // Windows/Linux/macOS — patrz JZ_SAFE_SHORTCUTS.
      if (!JZ_SAFE_SHORTCUTS.has(sc)) {
        capturing = false;
        shortcutBtn.classList.remove('capturing');
        showToast(JZ_I18N.t('toast.shortcutMustIncludeMod'), true);
        refreshShortcutDisplay();
        shortcutBtn.blur();
        return;
      }
      action.shortcut = sc;
      capturing = false;
      shortcutBtn.classList.remove('capturing');
      refreshShortcutDisplay();
      saveAll(true);
      shortcutBtn.blur();
      if (jzShortcutConflictsChrome(sc)) {
        showToast(JZ_I18N.t('toast.shortcutCollidesChrome'), true);
      }
      const internalConflicts = jzFindShortcutConflicts(sc, action.id);
      if (internalConflicts.length > 0) {
        const first = internalConflicts[0];
        const moreCount = internalConflicts.length - 1;
        const more = moreCount > 0
          ? JZ_I18N.t('action.shortcut.conflictMore', { count: moreCount, word: JZ_I18N.plural(moreCount, 'plural.more') })
          : '';
        showToast(JZ_I18N.t('action.shortcut.conflictToast', {
          actionLabel: first.actionLabel,
          ruleName: first.ruleName,
          more,
        }), true);
      }
    });
    shortcutBtn.addEventListener('blur', () => {
      if (capturing) {
        capturing = false;
        shortcutBtn.classList.remove('capturing');
        refreshShortcutDisplay();
      }
    });
    shortcutBtn.addEventListener('contextmenu', (e) => {
      e.preventDefault();
      if (action.shortcut) {
        action.shortcut = null;
        refreshShortcutDisplay();
        saveAll(true);
      }
    });
    shortcutClearBtn.addEventListener('click', (e) => {
      e.preventDefault();
      e.stopPropagation();
      if (action.shortcut) {
        action.shortcut = null;
        refreshShortcutDisplay();
        saveAll(true);
      }
    });

    delBtn.addEventListener('click', () => {
      if (!confirm(JZ_I18N.t('confirm.deleteAction', { label: action.label || '' }))) return;
      // Indeks liczymy świeżo po action.id — `idx` z czasu renderu mógł się
      // zdezaktualizować (sync pull / inny handler zmienił rule.actions).
      const delIdx = rule.actions.findIndex(a => a.id === action.id);
      if (delIdx < 0) return;
      rule.actions.splice(delIdx, 1);
      saveAll();
      // Przerysuj kartę aby odświeżyć badge
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) {
        renderActionsList(card, rule);
        // Odśwież badge w zakładce
        const badge = card.querySelector('.editor-tab[data-tab="actions"]');
        if (badge) {
          const count = rule.actions.length;
          badge.innerHTML = `${jzIcon('flagActions', 'tab-flag flag-actions')} ${JZ_I18N.t('rule.card.tab.actions')}${count ? ` <span style="background:#006870;color:white;padding:1px 5px;border-radius:8px;font-size:10px;margin-left:4px;">${count}</span>` : ''}`;
        }
      }
    });

    // Duplikuj — single click, inline w bieżącym rule (idx+1), nowy id, clickCount=0.
    if (dupBtn) {
      dupBtn.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        const dup = JSON.parse(JSON.stringify(action));
        dup.id = genId('a');
        dup.clickCount = 0;
        rule.actions.splice(idx + 1, 0, dup);
        saveAll();
        renderActionsList(card, rule);
      });
    }
    if (moveBtn) {
      moveBtn.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        showActionMoveMenu(rule, action, idx, moveBtn, 'move');
      });
    }
    if (copyBtn) {
      copyBtn.addEventListener('click', (e) => {
        e.preventDefault();
        e.stopPropagation();
        showActionMoveMenu(rule, action, idx, copyBtn, 'copy');
      });
    }

    list.appendChild(row);
  });

  addBtn.disabled = false;
  addBtn.textContent = rule.actions.length
    ? JZ_I18N.t('rule.card.actions.addWithCount', { count: rule.actions.length })
    : JZ_I18N.t('rule.card.actions.add');

  addBtn.onclick = () => {
    // Auto-przypisanie do baru: pierwszy dostępny w drzewie zestawu (rule → group → folder).
    // Jeśli żaden bar nie istnieje, action zostaje bez barId — content.js renderuje go w
    // legacy ghost panelu folderu (jeśli folder.showActionsPanel=true). User może później
    // przypisać przez UI w fazie 5.
    const inheritedBars = getInheritedBarsForRule(rule);
    const defaultBarId = inheritedBars.length > 0 ? inheritedBars[0].id : undefined;
    const newAction = {
      id: genId('a'),
      type: 'button',
      label: 'BTN',
      color: '#006870',
      code: '',
      visible: true,
    };
    if (defaultBarId) newAction.barId = defaultBarId;
    rule.actions.push(newAction);
    saveAll();
    renderActionsList(card, rule);
    // Aktualizuj badge
    const count = rule.actions.length;
    const badge = card.querySelector('.editor-tab[data-tab="actions"]');
    if (badge) {
      badge.innerHTML = `${jzIcon('flagActions', 'tab-flag flag-actions')} ${JZ_I18N.t('rule.card.tab.actions')}${count ? ` <span style="background:#006870;color:white;padding:1px 5px;border-radius:8px;font-size:10px;margin-left:4px;">${count}</span>` : ''}`;
    }
  };
}

// Pasek wyszukiwania nad edytorem kodu CodeMirror (od v2.13.130).
// Klik 🔍 → znajduje wszystkie wystąpienia, podświetla je na niebiesko, pokazuje
// licznik; strzałki ▲▼ (lub Ctrl+Alt+←/→) przechodzą między wynikami gdy > 1.
// Ctrl+Alt+F z edytora przenosi fokus do pola wyszukiwania.
function attachCmSearch(cm) {
  if (!cm || cm._jzSearchAttached) return;
  cm._jzSearchAttached = true;
  const t = (k) => (window.JZ_I18N ? JZ_I18N.t(k) : k);
  const wrap = cm.getWrapperElement();
  if (!wrap || !wrap.parentNode) return;

  const bar = document.createElement('div');
  bar.className = 'cm-search-bar';
  const input = document.createElement('input');
  input.type = 'search';
  input.className = 'cm-search-input';
  input.placeholder = t('codeSearch.placeholder');
  input.spellcheck = false;
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'cm-search-btn';
  btn.innerHTML = (window.JZ_ICONS && JZ_ICONS.search) || '?';
  btn.title = t('codeSearch.btn');
  const count = document.createElement('span');
  count.className = 'cm-search-count';
  const prev = document.createElement('button');
  prev.type = 'button';
  prev.className = 'cm-search-nav';
  prev.textContent = '▲';
  prev.title = t('codeSearch.prev');
  prev.hidden = true;
  const next = document.createElement('button');
  next.type = 'button';
  next.className = 'cm-search-nav';
  next.textContent = '▼';
  next.title = t('codeSearch.next');
  next.hidden = true;
  bar.appendChild(input);
  bar.appendChild(btn);
  bar.appendChild(count);
  bar.appendChild(prev);
  bar.appendChild(next);
  wrap.parentNode.insertBefore(bar, wrap);
  cm._jzSearchBar = bar;

  let matches = [];
  let marks = [];
  let idx = -1;
  let lastQuery = '';

  function clearMarks() {
    marks.forEach(m => { try { m.clear(); } catch (e) {} });
    marks = [];
  }
  function reset() {
    clearMarks();
    matches = [];
    idx = -1;
    lastQuery = '';
    count.textContent = '';
    count.classList.remove('cm-search-nomatch');
    prev.hidden = true;
    next.hidden = true;
  }
  // Przerysuj podświetlenia: bieżące trafienie żółte, pozostałe niebieskie.
  function paint() {
    clearMarks();
    const len = lastQuery.length;
    if (!len) return;
    matches.forEach((p, i) => {
      marks.push(cm.markText(cm.posFromIndex(p), cm.posFromIndex(p + len), {
        className: i === idx ? 'cm-search-hl-active' : 'cm-search-hl',
      }));
    });
  }
  function goTo() {
    paint();
    const start = matches[idx];
    const from = cm.posFromIndex(start);
    const to = cm.posFromIndex(start + lastQuery.length);
    cm.scrollIntoView({ from: from, to: to }, 80);
    count.textContent = (idx + 1) + ' / ' + matches.length;
  }
  function run() {
    const q = input.value;
    clearMarks();
    matches = [];
    idx = -1;
    lastQuery = q;
    count.classList.remove('cm-search-nomatch');
    if (q) {
      const lt = cm.getValue().toLowerCase();
      const lq = q.toLowerCase();
      let from = 0, p;
      while ((p = lt.indexOf(lq, from)) !== -1) {
        matches.push(p);
        from = p + lq.length;
      }
    }
    if (!matches.length) {
      count.textContent = q ? t('codeSearch.noMatch') : '';
      if (q) count.classList.add('cm-search-nomatch');
      prev.hidden = true;
      next.hidden = true;
      return;
    }
    idx = 0;
    const multi = matches.length > 1;
    prev.hidden = !multi;
    next.hidden = !multi;
    goTo();
  }
  function step(d) {
    if (matches.length < 2) return;
    idx = (idx + d + matches.length) % matches.length;
    goTo();
  }
  btn.addEventListener('click', run);
  prev.addEventListener('click', () => step(-1));
  next.addEventListener('click', () => step(1));
  input.addEventListener('keydown', (e) => {
    if (e.ctrlKey && e.altKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
      e.preventDefault();
      step(e.key === 'ArrowLeft' ? -1 : 1);
      return;
    }
    if (e.key === 'Enter') {
      e.preventDefault();
      // Re-search gdy zapytanie się zmieniło; inaczej przejdź do kolejnego trafienia.
      if (matches.length && input.value === lastQuery) step(e.shiftKey ? -1 : 1);
      else run();
    } else if (e.key === 'Escape') {
      input.value = '';
      reset();
    }
  });
  // Skróty z poziomu edytora kodu (od v2.13.131).
  cm.addKeyMap({
    'Ctrl-Alt-F': () => { input.focus(); input.select(); },
    'Ctrl-Alt-Left': () => step(-1),
    'Ctrl-Alt-Right': () => step(1),
  });
  // Edycja kodu unieważnia pozycje wyników — wymuś ponowne wyszukanie.
  cm.on('change', reset);
}

function initEditors(rule) {
  const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
  if (!card) return;
  const cssTa = card.querySelector('.editor-css');
  const jsTa = card.querySelector('.editor-js');
  const cssValidationEl = card.querySelector('.css-validation');

  const cmTheme = document.body.classList.contains('theme-dark') ? 'dracula' : 'default';

  const cssEd = CodeMirror.fromTextArea(cssTa, {
    mode: 'css',
    theme: cmTheme,
    lineNumbers: true,
    indentUnit: 2,
    tabSize: 2,
    lineWrapping: true
  });

  let cssValidationTimer = null;
  cssEd.on('change', () => {
    const newCss = cssEd.getValue();
    // Snapshot do historii jeśli zmiana znacząca (różnica > 50 znaków lub po pauzie)
    pushHistorySnapshotDebounced(rule, newCss, rule.js);
    rule.css = newCss;
    rule.updatedAt = Date.now();
    debouncedSave();

    // Walidacja CSS
    clearTimeout(cssValidationTimer);
    cssValidationTimer = setTimeout(() => {
      const issues = validateCSS(newCss);
      if (issues) {
        cssValidationEl.style.display = 'block';
        cssValidationEl.innerHTML = JZ_ICONS.warning + ' ' + issues.map(i => escapeHtml(i)).join(' • ');
      } else {
        cssValidationEl.style.display = 'none';
      }
    }, 400);
  });

  const jsEd = CodeMirror.fromTextArea(jsTa, {
    mode: 'javascript',
    theme: cmTheme,
    lineNumbers: true,
    indentUnit: 2,
    tabSize: 2,
    lineWrapping: true
  });
  const jsValidationEl = card.querySelector('.js-validation');
  const showJsIssues = (issues) => {
    if (issues) {
      jsValidationEl.style.display = 'block';
      jsValidationEl.innerHTML = JZ_ICONS.warning + ' ' + issues.map(i => escapeHtml(i)).join(' • ');
    } else {
      jsValidationEl.style.display = 'none';
    }
  };
  let jsValidationTimer = null;
  jsEd.on('change', () => {
    const newJs = jsEd.getValue();
    pushHistorySnapshotDebounced(rule, rule.css, newJs);
    rule.js = newJs;
    rule.updatedAt = Date.now();
    debouncedSave();

    // Walidacja składni JS
    clearTimeout(jsValidationTimer);
    jsValidationTimer = setTimeout(() => showJsIssues(validateJS(newJs)), 400);
  });

  editors[rule.id] = { css: cssEd, js: jsEd };

  attachCmSearch(cssEd);
  attachCmSearch(jsEd);

  const runOnceCb = card.querySelector('.rule-js-runonce');
  if (runOnceCb) {
    runOnceCb.addEventListener('change', (e) => {
      rule.jsRunOnce = e.target.checked;
      rule.updatedAt = Date.now();
      debouncedSave();
    });
  }

  setupResizeHandle(card.querySelector('.editor-resize-handle[data-target="css"]'), cssEd);
  setupResizeHandle(card.querySelector('.editor-resize-handle[data-target="js"]'), jsEd);

  // Pierwsza walidacja
  const initialIssues = validateCSS(rule.css || '');
  if (initialIssues) {
    cssValidationEl.style.display = 'block';
    cssValidationEl.innerHTML = JZ_ICONS.warning + ' ' + initialIssues.map(i => escapeHtml(i)).join(' • ');
  }
  showJsIssues(validateJS(rule.js || ''));

  setTimeout(() => cssEd.refresh(), 0);
}

// === Historia ===
const HISTORY_LIMIT = 5;
const historyTimers = {}; // ruleId -> timer

function pushHistorySnapshotDebounced(rule, newCss, newJs) {
  // Snapshot dodawany 3 sek po ostatniej zmianie
  if (historyTimers[rule.id]) clearTimeout(historyTimers[rule.id]);
  historyTimers[rule.id] = setTimeout(() => {
    if (!Array.isArray(rule.history)) rule.history = [];
    // Nie dodawaj jeśli jest identyczne z ostatnią wersją
    const last = rule.history[0];
    if (last && last.css === newCss && last.js === newJs) return;
    rule.history.unshift({
      ts: Date.now(),
      css: newCss,
      js: newJs
    });
    // Limit
    if (rule.history.length > HISTORY_LIMIT) {
      rule.history = rule.history.slice(0, HISTORY_LIMIT);
    }
    saveAll(true);
    // Aktualizuj badge w przycisku
    const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
    if (card) {
      const btn = card.querySelector('.btn-history');
      if (btn) btn.innerHTML = `${JZ_ICONS.undo} Historia<span class="history-count"> (${rule.history.length})</span>`;
    }
  }, 3000);
}

function showHistoryMenu(rule) {
  const history = rule.history || [];
  if (history.length === 0) {
    showToast(JZ_I18N.t('toast.noHistory'), false);
    return;
  }

  // Pokazuje listę w modal-like overlay
  const overlay = document.createElement('div');
  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:999;';

  const modal = document.createElement('div');
  modal.style.cssText = 'background:white;border-radius:8px;padding:20px;min-width:500px;max-width:700px;max-height:80vh;overflow-y:auto;';
  if (document.body.classList.contains('theme-dark')) {
    modal.style.background = '#1e293b';
    modal.style.color = '#f1f5f9';
  }

  let html = `<h3 style="margin-top:0;">${escapeHtml(JZ_I18N.t('history.modalTitle', { name: rule.name || '' }))}</h3>`;
  html += `<p style="font-size:12px;color:#6b7280;">${escapeHtml(JZ_I18N.t('history.header', { count: history.length }))}</p>`;

  history.forEach((snap, i) => {
    const date = new Date(snap.ts).toLocaleString(JZ_I18N.lang);
    const cssLines = (snap.css || '').split('\n').length;
    const jsLines = (snap.js || '').split('\n').length;
    html += `
      <div style="border:1px solid #e5e7eb;border-radius:6px;padding:10px;margin-bottom:8px;">
        <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
          <div>
            <strong style="font-size:13px;">${escapeHtml(JZ_I18N.t('history.version', { n: i + 1 }))}</strong>
            <span style="font-size:11px;color:#6b7280;margin-left:8px;">${escapeHtml(date)}</span>
          </div>
          <button data-restore="${i}" style="padding:4px 10px;background:#006870;color:white;border:none;border-radius:4px;cursor:pointer;font-size:12px;">${JZ_ICONS.undo} ${escapeHtml(JZ_I18N.t('history.restore'))}</button>
        </div>
        <div style="font-size:11px;color:#6b7280;font-family:monospace;">CSS: ${cssLines} • JS: ${jsLines}</div>
      </div>
    `;
  });

  html += `<div style="text-align:right;margin-top:12px;"><button id="closeHistoryBtn" style="padding:8px 14px;border-radius:4px;border:1px solid #d1d5db;background:white;cursor:pointer;">${escapeHtml(JZ_I18N.t('history.close'))}</button></div>`;

  modal.innerHTML = html;
  overlay.appendChild(modal);
  document.body.appendChild(overlay);

  modal.querySelectorAll('[data-restore]').forEach(btn => {
    btn.addEventListener('click', () => {
      const idx = parseInt(btn.dataset.restore);
      const snap = history[idx];
      if (!snap) return;
      if (!confirm(JZ_I18N.t('confirm.restoreVersion'))) return;

      // Aktualne -> historia (na górę)
      rule.history.unshift({ ts: Date.now(), css: rule.css, js: rule.js });
      if (rule.history.length > HISTORY_LIMIT) {
        rule.history = rule.history.slice(0, HISTORY_LIMIT);
      }
      // Wybrana wersja -> aktualna
      rule.css = snap.css;
      rule.js = snap.js;
      rule.updatedAt = Date.now();
      saveAll();

      // Aktualizuj edytor jeśli otwarty
      if (editors[rule.id]) {
        editors[rule.id].css.setValue(rule.css || '');
        editors[rule.id].js.setValue(rule.js || '');
      }
      overlay.remove();
      showToast(JZ_I18N.t('toast.versionRestored'));
    });
  });

  modal.querySelector('#closeHistoryBtn').addEventListener('click', () => overlay.remove());
  overlay.addEventListener('click', (e) => {
    if (e.target === overlay) overlay.remove();
  });
}

function setupResizeHandle(handle, editor) {
  if (!handle || !editor) return;
  let startY = 0;
  let startHeight = 0;
  let onMove = null;
  let onUp = null;

  handle.addEventListener('mousedown', (e) => {
    handle.classList.add('dragging');
    startY = e.clientY;
    startHeight = editor.getWrapperElement().offsetHeight;
    document.body.style.cursor = 'ns-resize';
    document.body.style.userSelect = 'none';

    onMove = (ev) => {
      const newHeight = Math.max(80, startHeight + (ev.clientY - startY));
      editor.setSize(null, newHeight);
    };
    onUp = () => {
      handle.classList.remove('dragging');
      document.body.style.cursor = '';
      document.body.style.userSelect = '';
      document.removeEventListener('mousemove', onMove);
      document.removeEventListener('mouseup', onUp);
    };

    document.addEventListener('mousemove', onMove);
    document.addEventListener('mouseup', onUp);
    e.preventDefault();
  });
}

// === Modal kodu akcji ===
// === Generic code editor modal (v2.12.1) ===
// Reusowane przez:
//   - button.code (openActionCodeModal)
//   - select.options[i].code (openSelectOptionCodeModal)
//   - przyszłe typed actions ze snippetami
//
// Konfig: { initialCode, title, onSave(newCode) — async, zwraca bool, parentModalId? }
// onSave zwraca true gdy zapisano OK; modal sam się zamyka po save.
// parentModalId (od v2.12.2): gdy podany, ten modal jest hidden przy open code editor
// i restored przy close — zapobiega stackowaniu dwóch modali (np. SELECT options modal +
// code editor jednocześnie zasłaniało okno).
let currentCodeEditorContext = null;

function openCodeEditorModal({ initialCode, title, onSave, parentModalId }) {
  currentCodeEditorContext = { initialCode: initialCode || '', onSave, parentModalId };
  // Legacy: currentActionContext nadal używane przez setupModalDismiss isDirty check.
  // Dla kompatybilności zostawiamy strukturę z initialCode.
  currentActionContext = { initialCode: initialCode || '' };
  document.getElementById('actionCodeTitle').textContent = title || '(bez nazwy)';
  const ta = document.getElementById('actionCodeText');
  ta.value = initialCode || '';

  // Hide parent modal (jeśli jest) zanim pokaż code editor.
  if (parentModalId) {
    const parent = document.getElementById(parentModalId);
    if (parent) parent.classList.remove('show');
  }

  document.getElementById('actionCodeModal').classList.add('show');

  if (actionCodeEditor) {
    try { if (actionCodeEditor._jzSearchBar) actionCodeEditor._jzSearchBar.remove(); } catch (e) {}
    try { actionCodeEditor.toTextArea(); } catch(e) {}
    actionCodeEditor = null;
  }
  // Canuluj poprzedni pending init — szybkie zamknięcie+otwarcie modala
  // tworzyłoby zdublowane instancje CodeMirror na tym samym <textarea>.
  clearTimeout(actionCodeEditorTimer);
  actionCodeEditorTimer = setTimeout(() => {
    const cmTheme = document.body.classList.contains('theme-dark') ? 'dracula' : 'default';
    actionCodeEditor = CodeMirror.fromTextArea(ta, {
      mode: 'javascript',
      theme: cmTheme,
      lineNumbers: true,
      indentUnit: 2,
      tabSize: 2,
      lineWrapping: true
    });
    actionCodeEditor.refresh();
    attachCmSearch(actionCodeEditor);
  }, 50);
}

function openActionCodeModal(rule, action) {
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || '').toUpperCase() || '(bez nazwy)',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) renderActionsList(card, rule);
      return true;
    },
  });
}

function closeActionCodeModal() {
  document.getElementById('actionCodeModal').classList.remove('show');
  clearTimeout(actionCodeEditorTimer);
  if (actionCodeEditor) {
    try { if (actionCodeEditor._jzSearchBar) actionCodeEditor._jzSearchBar.remove(); } catch (e) {}
    try { actionCodeEditor.toTextArea(); } catch(e) {}
    actionCodeEditor = null;
  }
  // Restore parent modal jeśli był.
  if (currentCodeEditorContext && currentCodeEditorContext.parentModalId) {
    const parent = document.getElementById(currentCodeEditorContext.parentModalId);
    if (parent) parent.classList.add('show');
  }
  currentActionContext = null;
  currentCodeEditorContext = null;
}

// Wyciągnięte z handlera saveActionCodeBtn — używane też w setupModalDismiss
// gdy user wybiera "Zapisz i zamknij" przy dirty close.
async function saveActionCode() {
  if (!currentCodeEditorContext || typeof currentCodeEditorContext.onSave !== 'function') return false;
  const newCode = actionCodeEditor ? actionCodeEditor.getValue() : '';
  return await currentCodeEditorContext.onSave(newCode);
}

document.getElementById('saveActionCodeBtn').addEventListener('click', async () => {
  const ok = await saveActionCode();
  closeActionCodeModal();
  if (!ok) return;
});

document.getElementById('closeActionCodeBtn').addEventListener('click', closeActionCodeModal);

// === Custom bar dropdown (od v2.12.3) ===
// Zastępuje <select>/<option> tam gdzie chcemy SVG ikony folder/group/rule.
// HTML <option> nie renderuje SVG, więc komponent oparty na <button> + popup panelu.
//
// createBarDropdown({
//   items: [{ value, name, parentType?, parentName?, indent?, disabled?, title?, lockHint? }],
//   currentValue,
//   onChange(value),  // fired when user picks an item
//   placeholder,      // text when nothing selected
//   disabled,         // disable button entirely
//   className,        // extra class on wrapper
// }) → returns DOM element (replace <select> with this)
//
// `parentType` z {'folder','group','rule'} → ikona z JZ_ICONS w button + items.
function createBarDropdown(opts) {
  const wrapper = document.createElement('div');
  wrapper.className = 'jz-bar-dropdown' + (opts.className ? ' ' + opts.className : '');

  const button = document.createElement('button');
  button.type = 'button';
  button.className = 'jz-bar-dropdown-btn';
  if (opts.disabled) button.disabled = true;

  let currentValue = opts.currentValue;
  let panel = null;

  function iconFor(type) {
    if (type === 'folder') return JZ_ICONS.folder;
    if (type === 'group')  return JZ_ICONS.group;
    if (type === 'rule')   return JZ_ICONS.rule;
    return '';
  }

  function refreshButton() {
    const current = opts.items.find(i => i.value === currentValue);
    button.innerHTML = '';
    if (!current) {
      const ph = document.createElement('span');
      ph.style.color = '#9ca3af';
      ph.textContent = opts.placeholder || '—';
      button.appendChild(ph);
    } else {
      const icon = iconFor(current.parentType);
      if (icon) {
        const wrap = document.createElement('span');
        wrap.style.display = 'inline-flex';
        wrap.innerHTML = icon;
        button.appendChild(wrap);
      }
      const name = document.createElement('span');
      name.className = 'jz-bar-dropdown-name';
      name.textContent = current.name;
      button.appendChild(name);
    }
    const arrow = document.createElement('span');
    arrow.className = 'jz-bar-dropdown-arrow';
    arrow.textContent = '▾';
    button.appendChild(arrow);
  }

  function closePanel() {
    if (panel) {
      panel.remove();
      panel = null;
      document.removeEventListener('click', outsideClick, true);
      document.removeEventListener('keydown', escClose, true);
    }
  }

  function outsideClick(e) {
    if (panel && !panel.contains(e.target) && !wrapper.contains(e.target)) closePanel();
  }
  function escClose(e) { if (e.key === 'Escape') closePanel(); }

  function openPanel() {
    closePanel();
    panel = document.createElement('div');
    panel.className = 'jz-bar-dropdown-panel';

    opts.items.forEach(item => {
      const row = document.createElement('div');
      row.className = 'jz-bar-dropdown-item'
        + (item.disabled ? ' disabled' : '')
        + (item.value === currentValue ? ' selected' : '');
      if (item.title) row.title = item.title;

      // Indent (dla hierarchii folder→group→rule w bar-move).
      if (item.indent) {
        const ind = document.createElement('span');
        ind.className = 'jz-bar-dropdown-item-indent';
        ind.style.width = (item.indent * 12) + 'px';
        row.appendChild(ind);
      }
      // Icon
      const icon = iconFor(item.parentType);
      if (icon) {
        const iconWrap = document.createElement('span');
        iconWrap.style.display = 'inline-flex';
        iconWrap.innerHTML = icon;
        row.appendChild(iconWrap);
      }
      // Text (parent prefix + name)
      const text = document.createElement('span');
      if (item.parentName) {
        const par = document.createElement('span');
        par.className = 'jz-bar-dropdown-parent';
        par.textContent = item.parentName + ' / ';
        text.appendChild(par);
      }
      text.appendChild(document.createTextNode(item.name));
      row.appendChild(text);
      // Lock indicator
      if (item.disabled && item.lockHint) {
        const lock = document.createElement('span');
        lock.className = 'jz-bar-dropdown-lock';
        lock.textContent = '🔒';
        row.appendChild(lock);
      }
      if (!item.disabled) {
        row.addEventListener('click', (e) => {
          e.stopPropagation();
          currentValue = item.value;
          refreshButton();
          closePanel();
          if (typeof opts.onChange === 'function') opts.onChange(item.value);
        });
      }
      panel.appendChild(row);
    });

    document.body.appendChild(panel);
    const rect = button.getBoundingClientRect();
    panel.style.left = rect.left + 'px';
    panel.style.top = (rect.bottom + 4) + 'px';
    panel.style.minWidth = Math.max(rect.width, 200) + 'px';
    // Jeśli panel wyszedłby poza viewport — flip nad button
    requestAnimationFrame(() => {
      const pr = panel.getBoundingClientRect();
      if (pr.bottom > window.innerHeight - 8) {
        panel.style.top = Math.max(8, rect.top - pr.height - 4) + 'px';
      }
    });

    setTimeout(() => {
      document.addEventListener('click', outsideClick, true);
      document.addEventListener('keydown', escClose, true);
    }, 0);
  }

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

  wrapper.appendChild(button);
  refreshButton();

  // Public API żeby caller mógł zaktualizować value externally (np. po move).
  wrapper.setValue = (v) => { currentValue = v; refreshButton(); };
  wrapper.getValue = () => currentValue;

  return wrapper;
}

// === SELECT editor modal (od v2.12.1, redesign v2.12.2) ===
// Edytor typu select z 2 wariantami:
//   variant='static': lista opcji {id, name, value} + jeden kod JS dostający `value`
//   variant='js':     tylko kod JS, który sam zarządza dropdownem (dostaje $el reference)
// Auto-save po każdej zmianie (live save). Modal ma "Zamknij".
let currentSelectOptionsCtx = null; // { ruleId, actionId }

function openSelectOptionsModal(rule, action) {
  // Defaults dla nowego modelu — migracja v7 ustawia variant='static' na istniejących.
  if (!action.variant) action.variant = 'static';
  if (!Array.isArray(action.options)) action.options = [];
  if (typeof action.code !== 'string') action.code = '';

  currentSelectOptionsCtx = { ruleId: rule.id, actionId: action.id };
  document.getElementById('selectOptionsTitle').textContent =
    (action.label || '').toUpperCase() || JZ_I18N.t('action.select.untitled');

  // ID + selektor JS dla TEGO konkretnego SELECT (od v2.12.5).
  document.getElementById('selectActionIdValue').textContent = action.id;
  document.getElementById('selectActionSelectorValue').textContent =
    `document.querySelector('[data-jz-action-id="${action.id}"]')`;

  // Set radio variant + show appropriate pane
  document.querySelectorAll('input[name="selectVariant"]').forEach(r => {
    r.checked = (r.value === action.variant);
  });
  refreshSelectVariantPanes();

  // Color pickery przeniesione do multi-color picker na liście akcji (v2.13.60).
  // Wcześniej tu były 4 pary pickerów dla dropdownBg/option/selected/hover.

  document.getElementById('selectOptionsModal').classList.add('show');
}

function closeSelectOptionsModal() {
  document.getElementById('selectOptionsModal').classList.remove('show');
  if (currentSelectOptionsCtx) {
    const rule = rules.find(r => r.id === currentSelectOptionsCtx.ruleId);
    if (rule) {
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) renderActionsList(card, rule);
    }
  }
  currentSelectOptionsCtx = null;
}

function getCurrentSelectAction() {
  if (!currentSelectOptionsCtx) return null;
  const rule = rules.find(r => r.id === currentSelectOptionsCtx.ruleId);
  if (!rule) return null;
  const action = rule.actions.find(a => a.id === currentSelectOptionsCtx.actionId);
  return action || null;
}

function refreshSelectVariantPanes() {
  const action = getCurrentSelectAction();
  if (!action) return;
  document.getElementById('selectStaticPane').style.display = action.variant === 'static' ? '' : 'none';
  document.getElementById('selectJsPane').style.display = action.variant === 'js' ? '' : 'none';
  if (action.variant === 'static') {
    renderSelectOptionsList();
    refreshSelectStaticCodeBtn();
  } else {
    refreshSelectJsCodeBtn();
  }
}

function codePreviewLabel(codeStr) {
  const codeText = (codeStr || '').trim();
  if (!codeText) return JZ_I18N.t('action.code.empty');
  const codeLines = codeText.split('\n').length;
  const firstLineRaw = codeText.split('\n')[0];
  const firstLine = firstLineRaw.slice(0, 40) + (firstLineRaw.length > 40 ? '…' : '');
  return JZ_I18N.t('rule.card.action.codePreview', {
    count: codeLines,
    word: JZ_I18N.plural(codeLines, 'plural.jsLine'),
    first: firstLine,
  });
}

function refreshSelectStaticCodeBtn() {
  const action = getCurrentSelectAction();
  if (!action) return;
  const btn = document.getElementById('selectStaticCodeBtn');
  btn.textContent = codePreviewLabel(action.code);
  btn.classList.toggle('has-code', !!(action.code && action.code.trim()));
}

function refreshSelectJsCodeBtn() {
  const action = getCurrentSelectAction();
  if (!action) return;
  const btn = document.getElementById('selectJsCodeBtn');
  btn.textContent = codePreviewLabel(action.code);
  btn.classList.toggle('has-code', !!(action.code && action.code.trim()));
}

function renderSelectOptionsList() {
  const action = getCurrentSelectAction();
  if (!action) return;
  if (!Array.isArray(action.options)) action.options = [];

  const list = document.getElementById('selectOptionsList');
  list.innerHTML = '';

  if (action.options.length === 0) {
    const empty = document.createElement('div');
    empty.style.cssText = 'padding:12px;color:#9ca3af;font-size:12px;text-align:center;';
    empty.textContent = JZ_I18N.t('action.select.empty');
    list.appendChild(empty);
    return;
  }

  action.options.forEach((opt, idx) => {
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;gap:6px;align-items:center;padding:6px;border:1px solid #e5e7eb;border-radius:6px;';

    const nameInput = document.createElement('input');
    nameInput.type = 'text';
    nameInput.value = opt.name || '';
    nameInput.placeholder = JZ_I18N.t('action.select.optionNamePlaceholder');
    nameInput.maxLength = 16;
    nameInput.style.cssText = 'flex:1;padding:4px 6px;font-size:12px;border:1px solid #d1d5db;border-radius:4px;';
    nameInput.addEventListener('input', () => {
      opt.name = nameInput.value.slice(0, 16);
      debouncedSave();
    });

    const arrow = document.createElement('span');
    arrow.textContent = '→';
    arrow.style.cssText = 'color:#9ca3af;font-size:12px;';

    const valueInput = document.createElement('input');
    valueInput.type = 'text';
    valueInput.value = opt.value || '';
    valueInput.placeholder = JZ_I18N.t('action.select.optionValuePlaceholder');
    valueInput.style.cssText = 'flex:1;padding:4px 6px;font-size:12px;border:1px solid #d1d5db;border-radius:4px;font-family:ui-monospace,Menlo,Consolas,monospace;';
    valueInput.addEventListener('input', () => {
      opt.value = valueInput.value;
      debouncedSave();
    });

    const delBtn = document.createElement('button');
    delBtn.innerHTML = JZ_ICONS.close;
    delBtn.title = JZ_I18N.t('action.select.deleteOption');
    delBtn.style.cssText = 'flex:0 0 auto;padding:4px;';
    delBtn.addEventListener('click', async () => {
      action.options.splice(idx, 1);
      await saveAll();
      renderSelectOptionsList();
    });

    row.appendChild(nameInput);
    row.appendChild(arrow);
    row.appendChild(valueInput);
    row.appendChild(delBtn);
    list.appendChild(row);
  });
}

// Listenery dla nowego modala SELECT
document.querySelectorAll('input[name="selectVariant"]').forEach(radio => {
  radio.addEventListener('change', async () => {
    const action = getCurrentSelectAction();
    if (!action) return;
    if (!radio.checked) return;
    action.variant = radio.value;
    await saveAll();
    refreshSelectVariantPanes();
  });
});

document.getElementById('addSelectOptionBtn').addEventListener('click', async () => {
  const action = getCurrentSelectAction();
  if (!action) return;
  if (!Array.isArray(action.options)) action.options = [];
  action.options.push({
    id: 'opt_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 6),
    name: '',
    value: '',
  });
  await saveAll();
  renderSelectOptionsList();
});

document.getElementById('selectStaticCodeBtn').addEventListener('click', () => {
  const action = getCurrentSelectAction();
  if (!action) return;
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || JZ_I18N.t('action.select.untitled')).toUpperCase(),
    parentModalId: 'selectOptionsModal',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      refreshSelectStaticCodeBtn();
      return true;
    },
  });
});

document.getElementById('selectJsCodeBtn').addEventListener('click', () => {
  const action = getCurrentSelectAction();
  if (!action) return;
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || JZ_I18N.t('action.select.untitled')).toUpperCase(),
    parentModalId: 'selectOptionsModal',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      refreshSelectJsCodeBtn();
      return true;
    },
  });
});

document.getElementById('closeSelectOptionsBtn').addEventListener('click', closeSelectOptionsModal);

// SELECT extra colors usunięte v2.13.61 — edytowalne przez multi-color picker na liście akcji.

// === Color hex utilities (od v2.13.18) — używane przez color-picker pary w wierszu akcji
// (ab-color-hex, ab-color-text-hex) oraz w modalach edytorów (modal-color-hex). ===
function normalizeHex(input) {
  if (!input) return null;
  let s = String(input).trim().toLowerCase();
  if (!s.startsWith('#')) s = '#' + s;
  if (/^#[0-9a-f]{3}$/.test(s)) {
    // expand shorthand #abc → #aabbcc
    s = '#' + s[1] + s[1] + s[2] + s[2] + s[3] + s[3];
  }
  if (!/^#[0-9a-f]{6}$/.test(s)) return null;
  return s;
}

// === Multi-color picker (od v2.13.59) — własny komponent zastępujący <input type="color">.
// Powód: natywny popover na Windows ma bug — zamyka się gdy myszka rusza za szybko między
// inputem a popoverem. Wcześniej każdy edytor (CSS/JS pane, actions) miał 2-5 natywnych
// kolor-inputów obok siebie — każdy z osobna trzeba było otwierać, każdy z osobna miał bug.
//
// Nowy UX:
// - Trigger: prostokąt 56×24 podzielony na N pionowych pasków (po jednym na każdy kolor).
// - Klik trigger → dropdown z rzędami: [label] [swatch] [hex input] [×].
// - Klik swatch → expand inline editor pod tym rzędem (HSL slider'y + paleta presetów).
// - Klik poza → close. ESC → close.
//
// API:
//   createMultiColorPicker({
//     defs: [{ key, label, defaultColor }],   // kolory do edycji
//     getValue: (key) => hex|'',              // '' = używaj defaultColor (faded stripe)
//     setValue: (key, hex) => void,           // hex='' żeby zresetować
//     disabled: bool,                          // tryb read-only (inherited row)
//   })
// → zwraca HTMLElement (wrap z triggerem + dropdownem).

// HSL <-> hex utils (potrzebne dla slider'ów). Wejście hex (#rrggbb) → {h,s,l} (0-360, 0-100, 0-100).
function _jzHexToHsl(hex) {
  const m = /^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex || '');
  if (!m) return { h: 0, s: 0, l: 50 };
  const r = parseInt(m[1], 16) / 255, g = parseInt(m[2], 16) / 255, b = parseInt(m[3], 16) / 255;
  const max = Math.max(r, g, b), min = Math.min(r, g, b);
  let h = 0, s = 0; const l = (max + min) / 2;
  if (max !== min) {
    const d = max - min;
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
    switch (max) {
      case r: h = (g - b) / d + (g < b ? 6 : 0); break;
      case g: h = (b - r) / d + 2; break;
      case b: h = (r - g) / d + 4; break;
    }
    h *= 60;
  }
  return { h: Math.round(h), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function _jzHslToHex(h, s, l) {
  s /= 100; l /= 100;
  const c = (1 - Math.abs(2 * l - 1)) * s;
  const hp = ((h % 360) + 360) % 360 / 60;
  const x = c * (1 - Math.abs(hp % 2 - 1));
  let r1 = 0, g1 = 0, b1 = 0;
  if (hp < 1)      { r1 = c; g1 = x; }
  else if (hp < 2) { r1 = x; g1 = c; }
  else if (hp < 3) { g1 = c; b1 = x; }
  else if (hp < 4) { g1 = x; b1 = c; }
  else if (hp < 5) { r1 = x; b1 = c; }
  else             { r1 = c; b1 = x; }
  const m = l - c / 2;
  const to = (v) => Math.round((v + m) * 255).toString(16).padStart(2, '0');
  return '#' + to(r1) + to(g1) + to(b1);
}

// Paleta presetów — kompromis między pełnym spektrum a "12 sensownych kolorów".
const _JZ_MCP_PALETTE = [
  '#000000', '#FFFFFF', '#6B7280', '#DC2626', '#EA580C', '#D97706',
  '#65A30D', '#16A34A', '#0891B2', '#2563EB', '#7C3AED', '#DB2777',
];

// Domyślnie aktywny picker — używamy globalnie żeby nowy picker zamknął wcześniejszy.
let _jzMcpActive = null;

function createMultiColorPicker({ defs, getValue, setValue, disabled = false }) {
  const wrap = document.createElement('div');
  wrap.className = 'jz-mcp';

  const trigger = document.createElement('button');
  trigger.type = 'button';
  trigger.className = 'jz-mcp-trigger' + (disabled ? ' disabled' : '');
  trigger.disabled = !!disabled;
  // Tooltip triggera = lista nazw kolorów po przecinku (wystarczająco informatywny, bez instrukcji).
  trigger.title = defs.map(d => d.label).join(', ');

  const dropdown = document.createElement('div');
  dropdown.className = 'jz-mcp-dropdown';
  dropdown.style.display = 'none';

  let activeRowIdx = -1; // -1 = żaden inline editor nie expanduje się
  // Direction (up/down) wyliczane RAZ przy otwarciu i nie zmienia się przy expand inline editora —
  // od v2.13.60. Wcześniej dropdown skakał w pionie po klik swatch.
  let _direction = null;

  const renderTrigger = () => {
    trigger.innerHTML = '';
    defs.forEach(d => {
      const stored = getValue(d.key);
      const effective = (typeof stored === 'string' && /^#[0-9a-f]{6}$/i.test(stored)) ? stored : d.defaultColor;
      const stripe = document.createElement('span');
      stripe.className = 'jz-mcp-stripe' + (stored ? '' : ' faded');
      stripe.style.background = effective;
      trigger.appendChild(stripe);
    });
  };

  const renderDropdown = () => {
    dropdown.innerHTML = '';
    defs.forEach((d, idx) => {
      const stored = getValue(d.key);
      const hasCustom = !!(stored && /^#[0-9a-f]{6}$/i.test(stored));
      const effective = hasCustom ? stored : d.defaultColor;
      const isActive = activeRowIdx === idx;

      const row = document.createElement('div');
      row.className = 'jz-mcp-row' + (isActive ? ' active' : '');

      const label = document.createElement('span');
      label.className = 'jz-mcp-label';
      label.textContent = d.label;
      label.title = d.label;
      row.appendChild(label);

      // Swatch — bez verbose tooltipa (klik = oczywisty na podstawie kontekstu i visual cue).
      const swatch = document.createElement('button');
      swatch.type = 'button';
      swatch.className = 'jz-mcp-swatch' + (hasCustom ? '' : ' is-default');
      swatch.style.background = effective;
      swatch.title = d.label;
      swatch.addEventListener('click', (e) => {
        e.stopPropagation();
        activeRowIdx = isActive ? -1 : idx;
        // Tylko re-render — pozycja DROPDOWNu się NIE zmienia (direction był wyliczony przy open).
        renderDropdown();
      });
      row.appendChild(swatch);

      const hexIn = document.createElement('input');
      hexIn.type = 'text';
      hexIn.className = 'jz-mcp-hex';
      hexIn.value = effective;
      hexIn.maxLength = 9;
      hexIn.spellcheck = false;
      hexIn.title = d.label;
      hexIn.addEventListener('input', () => {
        const norm = normalizeHex(hexIn.value);
        hexIn.classList.toggle('invalid', !norm);
        if (!norm) return;
        setValue(d.key, norm);
        renderTrigger();
        swatch.style.background = norm;
        swatch.classList.remove('is-default');
      });
      hexIn.addEventListener('blur', () => {
        const norm = normalizeHex(hexIn.value);
        if (norm) hexIn.value = norm;
        else {
          const cur = getValue(d.key);
          hexIn.value = (cur && /^#[0-9a-f]{6}$/i.test(cur)) ? cur : d.defaultColor;
          hexIn.classList.remove('invalid');
        }
      });
      hexIn.addEventListener('click', (e) => e.stopPropagation());
      row.appendChild(hexIn);

      // Od v2.13.61: × przycisk RESET w wierszu USUNIĘTY — user mylił z "zamknij dropdown".
      // Przywrócenie defaultu jest teraz pierwszym tile'em w palecie inline editora (klik swatch
      // żeby otworzyć editor → klik na "default" tile = revert).

      dropdown.appendChild(row);

      if (isActive) {
        const editor = _buildInlineColorEditor(effective, d.defaultColor, hasCustom, (hex) => {
          setValue(d.key, hex);
          renderTrigger();
          hexIn.value = hex;
          hexIn.classList.remove('invalid');
          swatch.style.background = hex;
          swatch.classList.remove('is-default');
        }, () => {
          // onReset — wraca do defaultu (zapis '' + zamyka editor + re-render).
          setValue(d.key, '');
          activeRowIdx = -1;
          renderTrigger();
          renderDropdown();
        });
        dropdown.appendChild(editor);
      }
    });
  };

  // Pozycjonowanie WYŁĄCZNIE przy pierwszym open. Direction (up/down) jest fixed na cały lifecycle
  // otwartego dropdownu — żeby ekspansja inline editora nie powodowała auto-flip / skakania.
  // Dla 'up' anchorujemy przez `bottom` (content rośnie w górę z bottom-anchor), dla 'down' przez `top`.
  const positionDropdownInitial = () => {
    dropdown.style.visibility = 'hidden';
    dropdown.style.display = 'block';
    dropdown.style.top = '0px';
    dropdown.style.left = '0px';
    dropdown.style.bottom = '';
    const triggerRect = trigger.getBoundingClientRect();
    const ddRect = dropdown.getBoundingClientRect();
    const spaceBelow = window.innerHeight - triggerRect.bottom;
    const spaceAbove = triggerRect.top;
    // Uwzględniamy potencjalną przyszłą wysokość po expand inline editora (~220px) — żeby od razu
    // wybrać kierunek w którym jest dość miejsca.
    const futureH = ddRect.height + 220;
    _direction = (spaceBelow < futureH + 8 && spaceAbove > spaceBelow) ? 'up' : 'down';
    let left = triggerRect.left;
    const ddWidth = ddRect.width || 320;
    const overflowRight = left + ddWidth - window.innerWidth + 8;
    if (overflowRight > 0) left -= overflowRight;
    if (left < 8) left = 8;
    if (_direction === 'up') {
      // Anchor bottom — content rośnie w górę bez przesunięcia top.
      dropdown.style.bottom = (window.innerHeight - triggerRect.top + 4) + 'px';
      dropdown.style.top = '';
    } else {
      dropdown.style.top = (triggerRect.bottom + 4) + 'px';
      dropdown.style.bottom = '';
    }
    dropdown.style.left = left + 'px';
    dropdown.style.visibility = '';
  };

  const close = () => {
    dropdown.style.display = 'none';
    activeRowIdx = -1;
    _direction = null;
    document.removeEventListener('click', onDocClick, true);
    document.removeEventListener('keydown', onKeyDown, true);
    if (_jzMcpActive === closeRef) _jzMcpActive = null;
  };
  const closeRef = { close };

  const onDocClick = (e) => {
    if (!wrap.contains(e.target)) close();
  };
  const onKeyDown = (e) => {
    if (e.key === 'Escape') close();
  };

  trigger.addEventListener('click', (e) => {
    e.stopPropagation();
    if (disabled) return;
    if (dropdown.style.display !== 'none') {
      close();
      return;
    }
    if (_jzMcpActive && _jzMcpActive !== closeRef) _jzMcpActive.close();
    _jzMcpActive = closeRef;
    renderDropdown();
    positionDropdownInitial();
    document.addEventListener('click', onDocClick, true);
    document.addEventListener('keydown', onKeyDown, true);
  });

  wrap.appendChild(trigger);
  wrap.appendChild(dropdown);
  renderTrigger();
  return wrap;
}

// HSL slider'y + paleta presetów. Wsuwa się pod aktywnym rzędem.
// Od v2.13.61: pierwszy tile palety = "default" (revert do defaultColor) — wcześniej ten reset
// był w osobnym × przycisku w wierszu, ale user mylił go z "zamknij dropdown".
function _buildInlineColorEditor(initialHex, defaultColor, hasCustom, onChange, onReset) {
  const editor = document.createElement('div');
  editor.className = 'jz-mcp-editor';
  let { h, s, l } = _jzHexToHsl(initialHex);

  const preview = document.createElement('div');
  preview.className = 'jz-mcp-preview';
  preview.style.background = initialHex;
  editor.appendChild(preview);

  const update = () => {
    const hex = _jzHslToHex(h, s, l);
    preview.style.background = hex;
    onChange(hex);
    refreshSliderGradients();
    valH.textContent = h; valS.textContent = s + '%'; valL.textContent = l + '%';
  };

  const makeSlider = (min, max, val, letter) => {
    const wrap = document.createElement('div');
    wrap.className = 'jz-mcp-slider-wrap';
    const lbl = document.createElement('span'); lbl.textContent = letter;
    const inp = document.createElement('input');
    inp.type = 'range'; inp.min = min; inp.max = max; inp.value = val;
    inp.className = 'jz-mcp-slider';
    const valOut = document.createElement('span');
    valOut.className = 'jz-mcp-slider-val';
    wrap.appendChild(lbl); wrap.appendChild(inp); wrap.appendChild(valOut);
    return { wrap, inp, valOut };
  };

  const hSlider = makeSlider(0, 360, h, 'H');
  const sSlider = makeSlider(0, 100, s, 'S');
  const lSlider = makeSlider(0, 100, l, 'L');
  const valH = hSlider.valOut, valS = sSlider.valOut, valL = lSlider.valOut;
  valH.textContent = h; valS.textContent = s + '%'; valL.textContent = l + '%';

  const refreshSliderGradients = () => {
    hSlider.inp.style.background =
      'linear-gradient(to right, hsl(0,100%,50%), hsl(60,100%,50%), hsl(120,100%,50%), hsl(180,100%,50%), hsl(240,100%,50%), hsl(300,100%,50%), hsl(360,100%,50%))';
    sSlider.inp.style.background =
      `linear-gradient(to right, hsl(${h}, 0%, ${l}%), hsl(${h}, 100%, ${l}%))`;
    lSlider.inp.style.background =
      `linear-gradient(to right, hsl(${h}, ${s}%, 0%), hsl(${h}, ${s}%, 50%), hsl(${h}, ${s}%, 100%))`;
  };
  refreshSliderGradients();

  hSlider.inp.addEventListener('input', () => { h = parseInt(hSlider.inp.value, 10); update(); });
  sSlider.inp.addEventListener('input', () => { s = parseInt(sSlider.inp.value, 10); update(); });
  lSlider.inp.addEventListener('input', () => { l = parseInt(lSlider.inp.value, 10); update(); });

  editor.appendChild(hSlider.wrap);
  editor.appendChild(sSlider.wrap);
  editor.appendChild(lSlider.wrap);

  // Paleta presetów — szybki wybór bez slider'ów. Pierwszy tile = "default" (revert).
  const palette = document.createElement('div');
  palette.className = 'jz-mcp-palette';
  if (typeof onReset === 'function') {
    const def = document.createElement('button');
    def.type = 'button';
    def.className = 'jz-mcp-palette-swatch jz-mcp-palette-default';
    def.style.background = defaultColor;
    def.innerHTML = '↺';
    def.title = JZ_I18N.t('mcp.default.title');
    def.addEventListener('click', (e) => {
      e.stopPropagation();
      onReset();
    });
    palette.appendChild(def);
  }
  _JZ_MCP_PALETTE.forEach(p => {
    const sw = document.createElement('button');
    sw.type = 'button';
    sw.className = 'jz-mcp-palette-swatch';
    sw.style.background = p;
    sw.title = p;
    sw.addEventListener('click', (e) => {
      e.stopPropagation();
      const hsl = _jzHexToHsl(p);
      h = hsl.h; s = hsl.s; l = hsl.l;
      hSlider.inp.value = h; sSlider.inp.value = s; lSlider.inp.value = l;
      update();
    });
    palette.appendChild(sw);
  });
  editor.appendChild(palette);

  // Stopujemy bubble żeby klik w editorze nie zamykał dropdown'u.
  editor.addEventListener('click', (e) => e.stopPropagation());
  return editor;
}

// Helper: bind color picker + hex text input + clear button do pola action[fieldName].
// Empty state (gdy field undefined) — picker pokazuje #ffffff, hex pole jest puste z placeholderem,
// clear button przywraca empty state. Po Wpisaniu poprawnego hex / wybraniu z pickera → zapis do
// action[fieldName] + debouncedSave().
//
// Zwraca `{ refresh }` — wywołaj `refresh()` po otwarciu modalu z aktualnym actionem żeby UI
// odzwierciedlał obecny stan.
function bindModalColorPair(pickerId, hexId, clearId, getAction, fieldName) {
  const picker = document.getElementById(pickerId);
  const hex = document.getElementById(hexId);
  const clear = document.getElementById(clearId);
  if (!picker || !hex || !clear) {
    console.warn('[JustZix] bindModalColorPair: missing element(s)', { pickerId, hexId, clearId });
    return { refresh: () => {} };
  }

  function refresh() {
    const action = getAction();
    const value = action ? action[fieldName] : undefined;
    if (value) {
      const norm = normalizeHex(value) || '#ffffff';
      picker.value = norm;
      hex.value = norm;
      hex.placeholder = '';
      hex.classList.remove('empty', 'invalid');
    } else {
      picker.value = '#ffffff';
      hex.value = '';
      hex.placeholder = '(default)';
      hex.classList.add('empty');
      hex.classList.remove('invalid');
    }
  }

  picker.addEventListener('input', (e) => {
    const action = getAction();
    if (!action) return;
    const hexVal = e.target.value;
    hex.value = hexVal;
    hex.classList.remove('empty', 'invalid');
    action[fieldName] = hexVal;
    debouncedSave();
  });

  hex.addEventListener('input', (e) => {
    const action = getAction();
    if (!action) return;
    const val = e.target.value.trim();
    if (val === '') {
      hex.classList.remove('invalid');
      hex.classList.add('empty');
      delete action[fieldName];
      debouncedSave();
      return;
    }
    hex.classList.remove('empty');
    const norm = normalizeHex(val);
    hex.classList.toggle('invalid', !norm);
    if (!norm) return;
    picker.value = norm;
    action[fieldName] = norm;
    debouncedSave();
  });

  hex.addEventListener('blur', () => {
    const action = getAction();
    if (!action) return;
    const current = action[fieldName];
    if (!current) {
      hex.value = '';
      hex.placeholder = '(default)';
      hex.classList.add('empty');
      hex.classList.remove('invalid');
    } else {
      const norm = normalizeHex(current);
      hex.value = norm || current;
      hex.classList.remove('empty', 'invalid');
    }
  });

  clear.addEventListener('click', () => {
    const action = getAction();
    if (!action) return;
    delete action[fieldName];
    refresh();
    debouncedSave();
  });

  return { refresh };
}

// Copy ID / selector to clipboard (od v2.12.5)
function copyToClipboard(text, btn) {
  if (!text) return;
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(() => flashCopyOk(btn)).catch(() => {});
  } else {
    const ta = document.createElement('textarea');
    ta.value = text; document.body.appendChild(ta);
    ta.select(); try { document.execCommand('copy'); flashCopyOk(btn); } catch (_) {}
    document.body.removeChild(ta);
  }
}
function flashCopyOk(btn) {
  if (!btn) return;
  const orig = btn.textContent;
  btn.textContent = '✓';
  setTimeout(() => { btn.textContent = orig; }, 800);
}
document.getElementById('selectActionIdCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('selectActionIdValue').textContent, e.currentTarget);
});
document.getElementById('selectActionSelectorCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('selectActionSelectorValue').textContent, e.currentTarget);
});

// Memory scope handler usunięty w v2.13.7 — memory zawsze włączona, single value per action.id.

// === INPUT editor modal (od v2.13.0) ===
let currentInputEditorCtx = null;  // { ruleId, actionId }

function openInputEditorModal(rule, action) {
  if (typeof action.code !== 'string') action.code = '';
  if (typeof action.placeholder !== 'string') action.placeholder = '';
  if (typeof action.defaultValue !== 'string') action.defaultValue = '';

  currentInputEditorCtx = { ruleId: rule.id, actionId: action.id };
  document.getElementById('inputEditorTitle').textContent =
    (action.label || '').toUpperCase() || JZ_I18N.t('action.select.untitled');
  document.getElementById('inputActionIdValue').textContent = action.id;
  document.getElementById('inputActionSelectorValue').textContent =
    `document.querySelector('[data-jz-action-id="${action.id}"]')`;
  document.getElementById('inputPlaceholder').value = action.placeholder || '';
  document.getElementById('inputDefaultValue').value = action.defaultValue || '';
  refreshInputCodeBtn();

  document.getElementById('inputEditorModal').classList.add('show');
}

function closeInputEditorModal() {
  document.getElementById('inputEditorModal').classList.remove('show');
  if (currentInputEditorCtx) {
    const rule = rules.find(r => r.id === currentInputEditorCtx.ruleId);
    if (rule) {
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) renderActionsList(card, rule);
    }
  }
  currentInputEditorCtx = null;
}

function getCurrentInputAction() {
  if (!currentInputEditorCtx) return null;
  const rule = rules.find(r => r.id === currentInputEditorCtx.ruleId);
  if (!rule) return null;
  return rule.actions.find(a => a.id === currentInputEditorCtx.actionId) || null;
}

function refreshInputCodeBtn() {
  const action = getCurrentInputAction();
  if (!action) return;
  const btn = document.getElementById('inputCodeBtn');
  btn.textContent = codePreviewLabel(action.code);
  btn.classList.toggle('has-code', !!(action.code && action.code.trim()));
}

// inputMemoryScope handler usunięty w v2.13.7 — memory zawsze włączona.
document.getElementById('inputPlaceholder').addEventListener('input', (e) => {
  const action = getCurrentInputAction();
  if (!action) return;
  action.placeholder = e.target.value.slice(0, 40);
  debouncedSave();
});
document.getElementById('inputDefaultValue').addEventListener('input', (e) => {
  const action = getCurrentInputAction();
  if (!action) return;
  action.defaultValue = e.target.value;
  debouncedSave();
});
document.getElementById('inputCodeBtn').addEventListener('click', () => {
  const action = getCurrentInputAction();
  if (!action) return;
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || JZ_I18N.t('action.select.untitled')).toUpperCase(),
    parentModalId: 'inputEditorModal',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      refreshInputCodeBtn();
      return true;
    },
  });
});
document.getElementById('closeInputEditorBtn').addEventListener('click', closeInputEditorModal);
document.getElementById('inputActionIdCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('inputActionIdValue').textContent, e.currentTarget);
});
document.getElementById('inputActionSelectorCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('inputActionSelectorValue').textContent, e.currentTarget);
});

// INPUT colorPlaceholder usunięte v2.13.61 — edytowalne przez multi-color picker na liście akcji.

// === SLIDER editor modal (od v2.13.17) ===
let currentSliderEditorCtx = null;  // { ruleId, actionId }

function openSliderEditorModal(rule, action) {
  // Sane defaults — defensywne, choć init powinien być zrobiony przy zmianie typu.
  if (typeof action.code !== 'string') action.code = '';
  if (typeof action.min !== 'number') action.min = 0;
  if (typeof action.max !== 'number') action.max = 100;
  if (typeof action.step !== 'number') action.step = 1;
  if (typeof action.defaultValue !== 'number') action.defaultValue = Math.round((action.min + action.max) / 2);
  if (typeof action.unit !== 'string') action.unit = '';

  currentSliderEditorCtx = { ruleId: rule.id, actionId: action.id };
  document.getElementById('sliderEditorTitle').textContent =
    (action.label || '').toUpperCase() || JZ_I18N.t('action.select.untitled');
  document.getElementById('sliderActionIdValue').textContent = action.id;
  document.getElementById('sliderActionSelectorValue').textContent =
    `document.querySelector('[data-jz-action-id="${action.id}"]')`;
  document.getElementById('sliderMin').value = String(action.min);
  document.getElementById('sliderMax').value = String(action.max);
  document.getElementById('sliderStep').value = String(action.step);
  document.getElementById('sliderDefaultValue').value = String(action.defaultValue);
  document.getElementById('sliderUnit').value = action.unit || '';
  refreshSliderCodeBtn();
  refreshSliderRangeError();

  document.getElementById('sliderEditorModal').classList.add('show');
}

function closeSliderEditorModal() {
  document.getElementById('sliderEditorModal').classList.remove('show');
  if (currentSliderEditorCtx) {
    const rule = rules.find(r => r.id === currentSliderEditorCtx.ruleId);
    if (rule) {
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) renderActionsList(card, rule);
    }
  }
  currentSliderEditorCtx = null;
}

function getCurrentSliderAction() {
  if (!currentSliderEditorCtx) return null;
  const rule = rules.find(r => r.id === currentSliderEditorCtx.ruleId);
  if (!rule) return null;
  return rule.actions.find(a => a.id === currentSliderEditorCtx.actionId) || null;
}

function refreshSliderCodeBtn() {
  const action = getCurrentSliderAction();
  if (!action) return;
  const btn = document.getElementById('sliderCodeBtn');
  btn.textContent = codePreviewLabel(action.code);
  btn.classList.toggle('has-code', !!(action.code && action.code.trim()));
}

// Inline walidator zakresu: pokazuje czerwony banner gdy min >= max (suwak by się nie renderował)
// lub step <= 0 (range slider wymaga step > 0). Nie blokujemy zapisu — user widzi feedback,
// ale dane są persystowane (consistency z resztą edytorów akcji).
function refreshSliderRangeError() {
  const action = getCurrentSliderAction();
  if (!action) return;
  const err = document.getElementById('sliderRangeError');
  const messages = [];
  if (!(action.max > action.min)) {
    messages.push(JZ_I18N.t('modal.sliderEditor.errMaxGtMin'));
  }
  if (!(action.step > 0)) {
    messages.push(JZ_I18N.t('modal.sliderEditor.errStepPositive'));
  }
  if (messages.length === 0) {
    err.style.display = 'none';
    err.textContent = '';
  } else {
    err.style.display = 'block';
    err.textContent = messages.join(' ');
  }
}

document.getElementById('sliderMin').addEventListener('input', (e) => {
  const action = getCurrentSliderAction();
  if (!action) return;
  const v = parseFloat(e.target.value);
  if (Number.isFinite(v)) action.min = v;
  refreshSliderRangeError();
  debouncedSave();
});
document.getElementById('sliderMax').addEventListener('input', (e) => {
  const action = getCurrentSliderAction();
  if (!action) return;
  const v = parseFloat(e.target.value);
  if (Number.isFinite(v)) action.max = v;
  refreshSliderRangeError();
  debouncedSave();
});
document.getElementById('sliderStep').addEventListener('input', (e) => {
  const action = getCurrentSliderAction();
  if (!action) return;
  const v = parseFloat(e.target.value);
  if (Number.isFinite(v)) action.step = v;
  refreshSliderRangeError();
  debouncedSave();
});
document.getElementById('sliderDefaultValue').addEventListener('input', (e) => {
  const action = getCurrentSliderAction();
  if (!action) return;
  const v = parseFloat(e.target.value);
  if (Number.isFinite(v)) action.defaultValue = v;
  debouncedSave();
});
document.getElementById('sliderUnit').addEventListener('input', (e) => {
  const action = getCurrentSliderAction();
  if (!action) return;
  action.unit = e.target.value.slice(0, 6);
  debouncedSave();
});
document.getElementById('sliderCodeBtn').addEventListener('click', () => {
  const action = getCurrentSliderAction();
  if (!action) return;
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || JZ_I18N.t('action.select.untitled')).toUpperCase(),
    parentModalId: 'sliderEditorModal',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      refreshSliderCodeBtn();
      return true;
    },
  });
});
document.getElementById('closeSliderEditorBtn').addEventListener('click', closeSliderEditorModal);
document.getElementById('sliderActionIdCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('sliderActionIdValue').textContent, e.currentTarget);
});
document.getElementById('sliderActionSelectorCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('sliderActionSelectorValue').textContent, e.currentTarget);
});

// SLIDER colorLabel/colorValue usunięte v2.13.61 — edytowalne na liście akcji.

// === TEXTAREA editor modal (od v2.13.21) ===
let currentTextareaEditorCtx = null;  // { ruleId, actionId }

function openTextareaEditorModal(rule, action) {
  if (typeof action.code !== 'string') action.code = '';
  if (typeof action.placeholder !== 'string') action.placeholder = '';
  if (typeof action.defaultValue !== 'string') action.defaultValue = '';
  if (typeof action.rows !== 'number') action.rows = 3;

  currentTextareaEditorCtx = { ruleId: rule.id, actionId: action.id };
  document.getElementById('textareaEditorTitle').textContent =
    (action.label || '').toUpperCase() || JZ_I18N.t('action.select.untitled');
  document.getElementById('textareaActionIdValue').textContent = action.id;
  document.getElementById('textareaActionSelectorValue').textContent =
    `document.querySelector('[data-jz-action-id="${action.id}"]')`;
  document.getElementById('textareaPlaceholder').value = action.placeholder || '';
  document.getElementById('textareaDefaultValue').value = action.defaultValue || '';
  document.getElementById('textareaRows').value = String(action.rows);
  refreshTextareaCodeBtn();

  document.getElementById('textareaEditorModal').classList.add('show');
}

function closeTextareaEditorModal() {
  document.getElementById('textareaEditorModal').classList.remove('show');
  if (currentTextareaEditorCtx) {
    const rule = rules.find(r => r.id === currentTextareaEditorCtx.ruleId);
    if (rule) {
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) renderActionsList(card, rule);
    }
  }
  currentTextareaEditorCtx = null;
}

function getCurrentTextareaAction() {
  if (!currentTextareaEditorCtx) return null;
  const rule = rules.find(r => r.id === currentTextareaEditorCtx.ruleId);
  if (!rule) return null;
  return rule.actions.find(a => a.id === currentTextareaEditorCtx.actionId) || null;
}

function refreshTextareaCodeBtn() {
  const action = getCurrentTextareaAction();
  if (!action) return;
  const btn = document.getElementById('textareaCodeBtn');
  btn.textContent = codePreviewLabel(action.code);
  btn.classList.toggle('has-code', !!(action.code && action.code.trim()));
}

document.getElementById('textareaPlaceholder').addEventListener('input', (e) => {
  const action = getCurrentTextareaAction();
  if (!action) return;
  action.placeholder = e.target.value.slice(0, 80);
  debouncedSave();
});
document.getElementById('textareaDefaultValue').addEventListener('input', (e) => {
  const action = getCurrentTextareaAction();
  if (!action) return;
  action.defaultValue = e.target.value;
  debouncedSave();
});
document.getElementById('textareaRows').addEventListener('input', (e) => {
  const action = getCurrentTextareaAction();
  if (!action) return;
  const n = parseInt(e.target.value, 10);
  if (Number.isFinite(n) && n >= 1 && n <= 20) {
    action.rows = n;
    debouncedSave();
  }
});
document.getElementById('textareaCodeBtn').addEventListener('click', () => {
  const action = getCurrentTextareaAction();
  if (!action) return;
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || JZ_I18N.t('action.select.untitled')).toUpperCase(),
    parentModalId: 'textareaEditorModal',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      refreshTextareaCodeBtn();
      return true;
    },
  });
});
document.getElementById('closeTextareaEditorBtn').addEventListener('click', closeTextareaEditorModal);
document.getElementById('textareaActionIdCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('textareaActionIdValue').textContent, e.currentTarget);
});
document.getElementById('textareaActionSelectorCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('textareaActionSelectorValue').textContent, e.currentTarget);
});

// TEXTAREA colorPlaceholder usunięte v2.13.61 — edytowalne na liście akcji.

// =============================================================================
// TOGGLE3 editor modal (od v2.13.25) — 3 stany (label + value) + defaultStateIdx + kod.
// =============================================================================
let currentToggle3EditorCtx = null;  // { ruleId, actionId }

function openToggle3EditorModal(rule, action) {
  if (typeof action.code !== 'string') action.code = '';
  if (!Array.isArray(action.states) || action.states.length !== 3) {
    action.states = [
      { id: 'st_' + Math.random().toString(36).slice(2, 8), label: JZ_I18N.t('action.toggle3.defaultState1'), value: '0' },
      { id: 'st_' + Math.random().toString(36).slice(2, 8), label: JZ_I18N.t('action.toggle3.defaultState2'), value: '1' },
      { id: 'st_' + Math.random().toString(36).slice(2, 8), label: JZ_I18N.t('action.toggle3.defaultState3'), value: '2' },
    ];
  }
  if (typeof action.defaultStateIdx !== 'number' || action.defaultStateIdx < 0 || action.defaultStateIdx > 2) {
    action.defaultStateIdx = 0;
  }

  currentToggle3EditorCtx = { ruleId: rule.id, actionId: action.id };
  document.getElementById('toggle3EditorTitle').textContent =
    (action.label || '').toUpperCase() || JZ_I18N.t('action.select.untitled');
  document.getElementById('toggle3ActionIdValue').textContent = action.id;
  document.getElementById('toggle3ActionSelectorValue').textContent =
    `document.querySelector('[data-jz-action-id="${action.id}"]')`;

  renderToggle3StatesList();
  refreshToggle3CodeBtn();

  document.getElementById('toggle3EditorModal').classList.add('show');
}

function closeToggle3EditorModal() {
  document.getElementById('toggle3EditorModal').classList.remove('show');
  if (currentToggle3EditorCtx) {
    const rule = rules.find(r => r.id === currentToggle3EditorCtx.ruleId);
    if (rule) {
      const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
      if (card) renderActionsList(card, rule);
    }
  }
  currentToggle3EditorCtx = null;
}

function getCurrentToggle3Action() {
  if (!currentToggle3EditorCtx) return null;
  const rule = rules.find(r => r.id === currentToggle3EditorCtx.ruleId);
  if (!rule) return null;
  return rule.actions.find(a => a.id === currentToggle3EditorCtx.actionId) || null;
}

function refreshToggle3CodeBtn() {
  const action = getCurrentToggle3Action();
  if (!action) return;
  const btn = document.getElementById('toggle3CodeBtn');
  btn.textContent = codePreviewLabel(action.code);
  btn.classList.toggle('has-code', !!(action.code && action.code.trim()));
}

function renderToggle3StatesList() {
  const action = getCurrentToggle3Action();
  if (!action) return;
  const list = document.getElementById('toggle3StatesList');
  list.innerHTML = '';
  action.states.forEach((state, idx) => {
    const row = document.createElement('div');
    row.className = 'toggle3-state-row';
    row.innerHTML = `
      <label class="toggle3-default-radio" title="${escapeAttr(JZ_I18N.t('action.toggle3.defaultStateTitle'))}">
        <input type="radio" name="toggle3DefaultState" value="${idx}" ${action.defaultStateIdx === idx ? 'checked' : ''}>
        <span class="toggle3-state-idx">${idx + 1}</span>
      </label>
      <input type="text" class="toggle3-state-label" placeholder="${escapeAttr(JZ_I18N.t('action.toggle3.stateLabel.placeholder'))}" maxlength="5" value="${escapeAttr(state.label || '')}">
      <input type="text" class="toggle3-state-value" placeholder="${escapeAttr(JZ_I18N.t('action.toggle3.stateValue.placeholder'))}" value="${escapeAttr(state.value || '')}">
    `;
    list.appendChild(row);

    const labelInput = row.querySelector('.toggle3-state-label');
    const valueInput = row.querySelector('.toggle3-state-value');
    const radio = row.querySelector('input[type="radio"]');

    labelInput.addEventListener('input', (e) => {
      const a = getCurrentToggle3Action();
      if (!a) return;
      a.states[idx].label = e.target.value.slice(0, 5);
      debouncedSave();
    });
    valueInput.addEventListener('input', (e) => {
      const a = getCurrentToggle3Action();
      if (!a) return;
      a.states[idx].value = e.target.value;
      debouncedSave();
    });
    radio.addEventListener('change', (e) => {
      const a = getCurrentToggle3Action();
      if (!a) return;
      if (e.target.checked) {
        a.defaultStateIdx = idx;
        debouncedSave();
      }
    });
  });
}

document.getElementById('toggle3CodeBtn').addEventListener('click', () => {
  const action = getCurrentToggle3Action();
  if (!action) return;
  openCodeEditorModal({
    initialCode: action.code || '',
    title: (action.label || JZ_I18N.t('action.select.untitled')).toUpperCase(),
    parentModalId: 'toggle3EditorModal',
    onSave: async (newCode) => {
      action.code = newCode;
      await saveAll();
      refreshToggle3CodeBtn();
      return true;
    },
  });
});
document.getElementById('closeToggle3EditorBtn').addEventListener('click', closeToggle3EditorModal);
document.getElementById('toggle3ActionIdCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('toggle3ActionIdValue').textContent, e.currentTarget);
});
document.getElementById('toggle3ActionSelectorCopy').addEventListener('click', (e) => {
  copyToClipboard(document.getElementById('toggle3ActionSelectorValue').textContent, e.currentTarget);
});

// TOGGLE3 colorBg/colorHover/colorInactiveText usunięte v2.13.61 — edytowalne na liście akcji.

// === Move/Copy menu (zestaw) ===
// Od v2.13.38: rozdzielone na dwa oddzielne dropdowny (parametr `mode: 'move' | 'copy'`)
// zamiast jednego menu z dwiema sekcjami. User reportował że łatwo się pomylić — sekcje
// w jednym dropdownie wyglądały bliźniaczo (te same nazwy grup), więc rozłączenie eliminuje
// pomyłki. Header sekcji nadal pokazuje "Przenieś do grupy:" lub "Skopiuj do grupy:" jako
// kontekst dla usera; lista grup poniżej.
function showMoveMenu(rule, anchorBtn, mode = 'move') {
  const menu = document.getElementById('moveMenu');
  // Cel: grupy. Pomijamy bieżącą grupę zestawu.
  const otherGroups = groups.filter(g => g.id !== rule.groupId);
  // Grupujemy po folderze dla czytelności.
  const byFolder = new Map();
  otherGroups.forEach(g => {
    const folder = folders.find(f => f.id === g.folderId);
    const fname = folder ? (folder.name || '(bez nazwy)') : '(brak folderu)';
    if (!byFolder.has(fname)) byFolder.set(fname, []);
    byFolder.get(fname).push(g);
  });

  const sectionKey = mode === 'copy' ? 'moveMenu.section.copyRule' : 'moveMenu.section.moveRule';
  let html = `<div class="move-menu-section">${escapeHtml(JZ_I18N.t(sectionKey))}</div>`;
  if (otherGroups.length === 0) {
    html += `<div class="move-menu-item" style="color:#9ca3af;">${escapeHtml(JZ_I18N.t('moveMenu.empty.groups'))}</div>`;
  } else {
    byFolder.forEach((gs, fname) => {
      html += `<div class="move-menu-section" style="font-size:9px;">${escapeHtml(fname)}</div>`;
      gs.forEach(g => {
        html += `<div class="move-menu-item" data-action="${mode}" data-target="${g.id}">${escapeHtml(g.name || '(bez nazwy)')}</div>`;
      });
    });
  }

  menu.innerHTML = html;
  const rect = anchorBtn.getBoundingClientRect();
  menu.style.left = rect.left + 'px';
  menu.style.top = (rect.bottom + 4) + 'px';
  menu.classList.add('show');

  menu.querySelectorAll('.move-menu-item[data-action]').forEach(item => {
    item.addEventListener('click', () => {
      const action = item.dataset.action;
      const target = item.dataset.target;
      const targetGroup = groups.find(g => g.id === target);
      if (!targetGroup) { menu.classList.remove('show'); return; }
      if (action === 'move') {
        // Przestaw zestaw na koniec docelowej grupy w tablicy rules[] — bez tego
        // zostaje na starym indeksie i renderuje się w nieoczekiwanej kolejności
        // (spójnie z moveRuleToGroup dla drag-and-drop).
        const srcIdx = rules.indexOf(rule);
        if (srcIdx >= 0) rules.splice(srcIdx, 1);
        rule.groupId = target;
        let lastIdxInTarget = -1;
        for (let i = rules.length - 1; i >= 0; i--) {
          if (rules[i].groupId === target) { lastIdxInTarget = i; break; }
        }
        if (lastIdxInTarget === -1) rules.push(rule);
        else rules.splice(lastIdxInTarget + 1, 0, rule);
        if (selectedRuleId === rule.id) {
          selectedFolderId = targetGroup.folderId;
          expandedFolders[targetGroup.folderId] = true;
          expandedGroups[target] = true;
          saveExpanded();
        }
      } else if (action === 'copy') {
        const copy = JSON.parse(JSON.stringify(rule));
        copy.id = genId('r');
        copy.groupId = target;
        copy.history = [];
        if (Array.isArray(copy.actions)) {
          copy.actions.forEach(a => { a.id = genId('a'); a.clickCount = 0; });
        }
        rules.push(copy);
      }
      saveAll();
      renderFolders();
      if (selectedGroupId) renderGroupEditor();
      else renderFolderEditor();
      menu.classList.remove('show');
    });
  });

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

// === Akcje — dropdowny przenieś/skopiuj (od v2.13.34, split od v2.13.38) ===
// Wcześniej (v2.13.34): jeden kebab dropdown z 3 sekcjami (Duplikuj + Przenieś + Skopiuj).
// User reportował że łatwo się pomylić między Przenieś i Skopiuj (te same nazwy zestawów w
// dwóch sekcjach pod sobą). Od v2.13.38 wiersz akcji ma 3 osobne icon-only buttony:
//  - Duplikuj (single click, inline w bieżącym rule, no dropdown)
//  - Przenieś (dropdown z listą innych rules grupowaną po folder/group, source splice)
//  - Skopiuj (analog, source nie ruszany)
// Ta funkcja obsługuje teraz tylko 'move' i 'copy' — duplicate jest inline w handler'ze
// przycisku, nie wymaga menu.
//
// Move/copy między rules clears `barId` jeśli docelowy rule nie ma tego baru w drzewie
// (folder/group/rule scope) — żeby user nie miał orphan barId po przeniesieniu.
function showActionMoveMenu(rule, action, idx, anchorBtn, mode = 'move') {
  const menu = document.getElementById('moveMenu');
  // Lista innych rules grupowana po folder → group → rules. Pomijamy bieżący rule.
  const otherRules = rules.filter(r => r.id !== rule.id && r.active !== false);

  // Build tree: byFolder[folderId] = { folderName, byGroup: Map<groupId, {groupName, rules:[]}> }
  const tree = new Map();
  otherRules.forEach(r => {
    const g = groups.find(gx => gx.id === r.groupId);
    if (!g) return;
    const f = folders.find(ff => ff.id === g.folderId);
    if (!f) return;
    if (!tree.has(f.id)) tree.set(f.id, { name: f.name || JZ_I18N.t('sidebar.fallback.folderName'), groups: new Map() });
    const folderNode = tree.get(f.id);
    if (!folderNode.groups.has(g.id)) folderNode.groups.set(g.id, { name: g.name || JZ_I18N.t('sidebar.fallback.groupName'), rules: [] });
    folderNode.groups.get(g.id).rules.push(r);
  });

  const sectionKey = mode === 'copy' ? 'action.menu.section.copyTo' : 'action.menu.section.moveTo';
  let html = `<div class="move-menu-section">${escapeHtml(JZ_I18N.t(sectionKey))}</div>`;
  if (otherRules.length === 0) {
    html += `<div class="move-menu-item" style="color:#9ca3af;">${escapeHtml(JZ_I18N.t('action.menu.empty.rules'))}</div>`;
  } else {
    tree.forEach(folderNode => {
      html += `<div class="move-menu-section" style="font-size:9px;">${escapeHtml(folderNode.name)}</div>`;
      folderNode.groups.forEach(groupNode => {
        groupNode.rules.forEach(targetRule => {
          const groupName = folderNode.groups.size > 1 ? `${groupNode.name} · ` : '';
          html += `<div class="move-menu-item" data-action="${mode}" data-target-rule="${escapeAttr(targetRule.id)}">${escapeHtml(groupName + (targetRule.name || JZ_I18N.t('sidebar.fallback.ruleName')))}</div>`;
        });
      });
    });
  }

  menu.innerHTML = html;
  const rect = anchorBtn.getBoundingClientRect();
  // Position: prefer below, flip up if too low.
  menu.classList.add('show');
  const menuRect = menu.getBoundingClientRect();
  const wantTop = rect.bottom + 4;
  const finalTop = (wantTop + menuRect.height > window.innerHeight - 8)
    ? Math.max(8, rect.top - menuRect.height - 4)
    : wantTop;
  menu.style.left = Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.left)) + 'px';
  menu.style.top = finalTop + 'px';

  // Helper — czy barId pasuje do drzewa docelowego rule.
  function barIdValidForTargetRule(barId, targetRule) {
    if (!barId) return false;
    const bar = (actionBars || []).find(b => b.id === barId);
    if (!bar) return false;
    if (bar.parentType === 'rule' && bar.parentId === targetRule.id) return true;
    if (bar.parentType === 'group' && bar.parentId === targetRule.groupId) return true;
    if (bar.parentType === 'folder') {
      const g = groups.find(gx => gx.id === targetRule.groupId);
      if (g && bar.parentId === g.folderId) return true;
    }
    return false;
  }

  menu.querySelectorAll('.move-menu-item[data-action]').forEach(item => {
    item.addEventListener('click', () => {
      const op = item.dataset.action;
      if (op === 'move' || op === 'copy') {
        const targetRuleId = item.dataset.targetRule;
        const targetRule = rules.find(r => r.id === targetRuleId);
        if (!targetRule) { menu.classList.remove('show'); return; }
        if (!Array.isArray(targetRule.actions)) targetRule.actions = [];
        const clone = JSON.parse(JSON.stringify(action));
        clone.id = genId('a');
        clone.clickCount = 0;
        // barId — wyczyść jeśli docelowy rule nie ma dostępu do tego baru w swoim drzewie.
        if (clone.barId && !barIdValidForTargetRule(clone.barId, targetRule)) {
          delete clone.barId;
        }
        targetRule.actions.push(clone);
        // Indeks liczymy świeżo po action.id — `idx` z czasu otwarcia menu mógł
        // się zdezaktualizować (inna akcja usunięta/przeniesiona w międzyczasie).
        if (op === 'move') {
          const srcIdx = rule.actions.findIndex(a => a.id === action.id);
          if (srcIdx >= 0) rule.actions.splice(srcIdx, 1);
        }
        saveAll();
        renderActionsList(document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`), rule);
        // Odśwież badge w zakładce źródłowej (count może się zmienić przy move).
        const card = document.querySelector(`.rule-card[data-rule-id="${rule.id}"]`);
        if (card) {
          const badge = card.querySelector('.editor-tab[data-tab="actions"]');
          if (badge) {
            const count = rule.actions.length;
            badge.innerHTML = `${jzIcon('flagActions', 'tab-flag flag-actions')} ${JZ_I18N.t('rule.card.tab.actions')}${count ? ` <span style="background:#006870;color:white;padding:1px 5px;border-radius:8px;font-size:10px;margin-left:4px;">${count}</span>` : ''}`;
          }
        }
        showToast(JZ_I18N.t(op === 'move' ? 'action.menu.toast.moved' : 'action.menu.toast.copied', {
          target: targetRule.name || JZ_I18N.t('sidebar.fallback.ruleName')
        }));
      }
      menu.classList.remove('show');
    });
  });

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

// === Akcje globalne ===
document.getElementById('addFolderBtn').addEventListener('click', () => {
  const folder = {
    id: genId('f'),
    name: JZ_I18N.t('default.folderName'),
    urlPatterns: [],
    urlPattern: '',
    enabled: true,
    showActionsPanel: false,
    actionsOrientation: 'vertical',
    color: '',
    icon: ''
  };
  folders.push(folder);
  // Auto-utwórz domyślną grupę dla nowego folderu
  groups.push({
    id: genId('g'),
    folderId: folder.id,
    name: JZ_I18N.t('default.groupName'),
    enabled: true
  });
  // Auto-rozwiń nowy folder żeby user od razu widział pustą grupę default w sidebarze
  // (inaczej świeży folder wyglądałby na bezczynny: trzeba kliknąć strzałkę żeby cokolwiek zobaczyć).
  expandedFolders[folder.id] = true;
  saveExpanded();
  selectedFolderId = folder.id;
  selectedGroupId = null;
  selectedRuleId = null;
  saveAll();
  renderFolders();
  renderFolderEditor();
});

document.getElementById('addGroupBtn').addEventListener('click', () => {
  if (!selectedFolderId) return;
  const folder = folders.find(f => f.id === selectedFolderId);
  if (!folder) return;
  const group = {
    id: genId('g'),
    folderId: folder.id,
    name: JZ_I18N.t('default.newGroupName'),
    enabled: true
  };
  groups.push(group);
  selectedGroupId = group.id;
  selectedRuleId = null;
  expandedFolders[folder.id] = true;
  expandedGroups[group.id] = true;
  saveExpanded();
  saveAll();
  renderFolders();
  renderGroupEditor();
});

// === Search ===
const searchInput = document.getElementById('searchInput');
const clearSearchBtn = document.getElementById('clearSearchBtn');

let searchTimer = null;
searchInput.addEventListener('input', (e) => {
  clearTimeout(searchTimer);
  searchTimer = setTimeout(() => {
    searchQuery = e.target.value.trim();
    clearSearchBtn.style.display = searchQuery ? 'block' : 'none';
    renderFolders();
  }, 150);
});

clearSearchBtn.addEventListener('click', () => {
  searchInput.value = '';
  searchQuery = '';
  clearSearchBtn.style.display = 'none';
  renderFolders();
  searchInput.focus();
});

// === Filtr "tylko aktywne" ===
document.getElementById('filterActiveOnly').addEventListener('change', (e) => {
  filterActiveOnly = e.target.checked;
  renderFolders();
});

// === Rozwiń / zwiń całe drzewo ===
document.getElementById('expandAllBtn').addEventListener('click', () => {
  expandedFolders = {};
  expandedGroups = {};
  for (const f of folders) expandedFolders[f.id] = true;
  for (const g of groups)  expandedGroups[g.id]  = true;
  saveExpanded();
  renderFolders();
});
document.getElementById('collapseAllBtn').addEventListener('click', () => {
  expandedFolders = {};
  expandedGroups = {};
  saveExpanded();
  renderFolders();
});

// === Powrót na pulpit (stronę główną panelu edycji) ===
function jzGoToDashboard() {
  selectedFolderId = null;
  selectedGroupId = null;
  selectedRuleId = null;
  renderDashboard();
  renderFolders();
}
// Klik w logotyp sidebara.
document.querySelector('.sidebar-brand .brand-logo')?.addEventListener('click', jzGoToDashboard);
// Ikona „dom" na pasku breadcrumb w edytorach katalogu/grupy/zestawu.
document.querySelectorAll('.breadcrumb-home').forEach(btn => {
  btn.addEventListener('click', jzGoToDashboard);
});

// === Synchronizacja ustawień (motyw + język) — encja singleton 'settings' ===
// Enkolejkowane z jawnych handlerów UI (klik przełącznika / zmiana selektora),
// NIE ze storage.onChanged — dzięki temu zapis storage przy pullu nie wywołuje
// ponownego enqueue (brak pętli). Wzorzec analogiczny do ai_keys.
async function jzEnqueueSettings() {
  if (typeof JZ_SYNC === 'undefined' || typeof JZ_SYNC.enqueueChange !== 'function') return;
  try {
    const d = await chrome.storage.local.get(['theme', 'userLang']);
    JZ_SYNC.enqueueChange({
      type: 'settings', id: 'settings', parentId: null,
      payload: { theme: d.theme || 'auto', userLang: d.userLang || 'auto' },
    });
    // enqueueChange utrwala kolejkę fire-and-forget; ten odczyt serializuje się
    // PO tym zapisie (operacje chrome.storage z jednego kontekstu są kolejkowane)
    // — gwarantuje utrwalenie kolejki przed ewentualnym reloadem strony.
    await chrome.storage.local.get(['theme']);
  } catch (e) {}
}

// === Theme toggle (od v3.2.79 — segmented control: klik = direct select; ===
//     poprzednio v3.2.78 cykl light→dark→auto→light).
document.querySelectorAll('#themeToggle3 .theme-toggle3-seg').forEach((seg) => {
  seg.addEventListener('click', async () => {
    const newTheme = seg.dataset.theme; // 'light' | 'dark' | 'auto'
    if (!newTheme) return;
    await chrome.storage.local.set({ theme: newTheme });
    applyTheme(newTheme);
    jzEnqueueSettings();
  });
});

// Sidebar toggle
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebarToggle');

function applySidebarState(collapsed) {
  sidebar.classList.toggle('collapsed', collapsed);
  sidebarToggle.innerHTML = collapsed ? JZ_ICONS.toggleRight : JZ_ICONS.toggleLeft;
  // Refresh CodeMirror, by zaktualizował szerokość
  setTimeout(() => {
    Object.values(editors).forEach(({ css, js }) => {
      try { css?.refresh(); } catch(e) {}
      try { js?.refresh(); } catch(e) {}
    });
  }, 220);
}

sidebarToggle.addEventListener('click', async () => {
  const collapsed = !sidebar.classList.contains('collapsed');
  applySidebarState(collapsed);
  await chrome.storage.local.set({ sidebarCollapsed: collapsed });
});

// Restore sidebar state on load
chrome.storage.local.get(['sidebarCollapsed']).then(data => {
  if (data.sidebarCollapsed) applySidebarState(true);
});

// Settings drop-up menu (Pomoc / Eksport / Import / Theme)
const settingsMenuBtn = document.getElementById('settingsMenuBtn');
const settingsMenu = document.getElementById('settingsMenu');
function setSettingsMenuOpen(open) {
  if (!settingsMenu || !settingsMenuBtn) return;
  settingsMenu.hidden = !open;
  settingsMenuBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
}
if (settingsMenuBtn && settingsMenu) {
  settingsMenuBtn.addEventListener('click', (e) => {
    e.stopPropagation();
    setSettingsMenuOpen(settingsMenu.hidden);
  });
  settingsMenu.addEventListener('click', (e) => {
    // Klik na przycisk wewnątrz — zamknij menu po wykonaniu akcji.
    if (e.target.closest('button')) setSettingsMenuOpen(false);
  });
  document.addEventListener('click', (e) => {
    if (settingsMenu.hidden) return;
    if (e.target.closest('.sidebar-footer')) return;
    setSettingsMenuOpen(false);
  });
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape' && !settingsMenu.hidden) setSettingsMenuOpen(false);
  });
}

// Usuwa paski akcji i okna (CSS/JS pane, konsole) których parentId wskazuje
// usunięty folder/grupę/zestaw. Bez tego osierocone encje nie dostają tombstone
// w _diffAndEnqueue (są w obu snapshotach) i wracają przy pull na innym urządzeniu.
function pruneOrphanWindows(deletedIds) {
  actionBars = actionBars.filter(b => !deletedIds.has(b.parentId));
  cssPanes = cssPanes.filter(p => !deletedIds.has(p.parentId));
  jsPanes = jsPanes.filter(p => !deletedIds.has(p.parentId));
  jsConsoles = jsConsoles.filter(c => !deletedIds.has(c.parentId));
  outputConsoles = outputConsoles.filter(c => !deletedIds.has(c.parentId));
}

document.getElementById('deleteFolderBtn').addEventListener('click', async () => {
  const folder = folders.find(f => f.id === selectedFolderId);
  if (!folder) return;
  const folderGroups = groups.filter(g => g.folderId === folder.id);
  const folderGroupIds = new Set(folderGroups.map(g => g.id));
  const folderRules = rules.filter(r => folderGroupIds.has(r.groupId));
  const msg = folderRules.length > 0
    ? JZ_I18N.t('confirm.deleteFolderWithStats', {
        name: folder.name,
        groups: folderGroups.length + ' ' + JZ_I18N.plural(folderGroups.length, 'plural.group'),
        rules: folderRules.length + ' ' + JZ_I18N.plural(folderRules.length, 'plural.rule'),
      })
    : JZ_I18N.t('confirm.deleteFolder', { name: folder.name });
  if (!confirm(msg)) return;

  const deletedIds = new Set([folder.id, ...folderGroupIds, ...folderRules.map(r => r.id)]);
  folders = folders.filter(f => f.id !== folder.id);
  groups = groups.filter(g => g.folderId !== folder.id);
  rules = rules.filter(r => !folderGroupIds.has(r.groupId));
  pruneOrphanWindows(deletedIds);
  selectedFolderId = folders[0]?.id || null;
  selectedGroupId = null;
  selectedRuleId = null;
  // KRYTYCZNE: await saveAll PRZED load. Bez await load()._snapshotEntities()
  // wykona się równolegle z saveAll()._diffAndEnqueue() — nadpisując in-memory
  // tablice STARYM stanem ze storage (jeśli load.get wyprzedzi saveAll.set),
  // przez co diff nie wykryje usunięć i enqueueDelete nie zostaje wywołane.
  // To był prawdopodobny powód dla którego usunięcia folderów nie syncowały.
  await saveAll();
  await load();
});

// addRuleBtn — usunięto z folder editora; zestawy dodaje się tylko z poziomu grupy (group editor → "+ Dodaj zestaw")
// lub poprzez "+ Dodaj zestaw w tej grupie" z group editora — patrz renderGroupEditor / addRuleInGroupBtn.

// Pomoc
document.getElementById('helpBtn').addEventListener('click', () => {
  chrome.tabs.create({ url: chrome.runtime.getURL('help.html') });
});

// === Tagowane linki do strony projektu / treści ze strony (UTM) ===
// jzAppendUtm() dokleja parametry UTM do dowolnego URL-a; jzProjectSiteUrl()
// buduje URL strony projektu w wersji językowej UI. Parametry:
//   utm_source=extension, utm_medium=referral
//   utm_campaign — przeglądarka (chrome/edge/firefox/opera) wykryta z userAgent;
//     dzięki temu kod jest identyczny we wszystkich 4 buildach (parytet)
//   utm_term — miejsce kliknięcia ('logo', 'settings', 'examples', 'blog')
//   utm_content — wersja wtyczki z manifestu
function jzBrowserName() {
  const ua = navigator.userAgent || '';
  return ua.includes('Firefox') ? 'firefox'
    : ua.includes('OPR/') ? 'opera'
    : ua.includes('Edg/') ? 'edge'
    : 'chrome';
}

function jzAppendUtm(url, term) {
  try {
    const u = new URL(url);
    // Whitelist schematu — wpuszczaj tylko http(s); javascript:/data: itp. → '#'.
    if (u.protocol !== 'http:' && u.protocol !== 'https:') return '#';
    u.searchParams.set('utm_source', 'extension');
    u.searchParams.set('utm_medium', 'referral');
    u.searchParams.set('utm_campaign', jzBrowserName());
    u.searchParams.set('utm_term', term);
    u.searchParams.set('utm_content', chrome.runtime.getManifest().version);
    return u.toString();
  } catch (e) {
    return url;
  }
}

function jzProjectSiteUrl(term) {
  const lang = (typeof JZ_I18N !== 'undefined' && JZ_I18N.lang) || 'pl';
  return jzAppendUtm(`https://justzix.com/${lang}/`, term);
}

// Strona projektu
document.getElementById('projectSiteBtn').addEventListener('click', () => {
  chrome.tabs.create({ url: jzProjectSiteUrl('settings') });
});

// Eksport/import
// === Pełny eksport z możliwością wyboru ===
let exportSelection = {}; // { folderId: { selected: bool, rules: { ruleId: bool } } }

document.getElementById('exportBtn').addEventListener('click', () => {
  // Inicjalizuj selekcję - domyślnie wszystko zaznaczone
  exportSelection = {};
  folders.forEach(f => {
    exportSelection[f.id] = { selected: true, rules: {} };
    getRulesForFolder(f.id).forEach(r => {
      exportSelection[f.id].rules[r.id] = true;
    });
  });

  // Reset radio na "wszystko"
  document.querySelector('input[name="exportMode"][value="all"]').checked = true;
  document.getElementById('exportSelector').style.display = 'none';

  renderExportTree();
  updateExportPayload();
  document.getElementById('exportModal').classList.add('show');
});

// Obsługa zmiany trybu
document.querySelectorAll('input[name="exportMode"]').forEach(r => {
  r.addEventListener('change', () => {
    const mode = document.querySelector('input[name="exportMode"]:checked').value;
    document.getElementById('exportSelector').style.display = mode === 'select' ? 'block' : 'none';
    updateExportPayload();
  });
});

function renderExportTree() {
  const list = document.getElementById('exportTreeList');
  list.innerHTML = '';

  if (folders.length === 0) {
    list.innerHTML = `<div style="color:#9ca3af;font-size:12px;text-align:center;padding:10px;">${escapeHtml(JZ_I18N.t('export.empty.foldersToExport'))}</div>`;
    return;
  }

  folders.forEach(folder => {
    const folderGroups = getGroupsForFolder(folder.id);
    const folderRules = getRulesForFolder(folder.id);
    const sel = exportSelection[folder.id];

    const folderEl = document.createElement('div');
    folderEl.className = 'export-folder-block';
    folderEl.innerHTML = `
      <label class="export-folder-row">
        <input type="checkbox" class="export-folder-cb" data-folder-id="${folder.id}" ${sel?.selected ? 'checked' : ''}>
        <span class="export-row-name">${escapeHtml(folder.name || '(bez nazwy)')}</span>
        <span class="export-row-count">${folderGroups.length} g, ${folderRules.length} z</span>
      </label>
      <div class="export-groups-list"></div>
    `;

    const groupsListEl = folderEl.querySelector('.export-groups-list');
    folderGroups.forEach(group => {
      const groupRules = rules.filter(r => r.groupId === group.id);
      const anyGroupRuleSelected = groupRules.some(r => sel?.rules?.[r.id]);
      const allGroupRulesSelected = groupRules.length > 0 && groupRules.every(r => sel?.rules?.[r.id]);
      const groupEl = document.createElement('div');
      groupEl.className = 'export-group-block';
      groupEl.innerHTML = `
        <label class="export-group-row">
          <input type="checkbox" class="export-group-cb" data-folder-id="${folder.id}" data-group-id="${group.id}" ${anyGroupRuleSelected ? 'checked' : ''}>
          <span class="export-row-name">${JZ_ICONS.group} ${escapeHtml(group.name || '(bez nazwy)')}</span>
          <span class="export-row-count subtle">${groupRules.length}</span>
        </label>
        <div class="export-rules-list"></div>
      `;
      const groupCb = groupEl.querySelector('.export-group-cb');
      groupCb.indeterminate = anyGroupRuleSelected && !allGroupRulesSelected;
      const rulesList = groupEl.querySelector('.export-rules-list');
      groupRules.forEach(rule => {
        const ruleSel = sel?.rules?.[rule.id];
        const ruleEl = document.createElement('label');
        ruleEl.className = 'export-rule-row';
        ruleEl.innerHTML = `
          <input type="checkbox" class="export-rule-cb" data-folder-id="${folder.id}" data-group-id="${group.id}" data-rule-id="${rule.id}" ${ruleSel ? 'checked' : ''}>
          <span class="export-row-name">${escapeHtml(rule.name || '(bez nazwy)')}</span>
          ${(rule.actions || []).length ? `<span class="export-row-count subtle">${rule.actions.length} akcji</span>` : ''}
        `;
        rulesList.appendChild(ruleEl);
      });
      groupsListEl.appendChild(groupEl);
    });

    list.appendChild(folderEl);
  });

  // Eventy checkboxów katalogów - toggle wszystkich grup/zestawów wewnątrz
  list.querySelectorAll('.export-folder-cb').forEach(cb => {
    cb.addEventListener('change', (e) => {
      const folderId = e.target.dataset.folderId;
      const checked = e.target.checked;
      exportSelection[folderId].selected = checked;
      Object.keys(exportSelection[folderId].rules).forEach(ruleId => {
        exportSelection[folderId].rules[ruleId] = checked;
      });
      list.querySelectorAll(`.export-rule-cb[data-folder-id="${folderId}"]`).forEach(rcb => {
        rcb.checked = checked;
      });
      list.querySelectorAll(`.export-group-cb[data-folder-id="${folderId}"]`).forEach(gcb => {
        gcb.checked = checked;
        gcb.indeterminate = false;
      });
      updateExportPayload();
    });
  });

  // Checkbox grupy — toggle wszystkich zestawów grupy
  list.querySelectorAll('.export-group-cb').forEach(cb => {
    cb.addEventListener('change', (e) => {
      const folderId = e.target.dataset.folderId;
      const groupId = e.target.dataset.groupId;
      const checked = e.target.checked;
      const groupRuleIds = rules.filter(r => r.groupId === groupId).map(r => r.id);
      groupRuleIds.forEach(rid => { exportSelection[folderId].rules[rid] = checked; });
      list.querySelectorAll(`.export-rule-cb[data-group-id="${groupId}"]`).forEach(rcb => {
        rcb.checked = checked;
      });
      // Aktualizuj rodzica (folder)
      const anyRuleSelected = Object.values(exportSelection[folderId].rules).some(v => v);
      const allRulesSelected = Object.values(exportSelection[folderId].rules).every(v => v);
      exportSelection[folderId].selected = anyRuleSelected;
      const folderCb = list.querySelector(`.export-folder-cb[data-folder-id="${folderId}"]`);
      if (folderCb) {
        folderCb.checked = anyRuleSelected;
        folderCb.indeterminate = anyRuleSelected && !allRulesSelected;
      }
      updateExportPayload();
    });
  });

  // Eventy checkboxów zestawów - aktualizuj stan rodziców (grupy + folderu)
  list.querySelectorAll('.export-rule-cb').forEach(cb => {
    cb.addEventListener('change', (e) => {
      const folderId = e.target.dataset.folderId;
      const groupId = e.target.dataset.groupId;
      const ruleId = e.target.dataset.ruleId;
      exportSelection[folderId].rules[ruleId] = e.target.checked;

      // Aktualizuj checkbox grupy
      const groupRuleIds = rules.filter(r => r.groupId === groupId).map(r => r.id);
      const anyInGroup = groupRuleIds.some(rid => exportSelection[folderId].rules[rid]);
      const allInGroup = groupRuleIds.every(rid => exportSelection[folderId].rules[rid]);
      const groupCb = list.querySelector(`.export-group-cb[data-group-id="${groupId}"]`);
      if (groupCb) {
        groupCb.checked = anyInGroup;
        groupCb.indeterminate = anyInGroup && !allInGroup;
      }

      // Aktualizuj checkbox folderu
      const anyRuleSelected = Object.values(exportSelection[folderId].rules).some(v => v);
      exportSelection[folderId].selected = anyRuleSelected;
      const folderCb = list.querySelector(`.export-folder-cb[data-folder-id="${folderId}"]`);
      if (folderCb) {
        folderCb.checked = anyRuleSelected;
        const allRulesSelected = Object.values(exportSelection[folderId].rules).every(v => v);
        folderCb.indeterminate = anyRuleSelected && !allRulesSelected;
      }
      updateExportPayload();
    });
  });

  // Ustaw indeterminate dla katalogów z częściową selekcją
  folders.forEach(folder => {
    const folderCb = list.querySelector(`.export-folder-cb[data-folder-id="${folder.id}"]`);
    if (!folderCb) return;
    const ruleStates = Object.values(exportSelection[folder.id]?.rules || {});
    if (ruleStates.length === 0) return;
    const anySelected = ruleStates.some(v => v);
    const allSelected = ruleStates.every(v => v);
    folderCb.checked = anySelected;
    folderCb.indeterminate = anySelected && !allSelected;
  });
}

document.getElementById('exportSelectAll').addEventListener('click', () => {
  folders.forEach(f => {
    if (!exportSelection[f.id]) exportSelection[f.id] = { selected: true, rules: {} };
    exportSelection[f.id].selected = true;
    getRulesForFolder(f.id).forEach(r => {
      exportSelection[f.id].rules[r.id] = true;
    });
  });
  renderExportTree();
  updateExportPayload();
});

document.getElementById('exportSelectNone').addEventListener('click', () => {
  folders.forEach(f => {
    if (!exportSelection[f.id]) exportSelection[f.id] = { selected: false, rules: {} };
    exportSelection[f.id].selected = false;
    Object.keys(exportSelection[f.id].rules).forEach(rid => {
      exportSelection[f.id].rules[rid] = false;
    });
  });
  renderExportTree();
  updateExportPayload();
});

// === Eksport/import okien (paski akcji + 4 typy okien) ===
// Bary i okna są przypięte do folderu/grupy/zestawu przez parentType+parentId.
// Eksport granularny musi je dołączyć (pole `windows`), a import — odtworzyć je
// z remapem parentId i własnego id.
const _WIN_KEYS = ['actionBars', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles'];
const _WIN_PREFIX = { actionBars: 'b', cssPanes: 'cp', jsPanes: 'jp', jsConsoles: 'jc', outputConsoles: 'oc' };

// Zbiera bary/okna przypięte do dowolnego parenta z `parentIds` (Set lub tablica).
function collectWindowsForExport(parentIds) {
  const ids = parentIds instanceof Set ? parentIds : new Set(parentIds || []);
  const src = { actionBars, cssPanes, jsPanes, jsConsoles, outputConsoles };
  const out = {};
  _WIN_KEYS.forEach(k => { out[k] = (src[k] || []).filter(w => w && ids.has(w.parentId)); });
  return JSON.parse(JSON.stringify(out));
}

// Importuje bary/okna z payloadu (pole `windows`), remapując parentId przez mapy
// id {oldId:newId} per parentType i nadając świeże własne id. Zwraca barIdMap
// (stare→nowe id baru) do remapu action.barId.
function importWindowsFromPayload(payload, idMaps) {
  const barIdMap = {};
  if (!payload || typeof payload !== 'object') return barIdMap;
  // Eksport granularny trzyma okna w polu `windows`; pełny backup ma je jako
  // klucze najwyższego poziomu (actionBars/cssPanes/...). Obsługujemy oba.
  const win = (payload.windows && typeof payload.windows === 'object') ? payload.windows : payload;
  const mapFor = (pt) => (pt === 'folder' ? idMaps.folder : pt === 'group' ? idMaps.group : pt === 'rule' ? idMaps.rule : null);
  const dest = { actionBars, cssPanes, jsPanes, jsConsoles, outputConsoles };
  _WIN_KEYS.forEach(k => {
    (Array.isArray(win[k]) ? win[k] : []).forEach(w => {
      if (!w || !w.id || !w.parentType || !w.parentId) return;
      const m = mapFor(w.parentType);
      if (!m) return;
      const newParentId = (m instanceof Map) ? m.get(w.parentId) : m[w.parentId];
      if (!newParentId) return; // rodzic nie został zaimportowany
      const copy = JSON.parse(JSON.stringify(w));
      const oldId = copy.id;
      copy.id = genId(_WIN_PREFIX[k]);
      copy.parentId = newParentId;
      if (k === 'actionBars') barIdMap[oldId] = copy.id;
      dest[k].push(copy);
    });
  });
  return barIdMap;
}

// Remapuje action.barId zaimportowanych zestawów przez barIdMap; gubione → usuwa.
function remapImportedActionBarIds(importedRules, barIdMap) {
  (importedRules || []).forEach(r => {
    (r.actions || []).forEach(a => {
      if (!a.barId) return;
      if (barIdMap[a.barId]) a.barId = barIdMap[a.barId];
      else delete a.barId;
    });
  });
}

async function updateExportPayload() {
  const mode = document.querySelector('input[name="exportMode"]:checked').value;
  const data = await chrome.storage.local.get(null);
  const summary = document.getElementById('exportSummary');

  let payload;
  let summaryText = '';

  if (mode === 'all') {
    payload = {
      type: 'justzix-full',
      version: 1,
      exportedAt: new Date().toISOString(),
      ...data
    };
    summaryText = JZ_I18N.t('export.summary.full', {
      icon: JZ_ICONS.package,
      stats: formatStats({ folders: folders.length, groups: groups.length, rules: rules.length }, ', ', true),
    });
  } else {
    // Wybrane elementy
    const selectedFolders = [];
    const selectedRules = [];
    folders.forEach(f => {
      const sel = exportSelection[f.id];
      if (!sel) return;
      const selectedRuleIds = Object.keys(sel.rules).filter(rid => sel.rules[rid]);
      if (selectedRuleIds.length === 0 && !sel.selected) return;
      if (selectedRuleIds.length > 0 || sel.selected) {
        selectedFolders.push(f);
        selectedRuleIds.forEach(rid => {
          const rule = rules.find(r => r.id === rid);
          if (rule) selectedRules.push(rule);
        });
      }
    });

    // Włącz wszystkie grupy folderów, których zestawy są w eksporcie (lub które są zaznaczone same)
    const selectedFolderIds = new Set(selectedFolders.map(f => f.id));
    const selectedGroupIds = new Set(selectedRules.map(r => r.groupId));
    const selectedGroups = groups.filter(g => selectedFolderIds.has(g.folderId) && (selectedGroupIds.has(g.id) || selectedFolders.find(f => f.id === g.folderId)));

    const partialParentIds = new Set([
      ...selectedFolderIds,
      ...selectedGroups.map(g => g.id),
      ...selectedRules.map(r => r.id),
    ]);
    payload = {
      type: 'justzix-full',
      version: 2,
      schemaVersion: STORAGE_VERSION,
      exportedAt: new Date().toISOString(),
      partial: true,
      folders: selectedFolders,
      groups: selectedGroups,
      rules: selectedRules,
      windows: collectWindowsForExport(partialParentIds),
      floatingHidden: Object.fromEntries(
        Object.entries(data.floatingHidden || {}).filter(([fid]) => selectedFolderIds.has(fid))
      ),
      expandedFolders: Object.fromEntries(
        Object.entries(data.expandedFolders || {}).filter(([fid]) => selectedFolderIds.has(fid))
      )
    };
    summaryText = JZ_I18N.t('export.summary.partial', {
      icon: JZ_ICONS.copy,
      stats: formatStats({ folders: selectedFolders.length, groups: selectedGroups.length, rules: selectedRules.length }, ', ', true),
    });
    if (selectedFolders.length === 0) {
      summaryText = JZ_I18N.t('export.summary.noElements', { icon: JZ_ICONS.warning });
    }
  }

  summary.innerHTML = summaryText;
  document.getElementById('exportText').value = JSON.stringify(payload, null, 2);

  // Zablokuj akcje gdy nic nie wybrano
  const nothingSelected = mode === 'select' && payload.folders.length === 0;
  document.getElementById('copyExportBtn').disabled = nothingSelected;
  document.getElementById('downloadExportBtn').disabled = nothingSelected;
}

document.getElementById('copyExportBtn').addEventListener('click', () => {
  const ta = document.getElementById('exportText');
  ta.select();
  navigator.clipboard.writeText(ta.value).then(() => showToast(JZ_I18N.t('toast.copyClipboard')));
});

document.getElementById('downloadExportBtn').addEventListener('click', () => {
  const json = document.getElementById('exportText').value;
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  const date = new Date().toISOString().slice(0, 10);
  const mode = document.querySelector('input[name="exportMode"]:checked').value;
  a.download = mode === 'all'
    ? `justzix-backup-${date}.json`
    : `justzix-partial-${date}.json`;
  a.click();
  URL.revokeObjectURL(url);
});

document.getElementById('closeExportBtn').addEventListener('click', () => {
  document.getElementById('exportModal').classList.remove('show');
});

document.getElementById('importBtn').addEventListener('click', () => {
  document.getElementById('importText').value = '';
  document.getElementById('importFile').value = '';
  document.getElementById('importDetection').style.display = 'none';
  const tgt = document.getElementById('importTarget');
  if (tgt) { tgt.style.display = 'none'; tgt.innerHTML = ''; }
  const warn = document.getElementById('importFullWarning');
  if (warn) warn.style.display = 'none';
  pendingFullImport = null;
  fullImportValid = false;
  document.getElementById('doImportBtn').disabled = false;
  document.getElementById('importModal').classList.add('show');
});

document.getElementById('importFile').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = () => {
    document.getElementById('importText').value = reader.result;
    detectFullImportType();
  };
  reader.readAsText(file);
});

document.getElementById('importText').addEventListener('input', detectFullImportType);

let fullImportValid = false;
// pendingFullImport zawiera ustaloną przez detectFullImportType strukturę:
//   { exportType: 'justzix-full' | 'justzix-folder' | 'justzix-group' | 'justzix-rule',
//     data: <parsed payload>, isPartial?: bool }
// Wybory usera (folder docelowy / grupa docelowa / tryb) są odczytywane bezpośrednio z DOM
// w momencie kliknięcia doImportBtn — nie duplikujemy ich w state.
let pendingFullImport = null;

// Detekcja typu eksportu — heurystyka łapie też legacy/CSS-Injector formaty.
function detectExportType(data) {
  if (!data || typeof data !== 'object') return null;
  if (isExportType(data.type, 'full')) return 'justzix-full';
  if (isExportType(data.type, 'folder')) return 'justzix-folder';
  if (isExportType(data.type, 'group')) return 'justzix-group';
  if (isExportType(data.type, 'rule')) return 'justzix-rule';
  // Heurystyka dla legacy bez `type`:
  if (Array.isArray(data.folders) && data.folders.length > 0) return 'justzix-full';
  if (data.folder && Array.isArray(data.rules)) return 'justzix-folder';
  if (data.group && Array.isArray(data.rules)) return 'justzix-group';
  if (data.rule) return 'justzix-rule';
  return null;
}

function detectFullImportType() {
  const json = document.getElementById('importText').value.trim();
  const detectionDiv = document.getElementById('importDetection');
  const targetDiv = document.getElementById('importTarget');
  const warningEl = document.getElementById('importFullWarning');
  const importBtn = document.getElementById('doImportBtn');
  fullImportValid = false;
  pendingFullImport = null;

  detectionDiv.style.display = 'none';
  targetDiv.style.display = 'none';
  targetDiv.innerHTML = '';
  if (warningEl) warningEl.style.display = 'none';

  if (!json) {
    importBtn.disabled = false;
    return;
  }

  let data;
  try {
    data = JSON.parse(json);
  } catch (err) {
    detectionDiv.style.display = 'block';
    detectionDiv.style.background = '#fee2e2';
    detectionDiv.style.border = '1px solid #fecaca';
    detectionDiv.innerHTML = JZ_I18N.t('import.detection.parseError', { icon: JZ_ICONS.warning, msg: escapeHtml(err.message) });
    importBtn.disabled = true;
    return;
  }

  const exportType = detectExportType(data);
  if (!exportType) {
    detectionDiv.style.display = 'block';
    detectionDiv.style.background = '#fee2e2';
    detectionDiv.style.border = '1px solid #fecaca';
    detectionDiv.innerHTML = JZ_I18N.t('import.detection.invalid', { icon: JZ_ICONS.warning });
    importBtn.disabled = true;
    return;
  }

  pendingFullImport = { exportType, data };

  if (exportType === 'justzix-full') {
    renderFullImportDetection(data);
    if (warningEl) warningEl.style.display = 'block';
  } else if (exportType === 'justzix-folder') {
    renderFolderImportDetection(data);
  } else if (exportType === 'justzix-group') {
    renderGroupImportDetection(data);
  } else if (exportType === 'justzix-rule') {
    renderRuleImportDetection(data);
  }

  importBtn.disabled = false;
  fullImportValid = true;
}

function renderFullImportDetection(data) {
  const detectionDiv = document.getElementById('importDetection');
  const isLegacy = !isExportType(data.type, 'full');
  const isPartial = !!data.partial;
  pendingFullImport.isPartial = isPartial;
  const folderCount = (data.folders || []).length;
  const ruleCount = (data.rules || []).length;
  const exportedAt = data.exportedAt ? new Date(data.exportedAt).toLocaleString('pl-PL') : null;

  detectionDiv.style.display = 'block';
  let html = '';

  if (isPartial) {
    detectionDiv.style.background = '#ede9fe';
    detectionDiv.style.border = '1px solid #c4b5fd';
    html = JZ_I18N.t('import.detection.partialBackup', { icon: JZ_ICONS.copy });
    html += `<span style="color:#4c1d95;">${escapeHtml(JZ_I18N.t('import.detection.partialContains', { stats: formatStats({ folders: folderCount, rules: ruleCount }, ', ', true) }))}</span>`;
    if (exportedAt) html += `<br><span style="color:#4c1d95;font-size:11px;">Wyeksportowane: ${exportedAt}</span>`;
    html += `<br><br><strong style="color:#5b21b6;font-size:12px;">Tryb importu:</strong>`;
    html += `<div style="margin-top:6px;">
      <label style="display:block;margin-bottom:4px;font-size:12px;color:#1f2937;cursor:pointer;">
        <input type="radio" name="partialImportMode" value="merge" checked> Scal z obecnymi (dodaj nowe, zachowaj istniejące)
      </label>
      <label style="display:block;font-size:12px;color:#1f2937;cursor:pointer;">
        <input type="radio" name="partialImportMode" value="replace"> Zastąp obecne (nadpisze wszystko, łącznie z niezawartymi w pliku)
      </label>
    </div>`;
  } else {
    detectionDiv.style.background = '#E5F4F4';
    detectionDiv.style.border = '1px solid #B8DDDF';
    html = JZ_I18N.t('import.detection.fullBackup', { icon: JZ_ICONS.check, legacy: isLegacy ? JZ_I18N.t('import.detection.legacySuffix') : '' });
    html += `<span style="color:#155e75;">${escapeHtml(JZ_I18N.t('import.detection.partialContains', { stats: formatStats({ folders: folderCount, rules: ruleCount }, ', ', true) }))}</span>`;
    if (exportedAt) html += `<br><span style="color:#155e75;font-size:11px;">Wyeksportowane: ${exportedAt}</span>`;

    if (folders.length > 0 || rules.length > 0) {
      html += JZ_I18N.t('import.detection.willOverwrite', { icon: JZ_ICONS.warning });
      html += `<br><span style="color:#7f1d1d;font-size:11px;">${escapeHtml(formatStats({ folders: folders.length, rules: rules.length }, ', ', true))}</span>`;
      html += JZ_I18N.t('import.detection.exportFirst');
    }
  }
  detectionDiv.innerHTML = html;
}

function renderFolderImportDetection(data) {
  const detectionDiv = document.getElementById('importDetection');
  const targetDiv = document.getElementById('importTarget');
  const folderName = data.folder?.name || '(bez nazwy)';
  const groupCount = (data.groups || []).length;
  const ruleCount = (data.rules || []).length;
  const conflict = data.folder ? checkFolderConflicts(data.folder) : null;

  detectionDiv.style.display = 'block';
  detectionDiv.style.background = '#E5F4F4';
  detectionDiv.style.border = '1px solid #B8DDDF';
  detectionDiv.innerHTML = JZ_I18N.t('import.detection.folder', {
    icon: JZ_ICONS.folder,
    name: escapeHtml(folderName),
    stats: formatStats({ groups: groupCount, rules: ruleCount }, ', ', true),
  });

  targetDiv.style.display = 'block';
  let html = `<strong style="color:#155e75;">${escapeHtml(JZ_I18N.t('import.target.folder.title'))}</strong>`;
  html += `<div style="margin-top:6px;">`;
  html += `<label style="display:block;margin-bottom:4px;cursor:pointer;">
    <input type="radio" name="folderImportMode" value="newCopy" checked>
    ${escapeHtml(JZ_I18N.t('import.target.folder.newCopy'))}
  </label>`;
  if (conflict) {
    html += `<label style="display:block;margin-bottom:4px;cursor:pointer;">
      <input type="radio" name="folderImportMode" value="overwrite">
      ${escapeHtml(JZ_I18N.t('import.target.folder.overwrite', { name: conflict.existing.name }))}
    </label>`;
  }
  if (folders.length > 0) {
    html += `<label style="display:block;cursor:pointer;">
      <input type="radio" name="folderImportMode" value="mergeInto">
      ${escapeHtml(JZ_I18N.t('import.target.folder.mergeInto'))}
      <select id="folderImportMergeTarget" style="margin-left:4px;padding:3px 6px;font-size:12px;">
        ${folders.map(f => `<option value="${escapeAttr(f.id)}">${escapeHtml(f.name || '(bez nazwy)')}</option>`).join('')}
      </select>
    </label>`;
  }
  html += `</div>`;
  targetDiv.innerHTML = html;
}

function renderGroupImportDetection(data) {
  const detectionDiv = document.getElementById('importDetection');
  const targetDiv = document.getElementById('importTarget');
  const groupName = data.group?.name || '(bez nazwy)';
  const ruleCount = (data.rules || []).length;

  detectionDiv.style.display = 'block';
  detectionDiv.style.background = '#E5F4F4';
  detectionDiv.style.border = '1px solid #B8DDDF';
  detectionDiv.innerHTML = JZ_I18N.t('import.detection.group', {
    icon: JZ_ICONS.group,
    name: escapeHtml(groupName),
    stats: formatStats({ rules: ruleCount }, ', ', true),
  });

  targetDiv.style.display = 'block';
  let html = `<strong style="color:#155e75;">${escapeHtml(JZ_I18N.t('import.target.group.title'))}</strong>`;
  html += `<div style="margin-top:6px;">`;
  html += `<label style="display:block;margin-bottom:4px;cursor:pointer;">
    <input type="radio" name="groupImportMode" value="existingFolder" checked>
    ${escapeHtml(JZ_I18N.t('import.target.group.existingFolder'))}
    <select id="groupImportTargetFolder" style="margin-left:4px;padding:3px 6px;font-size:12px;">
      ${folders.map(f => `<option value="${escapeAttr(f.id)}">${escapeHtml(f.name || '(bez nazwy)')}</option>`).join('')}
    </select>
  </label>`;
  html += `<label style="display:block;cursor:pointer;">
    <input type="radio" name="groupImportMode" value="newFolder">
    ${escapeHtml(JZ_I18N.t('import.target.group.newFolder'))}
  </label>`;
  html += `</div>`;
  targetDiv.innerHTML = html;

  if (folders.length === 0) {
    // Brak istniejących folderów — wymuszamy newFolder
    const r = targetDiv.querySelector('input[value="newFolder"]');
    if (r) r.checked = true;
    const r2 = targetDiv.querySelector('input[value="existingFolder"]');
    if (r2) r2.disabled = true;
  }
}

function renderRuleImportDetection(data) {
  const detectionDiv = document.getElementById('importDetection');
  const targetDiv = document.getElementById('importTarget');
  const ruleName = data.rule?.name || '(bez nazwy)';

  detectionDiv.style.display = 'block';
  detectionDiv.style.background = '#E5F4F4';
  detectionDiv.style.border = '1px solid #B8DDDF';
  detectionDiv.innerHTML = JZ_I18N.t('import.detection.rule', {
    icon: JZ_ICONS.tabCss,
    name: escapeHtml(ruleName),
  });

  targetDiv.style.display = 'block';
  let html = `<strong style="color:#155e75;">${escapeHtml(JZ_I18N.t('import.target.rule.title'))}</strong>`;
  html += `<div style="margin-top:6px;">`;
  html += `<label style="display:block;margin-bottom:4px;cursor:pointer;">
    <input type="radio" name="ruleImportMode" value="existingGroup" checked>
    ${escapeHtml(JZ_I18N.t('import.target.rule.existingGroup'))}
    <select id="ruleImportTargetFolder" style="margin-left:4px;padding:3px 6px;font-size:12px;">
      ${folders.map(f => `<option value="${escapeAttr(f.id)}">${escapeHtml(f.name || '(bez nazwy)')}</option>`).join('')}
    </select>
    <select id="ruleImportTargetGroup" style="margin-left:4px;padding:3px 6px;font-size:12px;"></select>
  </label>`;
  html += `<label style="display:block;cursor:pointer;">
    <input type="radio" name="ruleImportMode" value="newFolder">
    ${escapeHtml(JZ_I18N.t('import.target.rule.newFolder'))}
  </label>`;
  html += `</div>`;
  targetDiv.innerHTML = html;

  const folderSelect = targetDiv.querySelector('#ruleImportTargetFolder');
  const groupSelect = targetDiv.querySelector('#ruleImportTargetGroup');

  const refreshGroups = () => {
    const fid = folderSelect.value;
    const opts = groups.filter(g => g.folderId === fid);
    groupSelect.innerHTML = opts.map(g => `<option value="${escapeAttr(g.id)}">${escapeHtml(g.name || '(bez nazwy)')}</option>`).join('');
  };
  if (folderSelect) {
    folderSelect.addEventListener('change', refreshGroups);
    refreshGroups();
  }

  if (folders.length === 0) {
    const r = targetDiv.querySelector('input[value="newFolder"]');
    if (r) r.checked = true;
    const r2 = targetDiv.querySelector('input[value="existingGroup"]');
    if (r2) r2.disabled = true;
  }
}

// === Aplikatory importu dla poszczególnych typów eksportu ===
// Wszystkie używają deep clone + fresh ID, więc kolejność tablic (sortowanie)
// oraz ustawienia (color, icon, active/enabled, urlPatterns, showActionsPanel, ...)
// są zachowane automatycznie. Nowe elementy lądują na końcu tablicy docelowej.

// Import katalogu — 3 tryby: newCopy (top-level), overwrite (istniejący o tym samym id/nazwie), mergeInto (do wybranego folderu).
async function applyImportFolder(data, mode, targetFolderId) {
  const importedFolderRaw = JSON.parse(JSON.stringify(data.folder || {}));
  const importedGroups = JSON.parse(JSON.stringify(data.groups || []));
  const importedRules = JSON.parse(JSON.stringify(data.rules || []));
  // Mapy id stare→nowe — potrzebne by zaimportować bary/okna z remapem parentId.
  const folderIdMap = {};
  const groupIdMap = {};
  const ruleIdMap = {};

  const remapGroups = (newFolderId) => {
    importedGroups.forEach(g => {
      const oldId = g.id;
      g.id = genId('g');
      g.folderId = newFolderId;
      groupIdMap[oldId] = g.id;
      groups.push(g);
    });
  };
  const remapRules = () => {
    importedRules.forEach(r => {
      const oldRuleId = r.id;
      r.id = genId('r');
      ruleIdMap[oldRuleId] = r.id;
      const newGroupId = groupIdMap[r.groupId];
      if (!newGroupId) return;
      r.groupId = newGroupId;
      delete r.folderId;
      if (!Array.isArray(r.urlPatterns)) r.urlPatterns = [];
      if (Array.isArray(r.actions)) r.actions.forEach(a => a.id = genId('a'));
      rules.push(r);
    });
  };

  if (mode === 'mergeInto' && targetFolderId) {
    const target = folders.find(f => f.id === targetFolderId);
    if (!target) throw new Error('Folder docelowy nie istnieje');
    folderIdMap[importedFolderRaw.id] = target.id;
    remapGroups(target.id);
    remapRules();
  } else if (mode === 'overwrite') {
    const conflict = checkFolderConflicts(importedFolderRaw);
    if (!conflict) {
      // Brak konfliktu → degraduj do newCopy
      mode = 'newCopy';
    } else {
      const existing = conflict.existing;
      // Usuń stary folder + wszystkie jego grupy/zestawy
      const oldGroupIds = new Set(groups.filter(g => g.folderId === existing.id).map(g => g.id));
      const oldRuleIds = new Set(rules.filter(r => oldGroupIds.has(r.groupId)).map(r => r.id));
      for (let i = rules.length - 1; i >= 0; i--) {
        if (oldGroupIds.has(rules[i].groupId)) rules.splice(i, 1);
      }
      for (let i = groups.length - 1; i >= 0; i--) {
        if (groups[i].folderId === existing.id) groups.splice(i, 1);
      }
      // Folder zachowuje id (referencje sync), więc paski folderu zostają — kasujemy
      // tylko okna/paski osierocone po usuniętych grupach i zestawach.
      pruneOrphanWindows(new Set([...oldGroupIds, ...oldRuleIds]));
      const idx = folders.findIndex(f => f.id === existing.id);
      // Wstaw importowany folder w to samo miejsce — zachowuje order w sidebarze
      const newFolder = importedFolderRaw;
      folderIdMap[newFolder.id] = existing.id;
      newFolder.id = existing.id; // zachowaj ID — referencje sync
      if (idx >= 0) folders[idx] = newFolder; else folders.push(newFolder);
      remapGroups(newFolder.id);
      remapRules();
    }
  }

  if (mode === 'newCopy') {
    const newFolder = importedFolderRaw;
    const oldFolderId = newFolder.id;
    newFolder.id = genId('f');
    folderIdMap[oldFolderId] = newFolder.id;
    if (!newFolder.name) newFolder.name = JZ_I18N.t('default.folderName');
    // Konflikt nazwy: dorzuć suffix (import)
    if (folders.some(f => f.name === newFolder.name)) {
      newFolder.name = newFolder.name + ' (import)';
    }
    folders.push(newFolder);
    remapGroups(newFolder.id);
    remapRules();
  }

  // Bary i okna z importu — remap parentId przez mapy id, świeże własne id,
  // następnie remap action.barId zaimportowanych zestawów.
  const barIdMap = importWindowsFromPayload(data, { folder: folderIdMap, group: groupIdMap, rule: ruleIdMap });
  remapImportedActionBarIds(importedRules, barIdMap);
}

// Import grupy — 2 tryby: existingFolder (do wybranego) lub newFolder (tworzy nowy wrapper).
async function applyImportGroup(data, mode, targetFolderId) {
  const importedGroupRaw = JSON.parse(JSON.stringify(data.group || {}));
  const importedRules = JSON.parse(JSON.stringify(data.rules || []));

  let targetFolder = null;
  if (mode === 'existingFolder' && targetFolderId) {
    targetFolder = folders.find(f => f.id === targetFolderId);
    if (!targetFolder) throw new Error('Folder docelowy nie istnieje');
  } else {
    targetFolder = {
      id: genId('f'),
      name: (importedGroupRaw.name || 'Import') + ' (z grupy)',
      urlPatterns: ['*'],
      urlPattern: '*',
      enabled: true,
      active: true,
      showActionsPanel: false,
      actionsOrientation: 'vertical',
      color: '',
      icon: '',
    };
    folders.push(targetFolder);
  }

  const oldGroupId = importedGroupRaw.id;
  importedGroupRaw.id = genId('g');
  importedGroupRaw.folderId = targetFolder.id;
  groups.push(importedGroupRaw);

  const ruleIdMap = {};
  importedRules.forEach(r => {
    const oldRuleId = r.id;
    r.id = genId('r');
    ruleIdMap[oldRuleId] = r.id;
    r.groupId = importedGroupRaw.id; // wszystkie zestawy z grupy idą do tej grupy
    delete r.folderId;
    if (!Array.isArray(r.urlPatterns)) r.urlPatterns = [];
    if (Array.isArray(r.actions)) r.actions.forEach(a => a.id = genId('a'));
    rules.push(r);
  });

  // Bary i okna z importu (przypięte do grupy lub jej zestawów).
  const barIdMap = importWindowsFromPayload(data, {
    folder: {}, group: { [oldGroupId]: importedGroupRaw.id }, rule: ruleIdMap,
  });
  remapImportedActionBarIds(importedRules, barIdMap);
}

// Import zestawu — 2 tryby: existingGroup lub newFolder.
async function applyImportRule(data, mode, targetFolderId, targetGroupId) {
  const importedRule = JSON.parse(JSON.stringify(data.rule || {}));

  let targetGroup = null;
  if (mode === 'existingGroup' && targetGroupId) {
    targetGroup = groups.find(g => g.id === targetGroupId);
    if (!targetGroup) throw new Error('Grupa docelowa nie istnieje');
  } else {
    const newFolder = {
      id: genId('f'),
      name: (importedRule.name || 'Import') + ' (zestaw)',
      urlPatterns: ['*'],
      urlPattern: '*',
      enabled: true,
      active: true,
      showActionsPanel: false,
      actionsOrientation: 'vertical',
      color: '',
      icon: '',
    };
    const newGroup = {
      id: genId('g'),
      folderId: newFolder.id,
      name: JZ_I18N.t('default.groupName'),
      enabled: true,
      active: true,
    };
    folders.push(newFolder);
    groups.push(newGroup);
    targetGroup = newGroup;
  }

  const oldRuleId = importedRule.id;
  importedRule.id = genId('r');
  importedRule.groupId = targetGroup.id;
  delete importedRule.folderId;
  if (!Array.isArray(importedRule.urlPatterns)) importedRule.urlPatterns = [];
  if (Array.isArray(importedRule.actions)) importedRule.actions.forEach(a => a.id = genId('a'));
  rules.push(importedRule);

  // Bary i okna z importu (przypięte do tego zestawu).
  const barIdMap = importWindowsFromPayload(data, {
    folder: {}, group: {}, rule: { [oldRuleId]: importedRule.id },
  });
  remapImportedActionBarIds([importedRule], barIdMap);
}

// Pełne nadpisanie storage danymi importu, ale z zachowaniem kluczy lokalnych/konta
// (stan synchronizacji, klucze AI Helper, pamięć akcji). chrome.storage.local.clear()
// bez tego wylogowywał z chmury i kasował klucze API oraz preferencje motywu/języka.
async function replaceStorageFromImport(data) {
  const keep = await chrome.storage.local.get(['syncState', 'aiHelper', 'actionMemory', 'theme', 'userLang', 'expandedGroups', 'sidebarCollapsed', 'globalEnabled', 'nextActionSeq']);
  await chrome.storage.local.clear();
  const out = {};
  // theme/userLang oraz preferencje UI/stanu: gdy import ich nie zawiera, przywróć dotychczasowe
  for (const k of ['theme', 'userLang', 'expandedGroups', 'sidebarCollapsed', 'globalEnabled', 'nextActionSeq']) {
    if (keep[k] !== undefined) out[k] = keep[k];
  }
  Object.assign(out, data);
  // Eksport granularny/cząstkowy trzyma okna w polu `windows` — rozpakuj do
  // kluczy storage (actionBars/cssPanes/...). Pełny backup ma je już jako klucze
  // najwyższego poziomu, więc gdy `windows` brak — zostają jak są.
  if (out.windows && typeof out.windows === 'object') {
    for (const k of ['actionBars', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles']) {
      if (Array.isArray(out.windows[k])) out[k] = out.windows[k];
    }
    delete out.windows;
  }
  // syncState/aiHelper/actionMemory — zawsze lokalne, import ich nie nadpisuje
  for (const k of ['syncState', 'aiHelper', 'actionMemory']) {
    if (keep[k] !== undefined) out[k] = keep[k]; else delete out[k];
  }
  await chrome.storage.local.set(out);
}

document.getElementById('doImportBtn').addEventListener('click', async () => {
  if (!fullImportValid || !pendingFullImport) {
    showToast(JZ_I18N.t('toast.fileNotFullImport'), true);
    return;
  }
  const { exportType, data } = pendingFullImport;

  try {
    // Routing po typie eksportu — każda gałąź sama waliduje i aplikuje.
    if (exportType === 'justzix-folder') {
      const mode = document.querySelector('input[name="folderImportMode"]:checked')?.value || 'newCopy';
      const targetFolderId = document.getElementById('folderImportMergeTarget')?.value;
      await applyImportFolder(data, mode, targetFolderId);
      await saveAll(true);
      await load();
      document.getElementById('importModal').classList.remove('show');
      showToast(JZ_I18N.t('toast.importedFolder', { name: data.folder?.name || '' }));
      notifyTabs();
      return;
    }

    if (exportType === 'justzix-group') {
      const mode = document.querySelector('input[name="groupImportMode"]:checked')?.value || 'existingFolder';
      const targetFolderId = document.getElementById('groupImportTargetFolder')?.value;
      await applyImportGroup(data, mode, targetFolderId);
      await saveAll(true);
      await load();
      document.getElementById('importModal').classList.remove('show');
      showToast(JZ_I18N.t('toast.importedGroup', { name: data.group?.name || '' }));
      notifyTabs();
      return;
    }

    if (exportType === 'justzix-rule') {
      const mode = document.querySelector('input[name="ruleImportMode"]:checked')?.value || 'existingGroup';
      const targetFolderId = document.getElementById('ruleImportTargetFolder')?.value;
      const targetGroupId = document.getElementById('ruleImportTargetGroup')?.value;
      await applyImportRule(data, mode, targetFolderId, targetGroupId);
      await saveAll(true);
      await load();
      document.getElementById('importModal').classList.remove('show');
      showToast(JZ_I18N.t('toast.importedRule', { name: data.rule?.name || '' }));
      notifyTabs();
      return;
    }

    // === justzix-full (pełen backup) — stara logika ===
    const isPartial = !!data.partial;

    if (isPartial) {
      // Częściowy import - tryb merge lub replace
      const mode = document.querySelector('input[name="partialImportMode"]:checked')?.value || 'merge';

      if (mode === 'replace') {
        if (!confirm(JZ_I18N.t('confirm.replacePartial', {
          before: formatStats({ folders: folders.length, rules: rules.length }, ', ', true),
          after:  formatStats({ folders: data.folders.length, rules: data.rules.length }, ', ', true),
        }))) {
          return;
        }
        delete data.type; delete data.version; delete data.exportedAt; delete data.partial;
        await replaceStorageFromImport(data);
      } else {
        // Merge - dodaj/aktualizuj katalogi, grupy i zestawy
        const importedFolders = JSON.parse(JSON.stringify(data.folders || []));
        const importedRules = JSON.parse(JSON.stringify(data.rules || []));
        let importedGroups = Array.isArray(data.groups) ? JSON.parse(JSON.stringify(data.groups)) : [];
        // Stary format (bez groups[]) — auto-utwórz grupy "Domyślna" dla każdego folderu
        if (importedGroups.length === 0 && importedFolders.length > 0) {
          importedGroups = importedFolders.map(f => ({
            id: genId('g'), folderId: f.id, name: JZ_I18N.t('default.groupName'), enabled: true
          }));
          importedRules.forEach(r => {
            if (!r.groupId) {
              const g = importedGroups.find(gg => gg.folderId === r.folderId);
              if (g) r.groupId = g.id;
            }
          });
        }

        const conflicts = importedFolders.filter(imf => folders.find(f => f.id === imf.id));
        // 3-way prompt na konfliktach: overwrite / rename / cancel.
        let conflictMode = null; // 'overwrite' | 'rename'
        if (conflicts.length > 0) {
          const names = conflicts.map(c => `• ${c.name}`).join('\n');
          const choice = window.prompt(
            JZ_I18N.t('prompt.folderConflicts', {
              count: conflicts.length,
              word: JZ_I18N.plural(conflicts.length, 'plural.folder'),
              names,
            }),
            '2'
          );
          if (choice === null) return;
          const t = (choice || '').trim().toLowerCase();
          if (t === '1' || t === 'nadpisz' || t === 'overwrite') conflictMode = 'overwrite';
          else if (t === '2' || t === 'kopia' || t === 'kopie' || t === 'rename' || t === 'copy') conflictMode = 'rename';
          else { showToast(JZ_I18N.t('toast.unknownOption'), true); return; }
        }

        if (conflictMode === 'rename') {
          // Przemapuj ID + przemianuj konfliktujące foldery (i ich grupy/zestawy)
          const idMap = { folder: new Map(), group: new Map(), rule: new Map() };
          importedFolders.forEach(f => {
            const newId = genId('f');
            idMap.folder.set(f.id, newId);
            if (folders.find(ff => ff.id === f.id)) {
              f.name = (f.name || 'Katalog') + ' (import)';
            }
            f.id = newId;
          });
          importedGroups.forEach(g => {
            const oldId = g.id;
            const newId = genId('g');
            idMap.group.set(oldId, newId);
            const newFolderId = idMap.folder.get(g.folderId);
            if (newFolderId) g.folderId = newFolderId;
            g.id = newId;
          });
          importedRules.forEach(r => {
            const oldRuleId = r.id;
            r.id = genId('r');
            idMap.rule.set(oldRuleId, r.id);
            const newGroupId = idMap.group.get(r.groupId);
            if (newGroupId) r.groupId = newGroupId;
            if (Array.isArray(r.actions)) r.actions.forEach(a => a.id = genId('a'));
          });
          // Po renamie nie ma już konfliktów ID — zwykłe dorzucenie.
          // Idzie przez pamięć + saveAll(), NIE przez surowy storage.set —
          // inaczej load() odbudowałby _syncSnapshot z importem już w środku,
          // a diff nigdy nie zakolejkowałby tych encji do chmury.
          folders = [...folders, ...importedFolders];
          groups = [...groups, ...importedGroups];
          rules = [...rules, ...importedRules];
          // Bary i okna z importu — remap parentId przez mapy id.
          const barIdMap = importWindowsFromPayload(data, { folder: idMap.folder, group: idMap.group, rule: idMap.rule });
          remapImportedActionBarIds(importedRules, barIdMap);
          await saveAll();
        } else {
          // overwrite (lub brak konfliktów)
          const importedFolderIds = new Set(importedFolders.map(f => f.id));
          const removedGroupIds = new Set(groups.filter(g => importedFolderIds.has(g.folderId)).map(g => g.id));
          const newFolders = folders.filter(f => !importedFolderIds.has(f.id));
          const newGroups = groups.filter(g => !importedFolderIds.has(g.folderId));
          const newRules = rules.filter(r => !removedGroupIds.has(r.groupId));
          newFolders.push(...importedFolders);
          newGroups.push(...importedGroups);
          newRules.push(...importedRules);
          // Przez pamięć + saveAll() — patrz komentarz w gałęzi rename.
          folders = newFolders;
          groups = newGroups;
          rules = newRules;
          // Bary/okna z importu — overwrite zachowuje id encji, więc merge
          // replace-by-id. Osierocone okna po usuniętych grupach/zestawach
          // sprzątnie sanityzacja w load().
          const _impWin = (data.windows && typeof data.windows === 'object') ? data.windows : {};
          const _mergeWin = (arr, key) => {
            const imp = Array.isArray(_impWin[key]) ? JSON.parse(JSON.stringify(_impWin[key])) : [];
            if (!imp.length) return arr;
            const impIds = new Set(imp.map(w => w.id));
            return [...arr.filter(w => !impIds.has(w.id)), ...imp];
          };
          actionBars     = _mergeWin(actionBars, 'actionBars');
          cssPanes       = _mergeWin(cssPanes, 'cssPanes');
          jsPanes        = _mergeWin(jsPanes, 'jsPanes');
          jsConsoles     = _mergeWin(jsConsoles, 'jsConsoles');
          outputConsoles = _mergeWin(outputConsoles, 'outputConsoles');
          await saveAll();
        }
      }
    } else {
      // Pełen backup - całkowite nadpisanie
      if (folders.length > 0 || rules.length > 0) {
        if (!confirm(JZ_I18N.t('confirm.overwriteAll', {
          stats: formatStats({ folders: folders.length, rules: rules.length }, ', ', true),
        }))) {
          return;
        }
      }

      delete data.type; delete data.version; delete data.exportedAt; delete data.partial;
      await replaceStorageFromImport(data);
    }

    document.getElementById('importModal').classList.remove('show');
    selectedFolderId = null;
    selectedRuleId = null;
    await load();
    showToast(JZ_I18N.t('toast.importedSettings'));
    notifyTabs();
  } catch (err) {
    showToast(JZ_I18N.t('toast.error', { msg: err.message }), true);
  }
});

document.getElementById('closeImportBtn').addEventListener('click', () => {
  document.getElementById('importModal').classList.remove('show');
});

// === Eksport grupy z selekcją ===
let groupExportContext = null;
let groupExportSelection = {};

function exportGroupToFile(group) {
  // Otwórz modal z wyborem zestawów do eksportu.
  groupExportContext = group;
  groupExportSelection = {};
  rules.filter(r => r.groupId === group.id).forEach(r => { groupExportSelection[r.id] = true; });
  document.getElementById('groupExportTitle').textContent = group.name || '(bez nazwy)';
  renderGroupExportTree();
  updateGroupExportSummary();
  document.getElementById('groupExportModal').classList.add('show');
}

function renderGroupExportTree() {
  const list = document.getElementById('groupExportRulesList');
  list.innerHTML = '';
  if (!groupExportContext) return;
  const groupRules = rules.filter(r => r.groupId === groupExportContext.id);
  if (groupRules.length === 0) {
    list.innerHTML = `<div style="color:#9ca3af;font-size:12px;text-align:center;padding:10px;">${escapeHtml(JZ_I18N.t('export.empty.group'))}</div>`;
    return;
  }
  groupRules.forEach(rule => {
    const el = document.createElement('label');
    el.className = 'export-rule-row';
    el.innerHTML = `
      <input type="checkbox" class="ge-rule-cb" data-rule-id="${rule.id}" ${groupExportSelection[rule.id] ? 'checked' : ''}>
      <span class="export-row-name">${escapeHtml(rule.name || '(bez nazwy)')}</span>
      ${(rule.actions || []).length ? `<span class="export-row-count subtle">${rule.actions.length} akcji</span>` : ''}
    `;
    list.appendChild(el);
  });
  list.querySelectorAll('.ge-rule-cb').forEach(cb => {
    cb.addEventListener('change', (e) => {
      groupExportSelection[e.target.dataset.ruleId] = e.target.checked;
      updateGroupExportSummary();
    });
  });
}

function updateGroupExportSummary() {
  const summary = document.getElementById('groupExportSummary');
  const count = Object.values(groupExportSelection).filter(v => v).length;
  summary.innerHTML = count > 0
    ? JZ_I18N.t('export.summary.toExport', { icon: JZ_ICONS.package, count, word: JZ_I18N.plural(count, 'plural.rule') })
    : JZ_I18N.t('export.summary.noneSelected', { icon: JZ_ICONS.warning });
  document.getElementById('downloadGroupExportBtn').disabled = count === 0;
}

document.getElementById('groupExportSelectAll').addEventListener('click', () => {
  if (!groupExportContext) return;
  rules.filter(r => r.groupId === groupExportContext.id).forEach(r => { groupExportSelection[r.id] = true; });
  renderGroupExportTree();
  updateGroupExportSummary();
});
document.getElementById('groupExportSelectNone').addEventListener('click', () => {
  Object.keys(groupExportSelection).forEach(k => { groupExportSelection[k] = false; });
  renderGroupExportTree();
  updateGroupExportSummary();
});
document.getElementById('closeGroupExportBtn').addEventListener('click', () => {
  document.getElementById('groupExportModal').classList.remove('show');
  groupExportContext = null;
});
document.getElementById('downloadGroupExportBtn').addEventListener('click', () => {
  if (!groupExportContext) return;
  const selectedRules = rules
    .filter(r => r.groupId === groupExportContext.id)
    .filter(r => groupExportSelection[r.id]);
  if (selectedRules.length === 0) return;

  const payload = {
    type: 'justzix-group',
    version: 1,
    schemaVersion: STORAGE_VERSION,
    group: groupExportContext,
    rules: selectedRules,
    windows: collectWindowsForExport([
      groupExportContext.id,
      ...selectedRules.map(r => r.id),
    ]),
  };
  const json = JSON.stringify(payload, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  const safeName = (groupExportContext.name || JZ_I18N.t('inline.fileGroupLabel')).replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 40);
  const date = new Date().toISOString().slice(0, 10);
  a.download = `justzix-grupa-${safeName}-${date}.json`;
  a.click();
  URL.revokeObjectURL(url);
  document.getElementById('groupExportModal').classList.remove('show');
  groupExportContext = null;
  showToast(JZ_I18N.t('toast.exportedRules', { count: selectedRules.length, word: JZ_I18N.plural(selectedRules.length, 'plural.rule') }));
});

function openGroupImport(group) {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = '.json';
  input.onchange = (e) => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const data = JSON.parse(reader.result);
        // Akceptujemy 3 formaty: justzix-group, justzix-folder, justzix-rule (oraz stare css-injector-* dla wstecznej zgodności).
        let sourceRules = null;
        if (isExportType(data.type, 'group') && Array.isArray(data.rules)) {
          sourceRules = data.rules;
        } else if (isExportType(data.type, 'folder')) {
          // Eksport katalogu zawiera wiele grup — wrzucenie wszystkich zestawów do
          // jednej grupy docelowej spłaszczyłoby strukturę. Odsyłamy do importu katalogu.
          showToast(JZ_I18N.t('toast.groupImport.folderRejected'), true);
          return;
        } else if (isExportType(data.type, 'rule') && data.rule) {
          sourceRules = [data.rule];
        } else {
          showToast(JZ_I18N.t('toast.invalidPartialFormat'), true);
          return;
        }
        const importedRules = JSON.parse(JSON.stringify(sourceRules));
        const ruleIdMap = {};
        importedRules.forEach(r => {
          const oldRuleId = r.id;
          r.id = genId('r');
          ruleIdMap[oldRuleId] = r.id;
          r.groupId = group.id;
          if (!Array.isArray(r.urlPatterns)) r.urlPatterns = [];
          if (Array.isArray(r.actions)) r.actions.forEach(a => a.id = genId('a'));
          delete r.folderId;
        });
        rules.push(...importedRules);
        // Bary/okna — okna grupy źródłowej trafiają do grupy docelowej.
        const _grpMap = (data.group && data.group.id) ? { [data.group.id]: group.id } : {};
        const _barIdMap = importWindowsFromPayload(data, { folder: {}, group: _grpMap, rule: ruleIdMap });
        remapImportedActionBarIds(importedRules, _barIdMap);
        saveAll();
        renderFolders();
        renderGroupEditor();
        showToast(JZ_I18N.t('toast.importedRulesToGroup', {
          count: importedRules.length,
          word: JZ_I18N.plural(importedRules.length, 'plural.rule'),
        }));
      } catch (err) {
        showToast(JZ_I18N.t('toast.parseError', { msg: err.message }), true);
      }
    };
    reader.readAsText(file);
  };
  input.click();
}

// === Eksport / import pojedynczego zestawu ===
function exportRuleToFile(rule) {
  const payload = {
    type: 'justzix-rule',
    version: 1,
    schemaVersion: STORAGE_VERSION,
    rule: rule,
    windows: collectWindowsForExport([rule.id]),
  };
  const json = JSON.stringify(payload, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  const safeName = (rule.name || 'zestaw').replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 40);
  const date = new Date().toISOString().slice(0, 10);
  a.download = `justzix-zestaw-${safeName}-${date}.json`;
  a.click();
  URL.revokeObjectURL(url);
  showToast(JZ_I18N.t('toast.exportedRule'));
}

// ============================================================
// Shares (udostępnianie eksportu po linku) — flow nadawcy
// ============================================================
//
// Modal #shareCreateModal ma dwa stany: form (TTL + Generate) i result
// (link + token + copy buttons). Wywoływany z 4 punktów:
//   - openShareModalForFull()    — z exportModal (cały eksport)
//   - openShareModalForFolder()  — z folderExportModal (jeden katalog)
//   - openShareModalForGroup()   — z groupExportModal (jedna grupa)
//   - openShareModalForRule()    — z toolbara w ruleEditor (jeden zestaw)
//
// Wszystkie 4 budują payload taki sam jak istniejący "Pobierz plik",
// po czym wywołują openShareModal({exportType, payload, subjectText, summaryText}).
// Modal: POST /api/shares → switch do result view → copy buttons.

(function shareUi() {
  const modal = document.getElementById('shareCreateModal');
  if (!modal) return;
  const formView = document.getElementById('shareCreateForm');
  const resultView = document.getElementById('shareCreateResult');
  const subjectEl = document.getElementById('shareCreateSubject');
  const summaryEl = document.getElementById('shareCreateSummary');
  const errorEl = document.getElementById('shareCreateError');
  const generateBtn = document.getElementById('shareCreateGenerate');
  const cancelBtn = document.getElementById('shareCreateCancel');
  const closeBtn = document.getElementById('shareResultClose');
  const goToMineBtn = document.getElementById('shareResultGoToMine');
  const copyUrlBtn = document.getElementById('shareResultCopyUrl');
  const copyTokenBtn = document.getElementById('shareResultCopyToken');
  const urlInput = document.getElementById('shareResultUrl');
  const tokenInput = document.getElementById('shareResultToken');
  const expiresEl = document.getElementById('shareResultExpires');

  let current = null; // { exportType, payload }

  function setError(text) {
    if (!text) { errorEl.style.display = 'none'; errorEl.textContent = ''; return; }
    errorEl.style.display = 'block';
    errorEl.textContent = text;
  }

  function resetToForm() {
    formView.style.display = '';
    resultView.style.display = 'none';
    setError(null);
    generateBtn.disabled = false;
    generateBtn.textContent = 'Wygeneruj link';
    const def = document.querySelector('input[name="shareTtl"][value="86400"]');
    if (def) def.checked = true;
  }

  function open({ exportType, payload, subjectText, summaryText }) {
    if (typeof JZ_SYNC === 'undefined' || !JZ_SYNC.isAuthenticated()) {
      showToast(JZ_I18N.t('toast.signInFirst'), true);
      return;
    }
    current = { exportType, payload };
    subjectEl.textContent = subjectText || '';
    summaryEl.textContent = summaryText || '';
    resetToForm();
    modal.classList.add('show');
  }

  function close() {
    modal.classList.remove('show');
    current = null;
  }

  cancelBtn.addEventListener('click', close);
  closeBtn.addEventListener('click', close);
  modal.addEventListener('click', (e) => { if (e.target === modal) close(); });

  function ttlHuman(sec) {
    if (sec >= 86400) return Math.round(sec / 86400) === 1 ? '24h' : '48h';
    return `${Math.round(sec / 3600)}h`;
  }

  function humanizeShareError(e) {
    if (!e) return JZ_I18N.t('share.error.unknown');
    const code = e.code || '';
    if (code === 'share_failed') return JZ_I18N.t('share.error.shareFailed');
    if (code === 'bad_request') return JZ_I18N.t('share.error.badRequest', { msg: e.message || '' });
    if (code === 'rate_limited' || e.status === 429) return JZ_I18N.t('share.error.rateLimited');
    if (code === 'invalid_token' || e.status === 401) return JZ_I18N.t('share.error.invalidToken');
    if (!e.status || e.status >= 500) return JZ_I18N.t('share.error.serverError');
    return e.message || JZ_I18N.t('share.error.fallback', { msg: code || 'unknown' });
  }

  generateBtn.addEventListener('click', async () => {
    if (!current) return;
    setError(null);

    const ttlEl = document.querySelector('input[name="shareTtl"]:checked');
    const ttlSec = parseInt(ttlEl?.value || '86400', 10);

    // Local size check (5 MB) — szybciej niż pingnąć backend
    const json = JSON.stringify(current.payload);
    if (json.length > 5 * 1024 * 1024) {
      setError(JZ_I18N.t('share.error.tooLarge'));
      return;
    }

    generateBtn.disabled = true;
    generateBtn.textContent = JZ_I18N.t('btn.generating');
    try {
      const r = await JZ_SYNC.createShare({
        exportType: current.exportType,
        ttlSec,
        payload: current.payload,
      });
      formView.style.display = 'none';
      resultView.style.display = '';
      urlInput.value = r.shareUrl || '';
      tokenInput.value = r.shareToken || '';
      const exp = r.expiresAt ? new Date(r.expiresAt * 1000) : null;
      expiresEl.textContent = exp
        ? `Wygasa: ${exp.toLocaleString('pl-PL')} (za ${ttlHuman(ttlSec)})`
        : `Wygasa: za ${ttlHuman(ttlSec)}`;
    } catch (e) {
      generateBtn.disabled = false;
      generateBtn.textContent = 'Wygeneruj link';
      setError(humanizeShareError(e));
    }
  });

  async function clipboardCopy(value, msg) {
    try {
      await navigator.clipboard.writeText(value);
      showToast(msg);
    } catch (_) {
      // Fallback dla starszego Chrome / braku focus
      const ta = document.createElement('textarea');
      ta.value = value;
      document.body.appendChild(ta);
      ta.select();
      try { document.execCommand('copy'); showToast(msg); } catch (_) { showToast(JZ_I18N.t('toast.copyFailed'), true); }
      document.body.removeChild(ta);
    }
  }

  copyUrlBtn.addEventListener('click', () => clipboardCopy(urlInput.value, 'Skopiowano link'));
  copyTokenBtn.addEventListener('click', () => clipboardCopy(tokenInput.value, 'Skopiowano token'));

  goToMineBtn.addEventListener('click', () => {
    close();
    if (typeof window.openMySharesModal === 'function') {
      window.openMySharesModal();
    }
  });

  window.openShareModal = open;
})();

// === Wrapper helpers — budują payload identyczny jak "Pobierz plik" i wywołują openShareModal

function openShareModalForFull() {
  const json = document.getElementById('exportText').value;
  let payload;
  try { payload = JSON.parse(json); }
  catch (e) { showToast(JZ_I18N.t('toast.exportNotReady'), true); return; }
  const fc = (payload.folders || []).length;
  const gc = (payload.groups || []).length;
  const rc = (payload.rules || []).length;
  window.openShareModal({
    exportType: 'justzix-full',
    payload,
    subjectText: payload.partial ? JZ_I18N.t('share.subject.partial') : JZ_I18N.t('share.subject.full'),
    summaryText: formatStats({ folders: fc, groups: gc, rules: rc }, ' · ', true),
  });
}

function openShareModalForFolder() {
  if (!folderExportContext) return;
  const folder = folderExportContext;
  const selectedRules = rules
    .filter(r => folderExportSelection[r.id])
    .filter(r => {
      const g = groups.find(gg => gg.id === r.groupId);
      return g && g.folderId === folder.id;
    });
  if (selectedRules.length === 0) { showToast(JZ_I18N.t('toast.noRulesSelected'), true); return; }
  const groupIdsInExport = new Set(selectedRules.map(r => r.groupId));
  const selectedGroups = groups.filter(g => g.folderId === folder.id && groupIdsInExport.has(g.id));
  const payload = {
    type: 'justzix-folder',
    version: 2,
    schemaVersion: STORAGE_VERSION,
    folder,
    groups: selectedGroups,
    rules: selectedRules,
    windows: collectWindowsForExport([
      folder.id,
      ...selectedGroups.map(g => g.id),
      ...selectedRules.map(r => r.id),
    ]),
  };
  window.openShareModal({
    exportType: 'justzix-folder',
    payload,
    subjectText: `Katalog „${folder.name || '(bez nazwy)'}"`,
    summaryText: formatStats({ groups: selectedGroups.length, rules: selectedRules.length }, ' · ', true),
  });
}

function openShareModalForGroup() {
  if (!groupExportContext) return;
  const selectedRules = rules
    .filter(r => r.groupId === groupExportContext.id)
    .filter(r => groupExportSelection[r.id]);
  if (selectedRules.length === 0) { showToast(JZ_I18N.t('toast.noRulesSelected'), true); return; }
  const payload = {
    type: 'justzix-group',
    version: 1,
    schemaVersion: STORAGE_VERSION,
    group: groupExportContext,
    rules: selectedRules,
    windows: collectWindowsForExport([
      groupExportContext.id,
      ...selectedRules.map(r => r.id),
    ]),
  };
  window.openShareModal({
    exportType: 'justzix-group',
    payload,
    subjectText: `Grupa „${groupExportContext.name || '(bez nazwy)'}"`,
    summaryText: formatStats({ rules: selectedRules.length }, ' · ', true),
  });
}

function openShareModalForRule(rule) {
  const payload = {
    type: 'justzix-rule',
    version: 1,
    schemaVersion: STORAGE_VERSION,
    rule,
    windows: collectWindowsForExport([rule.id]),
  };
  const cssLines = (rule.css || '').split('\n').filter(l => l.trim()).length;
  const jsLines = (rule.js || '').split('\n').filter(l => l.trim()).length;
  const actionsCount = (rule.actions || []).length;
  const parts = [];
  if (cssLines) parts.push(`${cssLines} ${cssLines === 1 ? 'linia' : 'linii'} CSS`);
  if (jsLines) parts.push(`${jsLines} ${jsLines === 1 ? 'linia' : 'linii'} JS`);
  if (actionsCount) parts.push(`${actionsCount} ${actionsCount === 1 ? 'akcja' : 'akcji'}`);
  window.openShareModal({
    exportType: 'justzix-rule',
    payload,
    subjectText: `Zestaw „${rule.name || '(bez nazwy)'}"`,
    summaryText: parts.length ? parts.join(' · ') : 'pusty zestaw',
  });
}

// Wire-up dla 3 modali eksportu (4-ty wpięty inline w renderRuleEditor)
document.getElementById('shareFullExportBtn')?.addEventListener('click', openShareModalForFull);
document.getElementById('shareFolderExportBtn')?.addEventListener('click', openShareModalForFolder);
document.getElementById('shareGroupExportBtn')?.addEventListener('click', openShareModalForGroup);

// ============================================================
// Moje share — osobny modal #sharesModal (nie ma nic wspólnego
// z synchronizacją własnych danych; to publiczne udostępnianie).
// Otwierany z menu ustawień → "Moje share".
// ============================================================

(function sharesUi() {
  const modal = document.getElementById('sharesModal');
  if (!modal) return;
  const listEl = document.getElementById('sharesList');
  const quotaEl = document.getElementById('sharesQuota');
  const refreshBtn = document.getElementById('sharesRefreshBtn');
  const closeBtn = document.getElementById('sharesCloseBtn');

  function fmtBytesLocal(n) {
    if (n < 1024) return n + ' B';
    if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
    return (n / 1024 / 1024).toFixed(1) + ' MB';
  }

  function fmtRelHuman(targetMs) {
    const diff = Math.max(0, targetMs - Date.now());
    if (diff < 60 * 1000) return JZ_I18N.t('rel.lessThanMinute');
    if (diff < 60 * 60 * 1000) return JZ_I18N.t('rel.min', { n: Math.round(diff / 60000) });
    if (diff < 24 * 60 * 60 * 1000) {
      const h = Math.floor(diff / 3600000);
      const m = Math.round((diff - h * 3600000) / 60000);
      return m > 0 ? JZ_I18N.t('rel.hMin', { h, m }) : JZ_I18N.t('rel.h', { h });
    }
    const d = Math.floor(diff / (24 * 3600 * 1000));
    const h = Math.round((diff - d * 24 * 3600 * 1000) / 3600000);
    return h > 0 ? JZ_I18N.t('rel.dH', { d, h }) : JZ_I18N.t('rel.d', { d });
  }

  function exportTypeIcon(t) {
    if (t === 'justzix-full')   return JZ_ICONS.package || '';
    if (t === 'justzix-folder') return JZ_ICONS.folder || '';
    if (t === 'justzix-group')  return JZ_ICONS.copy || '';
    if (t === 'justzix-rule')   return JZ_ICONS.tabCss || '';
    return '';
  }
  function exportTypeLabel(t) {
    if (t === 'justzix-full')   return JZ_I18N.t('shareType.full');
    if (t === 'justzix-folder') return JZ_I18N.t('shareType.folder');
    if (t === 'justzix-group')  return JZ_I18N.t('shareType.group');
    if (t === 'justzix-rule')   return JZ_I18N.t('shareType.rule');
    return t || '—';
  }

  function summaryLine(s) {
    if (!s) return '';
    const parts = [];
    if (s.folders) parts.push(s.folders + ' ' + JZ_I18N.plural(s.folders, 'plural.folder'));
    if (s.groups)  parts.push(s.groups  + ' ' + JZ_I18N.plural(s.groups,  'plural.group'));
    if (s.rules)   parts.push(s.rules   + ' ' + JZ_I18N.plural(s.rules,   'plural.rule'));
    if (s.actions) parts.push(s.actions + ' ' + JZ_I18N.plural(s.actions, 'plural.action'));
    if (s.jsLines) parts.push(s.jsLines + ' ' + JZ_I18N.plural(s.jsLines, 'plural.jsLine'));
    return parts.join(' · ');
  }

  async function refreshShares() {
    listEl.innerHTML = `<div class="shares-loading">${escapeHtml(JZ_I18N.t('loading.shares'))}</div>`;
    try {
      const data = await JZ_SYNC.listShares();
      const shares = Array.isArray(data.shares) ? data.shares : [];
      const totalBytes = shares.reduce((acc, x) => acc + (x.payloadSize || 0), 0);
      quotaEl.textContent = `${shares.length}/20 aktywnych · ${fmtBytesLocal(totalBytes)} / 50 MB`;
      listEl.innerHTML = '';
      if (shares.length === 0) {
        listEl.innerHTML = `<div class="shares-empty">${JZ_I18N.t('shares.empty')}</div>`;
        return;
      }
      shares.forEach(s => {
        const expiresMs = (s.expiresAt || 0) * 1000;
        const expired = expiresMs <= Date.now();
        const summary = summaryLine(s.summary || {});
        const card = document.createElement('div');
        card.className = 'share-card';
        card.innerHTML = `
          <div class="share-card-header">
            <span class="share-type">
              ${exportTypeIcon(s.exportType)}
              ${escapeHtml(exportTypeLabel(s.exportType))}
              <span class="share-id">#${s.id}</span>
            </span>
            <span class="share-meta-right">
              ${expired
                ? `<span class="share-expired">${escapeHtml(JZ_I18N.t('shares.expired'))}</span>`
                : '<span class="share-expires">wygasa za ' + escapeHtml(fmtRelHuman(expiresMs)) + '</span>'}
              <br>pobrań: ${s.downloadCount || 0}
            </span>
          </div>
          <div class="share-token">${escapeHtml(s.shareToken || '')}</div>
          <div class="share-summary">
            ${escapeHtml(summary)} <span style="color:#aaa;">·</span> ${fmtBytesLocal(s.payloadSize || 0)}
          </div>
          <div class="share-actions">
            <button type="button" data-sh-copy-url="${escapeAttr(s.shareUrl || '')}">${JZ_ICONS.copy} Kopiuj link</button>
            <button type="button" data-sh-copy-token="${escapeAttr(s.shareToken || '')}">${JZ_ICONS.copy} Kopiuj token</button>
            <button type="button" class="danger" data-sh-revoke="${s.id}">${JZ_ICONS.close} Odwołaj</button>
          </div>
        `;
        listEl.appendChild(card);
      });
      // Bind buttons
      listEl.querySelectorAll('button[data-sh-copy-url]').forEach(btn => {
        btn.addEventListener('click', async () => {
          try { await navigator.clipboard.writeText(btn.dataset.shCopyUrl); showToast(JZ_I18N.t('toast.copyLink')); }
          catch (_) { showToast(JZ_I18N.t('toast.copyFailed'), true); }
        });
      });
      listEl.querySelectorAll('button[data-sh-copy-token]').forEach(btn => {
        btn.addEventListener('click', async () => {
          try { await navigator.clipboard.writeText(btn.dataset.shCopyToken); showToast(JZ_I18N.t('toast.copyToken')); }
          catch (_) { showToast(JZ_I18N.t('toast.copyFailed'), true); }
        });
      });
      listEl.querySelectorAll('button[data-sh-revoke]').forEach(btn => {
        btn.addEventListener('click', () => revokeShareFlow(btn.dataset.shRevoke));
      });
    } catch (e) {
      listEl.innerHTML = `<div class="shares-empty" style="color:#CC3333;">${escapeHtml(JZ_I18N.t('shares.fetchFailed', { msg: e.message || '' }))}</div>`;
    }
  }

  async function revokeShareFlow(id) {
    if (!confirm(JZ_I18N.t('shares.confirmRevoke', { id }))) return;
    try {
      await JZ_SYNC.revokeShare(id);
      showToast(JZ_I18N.t('shares.revoked', { id }));
      refreshShares();
    } catch (e) {
      showToast(JZ_I18N.t('toast.revokeError', { msg: e.message || '' }), true);
    }
  }

  function open() {
    if (typeof JZ_SYNC === 'undefined' || !JZ_SYNC.isAuthenticated()) {
      showToast(JZ_I18N.t('toast.signInFirst'), true);
      return;
    }
    modal.classList.add('show');
    refreshShares();
  }
  function close() { modal.classList.remove('show'); }

  refreshBtn.addEventListener('click', refreshShares);
  closeBtn.addEventListener('click', close);
  modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
  document.getElementById('sharesBtn')?.addEventListener('click', open);

  // Eksportujemy do window żeby przycisk "Zobacz Moje share" w shareCreateModal mógł otwierać.
  window.openMySharesModal = open;
})();

// ============================================================
// AI Helper — modal konfiguracji kluczy API (od v2.13.127)
// ============================================================
// Klucze trzymane w chrome.storage.local pod 'aiHelper'. Klucze z zaznaczonym
// checkboxem "synchronizuj" są synchronizowane między urządzeniami przez chmurę
// JustZix (JZ_SYNC) jako encja 'ai_keys' — od v2.13.133. Klucze bez zaznaczenia
// NIGDY nie opuszczają tego urządzenia.
(function aiHelperUi() {
  const modal = document.getElementById('aiHelperModal');
  if (!modal) return;
  const t = (k) => (window.JZ_I18N ? JZ_I18N.t(k) : k);
  const PROVIDERS = ['openai', 'anthropic', 'gemini'];
  const PROVIDER_LABEL = { openai: 'OpenAI', anthropic: 'Anthropic', gemini: 'Gemini' };
  const inEl = { openai: 'aiKeyOpenai', anthropic: 'aiKeyAnthropic', gemini: 'aiKeyGemini' };
  const cbEl = { openai: 'aiSyncOpenai', anthropic: 'aiSyncAnthropic', gemini: 'aiSyncGemini' };
  const provSel = document.getElementById('aiDefaultProvider');
  const modelSel = document.getElementById('aiDefaultModel');
  const loadBtn = document.getElementById('aiLoadModelsBtn');
  const statusEl = document.getElementById('aiModelStatus');
  let cfg = { keys: {}, defaultProvider: '', defaultModel: {} };
  let _aiKeysSelfWrite = false;

  function getKey(p) { return (document.getElementById(inEl[p]).value || '').trim(); }
  function getSync(p) { return document.getElementById(cbEl[p]).checked; }

  function refreshProviderOptions() {
    const withKey = PROVIDERS.filter(getKey);
    const prev = provSel.value;
    provSel.innerHTML = '';
    withKey.forEach(p => {
      const o = document.createElement('option');
      o.value = p; o.textContent = PROVIDER_LABEL[p];
      provSel.appendChild(o);
    });
    if (!withKey.length) {
      const o = document.createElement('option');
      o.value = ''; o.textContent = '—';
      provSel.appendChild(o);
    }
    if (withKey.includes(prev)) provSel.value = prev;
    else if (withKey.includes(cfg.defaultProvider)) provSel.value = cfg.defaultProvider;
  }

  function refreshModelOptions(list, selected) {
    modelSel.innerHTML = '';
    const auto = document.createElement('option');
    auto.value = ''; auto.textContent = t('modal.aiHelper.modelAuto');
    modelSel.appendChild(auto);
    const models = list || [];
    // Zapisany model gdy nie ma go na liście (modele jeszcze nie pobrane) — i tak
    // dodaj jako opcję. Bez tego select pokazałby pusty wybór, a `save()` nadpisałby
    // zapisany domyślny model wartością '' (modelSel.value cicho spada do '').
    if (selected && !models.includes(selected)) {
      const o = document.createElement('option');
      o.value = selected; o.textContent = selected;
      modelSel.appendChild(o);
    }
    models.forEach(m => {
      const o = document.createElement('option');
      o.value = m; o.textContent = m;
      modelSel.appendChild(o);
    });
    modelSel.value = selected || '';
  }

  function loadModels() {
    const p = provSel.value;
    if (!p || !getKey(p)) { statusEl.textContent = t('modal.aiHelper.needKey'); return; }
    statusEl.textContent = t('modal.aiHelper.loadingModels');
    loadBtn.disabled = true;
    // Klucz przekazany w wiadomości — NIE utrwalamy go w storage przed kliknięciem
    // Zapisz (inaczej kliknięcie Anuluj i tak zostawiałoby wpisany klucz zapisany).
    chrome.runtime.sendMessage({ type: 'jz-ai-list-models', provider: p, key: getKey(p) }, (resp) => {
      loadBtn.disabled = false;
      if (chrome.runtime.lastError || !resp) { statusEl.textContent = t('modal.aiHelper.errModels'); return; }
      if (!resp.ok) { statusEl.textContent = t('modal.aiHelper.errModels') + ' — ' + resp.error; return; }
      refreshModelOptions(resp.models, (cfg.defaultModel || {})[p] || '');
      statusEl.textContent = (resp.models || []).length + ' ' + t('modal.aiHelper.modelsLoaded');
    });
  }

  function reloadFields(local) {
    local = local || {};
    cfg = {
      keys: Object.assign({}, local.keys || {}),
      syncKeys: Object.assign({}, local.syncKeys || {}),
      defaultProvider: local.defaultProvider || '',
      defaultModel: local.defaultModel || {},
    };
    PROVIDERS.forEach(p => {
      document.getElementById(inEl[p]).value = cfg.keys[p] || '';
      document.getElementById(cbEl[p]).checked = !!cfg.syncKeys[p];
    });
    refreshProviderOptions();
    refreshModelOptions([], (cfg.defaultModel || {})[provSel.value] || '');
  }

  function open() {
    chrome.storage.local.get(['aiHelper']).then((d) => {
      reloadFields(d.aiHelper || {});
      statusEl.textContent = '';
      modal.classList.add('show');
    });
  }
  function close() { modal.classList.remove('show'); }

  function save() {
    const keys = {};
    const syncKeys = {};   // mapa bool: czy provider zaznaczony do synchronizacji
    const syncPayload = {}; // tylko zaznaczone klucze — to idzie do chmury
    PROVIDERS.forEach(p => {
      const k = getKey(p);
      if (k) {
        keys[p] = k;
        const on = getSync(p);
        syncKeys[p] = on;
        if (on) syncPayload[p] = k;
      }
    });
    const dp = provSel.value && keys[provSel.value] ? provSel.value : (Object.keys(keys)[0] || '');
    const dm = Object.assign({}, cfg.defaultModel);
    if (dp) dm[dp] = modelSel.value || '';
    cfg = { keys, syncKeys, defaultProvider: dp, defaultModel: dm };
    // Domyślny model jedzie do chmury razem z kluczem — tylko dla providerów
    // zaznaczonych do synchronizacji (spójnie z regułą „sync tylko zaznaczone").
    const syncModels = {};
    Object.keys(syncPayload).forEach(p => { syncModels[p] = dm[p] || ''; });
    // Oznacz własny zapis — listener onChanged poniżej pominie echo tego writeu,
    // by nie wywołać reloadFields kasującego ewentualne pola w trakcie zapisu.
    _aiKeysSelfWrite = true;
    chrome.storage.local.set({ aiHelper: cfg }).then(() => {
      // Synchronizacja chmurowa wyłącznie zaznaczonych kluczy (encja 'ai_keys').
      try {
        if (typeof JZ_SYNC !== 'undefined' && JZ_SYNC.enqueueChange) {
          JZ_SYNC.enqueueChange({ type: 'ai_keys', id: 'ai_keys', parentId: null, payload: { keys: syncPayload, models: syncModels } });
          chrome.runtime.sendMessage({ type: 'sync-now', trigger: 'ai-keys' }, () => { void chrome.runtime.lastError; });
        }
      } catch (_) {}
      // Sprzątanie po starym mechanizmie (chrome.storage.sync, v2.13.129–132).
      try { chrome.storage.sync.remove('aiHelper'); } catch (_) {}
      close();
    });
  }

  PROVIDERS.forEach(p => {
    document.getElementById(inEl[p]).addEventListener('input', refreshProviderOptions);
  });
  provSel.addEventListener('change', () => {
    refreshModelOptions([], (cfg.defaultModel || {})[provSel.value] || '');
    statusEl.textContent = '';
  });
  loadBtn.addEventListener('click', loadModels);
  document.getElementById('aiHelperSaveBtn').addEventListener('click', save);
  document.getElementById('aiHelperCancelBtn').addEventListener('click', close);
  modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
  document.getElementById('aiHelperBtn')?.addEventListener('click', open);
  // Zewnętrzny zapis aiHelper (np. sync pull encji 'ai_keys') przy otwartym modalu —
  // przeładuj pola, inaczej kliknięcie Zapisz nadpisałoby świeżo zsynchronizowane klucze.
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area === 'local' && changes.aiHelper && modal.classList.contains('show')) {
      // Echo własnego save() — pomiń, inaczej reloadFields nadpisałby pola tym
      // samym, co właśnie zapisaliśmy (a przy wyścigu — w trakcie zapisu).
      if (_aiKeysSelfWrite) { _aiKeysSelfWrite = false; return; }
      reloadFields(changes.aiHelper.newValue || {});
    }
  });
})();

// ============================================================
// Importuj z URL — flow odbiorcy
// ============================================================
//
// Dwa stany w #shareImportModal:
//   STAN 1 (input): wklej URL lub token, canonicalize, fetch preview (no-auth)
//   STAN 2 (preview): podsumowanie + ⚠ JS gdy jsLines > 0 + Pokaż kod (showJsPreviewForPayload)
//   Confirm → GET /api/shares/{token} → applyShareImport (regeneruje wszystkie ID)
//
// Mapowanie exportType → sposób umieszczenia w lokalnej bibliotece:
//   justzix-rule:   tworzy nowy folder + grupa "Domyślna" dla samego zestawu
//   justzix-group:  tworzy nowy folder, w nim importowana grupa + zestawy
//   justzix-folder: dodaje jako nowy katalog top-level
//   justzix-full:   merge — dorzuca wszystkie folders/groups/rules ze świeżymi ID

(function shareImportUi() {
  const modal = document.getElementById('shareImportModal');
  if (!modal) return;
  const inputView = document.getElementById('shareImportInputView');
  const previewView = document.getElementById('shareImportPreviewView');
  const inputField = document.getElementById('shareImportInputField');
  const inputErr = document.getElementById('shareImportInputError');
  const previewBody = document.getElementById('shareImportPreviewBody');
  const jsWarn = document.getElementById('shareImportJsWarn');
  const confirmErr = document.getElementById('shareImportConfirmError');
  const fetchBtn = document.getElementById('shareImportFetchBtn');
  const cancelBtn = document.getElementById('shareImportCancelBtn');
  const backBtn = document.getElementById('shareImportBackBtn');
  const confirmBtn = document.getElementById('shareImportConfirmBtn');
  const showCodeLink = document.getElementById('shareImportShowCode');

  // currentPreview = response from /preview; currentPayload = response from /share/{token}
  // Trzymamy preview do renderowania, payload pobieramy dopiero przy "Zaimportuj".
  let currentToken = null;
  let currentPreview = null;
  let currentPayload = null;

  function setInputErr(text) {
    if (!text) { inputErr.style.display = 'none'; inputErr.textContent = ''; return; }
    inputErr.style.display = 'block';
    inputErr.textContent = text;
  }
  function setConfirmErr(text) {
    if (!text) { confirmErr.style.display = 'none'; confirmErr.textContent = ''; return; }
    confirmErr.style.display = 'block';
    confirmErr.textContent = text;
  }

  function showInput() {
    inputView.style.display = '';
    previewView.style.display = 'none';
    setConfirmErr(null);
  }
  function showPreview() {
    inputView.style.display = 'none';
    previewView.style.display = '';
    setInputErr(null);
  }

  function open() {
    currentToken = null;
    currentPreview = null;
    currentPayload = null;
    inputField.value = '';
    setInputErr(null);
    setConfirmErr(null);
    fetchBtn.disabled = false;
    fetchBtn.textContent = JZ_I18N.t('modal.shareImport.fetch');
    confirmBtn.disabled = false;
    confirmBtn.textContent = JZ_I18N.t('modal.shareImport.confirm');
    showInput();
    modal.classList.add('show');
    setTimeout(() => inputField.focus(), 50);
  }
  function close() {
    modal.classList.remove('show');
    currentToken = null;
    currentPreview = null;
    currentPayload = null;
  }

  cancelBtn.addEventListener('click', close);
  backBtn.addEventListener('click', () => { currentPreview = null; currentPayload = null; showInput(); });
  modal.addEventListener('click', (e) => { if (e.target === modal) close(); });

  function exportTypeIcon(t) {
    if (t === 'justzix-full')   return JZ_ICONS.package || '';
    if (t === 'justzix-folder') return JZ_ICONS.folder || '';
    if (t === 'justzix-group')  return JZ_ICONS.copy || '';
    if (t === 'justzix-rule')   return JZ_ICONS.tabCss || '';
    return '';
  }
  function exportTypeLabel(t) {
    if (t === 'justzix-full')   return JZ_I18N.t('shareType.full');
    if (t === 'justzix-folder') return JZ_I18N.t('shareType.folder');
    if (t === 'justzix-group')  return JZ_I18N.t('shareType.group');
    if (t === 'justzix-rule')   return JZ_I18N.t('shareType.rule');
    return t || '—';
  }

  function fmtRelHumanLocal(targetMs) {
    const diff = Math.max(0, targetMs - Date.now());
    if (diff < 60 * 1000) return JZ_I18N.t('rel.lessThanMinute');
    if (diff < 60 * 60 * 1000) return JZ_I18N.t('rel.min', { n: Math.round(diff / 60000) });
    if (diff < 24 * 60 * 60 * 1000) {
      const h = Math.floor(diff / 3600000);
      const m = Math.round((diff - h * 3600000) / 60000);
      return m > 0 ? JZ_I18N.t('rel.hMin', { h, m }) : JZ_I18N.t('rel.h', { h });
    }
    const d = Math.floor(diff / (24 * 3600 * 1000));
    const h = Math.round((diff - d * 24 * 3600 * 1000) / 3600000);
    return h > 0 ? JZ_I18N.t('rel.dH', { d, h }) : JZ_I18N.t('rel.d', { d });
  }

  function fmtBytesLocal(n) {
    if (n < 1024) return n + ' B';
    if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
    return (n / 1024 / 1024).toFixed(1) + ' MB';
  }

  function renderPreview(p) {
    const s = p.summary || {};
    const expiresMs = (p.expiresAt || 0) * 1000;
    const owner = p.ownerName
      ? escapeHtml(p.ownerName)
      : `<span style="color:#888;">${escapeHtml(JZ_I18N.t('sharePreview.senderAnonymous'))}</span>`;
    const parts = [];
    if (s.folders) parts.push(`<strong>${s.folders}</strong> ${escapeHtml(JZ_I18N.plural(s.folders, 'plural.folder'))}`);
    if (s.groups)  parts.push(`<strong>${s.groups}</strong> ${escapeHtml(JZ_I18N.plural(s.groups, 'plural.group'))}`);
    if (s.rules)   parts.push(`<strong>${s.rules}</strong> ${escapeHtml(JZ_I18N.plural(s.rules, 'plural.rule'))}`);
    if (s.actions) parts.push(`<strong>${s.actions}</strong> ${escapeHtml(JZ_I18N.plural(s.actions, 'plural.action'))}`);

    previewBody.innerHTML = `
      <div style="display:grid;grid-template-columns:auto 1fr;gap:6px 14px;font-size:13px;color:#222;margin-bottom:14px;">
        <span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.senderLabel'))}</span> <span>${owner}</span>
        <span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.expiresLabel'))}</span> <span>${escapeHtml(JZ_I18N.t('sharePreview.expiresValue', { rel: fmtRelHumanLocal(expiresMs) }))}</span>
        <span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.typeLabel'))}</span> <span>${exportTypeIcon(p.exportType)} ${escapeHtml(exportTypeLabel(p.exportType))}</span>
        <span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.sizeLabel'))}</span> <span>${escapeHtml(fmtBytesLocal(p.payloadSize || 0))}</span>
        <span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.contentLabel'))}</span> <span>${parts.join(' · ') || '—'}</span>
        ${s.cssChars ? `<span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.cssLabel'))}</span><span>${escapeHtml(JZ_I18N.t('sharePreview.cssValueChars', { n: s.cssChars }))}</span>` : ''}
        ${s.jsLines  ? `<span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.jsLabel'))}</span><span>${s.jsLines} ${escapeHtml(JZ_I18N.plural(s.jsLines, 'plural.jsLine'))}</span>` : ''}
        <span style="color:#666;">${escapeHtml(JZ_I18N.t('sharePreview.downloadsLabel'))}</span> <span>${p.downloadCount || 0}</span>
      </div>
    `;
    if (s.jsLines && s.jsLines > 0) {
      jsWarn.style.display = 'block';
    } else {
      jsWarn.style.display = 'none';
    }
  }

  function humanizeError(e) {
    if (!e) return JZ_I18N.t('shareImport.error.unknown');
    const code = e.code || '';
    if (code === 'bad_request' || e.status === 400) return JZ_I18N.t('shareImport.error.badRequest');
    if (code === 'not_found' || e.status === 404) return JZ_I18N.t('shareImport.error.notFound');
    if (code === 'rate_limited' || e.status === 429) return JZ_I18N.t('shareImport.error.rateLimited');
    if (!e.status || e.status >= 500) return JZ_I18N.t('shareImport.error.serverError');
    return e.message || JZ_I18N.t('shareImport.error.fallback', { msg: code || 'unknown' });
  }

  fetchBtn.addEventListener('click', async () => {
    setInputErr(null);
    const raw = inputField.value;
    let token;
    try {
      token = JZ_SYNC.canonicalizeShareToken(raw);
    } catch (e) {
      setInputErr(e.message || JZ_I18N.t('shareImport.error.invalidTokenFormat'));
      return;
    }
    fetchBtn.disabled = true;
    fetchBtn.textContent = JZ_I18N.t('btn.generating');
    try {
      const preview = await JZ_SYNC.getSharePreview(token);
      currentToken = token;
      currentPreview = preview;
      renderPreview(preview);
      showPreview();
    } catch (e) {
      setInputErr(humanizeError(e));
    } finally {
      fetchBtn.disabled = false;
      fetchBtn.textContent = 'Pobierz';
    }
  });

  // === Code preview overlay (rules + actions) ===
  function showCodeOverlay(payload, exportType) {
    let collected = [];
    if (exportType === 'justzix-rule' && payload.rule) {
      collected.push(payload.rule);
    } else if (exportType === 'justzix-group' && Array.isArray(payload.rules)) {
      collected = payload.rules;
    } else if (exportType === 'justzix-folder' && Array.isArray(payload.rules)) {
      collected = payload.rules;
    } else if (exportType === 'justzix-full' && Array.isArray(payload.rules)) {
      collected = payload.rules;
    }
    const overlay = document.createElement('div');
    overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1100;';
    const inner = document.createElement('div');
    const isDark = document.body.classList.contains('theme-dark');
    inner.style.cssText = `background:${isDark ? '#1e293b' : 'white'};color:${isDark ? '#f1f5f9' : '#222'};border-radius:8px;padding:20px;width:80vw;max-width:900px;max-height:80vh;overflow-y:auto;`;
    let html = `<h3 style="margin-top:0;">${escapeHtml(JZ_I18N.t('sharePreview.codePreviewTitle'))}</h3>`;
    let foundAny = false;
    collected.forEach(r => {
      if (r.js && r.js.trim()) {
        foundAny = true;
        html += `<h4 style="margin:14px 0 4px;font-size:13px;">${JZ_ICONS.tabJs} JS w "${escapeHtml(r.name || '(bez nazwy)')}"</h4>`;
        html += `<pre style="background:#1f2937;color:#e5e7eb;padding:12px;border-radius:6px;font-size:11px;font-family:monospace;overflow:auto;max-height:300px;">${escapeHtml(r.js)}</pre>`;
      }
      (r.actions || []).forEach(a => {
        if (a.code && a.code.trim()) {
          foundAny = true;
          html += `<h4 style="margin:14px 0 4px;font-size:13px;">${JZ_ICONS.tabActions} Akcja "${escapeHtml(a.label || '')}" w "${escapeHtml(r.name || '')}"</h4>`;
          html += `<pre style="background:#1f2937;color:#e5e7eb;padding:12px;border-radius:6px;font-size:11px;font-family:monospace;overflow:auto;max-height:200px;">${escapeHtml(a.code)}</pre>`;
        }
      });
    });
    if (!foundAny) html += '<p>Brak kodu JS w tym eksporcie.</p>';
    html += '<div style="text-align:right;margin-top:14px;"><button id="closeCodePreviewBtn" style="padding:8px 14px;border-radius:4px;border:1px solid #d1d5db;background:white;cursor:pointer;color:#222;">Zamknij</button></div>';
    inner.innerHTML = html;
    overlay.appendChild(inner);
    document.body.appendChild(overlay);
    inner.querySelector('#closeCodePreviewBtn').addEventListener('click', () => overlay.remove());
    overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
  }

  showCodeLink.addEventListener('click', async (e) => {
    e.preventDefault();
    if (!currentToken) return;
    // Pobierz pełen payload jeśli jeszcze nie pobrany
    if (!currentPayload) {
      try {
        const r = await JZ_SYNC.getShare(currentToken);
        currentPayload = r;
      } catch (err) {
        setConfirmErr(humanizeError(err));
        return;
      }
    }
    showCodeOverlay(currentPayload.payload || {}, currentPayload.exportType || (currentPreview && currentPreview.exportType));
  });

  confirmBtn.addEventListener('click', async () => {
    if (!currentToken || !currentPreview) return;
    setConfirmErr(null);
    confirmBtn.disabled = true;
    confirmBtn.textContent = JZ_I18N.t('btn.importing');
    try {
      // Jeśli już pobrany przy "Pokaż kod" — wykorzystaj
      if (!currentPayload) {
        currentPayload = await JZ_SYNC.getShare(currentToken);
      }
      const exportType = currentPayload.exportType || currentPreview.exportType;
      const payload = currentPayload.payload || {};
      await applyShareImport(payload, exportType, currentPayload.ownerName);
      close();
      showToast(JZ_I18N.t('toast.importedFromLink'));
    } catch (e) {
      confirmBtn.disabled = false;
      confirmBtn.textContent = 'Zaimportuj';
      setConfirmErr(humanizeError(e));
    }
  });

  // Wire-up menu item
  document.getElementById('importFromUrlBtn')?.addEventListener('click', open);

  // Eksportujemy żeby z innych miejsc też można otworzyć (np. w przyszłości popup)
  window.openShareImportModal = open;
})();

// === applyShareImport — regeneruje wszystkie ID i wstawia do storage ===
async function applyShareImport(payload, exportType, ownerName) {
  const ts = new Date().toLocaleDateString('pl-PL');
  const ownerLabel = (ownerName || 'Import') + ' ' + ts;

  if (exportType === 'justzix-rule' && payload.rule) {
    const newFolder = {
      id: genId('f'),
      name: ownerLabel + ' (zestaw)',
      urlPatterns: ['*'],
      urlPattern: '*',
      enabled: true,
      active: true,
      showActionsPanel: false,
      actionsOrientation: 'vertical',
      color: '',
      icon: '',
    };
    const newGroup = {
      id: genId('g'),
      folderId: newFolder.id,
      name: JZ_I18N.t('default.groupName'),
      enabled: true,
      active: true,
    };
    const newRule = JSON.parse(JSON.stringify(payload.rule));
    const oldRuleId = newRule.id;
    newRule.id = genId('r');
    newRule.groupId = newGroup.id;
    delete newRule.folderId;
    if (!Array.isArray(newRule.urlPatterns)) newRule.urlPatterns = [];
    if (Array.isArray(newRule.actions)) newRule.actions.forEach(a => a.id = genId('a'));
    folders.push(newFolder);
    groups.push(newGroup);
    rules.push(newRule);
    const barIdMap = importWindowsFromPayload(payload, { folder: {}, group: {}, rule: { [oldRuleId]: newRule.id } });
    remapImportedActionBarIds([newRule], barIdMap);
  } else if (exportType === 'justzix-group' && payload.group) {
    const newFolder = {
      id: genId('f'),
      name: ownerLabel + ' (grupa)',
      urlPatterns: ['*'],
      urlPattern: '*',
      enabled: true,
      active: true,
      showActionsPanel: false,
      actionsOrientation: 'vertical',
      color: '',
      icon: '',
    };
    const newGroup = JSON.parse(JSON.stringify(payload.group));
    const oldGroupId = newGroup.id;
    newGroup.id = genId('g');
    newGroup.folderId = newFolder.id;
    folders.push(newFolder);
    groups.push(newGroup);
    const ruleIdMap = {};
    const importedRules = [];
    (payload.rules || []).forEach(r => {
      const nr = JSON.parse(JSON.stringify(r));
      const oldRuleId = nr.id;
      nr.id = genId('r');
      ruleIdMap[oldRuleId] = nr.id;
      nr.groupId = newGroup.id;
      delete nr.folderId;
      if (!Array.isArray(nr.urlPatterns)) nr.urlPatterns = [];
      if (Array.isArray(nr.actions)) nr.actions.forEach(a => a.id = genId('a'));
      rules.push(nr);
      importedRules.push(nr);
    });
    const barIdMap = importWindowsFromPayload(payload, { folder: {}, group: { [oldGroupId]: newGroup.id }, rule: ruleIdMap });
    remapImportedActionBarIds(importedRules, barIdMap);
  } else if (exportType === 'justzix-folder' && payload.folder) {
    const newFolder = JSON.parse(JSON.stringify(payload.folder));
    const oldFolderId = newFolder.id;
    newFolder.id = genId('f');
    if (!newFolder.name) newFolder.name = ownerLabel;
    folders.push(newFolder);
    const folderIdMap = { [oldFolderId]: newFolder.id };
    const groupIdMap = {};
    const ruleIdMap = {};
    const importedRules = [];
    (payload.groups || []).forEach(g => {
      const ng = JSON.parse(JSON.stringify(g));
      const oldId = ng.id;
      ng.id = genId('g');
      ng.folderId = newFolder.id;
      groupIdMap[oldId] = ng.id;
      groups.push(ng);
    });
    (payload.rules || []).forEach(r => {
      const nr = JSON.parse(JSON.stringify(r));
      const oldRuleId = nr.id;
      nr.id = genId('r');
      const newGroupId = groupIdMap[nr.groupId];
      if (!newGroupId) return; // skip rule bez parenta
      ruleIdMap[oldRuleId] = nr.id;
      nr.groupId = newGroupId;
      delete nr.folderId;
      if (!Array.isArray(nr.urlPatterns)) nr.urlPatterns = [];
      if (Array.isArray(nr.actions)) nr.actions.forEach(a => a.id = genId('a'));
      rules.push(nr);
      importedRules.push(nr);
    });
    const barIdMap = importWindowsFromPayload(payload, { folder: folderIdMap, group: groupIdMap, rule: ruleIdMap });
    remapImportedActionBarIds(importedRules, barIdMap);
  } else if (exportType === 'justzix-full') {
    // Merge — wszystkie folders/groups/rules ze świeżymi ID
    const folderIdMap = {};
    const groupIdMap = {};
    const ruleIdMap = {};
    const importedRules = [];
    (payload.folders || []).forEach(f => {
      const nf = JSON.parse(JSON.stringify(f));
      const oldId = nf.id;
      nf.id = genId('f');
      folderIdMap[oldId] = nf.id;
      folders.push(nf);
    });
    (payload.groups || []).forEach(g => {
      const ng = JSON.parse(JSON.stringify(g));
      const oldId = ng.id;
      ng.id = genId('g');
      const mapped = folderIdMap[ng.folderId];
      if (!mapped) return; // skip orphan
      ng.folderId = mapped;
      groupIdMap[oldId] = ng.id;
      groups.push(ng);
    });
    (payload.rules || []).forEach(r => {
      const nr = JSON.parse(JSON.stringify(r));
      const oldRuleId = nr.id;
      nr.id = genId('r');
      const mappedGroup = groupIdMap[nr.groupId];
      if (!mappedGroup) return;
      ruleIdMap[oldRuleId] = nr.id;
      nr.groupId = mappedGroup;
      delete nr.folderId;
      if (!Array.isArray(nr.urlPatterns)) nr.urlPatterns = [];
      if (Array.isArray(nr.actions)) nr.actions.forEach(a => a.id = genId('a'));
      rules.push(nr);
      importedRules.push(nr);
    });
    const barIdMap = importWindowsFromPayload(payload, { folder: folderIdMap, group: groupIdMap, rule: ruleIdMap });
    remapImportedActionBarIds(importedRules, barIdMap);
  } else {
    throw new Error(JZ_I18N.t('ruleImport.unknownFormat', { type: exportType }));
  }

  await saveAll();
  await load();
  notifyTabs();
}

function openRuleImport(targetRule) {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = '.json';
  input.onchange = (e) => {
    const file = e.target.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const data = JSON.parse(reader.result);
        if (!isExportType(data.type, 'rule') || !data.rule) {
          showToast(JZ_I18N.t('toast.invalidRuleFormat'), true);
          return;
        }
        const choice = window.prompt(
          JZ_I18N.t('prompt.ruleConflict.body', {
            new: data.rule.name || '',
            current: targetRule.name || '',
          }),
          '2'
        );
        if (choice === null) return;
        const t = (choice || '').trim().toLowerCase();
        const mode = (t === '1' || t === 'zastap' || t === 'zastąp' || t === 'overwrite') ? 'overwrite'
                    : (t === '2' || t === 'kopia' || t === 'copy' || t === 'rename') ? 'copy'
                    : null;
        if (!mode) { showToast(JZ_I18N.t('toast.unknownOption'), true); return; }

        const imported = JSON.parse(JSON.stringify(data.rule));
        if (!Array.isArray(imported.urlPatterns)) imported.urlPatterns = [];
        if (!Array.isArray(imported.actions)) imported.actions = [];
        imported.actions.forEach(a => a.id = genId('a'));

        if (mode === 'overwrite') {
          imported.id = targetRule.id;
          imported.groupId = targetRule.groupId;
          const idx = rules.findIndex(r => r.id === targetRule.id);
          if (idx > -1) rules[idx] = imported;
          const _bm = importWindowsFromPayload(data, { folder: {}, group: {}, rule: { [data.rule.id]: imported.id } });
          remapImportedActionBarIds([imported], _bm);
          saveAll();
          renderFolders();
          renderRuleEditor();
          showToast(JZ_I18N.t('toast.importedReplaced'));
        } else {
          // Kopia: dodaj jako nowy zestaw w tej samej grupie tuż po targetRule
          imported.id = genId('r');
          imported.groupId = targetRule.groupId;
          imported.name = (imported.name || 'Zestaw') + ' (import)';
          const idx = rules.findIndex(r => r.id === targetRule.id);
          if (idx > -1) rules.splice(idx + 1, 0, imported);
          else rules.push(imported);
          const _bm = importWindowsFromPayload(data, { folder: {}, group: {}, rule: { [data.rule.id]: imported.id } });
          remapImportedActionBarIds([imported], _bm);
          // Po imporcie skupmy widok na nowym zestawie
          selectedRuleId = imported.id;
          saveAll();
          renderFolders();
          renderRuleEditor();
          showToast(JZ_I18N.t('toast.importedAsCopy'));
        }
      } catch (err) {
        showToast(JZ_I18N.t('toast.parseError', { msg: err.message }), true);
      }
    };
    reader.readAsText(file);
  };
  input.click();
}

// === Eksport pojedynczego katalogu — z modalem selekcji ===
let folderExportContext = null;
let folderExportSelection = {}; // { ruleId: boolean }

document.getElementById('exportFolderBtn').addEventListener('click', () => {
  const folder = folders.find(f => f.id === selectedFolderId);
  if (!folder) return;
  folderExportContext = folder;
  folderExportSelection = {};
  getRulesForFolder(folder.id).forEach(r => { folderExportSelection[r.id] = true; });
  document.getElementById('folderExportTitle').textContent = folder.name || '(bez nazwy)';
  renderFolderExportTree();
  updateFolderExportSummary();
  document.getElementById('folderExportModal').classList.add('show');
});

function renderFolderExportTree() {
  const list = document.getElementById('folderExportTreeList');
  list.innerHTML = '';
  if (!folderExportContext) return;
  const folderGroups = getGroupsForFolder(folderExportContext.id);
  if (folderGroups.length === 0) {
    list.innerHTML = `<div style="color:#9ca3af;font-size:12px;text-align:center;padding:10px;">${escapeHtml(JZ_I18N.t('group.empty.noGroupsInFolder'))}</div>`;
    return;
  }
  folderGroups.forEach(group => {
    const groupRules = rules.filter(r => r.groupId === group.id);
    const groupEl = document.createElement('div');
    groupEl.className = 'export-group-block';
    groupEl.innerHTML = `
      <label class="export-group-row">
        <input type="checkbox" class="fe-group-cb" data-group-id="${group.id}">
        <span class="export-row-name">${JZ_ICONS.group} ${escapeHtml(group.name || '(bez nazwy)')}</span>
        <span class="export-row-count subtle">${groupRules.length}</span>
      </label>
      <div class="export-rules-list"></div>
    `;
    const rulesList = groupEl.querySelector('.export-rules-list');
    groupRules.forEach(rule => {
      const ruleEl = document.createElement('label');
      ruleEl.className = 'export-rule-row';
      ruleEl.innerHTML = `
        <input type="checkbox" class="fe-rule-cb" data-group-id="${group.id}" data-rule-id="${rule.id}" ${folderExportSelection[rule.id] ? 'checked' : ''}>
        <span class="export-row-name">${escapeHtml(rule.name || '(bez nazwy)')}</span>
        ${(rule.actions || []).length ? `<span class="export-row-count subtle">${rule.actions.length} akcji</span>` : ''}
      `;
      rulesList.appendChild(ruleEl);
    });
    list.appendChild(groupEl);
  });

  // Stany checkboxów grup (indeterminate)
  folderGroups.forEach(g => {
    const cb = list.querySelector(`.fe-group-cb[data-group-id="${g.id}"]`);
    if (!cb) return;
    const grIds = rules.filter(r => r.groupId === g.id).map(r => r.id);
    if (grIds.length === 0) { cb.checked = false; cb.indeterminate = false; return; }
    const any = grIds.some(rid => folderExportSelection[rid]);
    const all = grIds.every(rid => folderExportSelection[rid]);
    cb.checked = any;
    cb.indeterminate = any && !all;
  });

  // Eventy
  list.querySelectorAll('.fe-group-cb').forEach(cb => {
    cb.addEventListener('change', (e) => {
      const groupId = e.target.dataset.groupId;
      const checked = e.target.checked;
      rules.filter(r => r.groupId === groupId).forEach(r => { folderExportSelection[r.id] = checked; });
      list.querySelectorAll(`.fe-rule-cb[data-group-id="${groupId}"]`).forEach(rcb => { rcb.checked = checked; });
      updateFolderExportSummary();
    });
  });
  list.querySelectorAll('.fe-rule-cb').forEach(cb => {
    cb.addEventListener('change', (e) => {
      const ruleId = e.target.dataset.ruleId;
      const groupId = e.target.dataset.groupId;
      folderExportSelection[ruleId] = e.target.checked;
      const grIds = rules.filter(r => r.groupId === groupId).map(r => r.id);
      const any = grIds.some(rid => folderExportSelection[rid]);
      const all = grIds.every(rid => folderExportSelection[rid]);
      const groupCb = list.querySelector(`.fe-group-cb[data-group-id="${groupId}"]`);
      if (groupCb) {
        groupCb.checked = any;
        groupCb.indeterminate = any && !all;
      }
      updateFolderExportSummary();
    });
  });
}

function updateFolderExportSummary() {
  const summary = document.getElementById('folderExportSummary');
  const ruleCount = Object.values(folderExportSelection).filter(v => v).length;
  // Liczymy też ile grup będzie zawartych (te co mają zaznaczone zestawy)
  const groupsWithSelection = new Set();
  rules.forEach(r => {
    if (folderExportSelection[r.id]) groupsWithSelection.add(r.groupId);
  });
  summary.innerHTML = ruleCount > 0
    ? JZ_I18N.t('export.summary.toExportFolderShort', {
        icon: JZ_ICONS.package,
        groupCount: groupsWithSelection.size,
        groupWord: JZ_I18N.plural(groupsWithSelection.size, 'plural.group'),
        ruleCount,
        ruleWord: JZ_I18N.plural(ruleCount, 'plural.rule'),
      })
    : JZ_I18N.t('export.summary.noneSelectedShort', { icon: JZ_ICONS.warning });
  document.getElementById('downloadFolderExportBtn').disabled = ruleCount === 0;
}

document.getElementById('folderExportSelectAll').addEventListener('click', () => {
  if (!folderExportContext) return;
  getRulesForFolder(folderExportContext.id).forEach(r => { folderExportSelection[r.id] = true; });
  renderFolderExportTree();
  updateFolderExportSummary();
});
document.getElementById('folderExportSelectNone').addEventListener('click', () => {
  Object.keys(folderExportSelection).forEach(k => { folderExportSelection[k] = false; });
  renderFolderExportTree();
  updateFolderExportSummary();
});
document.getElementById('closeFolderExportBtn').addEventListener('click', () => {
  document.getElementById('folderExportModal').classList.remove('show');
  folderExportContext = null;
});
document.getElementById('downloadFolderExportBtn').addEventListener('click', () => {
  if (!folderExportContext) return;
  const folder = folderExportContext;
  // Włącz tylko grupy, które mają zaznaczone zestawy (lub wszystkie grupy folderu, jeśli puste).
  const selectedRules = rules
    .filter(r => folderExportSelection[r.id])
    .filter(r => {
      const g = groups.find(gg => gg.id === r.groupId);
      return g && g.folderId === folder.id;
    });
  if (selectedRules.length === 0) return;
  const groupIdsInExport = new Set(selectedRules.map(r => r.groupId));
  const selectedGroups = groups.filter(g => g.folderId === folder.id && groupIdsInExport.has(g.id));

  const payload = {
    type: 'justzix-folder',
    version: 2,
    schemaVersion: STORAGE_VERSION,
    folder: folder,
    groups: selectedGroups,
    rules: selectedRules,
    windows: collectWindowsForExport([
      folder.id,
      ...selectedGroups.map(g => g.id),
      ...selectedRules.map(r => r.id),
    ]),
  };
  const json = JSON.stringify(payload, null, 2);
  const blob = new Blob([json], { type: 'application/json' });
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  const safeName = (folder.name || 'katalog').replace(/[^a-zA-Z0-9-_]/g, '_').slice(0, 40);
  const date = new Date().toISOString().slice(0, 10);
  a.download = `justzix-${safeName}-${date}.json`;
  a.click();
  URL.revokeObjectURL(url);
  document.getElementById('folderExportModal').classList.remove('show');
  folderExportContext = null;
  showToast(JZ_I18N.t('folderExport.exportedToast', {
    groupCount: selectedGroups.length,
    groupWord: JZ_I18N.plural(selectedGroups.length, 'plural.group'),
    ruleCount: selectedRules.length,
    ruleWord: JZ_I18N.plural(selectedRules.length, 'plural.rule'),
  }));
});

// === Import pojedynczego katalogu ===
let pendingFolderImport = null;
let pendingConflictType = null; // 'name' | 'id' | null

function checkFolderConflicts(importedFolder) {
  // Konflikt po ID (ten sam katalog został wcześniej eksportowany i importowany)
  const conflictById = folders.find(f => f.id === importedFolder.id);
  if (conflictById) {
    return { type: 'id', existing: conflictById };
  }
  // Konflikt po nazwie (ostrzegawczy, ale nie blokujący)
  const conflictByName = folders.find(f => f.name === importedFolder.name);
  if (conflictByName) {
    return { type: 'name', existing: conflictByName };
  }
  return null;
}

document.getElementById('importFolderBtn').addEventListener('click', () => {
  document.getElementById('folderImportText').value = '';
  document.getElementById('folderImportFile').value = '';
  document.getElementById('folderImportConflicts').style.display = 'none';
  pendingFolderImport = null;
  document.getElementById('folderImportModal').classList.add('show');
});

document.getElementById('folderImportFile').addEventListener('change', (e) => {
  const file = e.target.files[0];
  if (!file) return;
  const reader = new FileReader();
  reader.onload = () => {
    document.getElementById('folderImportText').value = reader.result;
    detectFolderImportConflicts();
  };
  reader.readAsText(file);
});

document.getElementById('folderImportText').addEventListener('input', detectFolderImportConflicts);

function detectFolderImportConflicts() {
  const json = document.getElementById('folderImportText').value.trim();
  const conflictsDiv = document.getElementById('folderImportConflicts');
  const conflictsList = document.getElementById('folderImportConflictsList');
  const importBtn = document.getElementById('doFolderImportBtn');
  pendingFolderImport = null;
  pendingConflictType = null;

  if (!json) {
    conflictsDiv.style.display = 'none';
    const p = document.getElementById('folderImportPreview');
    if (p) p.style.display = 'none';
    importBtn.disabled = false;
    return;
  }

  let data;
  try {
    data = JSON.parse(json);
  } catch (err) {
    conflictsDiv.style.display = 'block';
    conflictsDiv.style.background = '#fee2e2';
    conflictsDiv.style.borderColor = '#fecaca';
    conflictsList.innerHTML = `<span style="color:#dc2626;">${escapeHtml(JZ_I18N.t('folderImport.parseError', { msg: err.message }))}</span>`;
    conflictsDiv.querySelectorAll('input[name="conflictAction"]').forEach(r => r.parentElement.style.display = 'none');
    importBtn.disabled = true;
    return;
  }

  // Wykryj pełen backup zamiast eksportu pojedynczego katalogu
  if (isExportType(data.type, 'full') || (Array.isArray(data.folders) && data.folders.length > 0 && !data.folder)) {
    conflictsDiv.style.display = 'block';
    conflictsDiv.style.background = '#fef3c7';
    conflictsDiv.style.borderColor = '#fde68a';
    conflictsList.innerHTML = `
      <strong style="color:#92400e;">${JZ_ICONS.warning} To pełen backup, nie pojedynczy katalog!</strong><br>
      <span style="color:#78350f;">Plik zawiera ${data.folders.length} katalogów oraz ${(data.rules || []).length} zestawów.</span><br>
      <span style="color:#78350f;font-size:11px;margin-top:6px;display:inline-block;">
        Aby zaimportować pełen backup, użyj <strong>${JZ_ICONS.import} Import ustawień</strong> w lewym dolnym rogu sidebara.
        Tutaj można importować tylko eksport pojedynczego katalogu (<strong>${JZ_ICONS.export} Eksport</strong> w pasku katalogu).
      </span>
    `;
    conflictsDiv.querySelectorAll('input[name="conflictAction"]').forEach(r => r.parentElement.style.display = 'none');
    importBtn.disabled = true;
    return;
  }

  // Sprawdź format eksportu katalogu
  if (!isExportType(data.type, 'folder') || !data.folder || !Array.isArray(data.rules)) {
    conflictsDiv.style.display = 'block';
    conflictsDiv.style.background = '#fee2e2';
    conflictsDiv.style.borderColor = '#fecaca';
    conflictsList.innerHTML = `<span style="color:#dc2626;">${escapeHtml(JZ_I18N.t('folderImport.invalidFormat'))}</span>`;
    conflictsDiv.querySelectorAll('input[name="conflictAction"]').forEach(r => r.parentElement.style.display = 'none');
    importBtn.disabled = true;
    return;
  }

  importBtn.disabled = false;
  pendingFolderImport = data;
  const conflict = checkFolderConflicts(data.folder);

  if (!conflict) {
    conflictsDiv.style.display = 'block';
    conflictsDiv.style.background = '#d1fae5';
    conflictsDiv.style.borderColor = '#a7f3d0';
    conflictsList.innerHTML = `<span style="color:#065f46;">${JZ_I18N.t('folderImport.noConflicts', {
      icon: JZ_ICONS.check,
      name: escapeHtml(data.folder.name),
      count: data.rules.length,
      word: JZ_I18N.plural(data.rules.length, 'plural.rule'),
    })}</span>`;
    conflictsDiv.querySelectorAll('input[name="conflictAction"]').forEach(r => r.parentElement.style.display = 'none');
    renderImportPreview(data.rules || []);
    return;
  }

  pendingConflictType = conflict.type;
  conflictsDiv.style.display = 'block';
  conflictsDiv.style.background = '#fef3c7';
  conflictsDiv.style.borderColor = '#fde68a';

  conflictsDiv.querySelectorAll('input[name="conflictAction"]').forEach(r => r.parentElement.style.display = '');

  // Zestawy należą do grup, nie bezpośrednio do folderu — liczymy przez groupId.
  // `r.folderId` zostawiamy jako fallback dla starszych (v2) wpisów bez groupId.
  const folderGroupIds = new Set(groups.filter(g => g.folderId === conflict.existing.id).map(g => g.id));
  const existingRulesCount = rules.filter(r => folderGroupIds.has(r.groupId) || r.folderId === conflict.existing.id).length;
  if (conflict.type === 'id') {
    conflictsList.innerHTML = JZ_I18N.t('folderImport.conflictSameId', {
      name: escapeHtml(conflict.existing.name),
      count: existingRulesCount,
      word: JZ_I18N.plural(existingRulesCount, 'plural.rule'),
    });
  } else {
    conflictsList.innerHTML = JZ_I18N.t('folderImport.conflictSameName', {
      name: escapeHtml(conflict.existing.name),
      count: existingRulesCount,
      word: JZ_I18N.plural(existingRulesCount, 'plural.rule'),
    });
  }
  renderImportPreview(data.rules || []);
}

function renderImportPreview(rules) {
  let preview = document.getElementById('folderImportPreview');
  if (!preview) {
    preview = document.createElement('div');
    preview.id = 'folderImportPreview';
    preview.style.cssText = 'margin-top:12px; padding:10px; background:#f9fafb; border:1px solid #e5e7eb; border-radius:4px; font-size:12px;';
    document.getElementById('folderImportConflicts').parentNode.insertBefore(
      preview,
      document.getElementById('folderImportConflicts').nextSibling
    );
  }

  if (rules.length === 0) {
    preview.style.display = 'none';
    return;
  }

  preview.style.display = 'block';
  let html = JZ_I18N.t('folderImport.fileContents', {
    icon: JZ_ICONS.package,
    count: rules.length,
    word: JZ_I18N.plural(rules.length, 'plural.rule'),
  });
  html += '<div style="max-height:200px;overflow-y:auto;margin-top:6px;">';

  rules.forEach((r, i) => {
    const cssLines = (r.css || '').split('\n').filter(l => l.length).length;
    const jsLines = (r.js || '').split('\n').filter(l => l.length).length;
    const actionsCount = (r.actions || []).length;
    const actionsCodeLines = (r.actions || []).reduce((acc, a) => acc + (a.code || '').split('\n').filter(l => l.length).length, 0);
    const hasJs = jsLines > 0 || actionsCodeLines > 0;

    html += `
      <div style="padding:6px 8px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:8px;">
        <span style="flex:1;color:#1f2937;">${escapeHtml(r.name || '(bez nazwy)')}</span>
        <span style="font-size:10px;color:#6b7280;font-family:monospace;white-space:nowrap;">
          CSS: ${cssLines}L • JS: ${jsLines}L${actionsCount ? ` • ${actionsCount} akcji (${actionsCodeLines}L)` : ''}
        </span>
        ${hasJs ? `<button data-preview-idx="${i}" style="padding:2px 8px;font-size:10px;background:#fef3c7;border:1px solid #fde68a;color:#92400e;border-radius:3px;cursor:pointer;" title="${escapeAttr(JZ_I18N.t('folderImport.previewJsBtn.title'))}">${JZ_ICONS.eye} JS</button>` : ''}
      </div>
    `;
  });
  html += '</div>';

  // Ostrzeżenie jeśli są zestawy z JS
  const totalJsLines = rules.reduce((acc, r) => {
    const jsLines = (r.js || '').split('\n').filter(l => l.length).length;
    const actionsCodeLines = (r.actions || []).reduce((a, ac) => a + (ac.code || '').split('\n').filter(l => l.length).length, 0);
    return acc + jsLines + actionsCodeLines;
  }, 0);
  if (totalJsLines > 0) {
    html += `<div style="margin-top:8px;padding:6px 10px;background:#fee2e2;border-left:3px solid #dc2626;color:#7f1d1d;font-size:11px;border-radius:3px;">
      ${JZ_ICONS.warning} <strong>Plik zawiera ${totalJsLines} linii kodu JavaScript</strong>, który zostanie wykonany na pasujących stronach. Przejrzyj kod przed importem (przyciski ${JZ_ICONS.eye} JS).
    </div>`;
  }

  preview.innerHTML = html;

  preview.querySelectorAll('[data-preview-idx]').forEach(btn => {
    btn.addEventListener('click', () => {
      const idx = parseInt(btn.dataset.previewIdx);
      const rule = rules[idx];
      if (!rule) return;
      showJsPreview(rule);
    });
  });
}

function showJsPreview(rule) {
  const overlay = document.createElement('div');
  overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:9999;';

  const modal = document.createElement('div');
  modal.style.cssText = 'background:white;border-radius:8px;padding:20px;width:80vw;max-width:900px;max-height:80vh;overflow-y:auto;';
  if (document.body.classList.contains('theme-dark')) {
    modal.style.background = '#1e293b';
    modal.style.color = '#f1f5f9';
  }

  let html = `<h3 style="margin-top:0;">${escapeHtml(JZ_I18N.t('folderImport.codePreviewTitle', { name: rule.name || '' }))}</h3>`;

  if (rule.js && rule.js.trim()) {
    html += `<h4 style="margin-bottom:6px;font-size:13px;color:#92400e;">${JZ_I18N.t('folderImport.jsHeader', { icon: JZ_ICONS.tabJs })}</h4>`;
    html += `<pre style="background:#1f2937;color:#e5e7eb;padding:12px;border-radius:6px;font-size:11px;font-family:monospace;overflow-x:auto;max-height:300px;overflow-y:auto;">${escapeHtml(rule.js)}</pre>`;
  }

  if (Array.isArray(rule.actions) && rule.actions.length > 0) {
    rule.actions.forEach(a => {
      if (a.code && a.code.trim()) {
        html += `<h4 style="margin-bottom:6px;font-size:13px;color:#6d28d9;">${JZ_ICONS.tabActions} Akcja: ${escapeHtml(a.label || '')}</h4>`;
        html += `<pre style="background:#1f2937;color:#e5e7eb;padding:12px;border-radius:6px;font-size:11px;font-family:monospace;overflow-x:auto;max-height:200px;overflow-y:auto;">${escapeHtml(a.code)}</pre>`;
      }
    });
  }

  html += '<div style="text-align:right;margin-top:12px;"><button id="closeJsPreviewBtn" style="padding:8px 14px;border-radius:4px;border:1px solid #d1d5db;background:white;cursor:pointer;">Zamknij</button></div>';

  modal.innerHTML = html;
  overlay.appendChild(modal);
  document.body.appendChild(overlay);

  modal.querySelector('#closeJsPreviewBtn').addEventListener('click', () => overlay.remove());
  overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
}

document.getElementById('doFolderImportBtn').addEventListener('click', async () => {
  if (!pendingFolderImport) {
    showToast(JZ_I18N.t('toast.noImportData'), true);
    return;
  }

  const importedFolder = JSON.parse(JSON.stringify(pendingFolderImport.folder));
  const importedRules = JSON.parse(JSON.stringify(pendingFolderImport.rules));
  // Stary format (bez groups[]) → zbuduj domyślną grupę pomocniczo.
  let importedGroups;
  if (Array.isArray(pendingFolderImport.groups) && pendingFolderImport.groups.length > 0) {
    importedGroups = JSON.parse(JSON.stringify(pendingFolderImport.groups));
  } else {
    const defaultGroupId = genId('g');
    importedGroups = [{
      id: defaultGroupId,
      folderId: importedFolder.id,
      name: JZ_I18N.t('default.groupName'),
      enabled: true
    }];
    importedRules.forEach(r => { if (!r.groupId) r.groupId = defaultGroupId; });
  }

  // Pomocnik: nadaje nowe ID grupom + przemapowuje r.groupId pomiędzy starymi i nowymi ID;
  // ustawia g.folderId i r.groupId zgodnie z docelowym folderem.
  function remapGroupsToFolder(targetFolderId) {
    const idMap = new Map();
    importedGroups.forEach(g => {
      const oldId = g.id;
      g.id = genId('g');
      g.folderId = targetFolderId;
      idMap.set(oldId, g.id);
    });
    importedRules.forEach(r => {
      const newGid = idMap.get(r.groupId);
      if (newGid) r.groupId = newGid;
      r.id = genId('r');
      if (Array.isArray(r.actions)) r.actions.forEach(a => a.id = genId('a'));
      // Sanityzuj nowe pola
      if (!Array.isArray(r.urlPatterns)) r.urlPatterns = [];
      // Usuń legacy folderId
      delete r.folderId;
    });
  }

  if (!pendingConflictType) {
    const newFolderId = genId('f');
    importedFolder.id = newFolderId;
    remapGroupsToFolder(newFolderId);
    folders.push(importedFolder);
    groups.push(...importedGroups);
    rules.push(...importedRules);
  } else {
    const action = document.querySelector('input[name="conflictAction"]:checked')?.value || 'rename';
    const conflict = checkFolderConflicts(pendingFolderImport.folder);
    const existing = conflict.existing;

    if (action === 'rename') {
      const newFolderId = genId('f');
      importedFolder.id = newFolderId;
      importedFolder.name = importedFolder.name + ' (import)';
      remapGroupsToFolder(newFolderId);
      folders.push(importedFolder);
      groups.push(...importedGroups);
      rules.push(...importedRules);
    } else if (action === 'overwrite') {
      // Usuń istniejący katalog, jego grupy i zestawy
      const existingGroupIds = new Set(groups.filter(g => g.folderId === existing.id).map(g => g.id));
      const existingRuleIds = new Set(rules.filter(r => existingGroupIds.has(r.groupId)).map(r => r.id));
      folders = folders.filter(f => f.id !== existing.id);
      groups = groups.filter(g => g.folderId !== existing.id);
      rules = rules.filter(r => !existingGroupIds.has(r.groupId));
      pruneOrphanWindows(new Set([existing.id, ...existingGroupIds, ...existingRuleIds]));
      importedFolder.id = existing.id;
      remapGroupsToFolder(existing.id);
      folders.push(importedFolder);
      groups.push(...importedGroups);
      rules.push(...importedRules);
    } else if (action === 'merge') {
      remapGroupsToFolder(existing.id);
      groups.push(...importedGroups);
      rules.push(...importedRules);
    }
  }

  await saveAll();
  document.getElementById('folderImportModal').classList.remove('show');
  pendingFolderImport = null;
  pendingConflictType = null;
  load();
  showToast(JZ_I18N.t('toast.importedFolder'));
});

document.getElementById('closeFolderImportBtn').addEventListener('click', () => {
  document.getElementById('folderImportModal').classList.remove('show');
  pendingFolderImport = null;
});

// Background poller informuje o zaaplikowanych encjach z pulla — przeładuj UI.
chrome.runtime.onMessage.addListener((msg) => {
  if (msg && msg.type === 'sync-pulled' && msg.applied && (msg.applied.applied || msg.applied.deletes)) {
    showToast(JZ_I18N.t('sync.toast.synced', { applied: msg.applied.applied, deletes: msg.applied.deletes }));
    load();
  }
});

// === Automatyczna synchronizacja przy wejściu/powrocie do strony Opcji =====
// chrome.alarms tikuje co 60s w tle, ale gdy user otwiera stronę po dłuższym
// czasie chce zobaczyć aktualny stan natychmiast. Wyzwalamy pull:
//   - przy załadowaniu options.html (DOMContentLoaded — nasz `load()` już zakończony)
//   - przy powrocie focusa na taba, jeśli minęła > 30s przerwa
let _lastSyncTriggerAt = 0;
async function maybeTriggerSync(trigger) {
  if (typeof JZ_SYNC === 'undefined') return;
  if (!JZ_SYNC.isAuthenticated()) return;
  const now = Date.now();
  if (now - _lastSyncTriggerAt < 5000) return; // dedup w obrębie 5s
  _lastSyncTriggerAt = now;
  chrome.runtime.sendMessage({ type: 'sync-now', trigger }).catch(() => {});
}

document.addEventListener('visibilitychange', () => {
  if (document.visibilityState === 'visible') {
    maybeTriggerSync('options-visible');
  }
});

window.addEventListener('focus', () => {
  maybeTriggerSync('options-focus');
});

// Storage onChanged → JZ_SYNC może być zaktualizowany przez background poller
// (np. po pull, po sync-now z innej karty). Zsynchronizuj wewnętrzny state i UI.
chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== 'local') return;
  if (typeof JZ_SYNC === 'undefined') return;
  if (!changes[JZ_SYNC._STORAGE_KEY]) return;
  JZ_SYNC.reload().then(() => {
    window.dispatchEvent(new CustomEvent('jz-sync-state-changed'));
  });
});

// Helper (od v2.13.31): wykrywa czy między oldArr i newArr zmieniły się TYLKO pola
// clickCount w action objects. Zwraca true gdy ich różnica = wyłącznie clickCount diff.
// Używane w storage.onChanged listenerze do detekcji clickCount-only writes z content.js
// nawet bez `_jzMutation: 'clickCount'` taga (defense in depth).
// JSON.stringify z rekurencyjnie posortowanymi kluczami — porównanie niewrażliwe
// na kolejność pól. Obiekt zrekonstruowany przez sync-pull ma inną kolejność
// kluczy niż edytowany in-memory, przez co zwykły JSON.stringify dawał false
// positive (zmiana clickCount klasyfikowana jako zmiana zewnętrzna → re-render).
function stableStringify(v) {
  if (v === null || typeof v !== 'object') return JSON.stringify(v);
  if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';
  return '{' + Object.keys(v).sort().map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';
}
function isOnlyClickCountChange(oldArr, newArr) {
  if (!Array.isArray(oldArr) || !Array.isArray(newArr)) return false;
  if (oldArr.length !== newArr.length) return false;
  for (let i = 0; i < oldArr.length; i++) {
    const o = oldArr[i];
    const n = newArr[i];
    if (!o || !n) return false;
    if (o.id !== n.id) return false;
    // Top-level rule fields — porównaj wszystkie poza actions.
    const oTopJson = stableStringify({ ...o, actions: undefined });
    const nTopJson = stableStringify({ ...n, actions: undefined });
    if (oTopJson !== nTopJson) return false;
    // Actions array: ta sama długość + każda action zgadza się oprócz clickCount.
    const oa = Array.isArray(o.actions) ? o.actions : [];
    const na = Array.isArray(n.actions) ? n.actions : [];
    if (oa.length !== na.length) return false;
    for (let j = 0; j < oa.length; j++) {
      const oAct = oa[j];
      const nAct = na[j];
      if (!oAct || !nAct) return false;
      if (oAct.id !== nAct.id) return false;
      // Compare strip(clickCount) — jeśli różnią się czymkolwiek innym, nie clickCount-only.
      const oStrip = stableStringify({ ...oAct, clickCount: undefined });
      const nStrip = stableStringify({ ...nAct, clickCount: undefined });
      if (oStrip !== nStrip) return false;
    }
  }
  return true;
}

// Storage onChanged → entities (`folders`, `groups`, `rules`, `actionBars`) mogą być zmienione
// bezpośrednio z content.js (toggle enabled w pływającym panelu, dblclick na barze flipujący
// orientation, ukrycie baru per session) lub z innych zakładek z opcjami / cross-device sync.
// Bez tego listenera options.js trzymałby stale in-memory state i przy najbliższym `saveAll()`
// nadpisałby storage starym stanem.
//
// Self-write detection (fix v2.13.27 — wcześniej JSON.stringify equality, zawodne):
// saveAll() wstawia świeży `_jzWriteTag` do każdego storage.set. Listener sprawdza newValue
// tagu — match → to nasz write (skip replacement + re-render, DOM closures zaczepione przy
// renderActionsList pozostają valid). Mismatch → external write (content.js dblclick na pasku,
// popup widget, cross-device sync) → replace tablic + re-render editora (akceptujemy focus
// loss — i tak musimy odświeżyć żeby pokazać external change).
chrome.storage.onChanged.addListener((changes, area) => {
  if (area !== 'local') return;

  // Self-write check — najpierw, bo jeśli to nasz write to mamy in-memory state aktualny
  // i nie chcemy ani replace'u ani re-renderu.
  if (changes._jzWriteTag && changes._jzWriteTag.newValue === _lastWriteTag) return;

  // ClickCount mutation z content.js (od v2.13.30, rozszerzone v2.13.31 o deep-diff fallback)
  // — odpalenie akcji na stronie inkrementuje clickCount i pisze rules. Bez detekcji listener
  // traktowałby to jak external write → replace tablicy rules → re-render editora → zakładka
  // CSS. Wykrywamy dwoma drogami:
  //   1) Fast-path: tag `_jzMutation: 'clickCount'` z content.js (od v2.13.30)
  //   2) Smart-diff: nawet bez tagu — porównaj stripped (clickCount=undefined) versions
  //      in-memory rules vs newValue. Jeśli różnią się TYLKO clickCount → traktuj jak fast-path.
  //      Chroni przed scenariuszami gdzie tag nie dotarł lub był wcześniejszy write rules
  //      z innej ścieżki nie używającej taga.
  let rulesAlreadyHandled = false;
  if (changes.rules) {
    const fresh = Array.isArray(changes.rules.newValue) ? changes.rules.newValue : [];
    const isClickCountTag = changes._jzMutation && changes._jzMutation.newValue === 'clickCount';
    const isClickCountDiff = isClickCountTag || isOnlyClickCountChange(rules, fresh);
    if (isClickCountDiff) {
      fresh.forEach(newRule => {
        const cur = rules.find(r => r.id === newRule.id);
        if (!cur || !Array.isArray(cur.actions)) return;
        (newRule.actions || []).forEach(newAction => {
          const oldAction = cur.actions.find(a => a.id === newAction.id);
          if (oldAction && oldAction.clickCount !== newAction.clickCount) {
            oldAction.clickCount = newAction.clickCount;
          }
        });
      });
      rulesAlreadyHandled = true;
      // Inne keys w changes (folders/groups/actionBars) wciąż mogą wymagać handlingu —
      // w typowym clickCount-write tylko `rules` jest obecne, więc wracamy.
      if (!changes.folders && !changes.groups && !changes.actionBars) {
        // Pull-write baseline (od v2.13.37): clickCount-only diff może pochodzić
        // z sync-pull (applyEntities taguje `_jzPullTag` — np. inne urządzenie
        // bumpnęło licznik akcji i wypchnęło regułę). In-memory rules są już
        // zgodne z serwerem (po skopiowaniu clickCount reszta pól identyczna —
        // z definicji clickCount-only diffu), więc _syncSnapshot MUSI dostać
        // świeży baseline ZANIM wrócimy. Bez tego early-return omijał
        // rekonciliację z l. ~10426 → następne user-deletion encji przyniesionej
        // tym pullem nie trafiało do diffu saveAll → tombstone nie szedł na
        // backend → encja wracała przy kolejnym pullu ("nie da się skasować").
        if (changes._jzPullTag && _syncSnapshot) {
          _syncSnapshot = _snapshotEntities();
        }
        return;
      }
    }
  }

  let entitiesChanged = false;
  let barsChanged = false;
  let panesChanged = false;
  // Liczniki użycia (statystyki na pulpicie) — zmiana z pull innego urządzenia
  // pisze jzUsageStatsRemote, lokalna aktywność pisze jzUsageStats. Same w sobie
  // nie wymagają re-renderu edytorów, ale otwarty pulpit musi się odświeżyć.
  const usageChanged = !!(changes.jzUsageStatsRemote || changes.jzUsageStats);

  function applyFresh(currentArr, newValueRaw) {
    const fresh = Array.isArray(newValueRaw) ? newValueRaw : [];
    currentArr.length = 0;
    fresh.forEach(item => currentArr.push(item));
  }

  if (changes.folders)                       { applyFresh(folders, changes.folders.newValue);       entitiesChanged = true; }
  if (changes.groups)                        { applyFresh(groups, changes.groups.newValue);         entitiesChanged = true; }
  if (changes.rules && !rulesAlreadyHandled) { applyFresh(rules, changes.rules.newValue);           entitiesChanged = true; }
  if (changes.actionBars)                    { applyFresh(actionBars, changes.actionBars.newValue); barsChanged = true; }
  // Faza 1 "Okna na froncie" (v12) — 4 nowe kolekcje. Brak rendererów w options.js dla nich
  // (UI dochodzi w kolejnych fazach), ale `panesChanged` musi być ustawione żeby pull-write
  // detection mógł zaktualizować `_syncSnapshot` (bez tego deletion-bug z v2.13.37 by się
  // powtórzył dla nowych typów: pull przynosi pane, user kasuje, diff nie widzi → tombstone
  // ginie). Renderowania brak — po prostu utrzymujemy spójność baseline.
  if (changes.cssPanes)       { applyFresh(cssPanes,       changes.cssPanes.newValue);       panesChanged = true; }
  if (changes.jsPanes)        { applyFresh(jsPanes,        changes.jsPanes.newValue);        panesChanged = true; }
  if (changes.jsConsoles)     { applyFresh(jsConsoles,     changes.jsConsoles.newValue);     panesChanged = true; }
  if (changes.outputConsoles) { applyFresh(outputConsoles, changes.outputConsoles.newValue); panesChanged = true; }

  if (!entitiesChanged && !barsChanged && !panesChanged && !usageChanged) return;

  // Re-sanityzacja po zewnętrznym zapisie — applyFresh tylko podmienia zawartość
  // tablic, nie czyści encji osieroconych ani nie uzupełnia brakujących pól.
  // Bez tego zapis z sync pulla / innej karty mógł wnieść do pamięci bar lub
  // okno z nieistniejącym parentem albo orphan barId w akcji. Idempotentne;
  // pomijamy dla zmian wyłącznie liczników użycia.
  if (entitiesChanged || barsChanged || panesChanged) {
    sanitizeEntitiesInMemory();
  }

  // Pull-write detection (od v2.13.37). applyEntities w sync.js taguje swoje pisania znacznikiem
  // `_jzPullTag`. Gdy widzimy ten tag — entities w in-memory są teraz w stanie zgodnym z serwerem
  // i muszą stać się nowym baseline dla `_syncSnapshot`. Bez tego:
  //  - pull przynosi entity X (X nie był w _syncSnapshot — load() skończył się przed pullem)
  //  - user kasuje X w panelu edycji
  //  - saveAll → _diffAndEnqueue: `for (id in before.f)` iteruje stale snapshot, X tam nie ma →
  //    enqueueDelete NIE odpalany → backend nigdy nie dostaje tombstone → następny pull X wraca →
  //    user widzi "nie da się skasować".
  // Dla NIE-pull external writes (np. content.js: bar visibility toggle z popup widgeta) nadal
  // _syncSnapshot zostaje stale — celowo, żeby ich zmiana została pikupowana przez następny saveAll
  // diff jako enqueueChange (content.js sam nie wywołuje enqueueChange).
  const isPullWrite = !!changes._jzPullTag;
  if (isPullWrite && _syncSnapshot) {
    _syncSnapshot = _snapshotEntities();
  }

  // External change — re-render editora żeby DOM closure'y zaczepiły fresh action objects.
  // userIsEditing nie sprawdzamy: external write oznacza że nasz stan był desynchronizowany,
  // musimy odświeżyć UI nawet kosztem ewentualnej utraty focusa (rzadkie — typowo external
  // change to dblclick orientation z frontu, toggle enabled z popupa, sync pull).

  // 1. Sidebar tree — odświeżamy.
  if (entitiesChanged && typeof renderFolders === 'function') {
    renderFolders();
  }

  // 2. Aktywny editor (folder / group / rule) — re-render.
  if (entitiesChanged) {
    if (selectedRuleId && typeof renderRuleEditor === 'function') {
      renderRuleEditor();
    } else if (selectedGroupId && typeof renderGroupEditor === 'function') {
      const g = groups.find(gx => gx.id === selectedGroupId);
      const f = folders.find(ff => ff.id === selectedFolderId);
      if (g && f) renderGroupEditor(g, f);
    } else if (selectedFolderId && typeof renderFolderEditor === 'function') {
      renderFolderEditor();
    }
  }

  // 3. Modal "Paski akcji" jeśli otwarty.
  if (barsChanged) {
    const abModal = document.getElementById('actionBarsModal');
    if (abModal && abModal.classList.contains('show') && typeof renderActionBarsList === 'function') {
      renderActionBarsList();
    }
    if (selectedRuleId && typeof renderActionsList === 'function') {
      const rule = rules.find(r => r.id === selectedRuleId);
      const card = document.querySelector(`.rule-card[data-rule-id="${selectedRuleId}"]`);
      if (rule && card) renderActionsList(card, rule);
    }
  }

  // 4. Modale "Okna CSS" / "Okna JS" jeśli otwarte (od v2.13.42, JS od v2.13.56) — re-render
  // gdy panes się zmieniły (sync pull / toggle z popup widget / chrome contextmenu). Bez tego
  // user na panelu edycji nie widział że na froncie wyłączył pane.
  if (panesChanged) {
    const cpModal = document.getElementById('cssPanesModal');
    if (cpModal && cpModal.classList.contains('show') && typeof renderCssPanesList === 'function') {
      renderCssPanesList();
    }
    const jpModal = document.getElementById('jsPanesModal');
    if (jpModal && jpModal.classList.contains('show') && typeof renderJsPanesList === 'function') {
      renderJsPanesList();
    }
    const jcModal = document.getElementById('jsConsolesModal');
    if (jcModal && jcModal.classList.contains('show') && typeof renderJsConsolesList === 'function') {
      renderJsConsolesList();
    }
    const ocModal = document.getElementById('outputConsolesModal');
    if (ocModal && ocModal.classList.contains('show') && typeof renderOutputConsolesList === 'function') {
      renderOutputConsolesList();
    }
    // Również odśwież licznik "Okna (N)" na obecnym editorze (jeśli widać button).
    // Suma CSS + JS panes + JS consoles + Output consoles (od v2.13.72).
    const wCntEls = document.querySelectorAll('.windows-count');
    wCntEls.forEach(cntEl => {
      const btn = cntEl.closest('button');
      if (!btn) return;
      let parentType, parentId;
      if (btn.id === 'folderWindowsBtn' && selectedFolderId) {
        parentType = 'folder'; parentId = selectedFolderId;
      } else if (btn.id === 'groupWindowsBtn' && selectedGroupId) {
        parentType = 'group'; parentId = selectedGroupId;
      } else if (btn.classList.contains('btn-windows')) {
        const card = btn.closest('.rule-card');
        const ruleId = card && card.dataset.ruleId;
        if (ruleId) { parentType = 'rule'; parentId = ruleId; }
      }
      if (parentType && parentId) {
        const total = countAllCssPanesForContext(parentType, parentId)
                    + countAllJsPanesForContext(parentType, parentId)
                    + countAllJsConsolesForContext(parentType, parentId)
                    + countAllOutputConsolesForContext(parentType, parentId);
        cntEl.textContent = `(${total})`;
      }
    });
  }

  // 5. Pulpit — jeśli aktualnie otwarty, przerysuj. Kafelki (biblioteka,
  //    ostatnio zmieniane reguły, statystyki) liczone są z encji; bez tego
  //    pull z innego urządzenia / zmiana zewnętrzna zostawiały na otwartym
  //    pulpicie nieaktualne liczby. `=== 'block'` — tylko gdy pulpit jest
  //    realnie widoczny, by nie przejąć widoku z aktywnego edytora.
  const dashView = document.getElementById('dashboardView');
  if (dashView && dashView.style.display === 'block' && typeof renderDashboard === 'function') {
    renderDashboard();
  }
});

// =============================================================================
// Sync UI — modal "Synchronizacja chmurowa"
// =============================================================================

(function syncUi() {
  if (typeof JZ_SYNC === 'undefined') return;

  const modal = document.getElementById('syncModal');
  const signedOut = document.getElementById('syncSignedOut');
  const signedIn = document.getElementById('syncSignedIn');
  const statusBar = document.getElementById('syncStatusBar');
  const statusDot = document.getElementById('syncStatusDot');

  function setStatusBar(text, kind) {
    if (!text) { statusBar.style.display = 'none'; return; }
    statusBar.style.display = 'block';
    statusBar.className = kind === 'error' ? 'sync-error' : kind === 'warn' ? 'sync-warn' : 'sync-info';
    statusBar.textContent = text;
  }

  function fmtRelativeTime(ms) {
    if (!ms) return 'jeszcze nie wykonano';
    const diff = Date.now() - ms;
    if (diff < 0) return JZ_I18N.t('sync.rel.justNow');
    if (diff < 30 * 1000) return JZ_I18N.t('sync.rel.justNow');
    if (diff < 60 * 1000) return Math.round(diff / 1000) + ' s temu';
    if (diff < 60 * 60 * 1000) return Math.round(diff / 60000) + ' min temu';
    if (diff < 24 * 60 * 60 * 1000) return Math.round(diff / 3600000) + ' godz. temu';
    try { return new Date(ms).toLocaleString('pl-PL'); } catch (_) { return 'dawno'; }
  }

  function setDot(stateName) {
    statusDot.classList.remove('connected', 'error', 'syncing');
    if (stateName) statusDot.classList.add(stateName);
    // Tooltip — opisuje stan + czas ostatniej sync.
    const last = JZ_SYNC.getLastSyncedAt ? JZ_SYNC.getLastSyncedAt() : 0;
    const lastTxt = fmtRelativeTime(last);
    let tip;
    if (!JZ_SYNC.isAuthenticated()) {
      tip = 'Synchronizacja chmurowa — niezalogowany';
    } else if (stateName === 'syncing') {
      tip = JZ_I18N.t('sync.tooltip.syncing');
    } else if (stateName === 'error') {
      tip = JZ_I18N.t('sync.tooltip.error', { last: lastTxt });
    } else {
      tip = JZ_I18N.t('sync.tooltip.connected', { last: lastTxt });
    }
    statusDot.title = tip;
  }

  function refreshDot() {
    setDot(JZ_SYNC.isAuthenticated() ? 'connected' : '');
  }

  // Tabs: signed-out używa data-out-pane, signed-in data-in-pane.
  function showOutPane(name) {
    signedOut.querySelectorAll('.sync-tab').forEach(t => t.classList.toggle('active', t.dataset.outPane === name));
    signedOut.querySelectorAll('.sync-pane').forEach(p => p.classList.toggle('active', p.dataset.outPane === name));
    // Pokaż właściwy primary button w stopce.
    document.querySelectorAll('.sync-out-action').forEach(b => {
      b.style.display = (b.dataset.for === name) ? '' : 'none';
    });
    setStatusBar('');
  }
  function showInPane(name) {
    signedIn.querySelectorAll('.sync-tab').forEach(t => t.classList.toggle('active', t.dataset.inPane === name));
    signedIn.querySelectorAll('.sync-pane').forEach(p => p.classList.toggle('active', p.dataset.inPane === name));
    setStatusBar('');
    if (name === 'key') renderKeyTab();
    if (name === 'backups') refreshBackups();
    if (name === 'status') { renderSignedInStatus(); refreshSessions(); }
    if (name === 'email') renderEmailArea();
  }

  function openModal() {
    setStatusBar('');
    if (JZ_SYNC.isAuthenticated()) {
      signedOut.style.display = 'none';
      signedIn.style.display = 'flex';
      renderSignedIn();
      showInPane('status');
    } else {
      signedIn.style.display = 'none';
      signedOut.style.display = 'flex';
      // Pre-generuj klucz dla register tabu
      document.getElementById('syncRegKey').textContent = JZ_SYNC.generateSyncKey();
      showOutPane('register');
    }
    modal.classList.add('show');
  }

  function closeModal() { modal.classList.remove('show'); }

  // Otwarcie z menu Ustawienia
  const syncBtn = document.getElementById('syncBtn');
  if (syncBtn) {
    syncBtn.addEventListener('click', () => {
      // Zamknij settings menu
      if (typeof setSettingsMenuOpen === 'function') setSettingsMenuOpen(false);
      // Świeże liczności chmury przy każdym otwarciu panelu (cache mógł się
      // zdezaktualizować, gdy w międzyczasie synchronizowało inne urządzenie).
      _cloudCountsCache = null;
      openModal();
    });
  }

  // === Wspólne — przełącznik tabów signed-out ===
  signedOut.querySelectorAll('.sync-tab').forEach(tab => {
    tab.addEventListener('click', () => showOutPane(tab.dataset.outPane));
  });
  // Wspólny Anuluj
  document.getElementById('syncOutCancel').addEventListener('click', closeModal);

  // === Tab Pierwsze urządzenie (register) ===
  document.getElementById('syncRegRegen').addEventListener('click', () => {
    document.getElementById('syncRegKey').textContent = JZ_SYNC.generateSyncKey();
  });
  document.getElementById('syncRegCopy').addEventListener('click', async () => {
    const key = document.getElementById('syncRegKey').textContent;
    try {
      await navigator.clipboard.writeText(key);
      setStatusBar('Klucz skopiowany do schowka. Zapisz go w bezpiecznym miejscu.', 'info');
    } catch (_) {
      setStatusBar(JZ_I18N.t('sync.error.copyManually'), 'warn');
    }
  });
  document.getElementById('syncRegSubmit').addEventListener('click', async () => {
    const key = document.getElementById('syncRegKey').textContent;
    const label = document.getElementById('syncRegLabel').value.trim();
    setDot('syncing');
    try {
      await JZ_SYNC.register({ syncKey: key, deviceLabel: label || null });
      setStatusBar('Zarejestrowano — klucz zapisz w bezpiecznym miejscu.', 'info');
      refreshDot();
      // Po rejestracji od razu pchnij obecny stan na backend (snapshot mojej biblioteki).
      await syncSeedFromCurrentState();
      // Trigger background tick, żeby sync ruszył.
      chrome.runtime.sendMessage({ type: 'sync-now', trigger: 'after-register' }).catch(() => {});
      openModal(); // przerysuj jako signed-in
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.registerFailed', { msg: e.message || e.code || JZ_I18N.t('sync.error.unknown') }), 'error');
      setDot('error');
    }
  });

  // === Tab Mam już klucz (login) ===
  document.getElementById('syncLoginSubmit').addEventListener('click', async () => {
    const key = document.getElementById('syncLoginKey').value.trim();
    const label = document.getElementById('syncLoginLabel').value.trim();
    if (!JZ_SYNC.isValidSyncKey(key)) {
      setStatusBar(JZ_I18N.t('sync.error.invalidKeyFormat'), 'error');
      return;
    }
    setDot('syncing');
    try {
      await JZ_SYNC.login({ syncKey: key, deviceLabel: label || null });
      setStatusBar('Zalogowano. Sprawdzam stan biblioteki…', 'info');
      refreshDot();
      // Strategia first-sync — backend rekomenduje snapshot PRZED jakimkolwiek
      // push'em i prompt user gdy oba urządzenia mają dane. Zob. api.md sekcja
      // "Co plugin powinien zaimplementować by było bezpiecznie".
      try {
        const snap = await JZ_SYNC.snapshot();
        const local = await chrome.storage.local.get(['folders', 'groups', 'rules']);
        const localCounts = {
          folders: (local.folders || []).length,
          groups:  (local.groups  || []).length,
          rules:   (local.rules   || []).length,
        };
        const serverCounts = {
          folders: (snap.folders || []).length,
          groups:  (snap.groups  || []).length,
          rules:   (snap.rules   || []).length,
        };
        const localTotal  = localCounts.folders  + localCounts.groups  + localCounts.rules;
        const serverTotal = serverCounts.folders + serverCounts.groups + serverCounts.rules;

        if (localTotal === 0 && serverTotal === 0) {
          setStatusBar('Zalogowano. Konto jest puste — start od czystej.', 'info');
        } else if (localTotal === 0 && serverTotal > 0) {
          // Server-only — zwykłe pociągnięcie
          await applyServerSnapshot(snap);
          setStatusBar(`Zsynchronizowano ${serverTotal} encji z chmury.`, 'info');
        } else if (localTotal > 0 && serverTotal === 0) {
          // Local-only — push wszystkiego (jak po register)
          await syncSeedFromCurrentState();
          setStatusBar(JZ_I18N.t('sync.status.sendingLocal', { count: localTotal }), 'info');
        } else {
          // KONFLIKT — oba mają dane. Zapytaj user'a.
          const choice = await resolveLoginConflict(localCounts, serverCounts, snap, local);
          if (choice === 'cancel') {
            await JZ_SYNC.logout();
            setStatusBar(JZ_I18N.t('sync.status.cancelled'), 'warn');
            refreshDot();
            openModal();
            return;
          }
          let summary = null;
          if (choice === 'server') {
            summary = await useServerAsCanon(snap, local);
            setStatusBar(`Lokalne wyczyszczone, zsynchronizowano ${serverTotal} encji z chmury.`, 'info');
          } else if (choice === 'mine') {
            summary = await sendLocalAsCanon(snap, local);
            setStatusBar(JZ_I18N.t('sync.status.sendingAndDeleting', { local: localTotal, remote: serverTotal }), 'info');
          } else if (choice === 'merge') {
            summary = await mergeServerAndLocal(snap, local);
            setStatusBar(JZ_I18N.t('sync.status.merged', { count: summary.stats.folder.kept + summary.stats.group.kept + summary.stats.rule.kept }), 'info');
          }
          if (summary) showSyncResult(summary);
        }
      } catch (e) {
        setStatusBar(JZ_I18N.t('sync.error.firstSyncFailed', { msg: e.message || '' }), 'warn');
      }
      chrome.runtime.sendMessage({ type: 'sync-now', trigger: 'after-login' }).catch(() => {});
      openModal();
      load(); // odśwież drzewo
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.loginFailed', { msg: e.message || e.code || JZ_I18N.t('sync.error.unknown') }), 'error');
      setDot('error');
    }
  });

  // Snapshot zwraca encje POGRUPOWANE (folders/groups/rules/actions arrays) —
  // bez pola `type` per encja. applyEntities wymaga `type` w każdej encji
  // (oczekuje formatu jak z /api/sync/pull). Dodajemy `type` ręcznie.
  function flattenSnapshot(snap) {
    return []
      .concat((snap.folders || []).map(e => ({ ...e, type: 'folder' })))
      .concat((snap.groups  || []).map(e => ({ ...e, type: 'group'  })))
      .concat((snap.rules   || []).map(e => ({ ...e, type: 'rule'   })))
      .concat((snap.actions || []).map(e => ({ ...e, type: 'action' })));
  }

  async function applyServerSnapshot(snap) {
    const allEntities = flattenSnapshot(snap);
    if (allEntities.length) await JZ_SYNC.applyEntities(allEntities);
  }

  // Helper — czyści pendingChanges/pendingDeletes w storage i reloads JZ_SYNC.
  // Konieczne przed merge/send-mine, bo applyEntities skipuje encje będące w
  // pendingu (mechanizm który normalnie chroni przed nadpisaniem niewysłanych
  // edycji; tu user świadomie wybrał strategię).
  async function clearSyncPending() {
    const stRaw = await chrome.storage.local.get([JZ_SYNC._STORAGE_KEY]);
    const st = stRaw[JZ_SYNC._STORAGE_KEY] || {};
    st.pendingChanges = [];
    st.pendingDeletes = [];
    await chrome.storage.local.set({ [JZ_SYNC._STORAGE_KEY]: st });
    await JZ_SYNC.reload();
  }

  // Helpery do liczenia ID-setów per typ (folder/group/rule/action).
  function buildLocalIdSets(local) {
    const ids = { folder: new Set(), group: new Set(), rule: new Set(), action: new Set() };
    (local.folders || []).forEach(f => ids.folder.add(f.id));
    (local.groups  || []).forEach(g => ids.group.add(g.id));
    (local.rules   || []).forEach(r => {
      ids.rule.add(r.id);
      (r.actions || []).forEach(a => ids.action.add(a.id));
    });
    return ids;
  }
  function buildServerIdSets(snap) {
    const ids = { folder: new Set(), group: new Set(), rule: new Set(), action: new Set() };
    (snap.folders || []).forEach(e => ids.folder.add(e.id));
    (snap.groups  || []).forEach(e => ids.group.add(e.id));
    (snap.rules   || []).forEach(e => ids.rule.add(e.id));
    (snap.actions || []).forEach(e => ids.action.add(e.id));
    return ids;
  }
  // Liczy encje per typ w aktualnym stanie storage.
  async function countLocalNow() {
    const data = await chrome.storage.local.get(['folders', 'groups', 'rules']);
    return {
      folder: (data.folders || []).length,
      group:  (data.groups  || []).length,
      rule:   (data.rules   || []).length,
      action: (data.rules   || []).reduce((s, r) => s + ((r.actions || []).length), 0),
    };
  }

  // Strategia "Wyślij moje lokalne" — tombstone wszystkich server-only encji,
  // potem enqueueChange wszystkich lokalnych. Nasz nowMs() (Lamport) gwarantuje
  // updatedAt > server now, więc backend zaakceptuje (LWW).
  // Zwraca summary: ile nadpisanych, ile dodanych po stronie konta, ile usuniętych.
  async function sendLocalAsCanon(snap, local) {
    await clearSyncPending();

    const localIds  = buildLocalIdSets(local);
    const serverIds = buildServerIdSets(snap);

    // Statystyki per typ — dla "send-mine":
    //   pushed   = count(localIds[t])              — wszystkie lokalne pójdą na konto
    //   replaced = count(localIds[t] ∩ serverIds[t]) — nadpisują istniejące w chmurze
    //   added    = count(localIds[t] - serverIds[t]) — całkowicie nowe na koncie
    //   removedFromServer = count(serverIds[t] - localIds[t]) — zostaną tombstone'owane
    const types = ['folder', 'group', 'rule', 'action'];
    const stats = {};
    for (const t of types) {
      let replaced = 0, added = 0, removed = 0;
      localIds[t].forEach(id => { if (serverIds[t].has(id)) replaced++; else added++; });
      serverIds[t].forEach(id => { if (!localIds[t].has(id)) removed++; });
      stats[t] = { pushed: localIds[t].size, replaced, added, removed };
    }

    // Tombstone server-only encji
    (snap.folders || []).forEach(e => {
      if (!localIds.folder.has(e.id)) JZ_SYNC.enqueueDelete({ type: 'folder', id: e.id });
    });
    (snap.groups || []).forEach(e => {
      if (!localIds.group.has(e.id)) JZ_SYNC.enqueueDelete({ type: 'group', id: e.id, parentId: e.parentId });
    });
    (snap.rules || []).forEach(e => {
      if (!localIds.rule.has(e.id)) JZ_SYNC.enqueueDelete({ type: 'rule', id: e.id, parentId: e.parentId });
    });
    (snap.actions || []).forEach(e => {
      if (!localIds.action.has(e.id)) JZ_SYNC.enqueueDelete({ type: 'action', id: e.id, parentId: e.parentId });
    });

    await syncSeedFromCurrentState();

    return { strategy: 'send-mine', stats, totalsAfter: await countLocalNow() };
  }

  // Pełny resync delegowany do background service workera. Tam idzie przez ten
  // sam _syncTickChain co zwykłe tiki — bez tego applyEntities resyncu (w
  // kontekście opcji) nakładało się z applyEntities tika (w SW): równoległy
  // read-modify-write na kolekcjach encji w storage = lost update.
  function _requestFullResync() {
    return new Promise((resolve, reject) => {
      try {
        chrome.runtime.sendMessage({ type: 'sync-full-resync' }, (r) => {
          if (chrome.runtime.lastError) {
            reject(new Error(chrome.runtime.lastError.message)); return;
          }
          if (r && r.ok) resolve(r);
          else reject(new Error((r && r.error) || 'full resync failed'));
        });
      } catch (e) { reject(e); }
    });
  }

  // Strategia "Scal" — applyEntities (server overwrites by ID, local-only zostaje),
  // potem ręcznie enqueueChange dla wszystkich lokalnych encji których NIE ma
  // w server snapshot (te trzeba pchnąć, bo diff vs baseline po apply ich nie wykryje).
  async function mergeServerAndLocal(snap, local) {
    await clearSyncPending();

    const localIds  = buildLocalIdSets(local);
    const serverIds = buildServerIdSets(snap);

    // Statystyki per typ — dla "merge":
    //   fromServerOverwrote = count(serverIds[t] ∩ localIds[t]) — nadpisane lokalnie
    //   fromServerAdded     = count(serverIds[t] - localIds[t]) — całkiem nowe lokalnie
    //   localKept           = count(localIds[t] - serverIds[t]) — zostały lokalne, pójdą na konto
    const types = ['folder', 'group', 'rule', 'action'];
    const stats = {};
    for (const t of types) {
      let overwrote = 0, addedFromServer = 0, kept = 0;
      serverIds[t].forEach(id => { if (localIds[t].has(id)) overwrote++; else addedFromServer++; });
      localIds[t].forEach(id => { if (!serverIds[t].has(id)) kept++; });
      stats[t] = { overwrote, addedFromServer, kept, pushed: kept };
    }

    await applyServerSnapshot(snap);

    // Ochrona przed „duchami". Encja lokalna nieobecna w snapshotcie serwera może być
    // (a) świeżo utworzona lokalnie → wypchnąć, albo (b) skasowana na innym urządzeniu,
    // a tombstone tu nie dotarł → skasować lokalnie, NIE wypychać. Snapshot nie niesie
    // tombstone'ów, więc dociągamy je przyrostowym pullem (pull?since=0).
    let tombstoned = new Set();
    try {
      tombstoned = await JZ_SYNC.fetchServerTombstones();
    } catch (e) {
      console.warn('[JustZix sync] fetchServerTombstones — scalanie bez ochrony przed duchami', e);
    }
    // Skasuj lokalnie encje skasowane na serwerze (z kaskadą — przez applyEntities).
    const fresh = await chrome.storage.local.get(['folders', 'groups', 'rules']);
    const ghosts = [];
    const ghostAt = Date.now();
    (fresh.folders || []).forEach(f => {
      if (!serverIds.folder.has(f.id) && tombstoned.has('folder:' + f.id)) {
        ghosts.push({ type: 'folder', id: f.id, payload: null, deletedAt: ghostAt });
      }
    });
    (fresh.groups || []).forEach(g => {
      if (!serverIds.group.has(g.id) && tombstoned.has('group:' + g.id)) {
        ghosts.push({ type: 'group', id: g.id, parentId: g.folderId, payload: null, deletedAt: ghostAt });
      }
    });
    (fresh.rules || []).forEach(r => {
      if (!serverIds.rule.has(r.id) && tombstoned.has('rule:' + r.id)) {
        ghosts.push({ type: 'rule', id: r.id, parentId: r.groupId, payload: null, deletedAt: ghostAt });
      }
      (r.actions || []).forEach(a => {
        if (!serverIds.action.has(a.id) && tombstoned.has('action:' + a.id)) {
          ghosts.push({ type: 'action', id: a.id, parentId: r.id, payload: null, deletedAt: ghostAt });
        }
      });
    });
    if (ghosts.length) {
      console.log('[JustZix sync] scalanie — skasowano lokalnie', ghosts.length,
        'encji skasowanych na serwerze (ochrona przed duchami)');
      await JZ_SYNC.applyEntities(ghosts);
    }

    // Push lokalnych unikatów — świeży storage PO apply i PO skasowaniu duchów.
    const fresh2 = await chrome.storage.local.get(['folders', 'groups', 'rules']);
    (fresh2.folders || []).forEach(f => {
      if (!serverIds.folder.has(f.id)) {
        JZ_SYNC.enqueueChange({ type: 'folder', id: f.id, parentId: null, payload: f });
      }
    });
    (fresh2.groups || []).forEach(g => {
      if (!serverIds.group.has(g.id)) {
        JZ_SYNC.enqueueChange({ type: 'group', id: g.id, parentId: g.folderId, payload: g });
      }
    });
    (fresh2.rules || []).forEach(r => {
      if (!serverIds.rule.has(r.id)) {
        JZ_SYNC.enqueueChange({ type: 'rule', id: r.id, parentId: r.groupId, payload: _ruleSansActions(r) });
      }
      (r.actions || []).forEach(a => {
        if (!serverIds.action.has(a.id)) {
          JZ_SYNC.enqueueChange({ type: 'action', id: a.id, parentId: r.id, payload: a });
        }
      });
    });

    // Bary i okna z konta — snapshot ich nie zawiera, więc dociągamy je
    // przyrostowym pullem od since=0 (lokalne bary/okna zostają — applyEntities
    // nie usuwa encji nieobjętych tombstone'em).
    try {
      await _requestFullResync();
    } catch (e) {
      console.warn('[JustZix sync] full resync (merge) error:', e);
    }

    return { strategy: 'merge', stats, totalsAfter: await countLocalNow() };
  }

  // Strategia "Użyj danych z konta" — wyczyść lokalne, zaaplikuj server snapshot.
  // Zwraca summary podobny do merge, tyle że "kept" zawsze 0.
  async function useServerAsCanon(snap, local) {
    const localIds  = buildLocalIdSets(local);
    const serverIds = buildServerIdSets(snap);

    const types = ['folder', 'group', 'rule', 'action'];
    const stats = {};
    for (const t of types) {
      stats[t] = {
        fromServer: serverIds[t].size,           // ile z konta zostanie zaaplikowanych
        localLost: localIds[t].size,             // ile lokalnych zostanie utraconych
        sharedReplaced: [...localIds[t]].filter(id => serverIds[t].has(id)).length,
      };
    }

    // Czyścimy też bary i okna (snapshot ich nie zawiera — bez tego zostałyby
    // osierocone wskazując na podmienione foldery/zestawy).
    await chrome.storage.local.set({
      folders: [], groups: [], rules: [],
      actionBars: [], cssPanes: [], jsPanes: [], jsConsoles: [], outputConsoles: [],
    });
    await clearSyncPending();
    // /api/sync/snapshot pomija bary i okna — pełną rekonstrukcję (wszystkie 9
    // typów encji) robimy przyrostowym pullem od since=0.
    await _requestFullResync();

    return { strategy: 'use-server', stats, totalsAfter: await countLocalNow() };
  }

  // Modal "Konflikt synchronizacji" — zwraca 'server' | 'mine' | 'merge' | 'cancel'.
  // Zamykamy główny syncModal zanim pokażemy modal konfliktu — bez tego user
  // musiał ręcznie zamykać sync żeby zobaczyć konflikt (mimo z-index 1010).
  function resolveLoginConflict(localCounts, serverCounts, snap, local) {
    return new Promise(resolve => {
      const conflictModal = document.getElementById('syncConflictModal');
      const mainModal = document.getElementById('syncModal');
      const wasMainOpen = mainModal && mainModal.classList.contains('show');
      if (wasMainOpen) mainModal.classList.remove('show');
      document.getElementById('syncConflictLocalCount').textContent =
        JZ_I18N.t('syncConflict.countLine', {
          folders: localCounts.folders,
          foldersWord: JZ_I18N.plural(localCounts.folders, 'plural.folder'),
          groups: localCounts.groups,
          groupsWord: JZ_I18N.plural(localCounts.groups, 'plural.group'),
          rules: localCounts.rules,
          rulesWord: JZ_I18N.plural(localCounts.rules, 'plural.rule'),
        });
      document.getElementById('syncConflictServerCount').textContent =
        JZ_I18N.t('syncConflict.countLine', {
          folders: serverCounts.folders,
          foldersWord: JZ_I18N.plural(serverCounts.folders, 'plural.folder'),
          groups: serverCounts.groups,
          groupsWord: JZ_I18N.plural(serverCounts.groups, 'plural.group'),
          rules: serverCounts.rules,
          rulesWord: JZ_I18N.plural(serverCounts.rules, 'plural.rule'),
        });

      function close(choice) {
        conflictModal.classList.remove('show');
        cleanup();
        resolve(choice);
      }
      const onUseServer = () => close('server');
      const onSendMine  = () => close('mine');
      const onMerge     = () => close('merge');
      const onCancel    = () => close('cancel');
      const onExport    = () => exportLocalAsBlob(local);
      function cleanup() {
        document.getElementById('syncConflictUseServer').removeEventListener('click', onUseServer);
        document.getElementById('syncConflictSendMine').removeEventListener('click', onSendMine);
        document.getElementById('syncConflictMerge').removeEventListener('click', onMerge);
        document.getElementById('syncConflictCancel').removeEventListener('click', onCancel);
        document.getElementById('syncConflictExportLocal').removeEventListener('click', onExport);
      }
      document.getElementById('syncConflictUseServer').addEventListener('click', onUseServer);
      document.getElementById('syncConflictSendMine').addEventListener('click', onSendMine);
      document.getElementById('syncConflictMerge').addEventListener('click', onMerge);
      document.getElementById('syncConflictCancel').addEventListener('click', onCancel);
      document.getElementById('syncConflictExportLocal').addEventListener('click', onExport);
      conflictModal.classList.add('show');
    });
  }

  // Modal pokazujący wynik scalania/synchronizacji — breakdown per typ
  // (katalogi/grupy/zestawy/akcje), żeby user widział co dokładnie się stało.
  function showSyncResult(summary) {
    const modal = document.getElementById('syncResultModal');
    const titleEl = document.getElementById('syncResultTitle');
    const descEl  = document.getElementById('syncResultDescription');
    const bodyEl  = document.getElementById('syncResultBody');
    if (!modal || !titleEl || !descEl || !bodyEl) return;

    const labels = {
      folder: JZ_I18N.t('syncResult.label.folders'),
      group:  JZ_I18N.t('syncResult.label.groups'),
      rule:   JZ_I18N.t('syncResult.label.rules'),
      action: JZ_I18N.t('syncResult.label.actions'),
    };
    const types = ['folder', 'group', 'rule', 'action'];

    let title = JZ_I18N.t('syncResult.fallback.title');
    let desc = '';
    let table = '';

    if (summary.strategy === 'merge') {
      title = JZ_I18N.t('syncResult.merge.title');
      desc = JZ_I18N.t('syncResult.merge.desc');
      table = JZ_I18N.t('syncResult.merge.tableHeader');
      for (const t of types) {
        const s = summary.stats[t] || { overwrote: 0, addedFromServer: 0, kept: 0 };
        const total = (summary.totalsAfter && summary.totalsAfter[t]) || 0;
        table += `<tr><td>${labels[t]}</td><td>${s.overwrote}</td><td>${s.addedFromServer}</td><td>${s.kept}</td><td>${total}</td></tr>`;
      }
      table += '</tbody></table>';
    } else if (summary.strategy === 'use-server') {
      title = JZ_I18N.t('syncResult.useServer.title');
      desc = JZ_I18N.t('syncResult.useServer.desc');
      table = JZ_I18N.t('syncResult.useServer.tableHeader');
      for (const t of types) {
        const s = summary.stats[t] || { fromServer: 0, localLost: 0, sharedReplaced: 0 };
        const total = (summary.totalsAfter && summary.totalsAfter[t]) || 0;
        table += `<tr><td>${labels[t]}</td><td>${s.fromServer}</td><td>${s.localLost}</td><td>${s.sharedReplaced}</td><td>${total}</td></tr>`;
      }
      table += '</tbody></table>';
    } else if (summary.strategy === 'send-mine') {
      title = JZ_I18N.t('syncResult.sendMine.title');
      desc = JZ_I18N.t('syncResult.sendMine.desc');
      table = JZ_I18N.t('syncResult.sendMine.tableHeader');
      for (const t of types) {
        const s = summary.stats[t] || { pushed: 0, replaced: 0, added: 0, removed: 0 };
        table += `<tr><td>${labels[t]}</td><td>${s.pushed}</td><td>${s.replaced}</td><td>${s.added}</td><td>${s.removed}</td></tr>`;
      }
      table += '</tbody></table>';
    }

    titleEl.textContent = title;
    descEl.textContent = desc;
    bodyEl.innerHTML = table || `<p class="result-empty">${escapeHtml(JZ_I18N.t('syncResult.empty'))}</p>`;

    const closeBtn = document.getElementById('syncResultClose');
    function onClose() {
      modal.classList.remove('show');
      closeBtn.removeEventListener('click', onClose);
      modal.removeEventListener('click', onBgClick);
    }
    function onBgClick(e) { if (e.target === modal) onClose(); }
    closeBtn.addEventListener('click', onClose);
    modal.addEventListener('click', onBgClick);
    modal.classList.add('show');
  }

  function exportLocalAsBlob(local) {
    const payload = {
      type: 'justzix-full',
      version: 1,
      schemaVersion: 3,
      folders: local.folders || [],
      groups: local.groups || [],
      rules: local.rules || [],
      exportedAt: Date.now(),
    };
    const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `justzix-pre-conflict-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`;
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    setTimeout(() => URL.revokeObjectURL(url), 1000);
  }

  // === Tab Zgubiłem klucz (recover) ===
  document.getElementById('syncRecoverSubmit').addEventListener('click', async () => {
    const email = document.getElementById('syncRecoverEmail').value.trim();
    if (!email || !email.includes('@')) {
      setStatusBar(JZ_I18N.t('sync.error.invalidEmail'), 'error');
      return;
    }
    try {
      await JZ_SYNC.recover(email);
      setStatusBar(JZ_I18N.t('sync.recovery.sent'), 'info');
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.generic', { msg: e.message || '' }), 'error');
    }
  });

  // Liczności encji w chmurze (kafelek panelu sync). Cache na sesję strony
  // opcji — pełny skan logu serwera jest kosztowny; odświeżamy wymuszenie po
  // ręcznej synchronizacji / snapshocie (wtedy stan chmury realnie się zmienia).
  let _cloudCountsCache = null;
  function _renderCloudCountsInto(el, counts) {
    if (!counts) { el.textContent = '—'; return; }
    const items = [
      [counts.folder || 0, 'dashboard.stats.folders'],
      [counts.group || 0,  'dashboard.stats.groups'],
      [counts.rule || 0,   'dashboard.stats.rules'],
      [counts.action || 0, 'dashboard.stats.actions'],
      [counts.bar || 0,    'dashboard.stats.bars'],
      [counts.window || 0, 'dashboard.stats.windows'],
    ];
    el.innerHTML = items.map(([n, key]) =>
      `<span style="display:inline-block;margin-right:14px;white-space:nowrap;">` +
      `<strong>${n}</strong> ${escapeHtml(JZ_I18N.t(key))}</span>`
    ).join('');
  }
  async function refreshCloudCounts(force) {
    const el = document.getElementById('syncCloudCounts');
    if (!el) return;
    if (typeof JZ_SYNC === 'undefined' || !JZ_SYNC.isAuthenticated()) { el.textContent = '—'; return; }
    if (!force && _cloudCountsCache) { _renderCloudCountsInto(el, _cloudCountsCache); return; }
    el.textContent = '…';
    try {
      const counts = await JZ_SYNC.fetchServerCounts();
      _cloudCountsCache = counts;
      const el2 = document.getElementById('syncCloudCounts');
      if (el2) _renderCloudCountsInto(el2, counts);
    } catch (e) {
      const el2 = document.getElementById('syncCloudCounts');
      if (el2) el2.textContent = '—';
    }
  }

  // Historia synchronizacji (zakładka Status). Log jest lokalny — `jzSyncHistory`
  // zapisuje background po każdym ticku, który coś zmienił. Mapy uuid→label
  // i uuid→numeryczne id sesji wypełniane z listy sesji (refreshSessions).
  let _deviceLabelByUuid = {};
  let _deviceNumByUuid = {};

  // Symbole operacji (neutralne językowo): + dodane, ~ zmienione, − usunięte.
  // Kierunek: ↑ wypchnięte z tego urządzenia, ↓ pobrane z chmury.
  const _histOpSym = { add: '+', edit: '~', change: '~', delete: '−' };
  const _histOpCls = { add: 'add', edit: 'edit', change: 'edit', delete: 'del' };
  const _histTypeKey = {
    folder: 'dashboard.stats.folders', group: 'dashboard.stats.groups',
    rule: 'dashboard.stats.rules', action: 'dashboard.stats.actions',
    bar: 'dashboard.stats.bars', css_pane: 'dashboard.stats.windows',
    js_pane: 'dashboard.stats.windows', js_console: 'dashboard.stats.windows',
    output_console: 'dashboard.stats.windows',
  };
  const _HIST_NAMES_CAP = 8;  // ile nazw pokazać w zgrupowanej pozycji (reszta jako wielokropek)

  // Identyfikator urządzenia do historii. Spójnie z „Aktywne sesje" pokazujemy
  // numeryczne id sesji (mapowane z uuid); gdy nieznane — skrócony uuid.
  function _histDeviceId(uuid) {
    if (!uuid) return '';
    const num = _deviceNumByUuid[uuid];
    if (num != null) return ' <code>id ' + escapeHtml(String(num)) + '</code>';
    return ' <code>' + escapeHtml(String(uuid).slice(0, 8)) + '…</code>';
  }

  // Stan ilościowy biblioteki po synchronizacji (sześć liczników) → HTML.
  function _histCountsStr(counts) {
    const c = counts || {};
    return [
      [c.folder, 'dashboard.stats.folders'],
      [c.group,  'dashboard.stats.groups'],
      [c.rule,   'dashboard.stats.rules'],
      [c.action, 'dashboard.stats.actions'],
      [c.bar,    'dashboard.stats.bars'],
      [c.window, 'dashboard.stats.windows'],
    ].map(([n, k]) => `<strong>${n || 0}</strong> ${escapeHtml(JZ_I18N.t(k))}`).join(' · ');
  }

  // Grupuje surową listę zmian po (kierunek, operacja, typ) — zamiast N
  // identycznych wierszy „↓− Akcje" pokazujemy jeden: „↓− Akcje ×N [: nazwy]".
  function _groupHistoryChanges(list) {
    const map = new Map();
    for (const ch of list) {
      const key = (ch.dir || 'out') + '|' + (ch.op || '') + '|' + (ch.type || '');
      let g = map.get(key);
      if (!g) { g = { dir: ch.dir, op: ch.op, type: ch.type, count: 0, names: [] }; map.set(key, g); }
      g.count++;
      if (ch.name) g.names.push(ch.name);
    }
    return [...map.values()];
  }

  // Pełny wpis historii — karta z sekcjami (lista 10 + popup pełnej historii).
  function _renderHistoryEntryFull(entry) {
    let when = '';
    try { when = new Date(entry.ts).toLocaleString(); } catch (e) {}
    let html = `<div class="jz-shist">`;
    html += `<div class="jz-shist-head"><span class="jz-shist-time">${escapeHtml(when)}</span></div>`;
    // Urządzenie inicjujące — nazwa + numeryczne id.
    const devLabel = escapeHtml(entry.deviceLabel || JZ_I18N.t('sync.session.noLabel'));
    html += `<div class="jz-shist-dev">${escapeHtml(JZ_I18N.t('modal.sync.history.device'))} ` +
      `${devLabel}${_histDeviceId(entry.deviceUuid)}</div>`;
    // Urządzenia źródłowe zmian pobranych.
    const from = Array.isArray(entry.fromDevices) ? entry.fromDevices : [];
    if (from.length) {
      const list = from.map(u => escapeHtml(_deviceLabelByUuid[u] || '').trim() + _histDeviceId(u)).join(', ');
      html += `<div class="jz-shist-dev">${escapeHtml(JZ_I18N.t('modal.sync.history.fromDevices'))} ${list}</div>`;
    }
    // Podsumowanie liczbowe (↑ wysłane · ↓ pobrane · ✕ usunięte).
    let sum = `↑ ${entry.pushed || 0} · ↓ ${entry.pulled || 0}`;
    if (entry.deleted) sum += ` · ✕ ${entry.deleted}`;
    html += `<div class="jz-shist-sum">${escapeHtml(sum)}</div>`;
    // Sekcja: które elementy dodano / zmieniono / usunięto — zgrupowane z licznikiem.
    html += `<div class="jz-shist-sec"><span class="jz-shist-sec-label">` +
      `${escapeHtml(JZ_I18N.t('modal.sync.history.changes'))}</span>`;
    const chg = Array.isArray(entry.changes) ? entry.changes : [];
    if (!chg.length) {
      html += ` <span class="jz-shist-empty">${escapeHtml(JZ_I18N.t('modal.sync.history.noDetail'))}</span>`;
    } else {
      html += `<div class="jz-shist-chg">`;
      for (const g of _groupHistoryChanges(chg)) {
        const dir = g.dir === 'in' ? '↓' : '↑';
        const sym = _histOpSym[g.op] || '·';
        const cls = _histOpCls[g.op] || '';
        const tk = _histTypeKey[g.type];
        let label = escapeHtml(tk ? JZ_I18N.t(tk) : (g.type || ''));
        if (g.count > 1) label += ' ×' + g.count;
        if (g.names.length) {
          const shown = g.names.slice(0, _HIST_NAMES_CAP).map(n => '„' + escapeHtml(n) + '”').join(', ');
          label += ': ' + shown + (g.names.length > _HIST_NAMES_CAP ? ' …' : '');
        }
        html += `<div class="jz-shist-chg-item">` +
          `<span class="jz-shist-op ${cls}">${dir}${sym}</span>` +
          `<span>${label}</span></div>`;
      }
      html += `</div>`;
    }
    html += `</div>`;
    // Sekcja: stan ilościowy po synchronizacji (starsze wpisy nie mają `counts`).
    if (entry.counts) {
      html += `<div class="jz-shist-counts"><span class="jz-shist-sec-label">` +
        `${escapeHtml(JZ_I18N.t('modal.sync.history.stateAfter'))}</span> ` +
        `${_histCountsStr(entry.counts)}</div>`;
    }
    html += `</div>`;
    return html;
  }

  function openSyncHistoryModal(hist) {
    const full = document.getElementById('syncHistoryFullList');
    if (full) {
      const all = hist.slice().reverse();
      full.innerHTML = all.length
        ? all.map(_renderHistoryEntryFull).join('')
        : `<div class="jz-shist-empty" style="padding:6px 0;">${escapeHtml(JZ_I18N.t('modal.sync.history.empty'))}</div>`;
    }
    const modal = document.getElementById('syncHistoryModal');
    if (modal) modal.classList.add('show');
  }

  async function renderSyncHistory() {
    const el = document.getElementById('syncHistoryList');
    if (!el) return;
    let hist = [];
    try {
      const d = await chrome.storage.local.get(['jzSyncHistory']);
      if (Array.isArray(d.jzSyncHistory)) hist = d.jzSyncHistory;
    } catch (e) {}
    const el2 = document.getElementById('syncHistoryList');
    if (!el2) return;
    if (hist.length === 0) {
      el2.innerHTML = `<div class="jz-shist-empty" style="padding:6px 0;">${escapeHtml(JZ_I18N.t('modal.sync.history.empty'))}</div>`;
      return;
    }
    const last10 = hist.slice(-10).reverse();
    el2.innerHTML = last10.map(_renderHistoryEntryFull).join('') +
      `<button type="button" id="syncHistoryViewAll" style="font-size:11px;margin-top:6px;">` +
      `${escapeHtml(JZ_I18N.t('modal.sync.history.viewAll'))}</button>`;
    const btn = document.getElementById('syncHistoryViewAll');
    if (btn) btn.addEventListener('click', () => openSyncHistoryModal(hist));
  }

  // === Signed-in render ===
  // Render tylko ogólnego stanu (Status tab) — nie woła refresh sessions/backups,
  // robi to showInPane.
  function renderSignedInStatus() {
    const s = JZ_SYNC.getState();
    const u = s.user || {};
    document.getElementById('syncUserId').textContent = u.id || '—';
    document.getElementById('syncUserEmail').textContent = u.email ? `(${u.email}${u.emailVerified ? ' ✓' : ' niezweryfikowany'})` : '';
    document.getElementById('syncUserDevice').textContent = s.deviceLabel ? JZ_I18N.t('sync.account.thisDevice', { label: s.deviceLabel }) : '';
    document.getElementById('syncPendingCount').textContent = JZ_SYNC.pendingCount();
    document.getElementById('syncLastCursor').textContent = s.lastPulledCursor;
    refreshCloudCounts(false);
    renderSyncHistory();
  }

  function renderEmailArea() {
    const s = JZ_SYNC.getState();
    const u = s.user || {};
    const emailAdd = document.getElementById('syncEmailAdd');
    const emailStatus = document.getElementById('syncEmailStatus');
    if (u.email) {
      emailAdd.style.display = 'none';
      emailStatus.style.display = 'flex';
      document.getElementById('syncEmailValue').textContent = u.email;
      document.getElementById('syncEmailVerified').textContent = u.emailVerified ? 'zweryfikowany ✓' : 'niezweryfikowany — kliknij link w mailu';
    } else {
      emailAdd.style.display = 'block';
      emailStatus.style.display = 'none';
    }
  }

  function renderKeyTab() {
    const has = JZ_SYNC.hasStoredSyncKey();
    document.getElementById('syncStoredKeyArea').style.display = has ? 'block' : 'none';
    document.getElementById('syncNoKeyArea').style.display = has ? 'none' : 'block';
    if (has) {
      const display = document.getElementById('syncStoredKeyDisplay');
      // Domyślnie ukryty — pokazujemy maskę. Klik „Pokaż klucz" zdejmuje blur.
      display.dataset.revealed = 'false';
      display.style.filter = 'blur(5px)';
      display.style.userSelect = 'none';
      display.textContent = JZ_SYNC.getStoredSyncKey();
      document.getElementById('syncKeyReveal').textContent = JZ_I18N.t('btn.showKey');
    }
  }

  function renderSignedIn() {
    renderSignedInStatus();
    renderEmailArea();
    // Sessions i backupy są lazy — wczytują się gdy user wejdzie w odpowiedni tab.
  }

  async function refreshSessions() {
    const list = document.getElementById('syncSessionsList');
    list.innerHTML = `<div class="sync-row-meta" style="font-size:11px;padding:8px 0;">${escapeHtml(JZ_I18N.t('loading.generic'))}</div>`;
    try {
      const data = await JZ_SYNC.me();
      // Aktualizuj user info (np. jeśli email się zmienił)
      const cur = JZ_SYNC.getState();
      if (data.user) {
        // Mutuj stan przez setApiBase trick? Nie — JZ_SYNC nie ma "patch user". Zaktualizuj przez bezpośredni zapis state.
        // Pełniej: po /me odśwież cache state.user (sync.js przechowa w storage przy następnym save).
        Object.assign(cur, { user: data.user });
        chrome.storage.local.set({ [JZ_SYNC._STORAGE_KEY]: cur }).catch(() => {});
      }
      list.innerHTML = '';
      (data.sessions || []).forEach(sess => {
        // Mapa uuid→label dla historii synchronizacji (nazwanie urządzeń źródłowych).
        if (sess.deviceUuid) {
          _deviceLabelByUuid[sess.deviceUuid] = sess.deviceLabel || '';
          _deviceNumByUuid[sess.deviceUuid] = sess.id;
        }
        const row = document.createElement('div');
        row.className = 'sync-row';
        const dt = sess.lastUsedAt ? new Date(sess.lastUsedAt * 1000).toLocaleString('pl-PL') : '—';
        row.innerHTML = `
          <span><strong>${sess.deviceLabel || JZ_I18N.t('sync.session.noLabel')}</strong>${sess.isCurrent ? JZ_I18N.t('sync.session.thisDeviceSuffix') : ''}<br>
            <span class="sync-row-meta">id ${sess.id} • ${dt} • ${sess.userAgent ? sess.userAgent.split(' ').pop() : ''}</span></span>
          ${sess.isCurrent ? '' : `<button type="button" class="danger" style="font-size:11px;padding:4px 8px;" data-revoke="${sess.id}">${escapeHtml(JZ_I18N.t('sync.session.revoke'))}</button>`}
        `;
        list.appendChild(row);
      });
      list.querySelectorAll('button[data-revoke]').forEach(btn => {
        btn.addEventListener('click', async () => {
          if (!confirm(JZ_I18N.t('confirm.revokeSession'))) return;
          try {
            await JZ_SYNC.revokeSession(btn.dataset.revoke);
            refreshSessions();
          } catch (e) {
            setStatusBar(JZ_I18N.t('sync.error.revokeFailed', { msg: e.message || '' }), 'error');
          }
        });
      });
      // Mapa urządzeń jest już wypełniona — odśwież historię, by „urządzenie
      // inicjujące" pokazało etykiety zamiast skróconych uuid.
      renderSyncHistory();
    } catch (e) {
      list.innerHTML = `<div class="sync-error">${escapeHtml(JZ_I18N.t('sync.session.fetchFailed', { msg: e.message || '' }))}</div>`;
    }
  }

  document.getElementById('syncRefreshMe').addEventListener('click', () => { renderSignedIn(); refreshSessions(); });

  // Modal historii synchronizacji — przycisk zamknięcia (overlay/ESC: setupModalDismiss).
  {
    const histClose = document.getElementById('syncHistoryClose');
    if (histClose) {
      histClose.addEventListener('click', () => {
        const m = document.getElementById('syncHistoryModal');
        if (m) m.classList.remove('show');
      });
    }
  }

  // === Tab switcher signed-in ===
  signedIn.querySelectorAll('.sync-tab').forEach(tab => {
    tab.addEventListener('click', () => showInPane(tab.dataset.inPane));
  });

  // === Zakładka „Klucz dostępu" ===
  document.getElementById('syncKeyReveal').addEventListener('click', () => {
    const display = document.getElementById('syncStoredKeyDisplay');
    const isRevealed = display.dataset.revealed === 'true';
    if (isRevealed) {
      display.style.filter = 'blur(5px)';
      display.style.userSelect = 'none';
      display.dataset.revealed = 'false';
      document.getElementById('syncKeyReveal').textContent = JZ_I18N.t('btn.showKey');
    } else {
      display.style.filter = 'none';
      display.style.userSelect = 'all';
      display.dataset.revealed = 'true';
      document.getElementById('syncKeyReveal').textContent = 'Ukryj klucz';
    }
  });

  document.getElementById('syncKeyCopy').addEventListener('click', async () => {
    const key = JZ_SYNC.getStoredSyncKey();
    if (!key) { setStatusBar('Brak klucza do skopiowania.', 'warn'); return; }
    try {
      await navigator.clipboard.writeText(key);
      setStatusBar('Klucz skopiowany do schowka.', 'info');
    } catch (_) {
      setStatusBar(JZ_I18N.t('sync.key.copyManual'), 'warn');
    }
  });

  document.getElementById('syncKeyForget').addEventListener('click', async () => {
    if (!confirm(JZ_I18N.t('confirm.removeKey'))) return;
    await JZ_SYNC.forgetSyncKey();
    setStatusBar(JZ_I18N.t('sync.key.removed'), 'info');
    renderKeyTab();
  });

  // === Backupy chmurowe ===
  function fmtBytes(n) {
    if (typeof n !== 'number') return '?';
    if (n < 1024) return n + ' B';
    if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
    return (n / 1024 / 1024).toFixed(2) + ' MB';
  }
  function fmtDate(ts) {
    if (!ts) return '—';
    try { return new Date(ts * 1000).toLocaleString('pl-PL'); }
    catch (_) { return String(ts); }
  }

  async function refreshBackups() {
    const list = document.getElementById('syncBackupsList');
    const quotaEl = document.getElementById('syncBackupsQuota');
    list.innerHTML = `<div class="sync-row-meta" style="font-size:11px;padding:8px 0;">${escapeHtml(JZ_I18N.t('loading.generic'))}</div>`;
    try {
      const data = await JZ_SYNC.listBackups();
      const total = fmtBytes(data.totalBytes || 0);
      const quota = fmtBytes(data.quota || 0);
      quotaEl.textContent = `${total} / ${quota}`;
      list.innerHTML = '';
      if (!Array.isArray(data.backups) || data.backups.length === 0) {
        list.innerHTML = `<div class="sync-row-meta" style="font-size:11px;padding:8px 0;">${escapeHtml(JZ_I18N.t('sync.backups.empty'))}</div>`;
        return;
      }
      data.backups.forEach(b => {
        const row = document.createElement('div');
        row.className = 'sync-row';
        row.style.alignItems = 'flex-start';
        const labelHtml = b.label ? `<strong>${escapeHtml(b.label)}</strong>` : `<strong>(bez etykiety)</strong>`;
        const kindBadge = b.kind === 'auto' ? '<span style="background:#888;color:white;padding:1px 5px;border-radius:8px;font-size:10px;margin-left:4px;">auto</span>' : '';
        row.innerHTML = `
          <span style="flex:1;">
            ${labelHtml}${kindBadge}<br>
            <span class="sync-row-meta">#${b.id} • ${fmtDate(b.createdAt)} • ${fmtBytes(b.payloadSize)} (skompresowane: ${fmtBytes(b.storageSize)}) • schema v${b.schemaVersion}</span>
          </span>
          <span class="sync-actions-row" style="flex-shrink:0;margin-top:0;gap:4px;">
            <button type="button" data-bk-restore="${b.id}" style="font-size:11px;padding:4px 8px;">Przywróć</button>
            <button type="button" data-bk-download="${b.id}" style="font-size:11px;padding:4px 8px;">JSON</button>
            <button type="button" class="danger" data-bk-delete="${b.id}" style="font-size:11px;padding:4px 8px;">Usuń</button>
          </span>
        `;
        list.appendChild(row);
      });
      // Bind buttons
      list.querySelectorAll('button[data-bk-restore]').forEach(btn => {
        btn.addEventListener('click', () => restoreBackupFlow(btn.dataset.bkRestore));
      });
      list.querySelectorAll('button[data-bk-download]').forEach(btn => {
        btn.addEventListener('click', () => downloadBackupJson(btn.dataset.bkDownload));
      });
      list.querySelectorAll('button[data-bk-delete]').forEach(btn => {
        btn.addEventListener('click', () => deleteBackupFlow(btn.dataset.bkDelete));
      });
    } catch (e) {
      list.innerHTML = `<div class="sync-error">${escapeHtml(JZ_I18N.t('sync.backups.fetchFailed', { msg: e.message || '' }))}</div>`;
    }
  }

  document.getElementById('syncBackupRefresh').addEventListener('click', refreshBackups);

  document.getElementById('syncBackupCreate').addEventListener('click', async () => {
    const label = document.getElementById('syncBackupLabel').value.trim();
    setStatusBar(JZ_I18N.t('sync.backups.creating'), 'info');
    setDot('syncing');
    try {
      const data = await chrome.storage.local.get([
        'folders', 'groups', 'rules', 'actionBars',
        'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles', 'schemaVersion',
      ]);
      const payload = {
        type: 'justzix-full',
        version: 1,
        schemaVersion: data.schemaVersion || 3,
        folders: data.folders || [],
        groups: data.groups || [],
        rules: data.rules || [],
        actionBars: data.actionBars || [],
        cssPanes: data.cssPanes || [],
        jsPanes: data.jsPanes || [],
        jsConsoles: data.jsConsoles || [],
        outputConsoles: data.outputConsoles || [],
        exportedAt: Date.now(),
      };
      const r = await JZ_SYNC.createBackup({
        kind: 'manual',
        label: label || null,
        schemaVersion: payload.schemaVersion,
        payload,
      });
      setStatusBar(`Backup #${r.id} utworzony — ${fmtBytes(r.payloadSize)} (skompresowane: ${fmtBytes(r.storageSize)}).`, 'info');
      document.getElementById('syncBackupLabel').value = '';
      refreshDot();
      refreshBackups();
      // Unieważnij cache kafelka pulpitu — bez tego „ostatni backup" pokazywał
      // starą datę do przeładowania strony opcji.
      _dashBackupCache = undefined;
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.backups.createFailed', { msg: e.message || '' }), 'error');
      setDot('error');
    }
  });

  async function restoreBackupFlow(id) {
    if (!confirm(JZ_I18N.t('confirm.restoreBackup', { id }))) return;
    setStatusBar('Przywracanie backupu…', 'info');
    setDot('syncing');
    try {
      // 1) Pobierz pełną treść backupu
      const full = await JZ_SYNC.getBackup(id);
      // 2) Powiedz backendowi by zrobił restore (tombstone wszystkiego + insert)
      const r = await JZ_SYNC.restoreBackup(id);
      // 3) Zaaplikuj payload lokalnie (omija pull — szybciej i bardziej deterministycznie)
      const p = full.payload || {};
      const restoreData = {
        folders: p.folders || [],
        groups: p.groups || [],
        rules: p.rules || [],
        schemaVersion: p.schemaVersion || 3,
      };
      // Paski akcji i okna odtwarzaj tylko jeśli backup je zawiera. Backupy
      // sprzed v2.13.182 nie mają tych pól — wtedy zachowujemy obecny stan
      // lokalny zamiast go kasować (wariant bezpieczny).
      for (const k of ['actionBars', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles']) {
        if (Array.isArray(p[k])) restoreData[k] = p[k];
      }
      await chrome.storage.local.set(restoreData);
      setStatusBar(JZ_I18N.t('sync.backups.restored', { id, count: r.replaced || 0, cursor: r.newCursor }), 'info');
      refreshDot();
      load();
      renderSignedIn();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.backups.restoreFailed', { msg: e.message || '' }), 'error');
      setDot('error');
    }
  }

  async function downloadBackupJson(id) {
    setStatusBar('Pobieram backup…', 'info');
    try {
      const full = await JZ_SYNC.getBackup(id);
      const blob = new Blob([JSON.stringify(full.payload || {}, null, 2)], { type: 'application/json' });
      const url = URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      const dt = full.createdAt ? new Date(full.createdAt * 1000).toISOString().slice(0, 10) : '';
      const safeLabel = (full.label || 'backup').replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 40);
      a.download = `justzix-cloud-${safeLabel}-${dt}-id${id}.json`;
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      setTimeout(() => URL.revokeObjectURL(url), 1000);
      setStatusBar('Pobrano backup #' + id + '.', 'info');
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.backups.downloadFailed', { msg: e.message || '' }), 'error');
    }
  }

  async function deleteBackupFlow(id) {
    if (!confirm(JZ_I18N.t('sync.backups.confirmDelete', { id }))) return;
    try {
      await JZ_SYNC.deleteBackup(id);
      setStatusBar(JZ_I18N.t('sync.backups.deleted', { id }), 'info');
      refreshBackups();
      // Unieważnij cache kafelka pulpitu — skasowany backup mógł być tym
      // pokazywanym jako „ostatni".
      _dashBackupCache = undefined;
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.backups.deleteFailed', { msg: e.message || '' }), 'error');
    }
  }


  document.getElementById('syncManualPush').addEventListener('click', async () => {
    setStatusBar(JZ_I18N.t('sync.tooltip.syncing'), 'info');
    setDot('syncing');
    try {
      const r = await new Promise(resolve => {
        chrome.runtime.sendMessage({ type: 'sync-now', trigger: 'manual-ui' }, resolve);
      });
      const summary = r && r.ok
        ? JZ_I18N.t('sync.pushPullSummary', {
            pushedAccepted: r.pushed?.accepted || 0,
            pushedRejected: r.pushed?.rejected || 0,
            applied: r.applied?.applied || 0,
            deletes: r.applied?.deletes || 0,
          })
        : JZ_I18N.t('sync.returned', { result: JSON.stringify(r) });
      setStatusBar(summary, 'info');
      // Ręczna synchronizacja zmienia stan chmury — wymuś świeże liczności.
      _cloudCountsCache = null;
      refreshDot();
      renderSignedIn();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.generic', { msg: e.message || '' }), 'error');
      setDot('error');
    }
  });

  document.getElementById('syncSnapshot').addEventListener('click', async () => {
    if (!confirm(JZ_I18N.t('confirm.snapshot'))) return;
    setStatusBar('Pobieranie snapshotu…', 'info');
    setDot('syncing');
    try {
      const snap = await JZ_SYNC.snapshot();
      const all = flattenSnapshot(snap);
      const r = await JZ_SYNC.applyEntities(all);
      setStatusBar(`Snapshot OK: ${r.applied} encji zastosowanych.`, 'info');
      refreshDot();
      renderSignedIn();
      load();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.snapshotFailed', { msg: e.message || '' }), 'error');
      setDot('error');
    }
  });

  // Pełny resync z kontem — dwukierunkowy: pobiera wszystko z konta, a potem
  // wysyła encje lokalne, których na koncie nie ma (reconcilePushMissing).
  // Naprawia encje, które nigdy nie trafiły na serwer i których diff nie łapie.
  document.getElementById('syncReconcile').addEventListener('click', async () => {
    setStatusBar(JZ_I18N.t('sync.reconcile.running'), 'info');
    setDot('syncing');
    try {
      await _requestFullResync();
      const res = await reconcilePushMissing();
      if (res.enqueued > 0) {
        await new Promise((resolve) => {
          chrome.runtime.sendMessage({ type: 'sync-now', trigger: 'manual-ui' }, () => {
            void chrome.runtime.lastError; resolve();
          });
        });
      }
      setStatusBar(res.enqueued > 0
        ? JZ_I18N.t('sync.reconcile.okPushed', { count: res.enqueued })
        : JZ_I18N.t('sync.reconcile.okInSync'), 'info');
      refreshDot();
      renderSignedIn();
      load();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.reconcile.failed', { msg: e.message || '' }), 'error');
      setDot('error');
    }
  });

  document.getElementById('syncEmailSubmit').addEventListener('click', async () => {
    const email = document.getElementById('syncEmailInput').value.trim();
    if (!email || !email.includes('@')) { setStatusBar(JZ_I18N.t('sync.error.invalidEmail'), 'error'); return; }
    try {
      const r = await JZ_SYNC.emailAdd(email);
      setStatusBar(r.emailSent === false ? JZ_I18N.t('sync.email.saveButFailed') : JZ_I18N.t('sync.email.verifyLinkSent', { email }), 'info');
      renderSignedIn();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.generic', { msg: e.message || '' }), 'error');
    }
  });

  document.getElementById('syncEmailRemove').addEventListener('click', async () => {
    if (!confirm(JZ_I18N.t('confirm.removeEmail'))) return;
    try {
      await JZ_SYNC.emailRemove();
      setStatusBar(JZ_I18N.t('sync.email.removed'), 'info');
      renderSignedIn();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.generic', { msg: e.message || '' }), 'error');
    }
  });

  document.getElementById('syncLogoutBtn').addEventListener('click', async () => {
    if (!confirm(JZ_I18N.t('confirm.signOut'))) return;
    try {
      await JZ_SYNC.logout();
      setStatusBar('Wylogowano.', 'info');
      refreshDot();
      openModal();
    } catch (e) {
      setStatusBar(JZ_I18N.t('sync.error.generic', { msg: e.message || '' }), 'error');
    }
  });

  document.getElementById('syncCloseBtn').addEventListener('click', closeModal);

  // Klik poza modalem zamyka
  modal.addEventListener('click', (e) => { if (e.target === modal) closeModal(); });

  // Po zalogowaniu po raz pierwszy musimy zrzucić istniejącą lokalną bibliotekę na backend.
  async function syncSeedFromCurrentState() {
    try {
      const data = await chrome.storage.local.get([
        'folders', 'groups', 'rules', 'actionBars', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles',
      ]);
      for (const f of (data.folders || [])) {
        JZ_SYNC.enqueueChange({ type: 'folder', id: f.id, parentId: null, payload: f });
      }
      for (const g of (data.groups || [])) {
        JZ_SYNC.enqueueChange({ type: 'group', id: g.id, parentId: g.folderId, payload: g });
      }
      for (const r of (data.rules || [])) {
        JZ_SYNC.enqueueChange({ type: 'rule', id: r.id, parentId: r.groupId, payload: _ruleSansActions(r) });
        for (const a of (r.actions || [])) {
          JZ_SYNC.enqueueChange({ type: 'action', id: a.id, parentId: r.id, payload: a });
        }
      }
      // Action bary + 4 typy okien — bez tego nie trafiają na konto po rejestracji
      // ani przez „Wyślij moje" (push akceptuje wszystkie 9 typów encji).
      for (const b of (data.actionBars || [])) {
        JZ_SYNC.enqueueChange({ type: 'bar', id: b.id, parentId: b.parentId, payload: b });
      }
      const _paneTypes = [
        ['cssPanes', 'css_pane'], ['jsPanes', 'js_pane'],
        ['jsConsoles', 'js_console'], ['outputConsoles', 'output_console'],
      ];
      for (const [key, type] of _paneTypes) {
        for (const p of (data[key] || [])) {
          JZ_SYNC.enqueueChange({ type, id: p.id, parentId: p.parentId, payload: p });
        }
      }
    } catch (e) {
      console.warn('[JustZix] syncSeedFromCurrentState error:', e);
    }
  }

  // Reconcile „wyślij brakujące" — enqueue lokalnych encji, których NIE MA na
  // serwerze (ani żywych, ani otombstone'owanych). Naprawia encje, które nigdy
  // nie trafiły na konto (push odrzucony, brak seeda) i których diff nie łapie,
  // bo się nie zmieniają. Encje już żywe na serwerze pomijamy (re-push nadpisałby
  // je świeższym updatedAt), otombstone'owane pomijamy (re-push by je wskrzesił).
  async function reconcilePushMissing() {
    if (typeof JZ_SYNC === 'undefined' || !JZ_SYNC.isAuthenticated()) return { enqueued: 0 };
    const idx = await JZ_SYNC.fetchServerEntityIndex();
    const data = await chrome.storage.local.get([
      'folders', 'groups', 'rules', 'actionBars', 'cssPanes', 'jsPanes', 'jsConsoles', 'outputConsoles',
    ]);
    let n = 0;
    const enq = (type, id, parentId, payload) => {
      if (!id) return;
      const key = type + ':' + id;
      if (idx.live.has(key) || idx.tombstoned.has(key)) return;
      JZ_SYNC.enqueueChange({ type, id, parentId: parentId || null, payload });
      n++;
    };
    for (const f of (data.folders || [])) enq('folder', f.id, null, f);
    for (const g of (data.groups || [])) enq('group', g.id, g.folderId, g);
    for (const r of (data.rules || [])) {
      enq('rule', r.id, r.groupId, _ruleSansActions(r));
      for (const a of (r.actions || [])) enq('action', a.id, r.id, a);
    }
    for (const b of (data.actionBars || [])) enq('bar', b.id, b.parentId, b);
    const _paneTypes = [
      ['cssPanes', 'css_pane'], ['jsPanes', 'js_pane'],
      ['jsConsoles', 'js_console'], ['outputConsoles', 'output_console'],
    ];
    for (const [key, type] of _paneTypes) {
      for (const p of (data[key] || [])) enq(type, p.id, p.parentId, p);
    }
    return { enqueued: n };
  }

  // Init dot — ten render zwykle widzi pusty state (JZ_SYNC.init() jeszcze nie
  // zostało wywołane), ale listener `jz-sync-state-changed` poniżej odświeży go
  // jak tylko `load()` skończy `JZ_SYNC.init()`.
  refreshDot();
  window.addEventListener('jz-sync-state-changed', () => {
    refreshDot();
    // Jeśli modal jest otwarty w tym momencie — przerysuj go (np. po wylogowaniu
    // przez 401 z background, modal sam wykryje brak auth i wróci do signed-out).
    if (modal.classList.contains('show')) {
      const isAuth = JZ_SYNC.isAuthenticated();
      const showingIn = signedIn.style.display !== 'none';
      if (isAuth !== showingIn) openModal();
    }
  });
})();

// Boot: najpierw inicjalizuj i18n (czyta storage.userLang), potem aplikuje tłumaczenia
// do statycznego HTML, dopiero potem load() (renderuje sidebar i edytory). Język jest
// dostępny przez JZ_I18N.t() we wszystkich późniejszych renderach.
(async () => {
  if (typeof JZ_I18N !== 'undefined') {
    await JZ_I18N.init();
    JZ_I18N.applyToDOM(document);
    setupLangSwitcher();
  }
  // Logo sidebara linkuje do strony projektu — ustaw href z UTM + wersją językową
  // po inicjalizacji i18n (zmiana języka robi location.reload(), więc href się odświeża).
  const _brandLink = document.querySelector('.sidebar-brand .brand-link');
  if (_brandLink) _brandLink.href = jzProjectSiteUrl('logo');
  await load();
  // load() ustawia _syncSnapshot. Teraz tickuj sync raz, jeśli zalogowani.
  if (typeof JZ_SYNC !== 'undefined' && JZ_SYNC.isAuthenticated()) {
    maybeTriggerSync('options-boot');
  }
})();

// Language switcher (rozwijana lista Auto + języki w menu Ustawień). setLang
// zapisuje userPref do storage.local; pełen reload aplikuje nowy język w całym
// UI bez ręcznego wywoływania dziesiątek render*() funkcji.
function setupLangSwitcher() {
  const sel = document.getElementById('langSelect');
  if (!sel) return;
  const paint = () => {
    sel.value = (JZ_I18N && JZ_I18N.pref) || 'auto';
  };
  paint();
  sel.addEventListener('change', async () => {
    const pref = sel.value;
    if (pref === JZ_I18N.pref) return;
    await JZ_I18N.setLang(pref);
    await jzEnqueueSettings();
    window.location.reload();
  });
  window.addEventListener('jz-lang-changed', paint);
}

// ============================================================
// Generyczny dismiss modali — klik poza popupem + ESC (od v2.9.3)
// ============================================================
// Każdy modal jest configurowany przez setupModalDismiss(modalId, opts):
//   opts.onDismiss?: () => void   — custom close (default: usuwa .show)
//   opts.isDirty?: () => boolean  — gdy true, zapyta przed zamknięciem
//   opts.dismissable?: boolean    — gdy false, modal nie zamyka się przez click outside / ESC
//                                   (np. krytyczny syncConflict gdzie user MUSI wybrać akcję)
const __modalDismissCallbacks = new Map();

function setupModalDismiss(modalId, opts = {}) {
  const overlay = document.getElementById(modalId);
  if (!overlay) return;
  const dismissable = opts.dismissable !== false;
  if (!dismissable) {
    __modalDismissCallbacks.set(modalId, () => false);
    return;
  }
  const isDirty = typeof opts.isDirty === 'function' ? opts.isDirty : () => false;
  const onSave = typeof opts.onSave === 'function' ? opts.onSave : null;
  const onDismiss = typeof opts.onDismiss === 'function'
    ? opts.onDismiss
    : () => overlay.classList.remove('show');

  const tryDismiss = async () => {
    if (!overlay.classList.contains('show')) return false;
    if (isDirty()) {
      if (onSave) {
        // 3-way flow: window.confirm daje 2 opcje, więc łańcuchujemy.
        // 1: "Zapisać przed zamknięciem? OK = zapisz, Anuluj = nie zapisuj (przejdź do potwierdzenia)"
        const save = window.confirm(JZ_I18N.t('modal.confirmSaveBeforeClose'));
        if (save) {
          await onSave();
        } else {
          // 2: "Zamknąć bez zapisywania? OK = utrać, Anuluj = wróć do edycji"
          const discard = window.confirm(JZ_I18N.t('modal.confirmDiscardChanges'));
          if (!discard) return false;
        }
      } else {
        // 2-way flow gdy nie ma onSave (modale read-only z dirty state input).
        const proceed = window.confirm(JZ_I18N.t('modal.confirmDismissDirty'));
        if (!proceed) return false;
      }
    }
    onDismiss();
    return true;
  };

  // Click w overlay (poza .modal) — używamy `click` (atomic mousedown+mouseup w tym
  // samym targecie) zamiast samego mousedown. Dzięki temu:
  //   - Drag-to-select z textarea wystający poza modal: mousedown w textarea, mouseup
  //     w overlay → click NIE wystrzeli (różne targety) → modal nie zamyka się.
  //   - Klik w overlay: mousedown+mouseup+click wszystkie na overlay → close.
  // mousedown na overlay przechwytujemy żeby:
  //   - preventDefault (żeby focus nie poszedł "pod" overlay przy zaznaczeniu tekstu w nim)
  //   - stopPropagation (żeby underlying element nie dostał mousedown).
  overlay.addEventListener('mousedown', (e) => {
    if (e.target === overlay) {
      e.preventDefault();
      e.stopPropagation();
    }
  });
  overlay.addEventListener('click', (e) => {
    if (e.target !== overlay) return;
    e.preventDefault();
    e.stopPropagation();
    tryDismiss();
  });
  __modalDismissCallbacks.set(modalId, tryDismiss);
}

// ESC zamyka najwyższy widoczny modal (z .show class).
document.addEventListener('keydown', (e) => {
  if (e.key !== 'Escape') return;
  const visible = Array.from(document.querySelectorAll('.modal-overlay.show'));
  if (visible.length === 0) return;
  // DOM order = z-index porządku przy domyślnych ustawieniach. Ostatni = na wierzchu.
  const top = visible[visible.length - 1];
  const cb = __modalDismissCallbacks.get(top.id);
  if (cb) cb();
});

// Konfiguracja konkretnych modali. setupModalDismiss tylko dodaje listenery — nie ingeruje
// w istniejące przyciski "Zamknij"/"Anuluj" ani logikę zapisu.
document.addEventListener('DOMContentLoaded', () => {
  // Modal kodu akcji — JEDYNY z prawdziwym dirty state (CodeMirror, niezapisany kod).
  // 3-way flow przy dirty: Zapisz / Odrzuć / Anuluj.
  setupModalDismiss('actionCodeModal', {
    isDirty: () => {
      if (!currentActionContext) return false;
      const initial = currentActionContext.initialCode || '';
      const current = actionCodeEditor ? actionCodeEditor.getValue() : '';
      return current !== initial;
    },
    onSave: async () => {
      await saveActionCode();
    },
    onDismiss: () => closeActionCodeModal(),
  });

  // Modali z live-save lub bez state — domyślne zamknięcie.
  setupModalDismiss('actionBarsModal', {
    onDismiss: () => closeActionBarsModal(), // wywoła onClose callback (refresh przycisku)
  });

  // SELECT options modal — bez dirty state (live save), close re-renderuje action row.
  setupModalDismiss('selectOptionsModal', {
    onDismiss: () => closeSelectOptionsModal(),
  });

  // INPUT editor modal (od v2.13.0) — analogicznie.
  setupModalDismiss('inputEditorModal', {
    onDismiss: () => closeInputEditorModal(),
  });

  // SLIDER / TEXTAREA / TOGGLE3 editor modale — analogicznie do INPUT.
  setupModalDismiss('sliderEditorModal',   { onDismiss: () => closeSliderEditorModal()   });
  setupModalDismiss('textareaEditorModal', { onDismiss: () => closeTextareaEditorModal() });
  setupModalDismiss('toggle3EditorModal',  { onDismiss: () => closeToggle3EditorModal()  });

  // Okna CSS / JS panes (od v2.13.61) — wcześniej user mógł zamknąć tylko przyciskiem "Zamknij"
  // (brak backdrop-close + brak ESC). Bug raportowany v2.13.61.
  setupModalDismiss('cssPanesModal', { onDismiss: () => closeCssPanesModal() });
  setupModalDismiss('jsPanesModal',  { onDismiss: () => closeJsPanesModal()  });
  // Konsole JS (od v2.13.66).
  setupModalDismiss('jsConsolesModal', { onDismiss: () => closeJsConsolesModal() });
  setupModalDismiss('outputConsolesModal', { onDismiss: () => closeOutputConsolesModal() });

  // Pozostałe — czysty close (.show off).
  ['exportModal', 'importModal', 'folderExportModal', 'groupExportModal',
   'folderImportModal', 'shareImportModal', 'sharesModal', 'shareCreateModal',
   'syncResultModal', 'syncModal', 'syncHistoryModal'].forEach(id => setupModalDismiss(id));

  // syncConflictModal — KRYTYCZNY: user MUSI wybrać strategię (overwrite/merge/cancel).
  // Click outside / ESC nie zamyka.
  setupModalDismiss('syncConflictModal', { dismissable: false });
});
