// Anti Copypaste Spam - content script (MV3)

const DEFAULT_SETTINGS = {
  enabled: true,
  threshold: 0.65, // Jaccard over char-4grams
  exactOnly: true, // 完全一致のみで判定
  minLength: 5,
  // 動画サムネサイズチェック（既定ON、許容サイズの統合リスト）
  videoThumbCheckEnabled: true,
  videoThumbAllowedSizes: [ { w: 251, h: 137 }, { w: 138, h: 250 } ],
  videoThumbTolerance: 0, // px; |w-allowed.w|<=tol && |h-allowed.h|<=tol を許容
  videoThumbAction: 'dim', // 'dim' | 'hide'
  // 動画時間チェック
  videoDurationCheckEnabled: false,
  videoDurationMinSec: 0,
  videoDurationMaxSec: 0,
  videoDurationAction: 'dim', // 'dim' | 'hide'
  videoDurationAllowedRanges: [], // [{min:sec, max:sec}] 空なら min/max を使用
  selectors: [
    'article',
    '.post',
    '.message',
    '.comment',
    '.res',
    '.reply',
    '.thread .row',
    '.body',
    '.postMessage',
    '.post-content',
    '.postBody',
  ],
  action: 'hide', // 'collapse' | 'dim' | 'hide'
  templates: [],
  ignoreRegexes: [
    // 既定では引用行を無視しない（必要ならオプションで追加）
  ],
  mediaExtLimits: { 
    ".mp4": 100000,
    ".jpg": 1000,
    ".png": 1000,
    ".webp": 100000,
  }, // bytes thresholds per extension
  mediaSkipEnabled: true,
  mediaCapacityRules: [], // [{ext:'.mp4', mode:'lt'|'ge'|'range', a:Number, b?:Number, enabled?:bool}]
  mediaCapacityAction: 'dim', // 'dim' | 'hide'
  allowedPhrases: [],
  ignoreInsertChars: '',
};

// Site-specific defaults (runtime override if user hasn't customized selectors)
const SITE_PROFILES = [
  {
    test: (loc) => /(^|\.)2chan\.net$/.test(loc.hostname),
    selectors: ['blockquote'],
    ignoreRegexes: [],
  },
];

let state = {
  settings: { ...DEFAULT_SETTINGS },
  initialized: false,
  lastScanAt: 0,
};

// Buffer for matched canonical texts to update stats in background without spamming storage
const __acps_hits = {
  // Map: normText -> latest detected post timestamp (epoch seconds) or 0 if unknown
  map: new Map(),
  timer: null,
};

function bufferHit(normText, postEpochSec) {
  try {
    if (!normText) return;
    const ts = Number(postEpochSec) || 0;
    const prev = __acps_hits.map.get(normText) || 0;
    if (ts > prev) __acps_hits.map.set(normText, ts);
    else if (!__acps_hits.map.has(normText)) __acps_hits.map.set(normText, 0);
    if (__acps_hits.timer) return;
    __acps_hits.timer = setTimeout(() => {
      try {
        const events = Array.from(__acps_hits.map.entries()).map(([key, ts]) => ({ key, ts }));
        __acps_hits.map.clear();
        __acps_hits.timer = null;
        if (events.length) chrome.runtime.sendMessage({ type: 'acps-hits', events });
      } catch { __acps_hits.map.clear(); __acps_hits.timer = null; }
    }, 500);
  } catch {}
}

function shouldRecordStats() {
  try {
    // Record only from visible top-level documents to avoid background/inactive tabs inflating counts
    if (document.visibilityState && document.visibilityState !== 'visible') return false;
    if (window.top && window.top !== window) return false; // skip iframes/previews
  } catch {}
  return true;
}

function parseTwoDigitYear(y) {
  const yy = Number(y);
  if (!isFinite(yy)) return null;
  // Map 00-69 -> 2000-2069, 70-99 -> 1970-1999
  return yy >= 70 ? (1900 + yy) : (2000 + yy);
}

function extractPostTimestampEpochSec(root) {
  try {
    const scope = (root && root.closest && (root.closest('td.rtd, table') || root)) || root || document;
    // Prefer Futaba's date span
    let t = '';
    try { t = scope.querySelector('.cnw')?.textContent || ''; } catch {}
    if (!t) {
      // Fallback: scan a limited amount of text for date-like pattern
      t = (scope.textContent || '').slice(0, 800);
    }
    // Patterns like: 25/10/13(月)19:52:57 or 25/10/13 19:52
    const m = t.match(/(\d{2})[\/\.](\d{1,2})[\/\.](\d{1,2}).{0,6}?(\d{1,2}):(\d{2})(?::(\d{2}))?/);
    if (!m) return null;
    const Y = parseTwoDigitYear(m[1]);
    const M = Number(m[2]) - 1; // zero-based
    const D = Number(m[3]);
    const h = Number(m[4]);
    const mi = Number(m[5]);
    const s = m[6] ? Number(m[6]) : 0;
    if ([Y, M, D, h, mi, s].some((v) => !isFinite(v))) return null;
    const dt = new Date(Y, M, D, h, mi, s);
    const epoch = Math.floor(dt.getTime() / 1000);
    // Basic sanity: ignore dates too far in past/future
    if (!epoch || epoch < 946684800 /* 2000-01-01 */ || epoch > 4102444800 /* 2100-01-01 */) return null;
    return epoch;
  } catch { return null; }
}

function log(...args) {
  // console.debug('[ACPS]', ...args);
}

