// Anti Copypaste Spam - background (MV3 service worker)

// Register single context menu to avoid submenu grouping
chrome.runtime.onInstalled.addListener(() => {
  try {
    chrome.contextMenus.create({
      id: 'acps-block-current',
      title: 'マウスカーソルの位置のレスをコピペブロック登録',
      contexts: ['page','selection'],
    });
  } catch {}
});

// Seed initial templates from bundled external-templates.json on first install
async function seedInitialTemplatesIfNeeded() {
  try {
    const { seededInitialTemplates = false } = await chrome.storage.local.get({ seededInitialTemplates: false });
    if (seededInitialTemplates) return;
    const url = chrome.runtime.getURL('external-templates.json');
    const res = await fetch(url);
    if (!res.ok) { /* try again on next startup */ return; }
    const data = await res.json();
    const bundled = Array.isArray(data) ? data : [];
    const normalizeItem = (v) => (typeof v === 'string') ? { text: v } : (v && typeof v.text === 'string') ? { text: v.text, sourceUrl: v.sourceUrl || '' } : null;
    const items = bundled.map(normalizeItem).filter((o) => o && o.text);
    if (!items.length) { /* nothing to seed; try again next time if needed */ return; }
    const { externalTemplates = [] } = await chrome.storage.local.get({ externalTemplates: [] });
    const existing = (externalTemplates || []).map((v) => (typeof v === 'string' ? { text: v } : v)).filter((o) => o && o.text);
    const map = new Map(existing.map((o) => [o.text, o]));
    for (const o of items) { if (!map.has(o.text)) map.set(o.text, o); }
    await chrome.storage.local.set({ externalTemplates: Array.from(map.values()), seededInitialTemplates: true });
  } catch (e) {
    // Leave the flag as false so we can retry on next startup/install
  }
}

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

// Remove invisible/formatting-only characters while keeping newlines and spaces
function stripInvisible(s) {
  if (!s) return '';
  s = s.replace(/[\u2028\u2029]/g, '\n');
  const INVIS_RE = /[\u200B\u200C\u200D\u2060\uFEFF\u00AD\u200E\u200F\u202A-\u202E\u2066-\u2069\uFE00-\uFE0F]|[\u{E0100}-\u{E01EF}]/gu;
  return s.replace(INVIS_RE, '');
}

function buildStripRegexFromChars(chars) {
  try {
    const parts = [];
    for (const ch of chars || '') {
      parts.push(ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
    }
    return parts.length ? new RegExp('(?:' + parts.join('|') + ')', 'gu') : null;
  } catch { return null; }
}

async function addExternalTemplate(text, sourceUrl, targetKey = 'externalTemplates') {
  if (!text) return;
  // Normalize using the same pipeline as content script (preserve newlines per line)
  let cleaned = stripInvisible(text.normalize('NFKC'))
    .replace(/\r\n?|\n/g, '\n')
    .replace(/[\t\f\v]/g, ' ')
    .replace(/\u00A0/g, ' ');
  try {
    const { ignoreInsertChars = '' } = await chrome.storage.local.get({ ignoreInsertChars: '' });
    const re = buildStripRegexFromChars(ignoreInsertChars || '');
    if (re) cleaned = cleaned.replace(re, '');
  } catch {}
  // Apply template-style normalization: strip URLs/IDs/dates, trim lines, keep newlines
  cleaned = cleaned
    .replace(/https?:\/\/\S+|www\.[^\s]+/gi, '')
    .replace(/ID:[A-Za-z0-9+/=\-]+/g, '')
    .replace(/\d{2,4}[\/\-.]\d{1,2}[\/\-.]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/g, '')
    // Deletion banners (align with content.js)
    .replace(/^スレッドを立てた人によって削除されました$/gmi, '')
    .replace(/^このレスは.*?削除されました$/gmi, '')
    .replace(/^(?:管理者|管理人|削除人|投稿者)によって削除されました$/gmi, '')
    .split('\n').map((l) => String(l).trim()).filter(Boolean).join('\n');
  // Apply ignoreRegexes patterns from settings, if any
  try {
    const got = await chrome.storage.local.get({ ignoreRegexes: [] });
    const pats = Array.isArray(got.ignoreRegexes) ? got.ignoreRegexes : [];
    for (const p of pats) {
      try { const rx = new RegExp(String(p), 'gmi'); cleaned = cleaned.replace(rx, ''); } catch {}
    }
    cleaned = cleaned.split('\n').map(l => l.trim()).filter(Boolean).join('\n');
  } catch {}
  if (!cleaned) return;
  const cur = await chrome.storage.local.get({ [targetKey]: [] });
  const list = Array.isArray(cur[targetKey]) ? cur[targetKey] : [];
  const items = (list || []).map((v) => (typeof v === 'string' ? { text: v } : v)).filter((o) => o && o.text);
  const map = new Map(items.map((o) => [o.text, o]));
  if (!map.has(cleaned)) map.set(cleaned, { text: cleaned, sourceUrl: sourceUrl || '' });
  await chrome.storage.local.set({ [targetKey]: Array.from(map.values()) });
}

chrome.contextMenus.onClicked.addListener(async (info, tab) => {
  if (!tab || tab.id == null) return;
  if (info.menuItemId !== 'acps-block-current') return;
  try {
    // Prefer user selection when available; otherwise use hover-resolved body from content script
    let text = info.selectionText ? String(info.selectionText) : '';
    if (!text) {
      const resp = await new Promise((resolve) => {
        try { chrome.tabs.sendMessage(tab.id, { type: 'acps-get-hover-body' }, resolve); } catch (e) { resolve({ ok: false, error: String(e) }); }
      });
      text = resp && resp.ok && resp.text ? String(resp.text) : '';
    }
    if (!text) return;
    await addExternalTemplate(text, tab && tab.url, 'shortTemplates');
    try { chrome.tabs.sendMessage(tab.id, { type: 'acps-refresh' }); } catch {}
    try {
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        func: (summary) => {
          const id = 'acps-toast';
          let box = document.getElementById(id);
          if (!box) {
            box = document.createElement('div');
            box.id = id; box.style.position = 'fixed'; box.style.right = '12px'; box.style.bottom = '12px';
            box.style.zIndex = '2147483647'; box.style.background = 'rgba(0,0,0,0.8)'; box.style.color = '#fff';
            box.style.padding = '8px 10px'; box.style.borderRadius = '6px'; box.style.font = '12px/1.4 system-ui, sans-serif';
            box.style.maxWidth = '60vw'; box.style.whiteSpace = 'pre-wrap';
            document.body.appendChild(box);
          }
          box.textContent = String(summary || ''); box.style.opacity = '1'; box.style.transition = 'opacity .3s';
          clearTimeout(box._t); box._t = setTimeout(() => { box.style.opacity = '0'; }, 2000);
        },
        args: ['ブロック登録: 1件'],
      });
    } catch {}
  } catch (e) {
    console.warn('Block-current failed', e);
  }
});

// Stats update: increment hits and update lastUsed for matched canonical texts
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || !msg.type) return;
  if (msg.type === 'acps-ts-fail') {
    (async () => {
      try {
        const host = String(msg.host || '').trim();
        if (!host) { sendResponse && sendResponse({ ok: true }); return; }
        const now = Math.floor(Date.now() / 1000);
        const { acpsTsErrors = {} } = await chrome.storage.local.get({ acpsTsErrors: {} });
        const map = (acpsTsErrors && typeof acpsTsErrors === 'object') ? acpsTsErrors : {};
        const ent = map[host] && typeof map[host] === 'object' ? map[host] : { count: 0, lastSeen: 0 };
        ent.count = (ent.count >>> 0) + 1;
        ent.lastSeen = now;
        map[host] = ent;
        await chrome.storage.local.set({ acpsTsErrors: map });
        sendResponse && sendResponse({ ok: true });
      } catch (e) {
        sendResponse && sendResponse({ ok: false, error: String(e && e.message || e) });
      }
    })();
    return true;
  }
  if (msg.type === 'acps-hits') {
    (async () => {
      try {
        // Support legacy 'keys' (no timestamp) and new 'events' [{key, ts}]
        let events = Array.isArray(msg.events) ? msg.events : null;
        if (!events && Array.isArray(msg.keys)) {
          events = msg.keys.map((k) => ({ key: k, ts: 0 }));
        }
        events = Array.isArray(events) ? events.filter(e => e && e.key) : [];
        if (!events.length) { sendResponse && sendResponse({ ok: true }); return; }
        const { acpsStats = {} } = await chrome.storage.local.get({ acpsStats: {} });
        const stats = (acpsStats && typeof acpsStats === 'object') ? acpsStats : {};
        const now = Math.floor(Date.now() / 1000);
        for (const ev of events) {
          const k = String(ev.key);
          const ts = Math.floor(Number(ev.ts) || 0);
          const s = stats[k] && typeof stats[k] === 'object' ? stats[k] : { hits: 0, lastUsed: 0 };
          if (ts > 0) {
            // Only count if this post's timestamp is newer than the last recorded match
            if (!s.lastUsed || ts > s.lastUsed) {
              s.hits = (s.hits >>> 0) + 1;
              s.lastUsed = ts;
            }
          } else {
            // No post time available: do not count (strict mode)
            // Keep lastUsed as-is; optionally could record a warning elsewhere
          }
          stats[k] = s;
        }
        await chrome.storage.local.set({ acpsStats: stats });
        sendResponse && sendResponse({ ok: true, updated: events.length });
      } catch (e) {
        sendResponse && sendResponse({ ok: false, error: String(e && e.message || e) });
      }
    })();
    return true; // async
  }
});