function loadSettings() {
  return new Promise((resolve) => {
    chrome.storage.local.get(DEFAULT_SETTINGS, (res) => {
      // Merge defaults to fill missing fields
      const merged = { ...DEFAULT_SETTINGS, ...res };
      merged.templates = Array.isArray(res.templates) ? res.templates : [];
      merged.selectors = Array.isArray(res.selectors) && res.selectors.length
        ? res.selectors
        : DEFAULT_SETTINGS.selectors;
      merged.ignoreRegexes = Array.isArray(res.ignoreRegexes)
        ? res.ignoreRegexes
        : DEFAULT_SETTINGS.ignoreRegexes;
      // Ensure allowedPhrases is array
      merged.allowedPhrases = Array.isArray(res.allowedPhrases) ? res.allowedPhrases : [];
      // Deep-merge mediaExtLimits to preserve defaults per extension (backward-compat)
      const resLimits = (res && typeof res.mediaExtLimits === 'object') ? res.mediaExtLimits : {};
      merged.mediaExtLimits = { ...DEFAULT_SETTINGS.mediaExtLimits, ...resLimits };
      merged.mediaSkipEnabled = ('mediaSkipEnabled' in res) ? !!res.mediaSkipEnabled : DEFAULT_SETTINGS.mediaSkipEnabled;
      // Media capacity rules (prefer stored rules; do NOT migrate from defaults)
      merged.mediaCapacityRules = Array.isArray(res.mediaCapacityRules) ? res.mediaCapacityRules : [];
      // If empty, migrate only when a legacy mediaExtLimits actually exists in storage
      try {
        if (!merged.mediaCapacityRules.length) {
          chrome.storage.local.get({ mediaExtLimits: null }, (raw) => {
            try {
              const limits = raw && raw.mediaExtLimits && typeof raw.mediaExtLimits === 'object' ? raw.mediaExtLimits : null;
              if (limits) {
                const tmp = [];
                for (const [ext, bytes] of Object.entries(limits)) {
                  const n = Number(bytes) || 0; if (n > 0) tmp.push({ ext, mode: 'ge', a: n, enabled: true });
                }
                merged.mediaCapacityRules = tmp;
              }
            } catch {}
            state.settings = merged; resolve(merged);
          });
          return; // resolve in callback above
        }
      } catch {}
      merged.mediaCapacityAction = (res.mediaCapacityAction === 'hide') ? 'hide' : 'dim';
      merged.ignoreInsertChars = typeof res.ignoreInsertChars === 'string' ? res.ignoreInsertChars : '';
      // video thumb settings (unified list) with backward compatibility
      function normSize(o) {
        const w = Number(o && o.w); const h = Number(o && o.h);
        return (w>0 && h>0) ? { w, h } : null;
      }
      let As = Array.isArray(res.videoThumbAllowedSizes) ? res.videoThumbAllowedSizes.map(normSize).filter(Boolean) : [];
      // Backward-compat: merge old separate arrays/fields if unified list absent
      if (!As.length) {
        const Ls = Array.isArray(res.videoThumbLandscapeSizes) ? res.videoThumbLandscapeSizes.map(normSize).filter(Boolean) : [];
        const Ps = Array.isArray(res.videoThumbPortraitSizes) ? res.videoThumbPortraitSizes.map(normSize).filter(Boolean) : [];
        if (!Ls.length && res && typeof res.videoThumbLandscape === 'object') { const n = normSize(res.videoThumbLandscape); if (n) Ls.push(n); }
        if (!Ps.length && res && typeof res.videoThumbPortrait === 'object') { const n = normSize(res.videoThumbPortrait); if (n) Ps.push(n); }
        const map = new Set();
        for (const o of [...Ls, ...Ps]) { const k = `${o.w}x${o.h}`; if (!map.has(k)) { map.add(k); As.push(o); } }
      }
      merged.videoThumbAllowedSizes = As.length ? As : DEFAULT_SETTINGS.videoThumbAllowedSizes.slice();
      merged.videoThumbCheckEnabled = ('videoThumbCheckEnabled' in res) ? !!res.videoThumbCheckEnabled : DEFAULT_SETTINGS.videoThumbCheckEnabled;
      merged.videoThumbTolerance = Number(res.videoThumbTolerance)||0;
      merged.videoThumbAction = (res.videoThumbAction === 'hide') ? 'hide' : 'dim';
      // video duration settings
      merged.videoDurationCheckEnabled = ('videoDurationCheckEnabled' in res) ? !!res.videoDurationCheckEnabled : DEFAULT_SETTINGS.videoDurationCheckEnabled;
      merged.videoDurationMinSec = Math.max(0, Number(res.videoDurationMinSec) || 0);
      merged.videoDurationMaxSec = Math.max(0, Number(res.videoDurationMaxSec) || 0);
      merged.videoDurationAction = (res.videoDurationAction === 'hide') ? 'hide' : 'dim';
      // Allowed ranges (prefer these over single min/max)
      try {
        const arr = Array.isArray(res.videoDurationAllowedRanges) ? res.videoDurationAllowedRanges : [];
        const norm = [];
        for (const it of arr) {
          const mi = Math.max(0, Number(it && it.min) || 0);
          const ma = Math.max(0, Number(it && it.max) || 0);
          norm.push({ min: mi, max: ma });
        }
        merged.videoDurationAllowedRanges = norm;
      } catch { merged.videoDurationAllowedRanges = []; }
      // Map deprecated 'collapse' action to 'dim'
      if (merged.action === 'collapse') merged.action = 'dim';
      state.settings = merged;
      resolve(merged);
    });
  });
}

function saveSettings(partial) {
  return new Promise((resolve) => {
    chrome.storage.local.set(partial, resolve);
  });
}

function toNFKC(s) {
  try {
    return s.normalize('NFKC');
  } catch {
    return s;
  }
}

// Remove invisible/formatting-only characters while keeping newlines and spaces
function stripInvisible(s) {
  if (!s) return '';
  // Normalize Unicode line/paragraph separators to \n first
  s = s.replace(/[\u2028\u2029]/g, '\n');
  // Remove zero-width and bidi/variation/word-joiner marks and BOM/SHY
  // - BMP set: ZWSP/ZWNJ/ZWJ/WORD JOINER/BOM/SHY/LRM/RLM/Bidi Embedding/Override/Isolates/VS1-16
  // - Supplementary variation selectors: U+E0100–U+E01EF
  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 normalizeText(raw, ignoreRegexes) {
  if (!raw) return '';
  let text = toNFKC(raw);
  text = stripInvisible(text)
    .replace(/\r\n?|\n/g, '\n')
    .replace(/[\t\f\v]/g, ' ')
    .replace(/\u00A0/g, ' ');

  // Remove user-specified insertable characters (treated as ignorable)
  try {
    const chars = state && state.settings && typeof state.settings.ignoreInsertChars === 'string' ? state.settings.ignoreInsertChars : '';
    if (chars) {
      // Build alternation to support any code point, escaping regex specials
      const parts = [];
      for (const ch of chars) {
        const code = ch.codePointAt(0);
        if (code == null) continue;
        // Escape regex specials
        const escaped = ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
        parts.push(escaped);
      }
      if (parts.length) {
        const re = new RegExp('(?:' + parts.join('|') + ')', 'gu');
        text = text.replace(re, '');
      }
    }
  } catch {}

  // Remove URLs
  text = text.replace(/https?:\/\/\S+|www\.[^\s]+/gi, '');
  // Remove anchors and metadata-like lines according to ignoreRegexes
  if (ignoreRegexes && ignoreRegexes.length) {
    for (const pattern of ignoreRegexes) {
      try {
        const re = new RegExp(pattern, 'gmi');
        text = text.replace(re, '');
      } catch {}
    }
  }
  // Remove known deletion banners to align with original posts
  const deletionBanners = [
    '^スレッドを立てた人によって削除されました$',
    '^このレスは.*?削除されました$',
    '^(?:管理者|管理人|削除人|投稿者)によって削除されました$',
  ];
  for (const p of deletionBanners) {
    try { text = text.replace(new RegExp(p, 'gmi'), ''); } catch {}
  }
  // Remove typical ID/日時-ish tokens lightly
  text = text.replace(/ID:[A-Za-z0-9+/=\-]+/g, '');
  text = text.replace(/\d{2,4}[\/\-.]\d{1,2}[\/\-.]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?/g, '');

  // Collapse whitespace
  text = text
    .split('\n')
    .map((l) => l.trim())
    .filter((l) => l.length)
    .join('\n');

  return text;
}

function getElementText(el) {
  // Gather text from element excluding our UI and red-colored banners
  const 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;
  };
  const walk = (node) => {
    if (!node) return '';
    if (node.nodeType === Node.TEXT_NODE) return node.nodeValue || '';
    if (node.nodeType !== Node.ELEMENT_NODE) return '';
    const elem = node;
    if (elem.classList && (elem.classList.contains('acps-control') || elem.classList.contains('acps-inline-btn'))) return '';
    const tag = elem.tagName ? elem.tagName.toLowerCase() : '';
    if (tag === 'br') return '\n';
    if (isRedish(elem)) return '';
    let out = '';
    elem.childNodes.forEach((ch) => { out += walk(ch); });
    return out;
  };
  return walk(el) || '';
}