// (No dynamic enable/disable; keep original stable menus)

// Reuse: learn-from-page logic exposed for popup
async function learnFromPage(tab) {
  if (!tab || tab.id == null) return;
  try {
    const results = await chrome.scripting.executeScript({
      target: { tabId: tab.id },
      func: () => {
        function isRedish(elem) {
          try {
            const cs = getComputedStyle(elem);
            const m = cs && cs.color ? cs.color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/) : null;
            if (m) {
              const r = parseInt(m[1],10), g = parseInt(m[2],10), b = parseInt(m[3],10);
              if (r >= 200 && g <= 60 && b <= 60) return true;
            }
          } catch {}
          if (elem.hasAttribute && elem.hasAttribute('color')) {
            const c = (elem.getAttribute('color') || '').toLowerCase();
            if (/^#?f{1,2}0{2,4}$/i.test(c) || c === 'red' || c.startsWith('#ff')) return true;
          }
          return false;
        }
        function txt(el) {
          const walk = (node) => {
            if (!node) return '';
            if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || '';
            if (node.nodeType !== Node.ELEMENT_NODE) return '';
            const tag = node.tagName ? node.tagName.toLowerCase() : '';
            if (tag === 'br') return '\n';
            if (node.classList && (node.classList.contains('acps-control') || node.classList.contains('acps-inline-btn'))) return '';
            if (isRedish(node)) return '';
            let out = '';
            node.childNodes.forEach((ch) => { out += walk(ch); });
            return out;
          };
          let text = walk(el);
          return text.replace(/\s+$/gm, '').trim();
        }
        const sels = [
          'blockquote',
          '.post, .message, .comment, .res, .reply, .postMessage, .post-content, .postBody, .cont'
        ].join(',');
        const out = [];
        const seen = new Set();
        document.querySelectorAll(sels).forEach((el) => {
          const t = txt(el);
          if (t && !seen.has(t)) { seen.add(t); out.push(t); }
        });
        return out;
      },
    });
    const texts = (results?.[0]?.result) || [];
    if (!texts.length) return;
    const cfg = await chrome.storage.local.get({ ignoreInsertChars: '' });
    const _stripRe = (function(chars){
      try { const parts = []; for (const ch of chars || '') { parts.push(ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); } return parts.length ? new RegExp('(?:' + parts.join('|') + ')', 'gu') : null; } catch { return null; }
    })(cfg.ignoreInsertChars || '');
    const normalized = texts.map((t) => t && t.normalize ? t.normalize('NFKC') : t)
      .map((t) => stripInvisible(t))
      .map((t) => t.replace(/\r\n?|\n/g, '\n').replace(/[\t\f\v]/g, ' ').replace(/\u00A0/g, ' '))
      .map((t) => _stripRe ? t.replace(_stripRe, '') : t)
      .map((t) => t.replace(/https?:\/\/\S+|www\.[^\s]+/gi, '')
        .replace(/ID:[A-Za-z0-9+/=\-]+/g, '')
        .replace(/\d{2,4}[\/\-.]\d{1,2}[\/\-.]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/g, '')
        .split('\n').map(l => l.trim()).filter(Boolean).join('\n')
      )
      .filter((t) => t && t.length >= 5);
    if (!normalized.length) return;
    const { externalTemplates = [] } = await chrome.storage.local.get({ externalTemplates: [] });
    const existing = (externalTemplates || []).map((v) => (typeof v === 'string' ? { text: v } : v)).filter((o) => o && o.text);
    const map = new Map(existing.map((o) => [o.text, o]));
    const before = map.size;
    for (const t of normalized) { if (!map.has(t)) map.set(t, { text: t, sourceUrl: tab.url || '' }); }
    const merged = Array.from(map.values());
    await chrome.storage.local.set({ externalTemplates: merged });
    const added = Math.max(0, map.size - before);
    const summary = `学習完了: 追加${added}`;
    try {
      await chrome.scripting.executeScript({
        target: { tabId: tab.id },
        func: (text) => {
          const id = 'acps-toast';
          let box = document.getElementById(id);
          if (!box) {
            box = document.createElement('div');
            box.id = id;
            box.style.position = 'fixed'; box.style.right = '12px'; box.style.bottom = '12px';
            box.style.zIndex = '2147483647'; box.style.background = 'rgba(0,0,0,0.8)'; box.style.color = '#fff';
            box.style.padding = '8px 10px'; box.style.borderRadius = '6px'; box.style.font = '12px/1.4 system-ui, sans-serif';
            box.style.maxWidth = '60vw'; box.style.whiteSpace = 'pre-wrap';
            document.body.appendChild(box);
          }
          box.textContent = String(text || ''); box.style.opacity = '1'; box.style.transition = 'opacity .3s';
          clearTimeout(box._t); box._t = setTimeout(() => { box.style.opacity = '0'; }, 2500);
        },
        args: [summary],
      });
    } catch {}
  } catch (e) {
    console.warn('learnFromPage (popup) failed', e);
  }
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || !msg.type) return;
  if (msg.type === 'acps-learn-now') {
    (async () => {
      try {
        const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
        const tab = tabs && tabs[0];
        await learnFromPage(tab);
        sendResponse({ ok: true });
      } catch (e) { sendResponse({ ok: false, error: String(e && e.message || e) }); }
    })();
    return true;
  }
});

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || !msg.type) return;
  if (msg.type === 'acps-get-default-domains') {
    try {
      const mf = chrome.runtime.getManifest();
      const set = new Set();
      const cs = Array.isArray(mf.content_scripts) ? mf.content_scripts : [];
      for (const c of cs) {
        for (const m of (c.matches || [])) set.add(m);
      }
      sendResponse({ ok: true, matches: Array.from(set.values()) });
    } catch (e) {
      sendResponse({ ok: false, error: String(e && e.message || e) });
    }
    return true;
  }
});