function charShingles(text, k = 4) {
  const s = text.replace(/\s+/g, '');
  const set = new Set();
  if (s.length <= k) {
    if (s.length > 0) set.add(s);
    return set;
  }
  for (let i = 0; i <= s.length - k; i++) {
    set.add(s.slice(i, i + k));
  }
  return set;
}

function jaccard(setA, setB) {
  if (!setA.size && !setB.size) return 1;
  let inter = 0;
  for (const v of setA) if (setB.has(v)) inter++;
  const uni = setA.size + setB.size - inter;
  return uni ? inter / uni : 0;
}

function buildControlBar(reasonText, onAllow) {
  const wrapper = document.createElement('div');
  wrapper.className = 'acps-control';
  const label = document.createElement('span');
  label.textContent = reasonText;
  const btnAllow = document.createElement('button');
  btnAllow.textContent = 'この文面を許可';
  btnAllow.className = 'acps-allow-btn';
  btnAllow.addEventListener('click', (e) => {
    e.stopPropagation();
    onAllow?.(e);
  });
  wrapper.append(label, btnAllow);
  return wrapper;
}

function ensureStyles() {
  if (document.getElementById('acps-style')) return;
  const style = document.createElement('style');
  style.id = 'acps-style';
  style.textContent = `
  /* Collapse should affect the textual body even when the root is a TABLE */
  .acps-flagged.acps-collapse { position: relative; }
  /* Collapse: prioritize our rules with !important to override site styles */
  .acps-flagged.acps-collapse .acps-body { max-height: 2.6em !important; overflow: hidden !important; display: block !important; }
  /* Fallback: if body marker is not available, collapse blockquote inside the flagged post */
  .acps-flagged.acps-collapse blockquote { max-height: 2.6em !important; overflow: hidden !important; display: block !important; }
  .acps-flagged.acps-dim { opacity: 0.35; }
  .acps-flagged.acps-hide { display: none !important; }
  .acps-control { font: 12px/1.4 system-ui, sans-serif; background: #ffe9a8; color: #333; border: 1px solid #e0c46a; padding: 4px 6px; margin: 4px 0; display: inline-flex; gap: 8px; align-items: center; }
  .acps-control button { font: inherit; padding: 2px 6px; cursor: pointer; }
  .acps-inline-btn { position: absolute; top: 0.25em; right: 0.25em; z-index: 9; background: rgba(255, 233, 168, 0.96); border: 1px solid #e0c46a; padding: 1px 6px; font: 11px system-ui, sans-serif; cursor: pointer; border-radius: 3px; box-shadow: 0 1px 2px rgba(0,0,0,0.08); opacity: 0; pointer-events: none; transition: opacity .15s ease-in-out; }
  .acps-host { position: relative; padding-right: 64px; }
  .acps-host:hover > .acps-inline-btn, .acps-inline-btn:focus { opacity: 1; pointer-events: auto; }
  .acps-reason { font: 12px/1.4 system-ui, sans-serif; background: #e6f0ff; color: #123; border: 1px solid #b7cffc; padding: 4px 6px; margin: 4px 0; display: inline-flex; gap: 8px; align-items: center; }
  `;
  document.documentElement.appendChild(style);
}

function sourceLabel(url) {
  if (!url) return null;
  try {
    const u = new URL(url, window.location.href);
    const seg = (u.pathname || '').split('/').filter(Boolean).pop();
    return seg || u.host || null;
  } catch {
    return null;
  }
}

// Build a canonical thread identifier from URL like 
// https://host/board/123456.html?foo -> https://host/board/123456.html
function canonicalThreadIdFromUrl(url) {
  if (!url) return null;
  try {
    const u = new URL(url, window.location.href);
    const path = u.pathname || '';
    // Find the last occurrence of digits.htm(l) in the path
    const re = /(\d+)\.html?/gi;
    let m, last = null;
    while ((m = re.exec(path))) last = m;
    if (!last) return null;
    const endIdx = last.index + last[0].length;
    const canonPath = path.slice(0, endIdx);
    // Normalize extension to .html for stable comparison
    const normPath = canonPath.replace(/\.htm$/i, '.html');
    return u.origin + normPath;
  } catch {
    return null;
  }
}

function getCandidates(selectors) {
  const set = new Set();
  const thrMain = document.querySelector('.thre');
  const scope = thrMain || document;
  for (const sel of selectors) {
    try {
      scope.querySelectorAll(sel).forEach((el) => set.add(el));
    } catch {}
  }
  if (set.size === 0) {
    try { scope.querySelectorAll('blockquote').forEach((el) => set.add(el)); } catch {}
  }
  // Filter out obvious container nodes with huge number of children but little text
  return Array.from(set).filter((el) => {
    // Avoid script/style/nav
    const tag = el.tagName?.toLowerCase();
    if (!tag || ['script', 'style', 'nav', 'header', 'footer', 'form'].includes(tag)) return false;
    return true;
  });
}