// Helper: derive origin pattern from URL
function originPattern(u) {
  try {
    const { origin } = new URL(u);
    return origin + '/*';
  } catch {
    return null;
  }
}

// Build a canonical thread identifier from URL like
// https://host/board/123456.html?foo -> https://host/board/123456.html
// Build a canonical thread identifier from URL like
// https://host/board/123456.html?foo -> https://host/board/123456.html
// (Undo menu removed to restore stable behavior)

// Robust extraction for Futaba-like pages, works in service worker
function extractBlocksFromHtml(html, pageUrl) {
  const blocks = [];
  let s = html || '';
  // Strip scripts/styles to avoid noise
  s = s.replace(/<script[\s\S]*?<\/script>/gi, '')
       .replace(/<style[\s\S]*?<\/style>/gi, '');
  // Replace <br> with newlines for correct line breaks
  s = s.replace(/<br\s*\/?>(\s*)/gi, '\n');
  // Remove inline red colored segments likely used for deletion banners
  // font[color=red|#f00|#ff0000..] ... / any tag with style="color: red|#ff...|rgb(255,0,0)"
  s = s.replace(/<font[^>]*\bcolor=(["'])\s*(?:red|#f{1}|#ff[0-9a-f]{4}|#ff[0-9a-f]{2}|#ff[0-9a-f]{4})\s*\1[^>]*>[\s\S]*?<\/font>/gi, '');
  s = s.replace(/<([a-z0-9]+)([^>]*\bstyle=(["'])[^\3>]*color\s*:\s*(?:red|#f{1,6}|rgb\(\s*255\s*,\s*0\s*,\s*0\s*\))[^\3>]*\3)[^>]*>[\s\S]*?<\/(?:\1)>/gi, '');

  // Futabaforest-specific: each post is a table#rNN enclosing td.c9-10 and a blockquote
  try {
    if (pageUrl && /futabaforest\.net/i.test(pageUrl)) {
      const reTable = /<table[^>]+id=["']r\d+["'][^>]*>[\s\S]*?<\/table>/gi;
      let mt;
      while ((mt = reTable.exec(s))) {
        const tbl = mt[0];
        const mBQ = /<blockquote[^>]*>([\s\S]*?)<\/blockquote>/i.exec(tbl);
        let inner = '';
        if (mBQ) inner = mBQ[1] || '';
        else {
          const mTD = /<td[^>]*class=["'][^"']*c9-10[^"']*["'][^>]*>([\s\S]*?)<\/td>/i.exec(tbl);
          inner = (mTD && mTD[1]) || '';
        }
        if (inner) {
          const text = inner
            .replace(/<[^>]+>/g, '')
            .replace(/&nbsp;/g, ' ')
            .replace(/&gt;/g, '>')
            .replace(/&lt;/g, '<')
            .replace(/&amp;/g, '&')
            .replace(/\s+$/gm, '')
            .trim();
          if (text) blocks.push(text);
        }
      }
    }
  } catch {}

  // Primary: extract blockquote contents
  const seen = new Set();
  // Futabaforest/Futaba: blockquote inside table[id^=r]
  const reBQ = /<blockquote[^>]*>([\s\S]*?)<\/blockquote>/gi;
  let m;
  while ((m = reBQ.exec(s))) {
    const inner = m[1] || '';
    const text = inner
      .replace(/<[^>]+>/g, '')
      .replace(/&nbsp;/g, ' ')
      .replace(/&gt;/g, '>')
      .replace(/&lt;/g, '<')
      .replace(/&amp;/g, '&')
      .replace(/\s+$/gm, '')
      .trim();
    if (text && !seen.has(text)) { seen.add(text); blocks.push(text); }
  }

  // Secondary: common Futaba containers if no blockquotes found
  if (!blocks.length) {
    const reDiv = /<(?:div|p|td)[^>]*class=["'](?:post|message|comment|res|reply|postMessage|post-content|postBody|cont|c9-10)["'][^>]*>([\s\S]*?)<\/(?:div|p|td)>/gi;
    while ((m = reDiv.exec(s))) {
      const inner = m[1] || '';
      const text = inner
        .replace(/<[^>]+>/g, '')
        .replace(/&nbsp;/g, ' ')
        .replace(/&gt;/g, '>')
        .replace(/&lt;/g, '<')
        .replace(/&amp;/g, '&')
        .replace(/\s+$/gm, '')
        .trim();
      if (text && !seen.has(text)) { seen.add(text); blocks.push(text); }
    }
  }

  // Fallback: split body by blank lines
  if (!blocks.length) {
    const bodyMatch = /<body[^>]*>([\s\S]*?)<\/body>/i.exec(s) || [];
    const body = (bodyMatch[1] || '')
      .replace(/<[^>]+>/g, '\n')
      .replace(/\n{3,}/g, '\n\n')
      .trim();
    if (body) {
      const parts = body.split(/\n\n+/).map(t => t.trim()).filter(t => t.length >= 20);
      const seen2 = new Set();
      for (const p of parts) {
        if (!seen2.has(p)) { seen2.add(p); blocks.push(p); }
      }
    }
  }
  return blocks;
}

function normalizeForTemplate(text, stripChars) {
  try { text = text.normalize('NFKC'); } catch {}
  let s = stripInvisible(text)
    .replace(/\r\n?|\n/g, '\n')
    .replace(/[\t\f\v]/g, ' ')
    .replace(/\u00A0/g, ' ');
  try {
    const re = buildStripRegexFromChars(stripChars || '');
    if (re) s = s.replace(re, '');
  } catch {}
  return s
    .replace(/https?:\/\/\S+|www\.[^\s]+/gi, '')
    // Strip deletion banners similar to content script
    .replace(/^スレッドを立てた人によって削除されました$/gmi, '')
    .replace(/^このレスは.*?削除されました$/gmi, '')
    .replace(/^(?:管理者|管理人|削除人|投稿者)によって削除されました$/gmi, '')
    .replace(/ID:[A-Za-z0-9+/=\-]+/g, '')
    .replace(/\d{2,4}[\/\-.]\d{1,2}[\/\-.]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/g, '')
    .split('\n').map(l => l.trim()).filter(Boolean).join('\n');
}

async function fetchReferenceUrls(urls) {
  // Request host permissions for all origins
  const origins = Array.from(new Set(urls.map(originPattern).filter(Boolean)));
  if (origins.length) {
    try {
      const granted = await new Promise((resolve, reject) => {
        chrome.permissions.request({ origins }, (ok) => {
          if (chrome.runtime.lastError) return reject(chrome.runtime.lastError);
          resolve(!!ok);
        });
      });
      if (!granted) throw new Error('Host permission denied');
    } catch (e) {
      console.warn('Permission request failed', e);
      throw e;
    }
  }
  const collected = [];
  const stats = [];
  let stripChars = '';
  try {
    const got = await chrome.storage.local.get({ ignoreInsertChars: '' });
    stripChars = got.ignoreInsertChars || '';
  } catch {}
  for (const u of urls) {
    try {
      const res = await fetch(u, { credentials: 'omit', cache: 'no-store' });
      if (!res.ok) continue;
      // Detect encoding
      const buf = await res.arrayBuffer();
      const ctype = res.headers.get('content-type') || '';
      let encoding = 'utf-8';
      const m1 = /charset=([^;\s]+)/i.exec(ctype);
      if (m1) encoding = m1[1].toLowerCase();
      if (!m1) {
        const headPreview = new TextDecoder('utf-8').decode(buf.slice(0, 4096));
        const m2 = /<meta[^>]+charset=["']?([^>"']+)/i.exec(headPreview);
        if (m2) encoding = m2[1].toLowerCase();
      }
      // Normalize known aliases
      if (/^(shift[-_]?jis|x-sjis|sjis|ms932|windows-31j)$/i.test(encoding)) encoding = 'shift_jis';
      if (/^(euc[-_]?jp)$/i.test(encoding)) encoding = 'euc-jp';
      let html;
      try {
        html = new TextDecoder(encoding).decode(buf);
      } catch {
        html = new TextDecoder('utf-8').decode(buf);
      }
      const blocks = extractBlocksFromHtml(html, u) || [];
      let extracted = blocks.length, passed = 0;
      for (const b of blocks) {
        const n = normalizeForTemplate(b, stripChars);
        if (n && n.length >= 5) { collected.push({ text: n, sourceUrl: u }); passed++; }
      }
      stats.push({ url: u, extracted, passed, added: 0 });
    } catch (e) {
      console.warn('Fetch failed for', u, e);
      stats.push({ url: u, error: String(e && e.message || e) });
    }
  }
  if (collected.length) {
    const { externalTemplates = [] } = await chrome.storage.local.get({ externalTemplates: [] });
    const existing = (externalTemplates || []).map((v) => (typeof v === 'string' ? { text: v } : v)).filter((o) => o && o.text);
    const map = new Map(existing.map((o) => [o.text, o]));
    const beforeSize = map.size;
    for (const o of collected) { if (!map.has(o.text)) map.set(o.text, o); }
    const merged = Array.from(map.values());
    await chrome.storage.local.set({ externalTemplates: merged });
    const totalAdded = Math.max(0, map.size - beforeSize);
    // Approximate per-URL added by comparing against previous set
    const existingSet = new Set(existing.map((o) => o.text));
    const perUrlCounts = new Map();
    for (const o of collected) {
      if (!existingSet.has(o.text)) perUrlCounts.set(o.sourceUrl, (perUrlCounts.get(o.sourceUrl) || 0) + 1);
    }
    stats.forEach((st) => { if (st && st.url) st.added = perUrlCounts.get(st.url) || 0; });
    return { totalAdded, stats };
  }
  return { totalAdded: 0, stats };
}

chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
  if (!msg || !msg.type) return;
  if (msg.type === 'acps-fetch-refs') {
    const urls = Array.isArray(msg.urls) ? msg.urls : [];
    fetchReferenceUrls(urls)
      .then((result) => sendResponse({ ok: true, ...result }))
      .catch((e) => sendResponse({ ok: false, error: String(e && e.message || e) }));
    return true; // async
  }
});