function parseSizeToBytes(str) {
  if (!str) return null;
  const m = String(str).trim().match(/([\d.,]+)\s*(B|KB|MB|GB)?/i);
  if (!m) return null;
  let n = parseFloat(m[1].replace(/,/g, ''));
  if (!isFinite(n)) return null;
  const unit = (m[2] || 'B').toUpperCase();
  const mult = unit === 'GB' ? 1024*1024*1024 : unit === 'MB' ? 1024*1024 : unit === 'KB' ? 1024 : 1;
  return Math.round(n * mult);
}

function extFromUrl(u) {
  try {
    const url = new URL(u, window.location.href);
    const path = url.pathname || '';
    const m = path.match(/\.([a-z0-9]+)$/i);
    return m ? ('.' + m[1].toLowerCase()) : null;
  } catch {
    const m = String(u || '').match(/\.([a-z0-9]+)(?:[#?].*)?$/i);
    return m ? ('.' + m[1].toLowerCase()) : null;
  }
}

function findSizeAfterAnchor(a) {
  // Collect text from following siblings until BR or next A
  let t = '';
  let node = a;
  for (let i = 0; i < 10 && node; i++) {
    node = node.nextSibling;
    if (!node) break;
    if (node.nodeType === Node.ELEMENT_NODE) {
      const tag = node.tagName.toLowerCase();
      if (tag === 'br') break;
      if (tag === 'a') break;
      t += ' ' + (node.textContent || '');
    } else if (node.nodeType === Node.TEXT_NODE) {
      t += ' ' + (node.nodeValue || '');
    }
  }
  const m = t.match(/\(([^)]+?)\s*B\)/i) || t.match(/-\s*\(([^)]+)\)/);
  if (m) {
    const b = parseSizeToBytes(m[1] + ' B');
    if (b) return b;
  }
  // Also try generic units like 4.1 MB
  const m2 = t.match(/([\d.,]+)\s*(KB|MB|GB)/i);
  if (m2) return parseSizeToBytes(m2[0]);
  return null;
}

function shouldRegulateByMedia(el, rules) {
  const list = Array.isArray(rules) ? rules : [];
  if (!list.length) return [];
  const root = findPostRoot(el) || el.closest && el.closest('td.rtd, table') || el;
  const anchors = root.querySelectorAll('a[href]');
  const out = [];
  for (const a of anchors) {
    const href = a.getAttribute('href') || '';
    const ext = extFromUrl(href);
    if (!ext) continue;
    let size = findSizeAfterAnchor(a);
    if (size == null) {
      // try from nearest image alt attribute which often holds bytes
      const img = root.querySelector('img[alt]');
      if (img) {
        const m = String(img.getAttribute('alt') || '').match(/([\d.,]+)\s*B/i);
        if (m) size = parseSizeToBytes(m[0]);
      }
    }
    if (size == null) continue; // cannot apply when size unknown
    for (const r of list) {
      if (!r || (r.enabled === false)) continue;
      const rx = String(r.ext || '').toLowerCase();
      if (!rx || rx !== ext.toLowerCase()) continue;
      const mode = String(r.mode || '').toLowerCase();
      const a = Math.max(0, Number(r.a) || 0);
      const b = Math.max(0, Number(r.b) || 0);
      let hit = false;
      if (mode === 'lt') hit = size < a;
      else if (mode === 'ge') hit = size >= a;
      else if (mode === 'range') {
        const lo = Math.min(a, b), hi = Math.max(a, b);
        hit = (size >= lo) && (hi === 0 ? true : size <= hi);
      }
      if (hit) {
        const modeLabel = mode === 'lt' ? '未満' : (mode === 'ge' ? '以上' : 'から〜まで');
        const valStr = mode === 'range' ? `${a}〜${b}B` : `${a}B`;
        out.push(`メディア容量規制: ${ext} ${modeLabel} ${valStr}（実サイズ ${size}B）`);
      }
    }
  }
  // Remove duplicates while preserving order
  const seen = new Set();
  return out.filter((s) => (s && !seen.has(s) && seen.add(s)));
}

function hasMediaAttachment(el) {
  try {
    const root = findPostRoot(el) || el.closest && el.closest('td.rtd, table') || el;
    if (root.querySelector('img')) return true;
  } catch {}
  const mediaExts = new Set(['.mp4', '.webm', '.jpg', '.jpeg', '.png', '.gif', '.webp']);
  const root = findPostRoot(el) || el.closest && el.closest('td.rtd, table') || el;
  const anchors = root.querySelectorAll('a[href]');
  for (const a of anchors) {
    const href = a.getAttribute('href') || '';
    const ext = extFromUrl(href);
    if (ext && mediaExts.has(ext)) return true;
  }
  return false;
}

function isOnlyArrowForMediaReply(normText, el) {
  // If the normalized text consists only of '>' or '＞' and whitespace/newlines,
  // and the post has a media attachment, treat it as media-reply and skip.
  const stripped = normText.replace(/[>＞\s]+/g, '');
  if (stripped.length === 0 && hasMediaAttachment(el)) return true;
  return false;
}

function isQuoteOnlyMediaReply(normText, el) {
  // If all non-empty lines start with '>' or '＞' and media is attached, skip.
  const lines = (normText || '').split('\n').map(s => s.trim()).filter(Boolean);
  if (!lines.length) return false;
  const allQuoted = lines.every((l) => /^[>＞]/.test(l));
  return allQuoted && hasMediaAttachment(el);
}

function attachInlineBlockButton(hostEl, normText) {
  // Temporarily disabled: inline "ブロック学習" button is not shown/usable
  return;
}

function markElement(target, reason, mode) {
  target.classList.add('acps-flagged');
  target.classList.remove('acps-hide', 'acps-dim', 'acps-collapse');
  switch (mode) {
    case 'hide': target.classList.add('acps-hide'); break;
    case 'dim': target.classList.add('acps-dim'); break;
    default: target.classList.add('acps-collapse');
  }
}

function unmarkElement(el) {
  const tgt = findPostRoot(el) || el;
  try {
    tgt.classList.remove('acps-hide', 'acps-dim', 'acps-collapse', 'acps-flagged');
    // Remove control bar and separator inserted by us
    const bar = tgt.querySelector(':scope > .acps-control');
    if (bar) bar.remove();
    const info = tgt.querySelector(':scope > .acps-reason');
    if (info) info.remove();
    const sep = tgt.querySelector(':scope > .acps-sep');
    if (sep) sep.remove();
    try {
      const body = tgt.querySelector('.acps-body');
      if (body) body.classList.remove('acps-body');
    } catch {}
  } catch {}
}

function findPostRoot(el) {
  let cur = el;
  let nearestTable = null;
  for (let i = 0; i < 10 && cur && cur !== document.documentElement; i++) {
    if (cur.tagName) {
      const tag = cur.tagName.toLowerCase();
      const id = cur.id || '';
      const cls = cur.className ? String(cur.className) : '';
      if (tag === 'table' && !nearestTable) nearestTable = cur;
      // If we're within a TD that represents a post cell, hide the enclosing TABLE
      if (tag === 'td' && (/\brtd\b/.test(cls) || /\brts\b/.test(cls))) {
        let up = cur;
        while (up && up !== document.documentElement) {
          if (up.tagName && up.tagName.toLowerCase() === 'table') return up;
          up = up.parentElement;
        }
      }
      if (tag === 'tr') {
        let up = cur;
        while (up && up !== document.documentElement) {
          if (up.tagName && up.tagName.toLowerCase() === 'table') return up;
          up = up.parentElement;
        }
      }
      if (tag === 'table' && (/^r\d+$/).test(id)) return cur; // Futaba post table
      if (tag === 'table' && /\bdeleted\b/.test(cls)) return cur; // Deleted view
      if (tag === 'table') {
        try { if (cur.querySelector && cur.querySelector('td.rtd, td.rts')) return cur; } catch {}
      }
      if (/\brtd\b/.test(cls)) return cur; // Futaba cell container (fallback)
      if (/\b(post|message|comment|res|reply)\b/.test(cls)) return cur;
      // Do NOT treat the thread container as a single post root
      if (tag === 'article') return cur;
    }
    cur = cur.parentElement;
  }
  return nearestTable || null;
}

function findBodyElement(root) {
  if (!root || !root.querySelector) return null;
  // Prefer true body text containers; fallback to first blockquote found
  const sels = [
    'blockquote',
    '.postMessage', '.post-content', '.postBody',
    '.message', '.comment', '.res', '.reply', '.cont'
  ];
  for (const sel of sels) {
    try {
      const el = root.querySelector(sel);
      if (el) return el;
    } catch {}
  }
  return null;
}

function getImgSize(img) {
  if (!img) return null;
  let w = null, h = null;
  try { const a = img.getAttribute('width'); if (a) w = parseInt(a, 10); } catch {}
  try { const a = img.getAttribute('height'); if (a) h = parseInt(a, 10); } catch {}
  if (!(w>0) || !(h>0)) {
    try { w = w>0 ? w : (img.naturalWidth || img.width || 0); } catch {}
    try { h = h>0 ? h : (img.naturalHeight || img.height || 0); } catch {}
  }
  if (!(w>0) || !(h>0)) return null;
  return { w, h };
}

function findVideoThumbInfo(root) {
  try {
    const scope = root || document;
    // Collect candidate video anchors within this post root
    const anchors = Array.from(scope.querySelectorAll('a[href]'));
    for (const a of anchors) {
      const href = a.getAttribute('href') || '';
      const ext = extFromUrl(href);
      if (!ext || !(ext === '.mp4' || ext === '.webm')) continue;
      // 1) Prefer an <img> directly inside the same anchor
      let img = null;
      try { img = a.querySelector('img'); } catch {}
      // 2) If not present, look for another anchor with the same href that contains an <img>
      if (!img) {
        try {
          const twin = anchors.find(x => x !== a && (x.getAttribute('href') || '') === href && x.querySelector && x.querySelector('img'));
          if (twin) img = twin.querySelector('img');
        } catch {}
      }
      // 3) As a last resort, look for the first <img> that appears after this anchor within the same root
      if (!img) {
        try {
          let n = a.nextSibling; let hops = 0;
          while (n && hops < 8) {
            if (n.nodeType === Node.ELEMENT_NODE) {
              const cand = n.querySelector ? n.querySelector('img') : null;
              if (cand) { img = cand; break; }
              if (n.tagName && n.tagName.toLowerCase() === 'img') { img = n; break; }
            }
            n = n.nextSibling; hops++;
          }
        } catch {}
      }
      const sz = getImgSize(img);
      if (sz) return { img, size: sz };
    }
  } catch {}
  return null;
}

function shouldFlagByVideoThumb(root) {
  try {
    if (!state.settings.videoThumbCheckEnabled) return null;
    const info = findVideoThumbInfo(root);
    if (!info) return null;
    const { w, h } = info.size;
    const As = Array.isArray(state.settings.videoThumbAllowedSizes) ? state.settings.videoThumbAllowedSizes : [];
    const tol = Math.max(0, Number(state.settings.videoThumbTolerance) || 0);
    const ok = As.some((o) => {
      if (o && Object.prototype.hasOwnProperty.call(o, 'enabled') && o.enabled === false) return false;
      const ow = Number(o && o.w) || 0; const oh = Number(o && o.h) || 0;
      return Math.abs(w - ow) <= tol && Math.abs(h - oh) <= tol;
    });
    if (ok) return null; // OK size
    // Mismatch
    return { reason: `動画サムネサイズ不一致 (${w}x${h})`, action: state.settings.videoThumbAction || 'dim' };
  } catch { return null; }
}

function findVideoHref(root) {
  try {
    const scope = root || document;
    const anchors = scope.querySelectorAll('a[href]');
    for (const a of anchors) {
      const href = a.getAttribute('href') || '';
      const ext = extFromUrl(href);
      if (!ext || !(ext === '.mp4' || ext === '.webm')) continue;
      // Resolve absolute URL relative to page
      try { return new URL(href, window.location.href).href; } catch { return href; }
    }
  } catch {}
  return null;
}

// Cache for video duration probes (per URL)
const __acps_vdur = { map: new Map(), inflight: new Map() };

function startVideoDurationProbe(url) {
  try {
    if (!url) return;
    if (__acps_vdur.map.has(url) || __acps_vdur.inflight.has(url)) return;
    const v = document.createElement('video');
    v.preload = 'metadata';
    v.muted = true;
    v.playsInline = true;
    v.style.position = 'fixed'; v.style.left = '-99999px'; v.style.top = '0'; v.width = 1; v.height = 1;
    document.documentElement.appendChild(v);
    const onDone = (dur) => {
      try { __acps_vdur.map.set(url, (typeof dur === 'number' && isFinite(dur) && dur > 0) ? dur : null); } catch {}
      try { v.removeAttribute('src'); v.load?.(); } catch {}
      try { v.remove(); } catch {}
      __acps_vdur.inflight.delete(url);
      try { scheduleScan(100); } catch {}
    };
    const timer = setTimeout(() => onDone(null), 8500);
    v.addEventListener('loadedmetadata', () => { clearTimeout(timer); onDone(v.duration); }, { once: true });
    v.addEventListener('error', () => { clearTimeout(timer); onDone(null); }, { once: true });
    __acps_vdur.inflight.set(url, true);
    // Assign after listeners
    v.src = url;
  } catch {}
}

function shouldFlagByVideoDuration(root) {
  try {
    if (!state.settings.videoDurationCheckEnabled) return null;
    const href = findVideoHref(root);
    if (!href) return null;
    if (!__acps_vdur.map.has(href) && !__acps_vdur.inflight.has(href)) startVideoDurationProbe(href);
    const dur = __acps_vdur.map.get(href);
    if (typeof dur !== 'number') return null; // not ready or unavailable -> do nothing
    const ranges = Array.isArray(state.settings.videoDurationAllowedRanges) ? state.settings.videoDurationAllowedRanges : [];
    let ok = true;
    if (ranges.length) {
      ok = ranges.some((r) => {
        if (r && Object.prototype.hasOwnProperty.call(r, 'enabled') && r.enabled === false) return false;
        const mi = Math.max(0, Number(r && r.min) || 0);
        const ma = Math.max(0, Number(r && r.max) || 0);
        if (mi && dur < mi) return false;
        if (ma && dur > ma) return false;
        return true;
      });
    } else {
      const minS = Math.max(0, Number(state.settings.videoDurationMinSec) || 0);
      const maxS = Math.max(0, Number(state.settings.videoDurationMaxSec) || 0);
      ok = true;
      if (minS > 0 && dur < minS) ok = false;
      if (maxS > 0 && dur > maxS) ok = false;
    }
    if (ok) return null;
    return { reason: `動画時間不一致 (${Math.round(dur)}s)`, action: state.settings.videoDurationAction || 'dim' };
  } catch { return null; }
}

function isWithinThreadContainer(node) {
  try {
    const thrMain = document.querySelector('.thre');
    if (!thrMain) return true; // If no thread container present, don't restrict
    const nearest = node && node.closest && node.closest('.thre');
    return nearest === thrMain;
  } catch { return true; }
}

function isPositionedOverlayInMainThread(node) {
  try {
    const thrMain = document.querySelector('.thre');
    if (!thrMain) return false;
    let cur = node;
    for (let i = 0; i < 8 && cur && cur !== document.documentElement; i++) {
      if (!thrMain.contains(cur)) break;
      if (cur.nodeType === Node.ELEMENT_NODE) {
        const cs = getComputedStyle(cur);
        const pos = cs && cs.position;
        if (pos === 'fixed' || pos === 'absolute') return true;
      }
      cur = cur.parentElement;
    }
  } catch {}
  return false;
}

// Removed overlay heuristics to keep logic simple; .thre scoping suffices

async function scan() {
  if (!state.settings.enabled) return;
  ensureStyles();
  // Safety: avoid accidentally dimming the entire thread container
  try {
    const thr = document.querySelector('.thre');
    if (thr) thr.classList.remove('acps-hide','acps-dim','acps-collapse','acps-flagged');
  } catch {}
  const { selectors, threshold, minLength, templates, action } = state.settings;

  const ignoreRegexes = state.settings.ignoreRegexes || [];
  const candidates = getCandidates(selectors);

  // Detect the first post root in document order and exclude it from detection
  let firstRoot = null;
  try {
    const seenRoots = new Set();
    for (const el of candidates) {
      const r = findPostRoot(el);
      if (!r || seenRoots.has(r)) continue;
      seenRoots.add(r);
      if (!firstRoot) { firstRoot = r; continue; }
      // If r precedes current firstRoot, update to earlier r
      if (firstRoot.compareDocumentPosition(r) & Node.DOCUMENT_POSITION_PRECEDING) {
        firstRoot = r;
      }
    }
  } catch {}

  const prior = []; // within page normalized texts
  const priorShingles = [];
  // Build allowed-phrases exact-match set (normalized)
  const { allowedPhrases = [] } = await new Promise((resolve) => chrome.storage.local.get({ allowedPhrases: [] }, resolve));
  const allowedSet = new Set((allowedPhrases || [])
    .map((t) => normalizeText(String(t || ''), ignoreRegexes))
    .filter(Boolean));
  // Include external templates (long/normal) and short templates
  const { externalTemplates = [], shortTemplates = [] } = await new Promise((resolve) => chrome.storage.local.get({ externalTemplates: [], shortTemplates: [] }, resolve));
  const extAll = (externalTemplates || []).map((v) => (typeof v === 'string' ? { text: v } : v)).filter((o) => o && o.text);
  const shortAll = (shortTemplates || []).map((v) => (typeof v === 'string' ? { text: v } : v)).filter((o) => o && o.text);
  // Skip entries learned from the same thread (identified by .../(digits).html)
  const curTid = canonicalThreadIdFromUrl(window.location.href);
  const extObjs = curTid
    ? extAll.filter((o) => !o.sourceUrl || canonicalThreadIdFromUrl(o.sourceUrl) !== curTid)
    : extAll;
  // short templates are always effective (no same-thread skip)
  const shortObjs = shortAll;
  const manualObjs = (templates || []).map((t) => ({ text: t }));
  const templateEntries = [...extObjs, ...manualObjs]
    .map((o) => {
      const norm = normalizeText(o.text, ignoreRegexes);
      return {
        norm,
        noWS: norm.replace(/\s+/g, ''),
        shingles: charShingles(norm, 4),
        shingles3: charShingles(norm, 3),
        sourceUrl: o.sourceUrl || null,
      };
    })
    .filter((e) => e.norm && e.norm.length);
  const shortEntries = shortObjs
    .map((o) => {
      const norm = normalizeText(o.text, ignoreRegexes);
      return { norm, noWS: norm.replace(/\s+/g, ''), sourceUrl: o.sourceUrl || null };
    })
    .filter((e) => e.norm && e.norm.length);
  const noWSIndex = new Map(templateEntries.map((e) => [e.noWS, e]));
  const byNorm = new Map(templateEntries.map((e) => [e.norm, e]));
  const byNormShort = new Map(shortEntries.map((e) => [e.norm, e]));

  // Process each post root once to avoid touching overlays/popups
  const processedRoots = new Set();
  for (const el of candidates) {
    // Avoid re-processing our own controls
    if (el.classList && el.classList.contains('acps-control')) continue;
    const root = findPostRoot(el);
    if (!root) continue; // Root-limited: skip elements without a post root
    // Only process roots within the main thread container if present
    if (!isWithinThreadContainer(root)) continue;
    // Skip overlays positioned within the thread container (e.g., preview popups)
    if (isPositionedOverlayInMainThread(root)) continue;
    if (processedRoots.has(root)) continue;
    processedRoots.add(root);
    const bodyEl = findBodyElement(root) || root;
    const rawText = getElementText(bodyEl).trim();
    const norm = normalizeText(rawText, ignoreRegexes);
    // 規制理由の集約（複数併記対応）
    const reasons = [];
    let regulateAction = null; // 'hide' が優先、なければ 'dim'
    const applyAction = (a) => { if (a === 'hide') regulateAction = 'hide'; else if (!regulateAction) regulateAction = 'dim'; };
    // 動画サムネ
    try {
      const v = shouldFlagByVideoThumb(root);
      if (v) { reasons.push(v.reason); applyAction(v.action === 'hide' ? 'hide' : 'dim'); }
    } catch {}
    // 動画時間（非同期プローブ; 未取得時は理由に含めない）
    try {
      const vd = shouldFlagByVideoDuration(root);
      if (vd) { reasons.push(vd.reason); applyAction(vd.action === 'hide' ? 'hide' : 'dim'); }
    } catch {}
    // メディア容量規制
    try {
      if (state.settings.mediaSkipEnabled) {
        const mediaReasons = shouldRegulateByMedia(root, state.settings.mediaCapacityRules);
        if (mediaReasons && mediaReasons.length) {
          reasons.push(...mediaReasons);
          applyAction((state.settings.mediaCapacityAction || 'dim') === 'hide' ? 'hide' : 'dim');
        }
      }
    } catch {}
    if (reasons.length) {
      const actionFinal = regulateAction === 'hide' ? 'hide' : 'dim';
      const combined = reasons.join(' ／ ');
      markElement(root, combined, actionFinal);
      if (actionFinal !== 'hide') {
        try {
          const bodyEl2 = findBodyElement(root) || root;
          const container2 = bodyEl2.parentElement || root;
          let info = container2.querySelector(':scope > .acps-reason');
          if (!info) {
            info = document.createElement('div'); info.className = 'acps-reason'; info.textContent = combined;
            try { container2.insertBefore(info, bodyEl2); } catch { container2.insertBefore(info, container2.firstChild); }
          } else { info.textContent = combined; }
        } catch {}
      }
      // 規制理由がある場合はコピペ検出をスキップ
      continue;
    }
    // Unconditionally skip the very first post in the thread/page
    try {
      if (firstRoot && root === firstRoot) { attachInlineBlockButton(root, norm); continue; }
    } catch {}
    // Short templates exact-match check (minLengthは無視)
    let reason = '';
    let matchedEntry = null;
    const shortHit = byNormShort.get(norm);
    if (shortHit) {
      matchedEntry = shortHit;
      reason = 'コピペ疑い(短文 完全一致)';
    }
    // If not matched by short list, apply minLength guard
    if (!reason) {
      if (norm.length <= minLength) { attachInlineBlockButton(el, norm); continue; }
    }
    // Skip media reply styles
    if (isOnlyArrowForMediaReply(norm, root) || isQuoteOnlyMediaReply(norm, root)) continue;

    // Special exemption: if large media is attached and text equals the specific cheer, skip detection
    // Media capacity regulation: if any rule matches, regulate display (dim/hide)
    // （上で集約済みのためメディア容量規制の個別処理は不要）

    // Allowed phrases (exact) skip
    if (allowedSet.has(norm)) { continue; }

    const isShort = norm.length < minLength;
    const sh = charShingles(norm, 4);
    const sh3 = isShort ? charShingles(norm, 3) : null;

    // reason/matchedEntry may already be set by shortHit above
    // Exact match first (全文正規化の完全一致)
    if (!reason) {
      const exactEntry = byNorm.get(norm);
      if (exactEntry) {
        matchedEntry = exactEntry;
        const label = sourceLabel(exactEntry.sourceUrl);
        reason = label ? `コピペ疑い(ログ一致 ${label} 完全一致)` : `コピペ疑い(学習一致 完全一致)`;
      }
    }
    // If exactOnly=false (将来用) then allow approximate
    if (!reason && !state.settings.exactOnly && templateEntries.length) {
      let maxT = 0;
      const shortThreshold = Math.max(0.85, threshold);
      const nowS = norm.replace(/\s+/g, '');
      const exactNoWS = noWSIndex.get(nowS);
      if (exactNoWS) {
        matchedEntry = exactNoWS;
        const label = sourceLabel(exactNoWS.sourceUrl);
        reason = label ? `コピペ疑い(ログ一致 ${label} 完全一致)` : `コピペ疑い(学習一致 完全一致)`;
      } else {
        for (const e of templateEntries) {
          let sim = jaccard(sh, e.shingles);
          if (isShort && sh3) {
            const sim3 = jaccard(sh3, e.shingles3);
            if (sim3 > sim) sim = sim3;
          }
          if (sim > maxT) maxT = sim;
          const th = isShort ? shortThreshold : threshold;
          if (sim >= th) {
            matchedEntry = e;
            const label = sourceLabel(e.sourceUrl);
            reason = label ? `コピペ疑い(ログ一致 ${label} ${sim.toFixed(2)})` : `コピペ疑い(学習一致 ${sim.toFixed(2)})`;
            break;
          }
        }
      }
    }

    // If not matched templates, try within-page duplicates
    if (!reason) {
      if (state.settings.exactOnly) {
        // Exact-only: flag only if an identical normalized text already appeared
        if (prior.includes(norm)) {
          reason = `コピペ疑い(スレ内重複 1.00)`;
        }
      } else if (priorShingles.length) {
        let maxP = 0;
        for (const psh of priorShingles) {
          const sim = jaccard(sh, psh);
          if (sim > maxP) maxP = sim;
          if (sim >= threshold) {
            reason = `コピペ疑い(スレ内重複 ${sim.toFixed(2)})`;
            break;
          }
        }
      }
    }

    // Update prior lists
    prior.push(norm);
    priorShingles.push(sh);

    // Act if flagged
    if (reason) {
      // Record hit for stats only when matched against learned/template lists (not intra-thread duplicates)
      // and when this is a visible top-level page to avoid background inflation
      if (matchedEntry && shouldRecordStats()) {
        let postTs = null;
        try { postTs = extractPostTimestampEpochSec(root); } catch {}
        if (postTs && postTs > 0) {
          try { bufferHit(norm, postTs); } catch {}
        } else {
          // Report extraction failure for this domain so the user can inspect in options
          try {
            const host = location.hostname || null;
            if (host) chrome.runtime.sendMessage({ type: 'acps-ts-fail', host });
          } catch {}
        }
      }
      // Always flag the post root to avoid affecting overlay/popups
      try { if (bodyEl) bodyEl.classList.add('acps-body'); } catch {}
      markElement(root, reason, action);
      if (action !== 'hide') {
        // Insert control bar near the body element (avoid placing under <table>)
        const bar = buildControlBar(reason, async (ev) => {
          // Toggle allow state and update button text in place
          const btn = ev && ev.currentTarget;
          const got = await chrome.storage.local.get({ allowedPhrases: [] });
          const cur = Array.isArray(got.allowedPhrases) ? got.allowedPhrases : [];
          const idx = cur.indexOf(norm);
          if (idx >= 0) {
            // Un-allow: remove from whitelist
            cur.splice(idx, 1);
            await chrome.storage.local.set({ allowedPhrases: cur });
            if (btn) btn.textContent = 'この文面を許可';
            try { scheduleScan(50); } catch {}
          } else {
            // Allow: add to whitelist and reveal this post
            await chrome.storage.local.set({ allowedPhrases: [...cur, norm] });
            if (btn) btn.textContent = '許可を取り消す';
            try { root.classList.remove('acps-hide','acps-dim','acps-collapse','acps-flagged'); } catch {}
          }
        });
        // Place the bar just before the body element when possible
        const bodyEl = findBodyElement(root) || root;
        // Avoid duplicates within the same container
        const container = bodyEl.parentElement || root;
        let barEl = container.querySelector(':scope > .acps-control');
        if (!barEl) {
          try { container.insertBefore(bar, bodyEl); } catch { container.insertBefore(bar, container.firstChild); }
          barEl = bar;
        }
        // Set initial allow button label based on current whitelist membership
        try {
          const allowBtn = barEl.querySelector && barEl.querySelector('.acps-allow-btn');
          if (allowBtn) {
            allowBtn.textContent = allowedSet.has(norm) ? '許可を取り消す' : 'この文面を許可';
          }
        } catch {}
        if (action === 'dim' && barEl && barEl.parentNode === container) {
          // Insert a single separator after the bar if not present
          const next = barEl.nextSibling;
          const isSep = (node) => node && node.nodeType === Node.ELEMENT_NODE && node.classList && node.classList.contains('acps-sep');
          if (!isSep(next)) {
            const br = document.createElement('br');
            br.className = 'acps-sep';
            container.insertBefore(br, barEl.nextSibling);
          }
        }
      }
    } else {
      // If previously flagged but now not, clear our marks
      if (root.classList.contains('acps-flagged')) {
        unmarkElement(root);
      }
      attachInlineBlockButton(root, norm);
    }
  }

  state.lastScanAt = Date.now();
}

function scheduleScan(ms = 50) {
  window.clearTimeout(scheduleScan._t);
  scheduleScan._t = window.setTimeout(() => scan().catch(() => {}), ms);
}

async function init() {
  await loadSettings();
  // Apply site profile if selectors unchanged from defaults
  try {
    const sameAsDefault = JSON.stringify(state.settings.selectors) === JSON.stringify(DEFAULT_SETTINGS.selectors);
    const prof = SITE_PROFILES.find((p) => p.test(window.location));
    if (prof) {
      if (sameAsDefault) state.settings.selectors = prof.selectors.slice();
      // Merge ignore rules
      const curIg = new Set(state.settings.ignoreRegexes || []);
      for (const r of prof.ignoreRegexes || []) curIg.add(r);
      // Remove quote-ignoring to allow "引用+レス"の丸ごとコピペ検出
      state.settings.ignoreRegexes = Array.from(curIg).filter((p) => p !== '^>.*$');
      if (typeof prof.minLength === 'number' && state.settings.minLength === DEFAULT_SETTINGS.minLength) {
        state.settings.minLength = prof.minLength;
      }
    }
  } catch {}
  state.initialized = true;
  scheduleScan(0);

  // React to DOM changes (AJAX loading)
  const mo = new MutationObserver((muts) => {
    let relevant = false;
    for (const m of muts) {
      if (m.addedNodes && m.addedNodes.length) { relevant = true; break; }
    }
    if (relevant) scheduleScan(200);
  });
  mo.observe(document.documentElement, { subtree: true, childList: true });

  // React to settings/templates changes
  chrome.storage.onChanged.addListener((changes, area) => {
    if (area !== 'local') return;
    let shouldReload = false;
    const next = { ...state.settings };
    for (const [k, v] of Object.entries(changes)) {
      next[k] = v.newValue;
      shouldReload = true;
    }
    state.settings = next;
    if (shouldReload) scheduleScan(50);
  });

  // Allow background to request a rescan immediately (after registrations, etc.)
  try {
    chrome.runtime.onMessage.addListener((msg) => {
      if (msg && msg.type === 'acps-refresh') scheduleScan(50);
    });
  } catch {}

  // Track the element under mouse to support hover-based block registration
  try {
    document.addEventListener('mousemove', (e) => {
      window.__acps_hoverTarget = e.target;
    }, { capture: true, passive: true });
  } catch {}

  // Provide body extraction for current hover target
  try {
    chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
      if (!msg || !msg.type) return;
      if (msg.type === 'acps-get-hover-body') {
        try {
          const t = window.__acps_hoverTarget || null;
          if (!t) { sendResponse({ ok: false, error: 'no-hover' }); return true; }
          const candidate = t.closest ? t.closest('blockquote, .post, .message, .comment, .res, .reply, .postMessage, .post-content, .postBody, .cont, td.rtd, table') || t : t;
          const root = findPostRoot(candidate) || candidate;
          if (!root) { sendResponse({ ok: false, error: 'no-root' }); return true; }
          if (!isWithinThreadContainer(root)) { sendResponse({ ok: false, error: 'outside-thread' }); return true; }
          if (isPositionedOverlayInMainThread(root)) { sendResponse({ ok: false, error: 'overlay' }); return true; }
          const bodyEl = findBodyElement(root) || root;
          const raw = getElementText(bodyEl).trim();
          const norm = normalizeText(raw, state.settings.ignoreRegexes || []);
          sendResponse({ ok: !!norm, text: norm });
        } catch (e) {
          sendResponse({ ok: false, error: String(e && e.message || e) });
        }
        return true;
      }
    });
  } catch {}
}

init().catch(() => {});
