Greasy Fork is available in English.

Advanced Word Replacer

Advanced word replacer: cross-node matching, sorted list, auto-merge novel groups, floating UI.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Advanced Word Replacer
// @namespace    http://tampermonkey.net
// @version      99.12.45
// @description  Advanced word replacer: cross-node matching, sorted list, auto-merge novel groups, floating UI.
// @author       You
// @match        *://*/*
// @exclude      *://greatest.deepsurf.us/*
// @exclude      *://sleazyfork.org/*
// @connect      api.github.com
// @connect      github.com
// @connect      update.greatest.deepsurf.us
// @connect      greatest.deepsurf.us
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function () {
  "use strict";

  const SCRIPT_VERSION =
    typeof GM_info !== "undefined" && GM_info.script && GM_info.script.version
      ? String(GM_info.script.version).trim()
      : "50.0";
  const currentHost = window.location.hostname.toLowerCase();
  const HIGHLIGHT_CLASS = "word-replacer-highlight";
  let observer, replacerTimeout, cloudTitleSyncTimeout;
  let lastProcessedValueMap = new WeakMap(),
    originalTextMap = new WeakMap(),
    hashOriginalTextMap = new WeakMap(); // FIX v54: terpisah dari originalTextMap agar walker utama tidak me-restore node hash

  // Anti-Flicker Rate Limiter: per-node mutation throttle maps
  let nodeLastReplacedTimeMap = new WeakMap();
  let nodeReplaceCountMap = new WeakMap();
  let nodeFlickerLockSet = new WeakSet();
  const NODE_FLICKER_WINDOW_MS = 600;
  const NODE_FLICKER_MAX_COUNT = 2;

  // Cache in-memory untuk active novel ID
  let _activeNovelIdCache = null;
  // Guard untuk mencegah self-mutation loop (flickering)
  let _replacingNow = false;

  let toast,
    toastTimeout,
    cachedToken = "",
    cachedGistId = "";

  let cachedGistDetails = null;
  let cachedUserProfile = null;

  let startX = 0,
    startY = 0;
  let tabAktif = "daftar";
  let settingSubTab = "filter";
  let subjekEdit = null;
  let showOtherTerms = !!GM_getValue("awr_show_other_terms", false);
  // ── FIX Bug 2: Scanner tab persistent state ────────────────────────────────
  // Disimpan di scope modul agar searchQuery/filterMode/sortMode/bulkSelected
  // tidak direset tiap rerenderScanner() -> renderTampilan() dipanggil ulang.
  let _scannerState = { searchQuery: "", filterMode: "all", sortMode: "alpha", bulkSelected: new Set() };
  let panel;

  // ── UI Health Monitor & Watchdog ──────────────────────────────────────────
  // Variabel untuk sistem auto-recovery UI
  let _uiHealthCheckInterval = null;
  let _lastRenderTimestamp = 0;
  let _renderInProgress = false;
  let _renderFailCount = 0;
  let _autoRetryTimer = null;
  let _lastHealthRecoveryTime = 0;
  const UI_HEALTH_CHECK_MS = 2000;     // cek setiap 2 detik
  const UI_FROZEN_THRESHOLD_MS = 8000; // render dianggap macet setelah 8 detik
  const RENDER_FAIL_MAX = 6;           // maks auto-retry sebelum berhenti
  const HEALTH_RECOVERY_COOLDOWN = 3000; // jeda min antar health-monitor recovery

  // ── Undo Stack ─────────────────────────────────────────────────────────────
  // Menyimpan snapshot kamus sebelum setiap operasi destruktif (simpan/hapus/revert)
  let _undoStack = [];
  const UNDO_STACK_MAX = 15; // maksimum entry undo yang disimpan per sesi

  // Watchdog untuk replacer: jika _replacingNow terlalu lama, reset
  let _replacerWatchdogTimer = null;
  const REPLACER_TIMEOUT_MS = 5000; // replacer dianggap macet setelah 5 detik

  function safeRenderTampilan() {
    // Batalkan auto-retry yang sedang terjadwal — kita akan render sekarang
    if (_autoRetryTimer) { clearTimeout(_autoRetryTimer); _autoRetryTimer = null; }

    if (_renderInProgress) {
      const elapsed = Date.now() - _lastRenderTimestamp;
      if (elapsed < UI_FROZEN_THRESHOLD_MS) return; // masih dalam batas wajar
      // Render macet — paksa reset state
      _renderInProgress = false;
      _replacingNow = false;
      console.warn("[AWR] Render macet (", elapsed, "ms), memaksa reset...");
    }
    _renderInProgress = true;
    _lastRenderTimestamp = Date.now();
    try {
      renderTampilan();
      _renderFailCount = 0; // sukses — reset counter
    } catch (err) {
      _renderFailCount++;
      console.error("[AWR] renderTampilan error (#" + _renderFailCount + "):", err);
      // Auto-retry bertahap tanpa tombol: 1s → 2s → 4s → ... (backoff)
      if (_renderFailCount <= RENDER_FAIL_MAX) {
        const delay = Math.min(1000 * Math.pow(1.5, _renderFailCount - 1), 8000);
        console.warn("[AWR] Akan auto-retry dalam", Math.round(delay / 100) / 10, "detik...");
        _autoRetryTimer = setTimeout(() => {
          _autoRetryTimer = null;
          _renderInProgress = false; // pastikan flag bersih sebelum retry
          safeRenderTampilan();
        }, delay);
      } else {
        console.error("[AWR] Terlalu banyak gagal (" + _renderFailCount + "x), berhenti auto-retry.");
        _renderFailCount = 0; // reset agar health monitor bisa coba lagi nanti
      }
    } finally {
      // Hanya reset jika tidak ada auto-retry yang dijadwalkan
      if (!_autoRetryTimer) _renderInProgress = false;
    }
  }

  function startReplacerWatchdog() {
    if (_replacerWatchdogTimer) clearTimeout(_replacerWatchdogTimer);
    _replacerWatchdogTimer = setTimeout(() => {
      if (_replacingNow) {
        console.warn("[AWR] Replacer watchdog: _replacingNow macet, memaksa reset.");
        _replacingNow = false;
      }
    }, REPLACER_TIMEOUT_MS);
  }

  // Simpan snapshot kamus ke undo stack sebelum operasi destruktif
  function pushUndoSnapshot(label) {
    try {
      const snapshot = JSON.stringify(core ? core.getKamus() : {});
      _undoStack.push({ snapshot, label: label || "Perubahan", ts: Date.now() });
      if (_undoStack.length > UNDO_STACK_MAX) _undoStack.shift();
    } catch(e) { console.warn("[AWR] pushUndoSnapshot gagal:", e); }
  }

  function popUndoAndRestore() {
    if (_undoStack.length === 0) {
      panggilToast("⟲ Tidak ada yang bisa di-undo.", "warn");
      return;
    }
    const last = _undoStack.pop();
    try {
      const restored = JSON.parse(last.snapshot);
      core.saveKamus(restored);
      core.simpanKeAwan(restored);
      core.jalankanPengganti(true);
      renderTampilan();
      panggilToast(`⟲ Undo "${last.label}" berhasil dikembalikan.`, "success");
    } catch(e) {
      panggilToast("⟲ Undo gagal: " + (e.message || "error"), "warn");
    }
  }

  function _triggerHealthRecovery(reason) {
    const now = Date.now();
    // Cooldown: jangan recovery lebih sering dari HEALTH_RECOVERY_COOLDOWN
    if (now - _lastHealthRecoveryTime < HEALTH_RECOVERY_COOLDOWN) return;
    if (_renderInProgress || _autoRetryTimer) return;
    _lastHealthRecoveryTime = now;
    console.warn("[AWR] Health recovery:", reason);
    safeRenderTampilan();
  }

  function startUIHealthMonitor(shadowRoot, wrapperEl, hostEl) {
    if (_uiHealthCheckInterval) clearInterval(_uiHealthCheckInterval);
    _uiHealthCheckInterval = setInterval(() => {
      try {
        // 1. Pastikan hostElement masih di document.body
        if (!document.body.contains(hostEl)) {
          console.warn("[AWR] hostElement terlepas dari DOM, menambahkan ulang...");
          document.body.appendChild(hostEl);
        }

        // 2. Pastikan wrapper masih di shadow root
        if (!shadowRoot.contains(wrapperEl)) {
          console.warn("[AWR] wrapper terlepas dari shadow DOM, menambahkan ulang...");
          shadowRoot.appendChild(wrapperEl);
        }

        // 3. Pastikan panel masih di wrapper
        if (panel && !wrapperEl.contains(panel)) {
          console.warn("[AWR] panel terlepas dari wrapper, menambahkan ulang...");
          wrapperEl.appendChild(panel);
        }

        // 4. Cek kesehatan panel jika terlihat (tidak hidden)
        if (panel && !panel.classList.contains("hidden")) {
          const body = panel.querySelector(".replacer-body");
          const hasHeader = panel.querySelector(".replacer-header");

          // Panel sama sekali blank (tidak ada header atau body)
          if (!body || !hasHeader) {
            _triggerHealthRecovery("Panel blank — tidak ada header/body");
            return;
          }

          const activePane = body.querySelector(".tab-pane.active");

          // Tab pane aktif kosong sama sekali
          if (activePane && activePane.childElementCount === 0) {
            _triggerHealthRecovery("Tab pane aktif kosong");
            return;
          }

          // Cek khusus Scanner: pane aktif harus punya .awr-scanner-list
          if (tabAktif === "scanner" && activePane) {
            const hasScannerList = activePane.querySelector(".awr-scanner-list");
            const hasEmptyMsg   = activePane.querySelector("[style*='scanner_empty']");
            // v99.12.36: awr-scanner-error-state = error yang sudah ditangani, BUKAN stuck state
            // Jangan trigger recovery karena akan membuat retry loop
            const hasErrorState = activePane.querySelector(".awr-scanner-error-state");
            // Pane ada tapi tidak punya elemen scanner sama sekali (dan bukan error/empty state)
            if (!hasScannerList && !hasEmptyMsg && !hasErrorState && activePane.childElementCount < 2) {
              _triggerHealthRecovery("Scanner pane tidak punya konten");
              return;
            }
          }
        }

        // 5. Watchdog untuk _replacingNow yang macet
        if (_replacingNow) {
          startReplacerWatchdog();
        }

      } catch (healthErr) {
        console.error("[AWR] Health monitor error:", healthErr);
      }
    }, UI_HEALTH_CHECK_MS);
  }

  const SVGS = {
    pen: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>`,
    trash: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`,
    book: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20M4 4.5A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5z"/></svg>`,
    recycle: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/></svg>`,
    gear: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1-2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1-2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
    cloud: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
    radar: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 2a10 10 0 0 1 10 10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>`,
    folder: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>`,
    eye: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`,
    link: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>`,
    disk: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>`,
    close: `<svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`,
    undo: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>`,
    copy: `<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`,
    refresh: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>`,
    lock: `<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>`,
  };

  function smartSanitizeTerm(text) {
    if (!text) return "";
    let trimmed = text.trim();
    // Hanya strip titik/koma/titikdua/titik-koma di akhir jika ada huruf/angka sebelumnya
    // TIDAK strip: !, ?, = agar kalimat panjang dengan karakter tsb tetap bisa dicocokkan
    if (/[\p{L}\p{N}]/u.test(trimmed)) {
      trimmed = trimmed.replace(/[.\:;]+$/, "");
    }
    return trimmed.trim();
  }

  function buildRegexPattern(term) {
    if (!term) return "";
    const normalized = term.normalize("NFC").toLowerCase();
    const vowelMap = {
      a: "(?:a|ā|aa)",
      ā: "(?:a|ā|aa)",
      e: "(?:e|ē|ee)",
      ē: "(?:e|ē|ee)",
      i: "(?:i|ī|ii)",
      ī: "(?:i|ī|ii)",
      o: "(?:o|ō|oo|ou)",
      ō: "(?:o|ō|oo|ou)",
      u: "(?:u|ū|uu)",
      ū: "(?:u|ū|uu)",
    };

    // Deteksi term multi-kata (ada spasi)
    const isMultiWord = /\s/.test(normalized);

    // Multi-kata: split hanya di spasi; satu-kata: split di spasi+tanda-hubung
    const parts = isMultiWord
      ? normalized.split(/\s+/)
      : normalized.split(/[\s\-_—–]+/);

    const patternParts = parts.map((part) => {
      const chars = part.split("");
      const escapedChars = chars.map((ch) => {
        if (ch === "*") return ".*?";
        // Vowel alternation HANYA untuk term satu-kata (lebih ringan, kurangi backtracking)
        if (!isMultiWord && vowelMap[ch]) return vowelMap[ch];
        return ch.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
      });
      return escapedChars.join("[\\u200b\\u200c\\u200d\\ufeff]?");
    });

    // Multi-kata: separator mengizinkan spasi, tanda baca, dan zero-width chars
    // FIX: Juga mengizinkan tanda baca (koma, titik, dll.) di antara kata
    // Satu-kata: separator kompleks untuk menangani tanda-hubung & zero-width chars
    const separator = isMultiWord
      ? "([\\s\\u00a0\\u200b\\u200c\\u200d\\ufeff,;:.!?'\"\\-\\u2018\\u2019\\u201c\\u201d]*[\\s\\u00a0\\u200b\\u200c\\u200d\\ufeff][\\s\\u00a0\\u200b\\u200c\\u200d\\ufeff,;:.!?'\"\\-\\u2018\\u2019\\u201c\\u201d]*)"
      : "([\\s\\-_—–\\u200b\\u200c\\u200d\\ufeff\\u00a0]*)";

    return patternParts.join(separator);
  }

  function buildBoundaryRegex(term, pattern, flags = "giu") {
    if (!term) return new RegExp("", flags);
    const normalized = term.normalize("NFC");
    const startsWithWordChar = /^[\p{L}\p{N}]/u.test(normalized);
    const endsWithWordChar = /[\p{L}\p{N}]$/u.test(normalized);
    const prefix = startsWithWordChar ? "(?<![\\p{L}\\b\\p{N}])" : "";
    const suffix = endsWithWordChar ? "(?![\\p{L}\\b\\p{N}])" : "";
    return new RegExp(prefix + pattern + suffix, flags);
  }

  function reconstructReplacement(replacement, capturedSeparators) {
    const repWords = replacement.split(/\s+/);
    const m = repWords.length;
    const numSeps = capturedSeparators.length;

    if (m <= 1) return replacement;

    const sepsToUse = [];
    if (numSeps === 0) {
      for (let i = 0; i < m - 1; i++) sepsToUse.push(" ");
    } else if (m - 1 === numSeps) {
      for (let i = 0; i < numSeps; i++)
        sepsToUse.push(capturedSeparators[i] || " ");
    } else if (m - 1 < numSeps) {
      for (let i = 0; i < m - 1; i++) {
        sepsToUse.push(
          i === m - 2
            ? capturedSeparators.slice(i).join("")
            : capturedSeparators[i] || " ",
        );
      }
    } else {
      for (let i = 0; i < m - 1; i++) {
        sepsToUse.push(i < numSeps ? capturedSeparators[i] || " " : " ");
      }
    }

    let result = "";
    for (let i = 0; i < m; i++) {
      result += repWords[i];
      if (i < m - 1) result += sepsToUse[i];
    }
    return result;
  }

  function unwrapFontTags() {
    if (!document.body) return;
    const fontTags = document.body.querySelectorAll("font");
    if (fontTags.length === 0) return;

    fontTags.forEach((font) => {
      const parent = font.parentNode;
      if (!parent) return;
      while (font.firstChild) {
        parent.insertBefore(font.firstChild, font);
      }
      parent.removeChild(font);
    });
    document.body.normalize();
  }

  function gmFetch(url, options = {}) {
    return new Promise((resolve, reject) => {
      const headers = options.headers || {};
      if (
        options.body &&
        !headers["Content-Type"] &&
        !headers["content-type"]
      ) {
        headers["Content-Type"] = "application/json";
      }
      GM_xmlhttpRequest({
        method: options.method || "GET",
        url: url,
        headers: headers,
        data: options.body || null,
        timeout: 10000,
        onload: function (response) {
          const isSuccess =
            (response.status >= 200 && response.status < 300) ||
            response.status === 302 ||
            response.status === 307;
          resolve({
            ok: isSuccess,
            status: response.status,
            statusText: response.statusText,
            json: () => {
              try {
                return Promise.resolve(JSON.parse(response.responseText));
              } catch (e) {
                return Promise.reject(e);
              }
            },
            text: () => Promise.resolve(response.responseText),
          });
        },
        ontimeout: () => reject(new Error("Request timeout")),
        onerror: (error) => reject(error),
      });
    });
  }

  async function verifyGitHubToken(token) {
    if (!token) return null;
    try {
      const response = await gmFetch(
        `https://api.github.com/user?t=${Date.now()}`,
        {
          method: "GET",
          headers: {
            Authorization: `token ${token}`,
            Accept: "application/vnd.github.v3+json",
          },
        },
      );
      if (response.ok) {
        return await response.json();
      }
    } catch (e) {
      console.error("Token verification failed:", e);
    }
    return null;
  }

  function extractGistId(str) {
    if (!str) return "";
    const clean = str.trim();
    const match =
      clean.match(/\/([a-f0-9]{32,})$/i) || clean.match(/^([a-f0-9]{32,})$/i);
    if (match) return match[1];
    const parts = clean.split("/");
    return parts[parts.length - 1].trim();
  }

  // GitHub credentials & Cloud storage
  function getGitHubToken() {
    if (cachedToken) return cachedToken;
    const val = GM_getValue("awr_github_token");
    return val ? String(val).trim() : "";
  }

  function getGistId() {
    if (cachedGistId) return cachedGistId;
    const val = GM_getValue("awr_gist_id");
    return val ? String(val).trim() : "";
  }

  function saveGitHubCredentials(token, gistId) {
    cachedToken = token.trim();
    cachedGistId = gistId.trim();
    GM_setValue("awr_github_token", cachedToken);
    GM_setValue("awr_gist_id", cachedGistId);
  }

  async function findExistingGist(token) {
    try {
      const response = await gmFetch(
        `https://api.github.com/gists?per_page=100&t=${Date.now()}`,
        {
          method: "GET",
          headers: {
            Authorization: `token ${token}`,
            Accept: "application/vnd.github.v3+json",
          },
        },
      );
      if (response.ok) {
        const gists = await response.json();
        if (Array.isArray(gists)) {
          const targetGist = gists.find(
            (gist) => gist.files && gist.files["awr_replacer_config.json"],
          );
          if (targetGist) return targetGist.id;
        }
      }
    } catch (e) {
      console.error("Gagal mendeteksi Gist otomatis:", e);
    }
    return null;
  }

  async function fetchGistDetails(token, gistId) {
    if (!token || !gistId) return null;
    try {
      const response = await gmFetch(
        `https://api.github.com/gists/${gistId}?t=${Date.now()}`,
        {
          method: "GET",
          headers: {
            Authorization: `token ${token}`,
            Accept: "application/vnd.github.v3+json",
          },
        },
      );
      if (response.ok) return await response.json();
    } catch (e) {
      console.error("Gagal mengambil rincian Gist:", e);
    }
    return null;
  }

  async function createGist(token, payload) {
    if (!token) return null;
    try {
      const body = {
        description: "Advanced Word Replacer Cloud Configuration",
        public: false,
        files: {
          "awr_replacer_config.json": {
            content: JSON.stringify(payload, null, 2),
          },
        },
      };
      const response = await gmFetch("https://api.github.com/gists", {
        method: "POST",
        headers: {
          Authorization: `token ${token}`,
          Accept: "application/vnd.github.v3+json",
        },
        body: JSON.stringify(body),
      });
      if (response.ok) {
        const data = await response.json();
        return data.id;
      }
    } catch (e) {
      console.error("Gagal membuat Gist baru:", e);
    }
    return null;
  }

  async function simpanKeAwan(
    kamus = null,
    domains = null,
    deletedWords = null,
    forceSnapshot = false,
  ) {
    const token = getGitHubToken();
    const gistId = getGistId();
    if (!token || !gistId) return;

    const finalKamus = kamus || getKamus();
    const finalDomains = domains || getTargetDomains();
    const finalBlacklist = getBlacklistDomains();
    const finalFilterMode = getFilterMode();
    const finalNovelTitles = JSON.parse(
      GM_getValue("awr_novel_titles_v2", "{}"),
    );
    const finalDeletedWords = deletedWords || getDeletedWords();

    // Sertakan semua pengaturan pengguna dalam backup cloud
    const payload = {
      kamus: finalKamus,
      domains: finalDomains,
      blacklist: finalBlacklist,
      filterMode: finalFilterMode,
      novelTitles: finalNovelTitles,
      deletedWords: finalDeletedWords,
      highlightAktif: GM_getValue("highlight_aktif_v4", true),
      lang: GM_getValue("awr_lang_v1", "id"),
      updateMode: GM_getValue("awr_update_mode_v1", "auto"),
      recycleAutoDeleteDays: GM_getValue("awr_recycle_auto_delete_days", "30"),
      showOtherTerms: GM_getValue("awr_show_other_terms", false),
    };

    const payloadStr = JSON.stringify(payload, null, 2);
    const filesToUpload = {
      "awr_replacer_config.json": { content: payloadStr },
    };

    if (forceSnapshot) {
      filesToUpload[getBackupFilename()] = { content: payloadStr };
    }

    try {
      const response = await gmFetch(`https://api.github.com/gists/${gistId}`, {
        method: "PATCH",
        headers: {
          Authorization: `token ${token}`,
          Accept: "application/vnd.github.v3+json",
        },
        body: JSON.stringify({ files: filesToUpload }),
      });
      if (response.ok) {
        const updatedDetails = await response.json();
        cachedGistDetails = updatedDetails;
      }
    } catch (e) {
      console.error("Kesalahan koneksi saat menyimpan ke awan:", e);
    }
  }

  async function sinkronisasiDariAwan(forceUpdate = false) {
    const token = getGitHubToken();
    const gistId = getGistId();
    if (!token || !gistId) return;

    try {
      const details = await fetchGistDetails(token, gistId);
      if (
        details &&
        details.files &&
        details.files["awr_replacer_config.json"]
      ) {
        let contentText = details.files["awr_replacer_config.json"].content;
        if (!contentText && details.files["awr_replacer_config.json"].raw_url) {
          const rawRes = await gmFetch(
            details.files["awr_replacer_config.json"].raw_url,
          );
          if (rawRes.ok) contentText = await rawRes.text();
        }
        if (contentText) {
          const parsed = JSON.parse(contentText);
          if (parsed) {
            if (parsed.kamus)
              GM_setValue("kamus_kata_v5", JSON.stringify(parsed.kamus));
            if (parsed.domains)
              GM_setValue("target_domains_v4", JSON.stringify(parsed.domains));
            if (parsed.blacklist)
              GM_setValue(
                "blacklist_domains_v1",
                JSON.stringify(parsed.blacklist),
              );
            if (parsed.filterMode)
              GM_setValue("filter_mode_v1", parsed.filterMode);
            if (parsed.deletedWords)
              GM_setValue(
                "awr_deleted_words_v1",
                JSON.stringify(parsed.deletedWords),
              );
            if (parsed.novelTitles)
              GM_setValue(
                "awr_novel_titles_v2",
                JSON.stringify(parsed.novelTitles),
              );
            // Pulihkan semua pengaturan tambahan dari cloud
            if (typeof parsed.highlightAktif !== "undefined")
              GM_setValue("highlight_aktif_v4", !!parsed.highlightAktif);
            if (parsed.lang)
              GM_setValue("awr_lang_v1", parsed.lang);
            if (parsed.updateMode)
              GM_setValue("awr_update_mode_v1", parsed.updateMode);
            if (parsed.recycleAutoDeleteDays !== undefined)
              GM_setValue("awr_recycle_auto_delete_days", String(parsed.recycleAutoDeleteDays));
            if (typeof parsed.showOtherTerms !== "undefined") {
              GM_setValue("awr_show_other_terms", !!parsed.showOtherTerms);
              showOtherTerms = !!parsed.showOtherTerms;
            }
            cachedGistDetails = details;
            // Sinkronisasi cloud TIDAK mengubah grup aktif yang dipilih pengguna

            if (forceUpdate) jalankanPengganti(true);
          }
        }
      }
    } catch (e) {
      console.error("Gagal sinkronisasi data dari awan:", e);
    }
  }

  function getBackupFilename() {
    const now = new Date();
    const pad = (num) => String(num).padStart(2, "0");
    return `backup_${now.getFullYear()}_${pad(now.getMonth() + 1)}_${pad(now.getDate())}_${pad(now.getHours())}_${pad(now.getMinutes())}_${pad(now.getSeconds())}.json`;
  }

  function parseBackupFilename(filename) {
    const match = filename.match(
      /^backup_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})\.json$/,
    );
    if (match) {
      const [_, yyyy, mm, dd, hh, min, ss] = match;
      return `${dd}/${mm}/${yyyy}, ${hh}:${min}:${ss}`;
    }
    return filename;
  }

  function getActiveNovelId() {
    if (_activeNovelIdCache !== null) return _activeNovelIdCache;
    _activeNovelIdCache = GM_getValue("awr_active_novel_id_v1", "");
    return _activeNovelIdCache;
  }
  function setActiveNovelId(id) {
    _activeNovelIdCache = null; // Invalidate cache first to prevent race condition [1]
    GM_setValue("awr_active_novel_id_v1", id);
    _activeNovelIdCache = id;
  }

  function getDeletedWords() {
    try {
      const val = GM_getValue("awr_deleted_words_v1");
      return val ? JSON.parse(val) : {};
    } catch (e) {
      return {};
    }
  }

  function saveDeletedWords(obj) {
    try {
      GM_setValue("awr_deleted_words_v1", JSON.stringify(obj));
    } catch (e) {
      console.error("Gagal menyimpan kata terhapus", e);
    }
  }

  function bersihkanRecycleBinOtomatis() {
    const limitDays = parseInt(
      GM_getValue("awr_recycle_auto_delete_days", "30"),
    );
    if (limitDays <= 0) return;

    const deletedWords = getDeletedWords(),
      now = Date.now(),
      msLimit = limitDays * 24 * 60 * 60 * 1000;
    let hasChanges = false;

    for (const key in deletedWords) {
      const item = deletedWords[key];
      if (item && item.deletedAt) {
        if (now - item.deletedAt >= msLimit) {
          delete deletedWords[key];
          hasChanges = true;
        }
      } else if (item) {
        item.deletedAt = now;
        hasChanges = true;
      }
    }

    if (hasChanges) {
      saveDeletedWords(deletedWords);
      simpanKeAwan(null, null, deletedWords);
    }
  }

  async function exportCredentials() {
    const token = getGitHubToken();
    const gistId = getGistId();
    const tanggal = new Date().toLocaleString("id-ID");
    const isi = [
      "// AWR Credentials - Advanced Word Replacer",
      "// Dibuat: " + tanggal,
      "// JANGAN bagikan file ini kepada siapapun!",
      "",
      'var AWR_TOKEN = ' + JSON.stringify(token) + ';',
      'var AWR_GIST_ID = ' + JSON.stringify(gistId) + ';',
      ""
    ].join("\n");

    // Coba showSaveFilePicker agar user bisa memilih lokasi simpan
    if (typeof window.showSaveFilePicker === "function") {
      try {
        const handle = await window.showSaveFilePicker({
          suggestedName: "awr_credentials.txt",
          types: [
            { description: "File Teks (.txt)", accept: { "text/plain": [".txt"] } },
            { description: "File JavaScript (.js)", accept: { "text/javascript": [".js"] } },
          ],
        });
        const writable = await handle.createWritable();
        await writable.write(isi);
        await writable.close();
        return;
      } catch (err) {
        if (err.name === "AbortError") return; // dibatalkan user
        // Gagal selain dibatalkan — lanjut ke fallback
      }
    }

    // Fallback: browser tidak mendukung dialog — unduh ke folder Downloads
    const blob = new Blob([isi], { type: "text/javascript" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = "awr_credentials.js";
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);
    panggilToast("⚠️ Browser tidak mendukung dialog lokasi. File diunduh ke folder Downloads.", "info");
  }

  function compareVersions(v1, v2) {
    const parts1 = v1.split(".").map(Number);
    const parts2 = v2.split(".").map(Number);
    for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
      const p1 = parts1[i] || 0;
      const p2 = parts2[i] || 0;
      if (p1 > p2) return 1;
      if (p1 < p2) return -1;
    }
    return 0;
  }

  function checkForUpdates(manual = false) {
    if (manual) panggilToast(t("toast_checking_update"), "info");
    GM_xmlhttpRequest({
      method: "GET",
      url:
        "https://update.greatest.deepsurf.us/scripts/580034/Advanced%20Word%20Replacer.meta.js?t=" +
        Date.now(),
      onload: function (response) {
        if (response.status === 200) {
          const match = response.responseText.match(/@version\s+([\d.]+)/);
          if (match) {
            const latestVersion = match[1].trim();
            const currentVersion = SCRIPT_VERSION.trim();
            if (compareVersions(latestVersion, currentVersion) > 0) {
              if (
                typeof window.AWR_UI_LIBRARY !== "undefined" &&
                typeof window.AWR_UI_LIBRARY.tampilkanKonfirmasi === "function"
              ) {
                window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
                  t("modal_update_title"),
                  t("modal_update_desc", latestVersion),
                  () => {
                    window.open(
                      "https://update.greatest.deepsurf.us/scripts/580034/Advanced%20Word%20Replacer.user.js",
                      "_blank",
                    );
                  },
                );
              } else {
                if (confirm(t("modal_update_desc", latestVersion))) {
                  window.open(
                    "https://update.greatest.deepsurf.us/scripts/580034/Advanced%20Word%20Replacer.user.js",
                    "_blank",
                  );
                }
              }
            } else {
              if (manual)
                panggilToast(
                  t("toast_already_latest", currentVersion),
                  "success",
                );
            }
          }
        } else if (manual) {
          panggilToast("Gagal memeriksa pembaruan.", "warn");
        }
      },
      onerror: () => {
        if (manual) panggilToast("Gagal memeriksa pembaruan.", "warn");
      },
    });
  }

  function isInsideNativeGlossary(el) {
    if (!el) return false;
    const element = el.nodeType === Node.TEXT_NODE ? el.parentElement : el;
    if (!element) return false;
    return !!element.closest(
      // Selektor umum (dibatasi ke inline elements agar tidak salah deteksi wrapper/parent)
      ".wtr-glossary, [data-term-id], [data-term], .glossary-term, .translation-term, [data-translation], .term-tooltip, .wtr-term, [data-wtr-term]," +
      // wtr-lab.com kata unik: HANYA span dengan data-slot="popover-trigger" (Radix UI popover trigger).
      // Dibatasi ke span agar wrapper <div>/<p>/<article> yang juga mungkin punya data-hash tidak
      // menyebabkan seluruh konten paragraf dianggap "di dalam glosari" dan skip semua penggantian.
      "span[data-slot='popover-trigger'], span[data-hash].text-patch",
    );
  }

  function getNovelBaseDomain(host) {
    if (!host) return "";
    let clean = host.toLowerCase().trim();
    let parts = clean.split(".");
    if (parts.length <= 2) return clean;
    const chapterPrefixRegex =
      /^(www|m|ch(apter)?-\d+|c\d+|vol(ume)?-\d+|\d+|vol(ume)?-\d+-ch(apter)?-\d+|ch(apter)?-\d+-vol(ume)?-\d+)$/i;
    while (parts.length > 2 && chapterPrefixRegex.test(parts[0])) {
      parts.shift();
    }
    return parts.join(".");
  }

  function titlesMatchFuzzy(title1, title2) {
    if (!title1 || !title2) return false;
    const t1 = title1.toLowerCase().trim();
    const t2 = title2.toLowerCase().trim();
    if (t1 === t2) return true;
    const stopWords = new Set([
      "a","an","the","of","in","on","at","to","for","by","and","or","is","its",
      "it","that","this","my","your","i","we","are","was","be","has","with","from"
    ]);
    function extractKeywords(t) {
      return t.split(/[\s\-_,.:;!?()[\]{}'"\/\\]+/)
        .map(w => w.replace(/[^a-z0-9\u00C0-\u024F\u4e00-\u9fff\u3040-\u30ff]/gi, "").toLowerCase())
        .filter(w => w.length > 2 && !stopWords.has(w));
    }
    const kw1 = extractKeywords(t1);
    const kw2 = extractKeywords(t2);
    if (kw1.length === 0 || kw2.length === 0) return false;
    const set1 = new Set(kw1);
    const set2 = new Set(kw2);
    let matchCount = 0;
    for (const w of set1) { if (set2.has(w)) matchCount++; }
    const minLen = Math.min(set1.size, set2.size);
    return matchCount >= 1 && matchCount >= Math.ceil(minLen * 0.6);
  }

  function sameNovelByUrl(url1, url2) {
    if (!url1 || !url2) return false;
    try {
      const toAbs = (u) => u.startsWith("http") ? u : "https://" + u;
      const u1 = new URL(toAbs(url1));
      const u2 = new URL(toAbs(url2));
      if (u1.hostname.replace(/^www\./, "") !== u2.hostname.replace(/^www\./, "")) return false;

      const NOVEL_KEYWORDS = new Set([
        "novel", "series", "serie", "book", "b",
        "story", "fiction", "f", "read", "manga", "manhua", "manhwa"
      ]);
      const isLocale = (s) =>
        /^[a-z]{1,3}(-[a-z]{2,4})?$/i.test(s) && !NOVEL_KEYWORDS.has(s.toLowerCase());

      function extractNovelKey(pathname) {
        const parts = pathname.split("/").filter(Boolean);
        let i = 0;
        while (i < parts.length && isLocale(parts[i])) i++;
        for (; i < parts.length - 1; i++) {
          if (NOVEL_KEYWORDS.has(parts[i].toLowerCase())) {
            return parts[i + 1].toLowerCase();
          }
        }
        i = 0;
        while (i < parts.length && isLocale(parts[i])) i++;
        return (parts[i] || "").toLowerCase();
      }

      const key1 = extractNovelKey(u1.pathname);
      const key2 = extractNovelKey(u2.pathname);
      return !!(key1 && key2 && key1 === key2);
    } catch (_) {
      return false;
    }
  }

  function getCachedNovelTitle(novelId) {
    if (!novelId) return "";
    try {
      const cacheVal = GM_getValue("awr_novel_titles_v2", "{}");
      const cache =
        typeof cacheVal === "string" ? JSON.parse(cacheVal) : cacheVal;
      const rawTitle = cache[novelId] || "";
      const cleaned = cleanTitleText(rawTitle);

      if (cleaned && cleaned !== rawTitle) {
        cache[novelId] = cleaned;
        GM_setValue("awr_novel_titles_v2", JSON.stringify(cache));
      }
      return cleaned;
    } catch (e) {
      return "";
    }
  }

  function triggerLazyTitleSync() {
    if (cloudTitleSyncTimeout) clearTimeout(cloudTitleSyncTimeout);
    cloudTitleSyncTimeout = setTimeout(() => {
      simpanKeAwan();
    }, 5000);
  }

  function saveCachedNovelTitle(novelId, title) {
    if (!novelId || !title) return;
    try {
      const cacheVal = GM_getValue("awr_novel_titles_v2", "{}");
      const cache =
        typeof cacheVal === "string" ? JSON.parse(cacheVal) : cacheVal;
      if (cache[novelId] !== title) {
        cache[novelId] = title;
        GM_setValue("awr_novel_titles_v2", JSON.stringify(cache));
        triggerLazyTitleSync();
      }
    } catch (e) {
      console.error("Gagal menyimpan cache judul", e);
    }
  }

  function cleanTitleText(str) {
    if (!str) return "";
    let parts = str
      .split(/\s+[-|–]\s+|\s*\|\s*/)
      .map((p) => p.trim())
      .filter(Boolean);
    let novelParts = parts.filter((p) => {
      let pLower = p.toLowerCase().trim();
      if (/^(chapter|ch|chap|volume|vol|bab|b\.)\.?\s*\d+/i.test(p))
        return false;
      if (/^\d+$/.test(p)) return false;
      if (
        pLower === "wtr-lab" ||
        pLower === "wtr" ||
        pLower === "lab" ||
        pLower === "wtr lab" ||
        pLower === "mtl" ||
        pLower === "lightnovel"
      )
        return false;
      if (
        /^(webnovel|wuxiaworld|novelupdates|qidian|readlightnovel|novelhall|boxnovel|royalroad|scribblehub|wattpad|story|novel|book|b|lightnovel|fiction|series)$/i.test(
          p,
        )
      )
        return false;
      return true;
    });

    if (novelParts.length > 0) {
      let t = novelParts[0];
      t = t.replace(/^\s*Read\s+/i, "");
      t = t.replace(
        /\s+RAW\s+(Indonesia|English|Spanish|German|Japanese)?\s+Translation\s*$/i,
        "",
      );
      t = t.replace(
        /\s+(Indonesia|English|Spanish|German|Japanese)?\s+Translation\s*$/i,
        "",
      );
      t = t.replace(/\s+RAW\s*$/i, "");
      t = t.replace(/\s+(online|free|novel|chapter|b\.)\s*$/i, "");

      t = t.replace(
        /\s*[:-|–|—]*\s*\b(?:chapter|ch|chap|volume|vol|bab|b\.|c|episode|ep)\.?\s*\d+\b.*/i,
        "",
      );
      t = t.replace(/\s+[:-|–|—]\s*\d+\s*$/i, "");
      t = t.replace(
        /^\s*(?:chapter|ch|chap|volume|vol|bab|b\.|c|episode|ep)\.?\s*\d+\s*[:-|–|—]*\s*/i,
        "",
      );

      return t.trim();
    }
    return "";
  }

  function extractNovelTitleFromDOM() {
    const metaSelectors = [
      'meta[property="og:novel:book_name"]',
      'meta[name="novel:book_name"]',
      'meta[name="book_name"]',
      'meta[property="og:title"]',
    ];
    for (const sel of metaSelectors) {
      const el = document.querySelector(sel);
      if (el && el.getAttribute("content")) {
        const clean = cleanTitleText(el.getAttribute("content"));
        if (clean && clean.length > 2) return clean;
      }
    }

    const breadcrumbs = document.querySelectorAll(
      '.breadcrumb-item a, .breadcrumb a, [class*="breadcrumb"] a, .breadcrumbs a',
    );
    for (const a of breadcrumbs) {
      const text = a.textContent.trim(),
        href = a.getAttribute("href") || "";
      if (
        text &&
        href &&
        (href.includes("/novel/") ||
          href.includes("/book/") ||
          href.includes("/story/") ||
          href.includes("/fiction/") ||
          href.includes("/series/"))
      ) {
        const clean = cleanTitleText(text);
        if (clean && clean.length > 2) return clean;
      }
    }

    const classPatterns = [
      ".book-title",
      ".novel-title",
      ".story-title",
      ".series-title",
      ".series-name",
      ".seriestitle",
      ".fic-title",
      ".fic_title",
      ".book-name",
      ".novel-name",
      ".title-book",
      "h1",
    ];
    for (const sel of classPatterns) {
      const el = document.querySelector(sel);
      if (el) {
        if (
          el.closest(
            "header, .header, #header, .navbar, .nav, .logo, #logo, footer, .footer, #footer",
          )
        )
          continue;
        if (el.textContent.trim()) {
          const clean = cleanTitleText(el.textContent);
          if (clean && clean.length > 2) return clean;
        }
      }
    }
    return cleanTitleText(document.title);
  }

  function getNovelContext() {
    const url = window.location.href,
      host = window.location.hostname.toLowerCase();
    let novelId = "",
      novelTitle = "",
      novelUrl = url;

    const storyMatch =
      url.match(/(https?:\/\/[^\/]+.*?\/story\/\d+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/book\/.*?_\d+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/book\/\d+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/novel\/\d+\/[^/]+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/novel\/\d+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/novel\/[^/]+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/fiction\/\d+)/i) ||
      url.match(/(https?:\/\/[^\/]+.*?\/series\/\d+)/i);

    if (storyMatch) {
      novelId = storyMatch[1].toLowerCase().trim();
    } else {
      const pathParts = window.location.pathname.split("/").filter(Boolean);
      const idx = pathParts.findIndex((p) =>
        ["novel", "series", "book", "b", "story", "fiction", "f"].includes(
          p.toLowerCase(),
        ),
      );
      if (idx !== -1 && pathParts[idx + 1]) {
        novelId = `${host}_novel_${pathParts[idx + 1]}`;
      } else if (pathParts.length > 0) {
        novelId = `${host}_novel_${pathParts[0]}`;
      } else {
        novelId = host;
      }
    }

    novelTitle = getCachedNovelTitle(novelId);
    if (!novelTitle) {
      const extracted = extractNovelTitleFromDOM();
      if (extracted && extracted.length > 2) {
        novelTitle = extracted;
        saveCachedNovelTitle(novelId, novelTitle);
      }
    }
    if (!novelTitle) {
      const lastPart = novelId.split("/").pop() || novelId.split("_").pop();
      novelTitle = lastPart
        .replace(/[-_]/g, " ")
        .replace(/\b\w/g, (c) => c.toUpperCase());
    }

    return { id: novelId, title: novelTitle, url: novelUrl };
  }

  function getKamus() {
    try {
      let val = GM_getValue("kamus_kata_v5");
      let kamus = val ? JSON.parse(val) : null;
      if (kamus) {
        let cleaned = {},
          hasChanges = false;
        for (const key in kamus) {
          // hash: entries must preserve their original casing (data-hash values are case-sensitive)
          const normalizedKey = key.startsWith("hash:")
            ? key.normalize("NFC").trim()
            : key.normalize("NFC").trim().toLowerCase();
          if (key !== normalizedKey) hasChanges = true;
          if (!cleaned[normalizedKey]) {
            let entryVal = kamus[key];
            if (entryVal && typeof entryVal === "object" && entryVal.novelId) {
              const normNId = entryVal.novelId.normalize("NFC").trim().toLowerCase();
              if (normNId !== entryVal.novelId) {
                hasChanges = true;
                entryVal = Object.assign({}, entryVal, { novelId: normNId });
              }
            }
            cleaned[normalizedKey] = entryVal;
          }
        }
        if (hasChanges) {
          GM_setValue("kamus_kata_v5", JSON.stringify(cleaned));
          kamus = cleaned;
        }
        return kamus;
      }

      const v4Val = GM_getValue("kamus_kata_v4");
      if (v4Val) {
        const v4 = JSON.parse(v4Val),
          v5 = {};
        for (const key in v4) {
          v5[key.normalize("NFC").trim().toLowerCase()] = {
            to: v4[key],
            global: true,
            domain: getNovelBaseDomain(currentHost),
          };
        }
        GM_setValue("kamus_kata_v5", JSON.stringify(v5));
        return v5;
      }

      const defaultKamus = {
        silahkan: { to: "silakan", global: true, domain: "wikipedia.org" },
        wikipedia: {
          to: "Ensiklopedia Bebas",
          global: true,
          domain: "wikipedia.org",
        },
        salah: { to: "keliru", global: true, domain: "detik.com" },
      };
      GM_setValue("kamus_kata_v5", JSON.stringify(defaultKamus));
      return defaultKamus;
    } catch (e) {
      return {};
    }
  }

  function saveKamus(obj) {
    try {
      GM_setValue("kamus_kata_v5", JSON.stringify(obj));
    } catch (e) {
      console.error("Gagal menyimpan kamus v5", e);
    }
  }

  // remapMode: "auto" (smart match), "force" (paksa remap ke grup aktif), "keep" (pertahankan ID asli)
  function mergeKamus(backupKamus, backupNovelTitles, remapMode = "auto") {
    const currentKamus = getKamus();
    const activeNovelId = getActiveNovelId();
    const currentNovel = getNovelContext();
    // Target ID untuk remap: grup yang sedang aktif diprioritaskan
    const targetId = (activeNovelId && activeNovelId !== "GLOBAL_ONLY")
      ? activeNovelId.normalize("NFC").trim().toLowerCase()
      : (currentNovel.id || "").normalize("NFC").trim().toLowerCase();

    const localTitlesCache = (() => {
      try { return JSON.parse(GM_getValue("awr_novel_titles_v2", "{}")); } catch (e) { return {}; }
    })();
    const combinedTitles = Object.assign({}, localTitlesCache, backupNovelTitles || {});

    let mergedCount = 0;
    for (const key in backupKamus) {
      const normKey = key.normalize("NFC").trim().toLowerCase();
      // Skip jika kata sudah ada di kamus lokal
      if (currentKamus[normKey]) continue;

      const srcEntry = backupKamus[key];
      let entry = (srcEntry && typeof srcEntry === "object")
        ? Object.assign({}, srcEntry)
        : srcEntry;

      if (entry && typeof entry === "object" && entry.novelId) {
        entry.novelId = entry.novelId.normalize("NFC").trim().toLowerCase();
      }

      if (entry && typeof entry === "object" && entry.novelId && !entry.global) {
        const entryId = entry.novelId;

        if (remapMode === "force" && targetId) {
          // Paksa remap semua ID ke grup aktif
          entry.novelId = targetId;
          entry.novelTitle = entry.novelTitle || currentNovel.title || "";
        } else if (remapMode === "auto") {
          // Smart remap: coba cocokan via judul/URL
          if (entryId !== targetId) {
            const backupTitle = (
              entry.novelTitle || combinedTitles[entryId] || ""
            ).toLowerCase().trim();
            const currentTitle = (currentNovel.title || "").toLowerCase().trim();

            if (titlesMatchFuzzy(backupTitle, currentTitle)) {
              entry.novelId = targetId || entryId;
              if (!entry.novelTitle) entry.novelTitle = currentNovel.title;
            } else if (sameNovelByUrl(entryId, currentNovel.id || "")) {
              entry.novelId = targetId || entryId;
              if (!entry.novelTitle) entry.novelTitle = currentNovel.title;
            } else if (entry.novelUrl && sameNovelByUrl(entry.novelUrl, currentNovel.url || currentNovel.id || "")) {
              entry.novelId = targetId || entryId;
              if (!entry.novelTitle) entry.novelTitle = currentNovel.title;
            }
          }
        }
        // remapMode === "keep": tidak ada perubahan ID, pertahankan ID asli
      }

      currentKamus[normKey] = entry;
      mergedCount++;
    }
    saveKamus(currentKamus);
    simpanKeAwan(currentKamus);
    return mergedCount;
  }

  function perbaikiIdNovel() {
    const kamus = getKamus();
    const currentNovel = getNovelContext();
    const pageTitle = (currentNovel.title || "").toLowerCase().trim();
    if (!pageTitle || !currentNovel.id) return 0;

    const titlesCache = (() => {
      try { return JSON.parse(GM_getValue("awr_novel_titles_v2", "{}")); } catch(e) { return {}; }
    })();

    let count = 0;
    let berubah = false;
    for (const key in kamus) {
      const entry = kamus[key];
      if (!entry || typeof entry !== "object" || entry.global || !entry.novelId) continue;
      if (entry.novelId === currentNovel.id) continue;

      const entryTitle = (
        entry.novelTitle ||
        titlesCache[entry.novelId] ||
        getCachedNovelTitle(entry.novelId) ||
        ""
      ).toLowerCase().trim();

      if (titlesMatchFuzzy(entryTitle, pageTitle)) {
        entry.novelId = currentNovel.id;
        if (!entry.novelTitle) entry.novelTitle = currentNovel.title;
        kamus[key] = entry;
        count++;
        berubah = true;
      }
    }

    if (berubah) {
      saveKamus(kamus);
      simpanKeAwan(kamus);
    }
    return count;
  }

  function getTargetDomains() {
    try {
      return JSON.parse(
        GM_getValue(
          "target_domains_v4",
          JSON.stringify([
            "wtr-lab.com","webnovel.com","novelbin.com","novelbin.net",
            "novelupdates.com","wuxiaworld.com","royalroad.com","scribblehub.com",
            "lightnovelworld.com","mtlnovel.com","novelhall.com","boxnovel.com",
            "readlightnovel.me","noveltranslate.com","readnovelfull.com",
            "zinnovel.com","novelsonline.net","fanfiction.net",
            "archiveofourown.org","wattpad.com"
          ]),
        ),
      );
    } catch (e) {
      return [
        "wtr-lab.com","webnovel.com","novelbin.com","novelbin.net",
        "novelupdates.com","wuxiaworld.com","royalroad.com","scribblehub.com",
        "lightnovelworld.com","mtlnovel.com","novelhall.com","boxnovel.com",
        "readlightnovel.me","noveltranslate.com","readnovelfull.com",
        "zinnovel.com","novelsonline.net","fanfiction.net",
        "archiveofourown.org","wattpad.com"
      ];
    }
  }

  function saveTargetDomains(domains) {
    try {
      GM_setValue("target_domains_v4", JSON.stringify(domains));
    } catch (e) {
      console.error("Gagal domain whitelist", e);
    }
  }

  function getBlacklistDomains() {
    try {
      return JSON.parse(
        GM_getValue(
          "blacklist_domains_v1",
          '["google.com", "facebook.com", "youtube.com"]',
        ),
      );
    } catch (e) {
      return ["google.com", "facebook.com", "youtube.com"];
    }
  }

  function saveBlacklistDomains(domains) {
    try {
      GM_setValue("blacklist_domains_v1", JSON.stringify(domains));
    } catch (e) {
      console.error("Gagal domain blacklist", e);
    }
  }

  function getFilterMode() {
    return GM_getValue("filter_mode_v1", "whitelist");
  }
  function saveFilterMode(mode) {
    GM_setValue("filter_mode_v1", mode);
  }

  function getKamusAktif(domainAllowed) {
    const kamus = getKamus();
    const aktif = {};
    const currentNovel = getNovelContext();
    const pageBaseDomain = getNovelBaseDomain(currentHost);
    const activeNovelId = getActiveNovelId();

    // Mode GLOBAL_ONLY: hanya aktifkan kata global
    if (activeNovelId === "GLOBAL_ONLY") {
      for (const salah in kamus) {
        if (salah.startsWith("_off_:")) continue; // v99.12.37: rule dinonaktifkan sementara
        const item = kamus[salah];
        if (item && typeof item === "object" && item.global) {
          const toVal = typeof item.to === "string" ? item.to : "";
          aktif[salah] = { to: toVal, cs: !!(item.caseSensitive) };
        }
      }
      return aktif;
    }

    // ID efektif: jika grup diaktifkan secara eksplisit, gunakan itu; jika tidak, gunakan ID halaman saat ini
    const effectiveId = activeNovelId
      ? activeNovelId.normalize("NFC").trim().toLowerCase()
      : (currentNovel.id || "").normalize("NFC").trim().toLowerCase();

    for (const salah in kamus) {
      if (salah.startsWith("_off_:")) continue; // v99.12.37: rule dinonaktifkan sementara
      const item = kamus[salah];
      if (!item) continue;

      // Entri lama (plain string)
      if (typeof item === "string") {
        aktif[salah] = { to: item, cs: false };
        continue;
      }
      if (typeof item !== "object") continue;

      const toVal = typeof item.to === "string" ? item.to : "";
      const cs = !!(item.caseSensitive);

      // Kata global: selalu aktif (ketika domain diizinkan)
      if (item.global) {
        aktif[salah] = { to: toVal, cs };
        continue;
      }

      // Kata spesifik novel/grup
      if (item.novelId) {
        const itemId = item.novelId.normalize("NFC").trim().toLowerCase();

        // Cocokkan ID langsung (termasuk grup yang diaktifkan secara eksplisit)
        if (effectiveId && itemId === effectiveId) {
          aktif[salah] = { to: toVal, cs };
          continue;
        }

        // FIX v53 (Hipotesis D): Kembalikan guard !activeNovelId untuk fuzzy match,
        // tapi tetap aktifkan jika item berasal dari domain yang sama dengan halaman saat ini.
        // Ini mencegah aturan dari novel lain yang judulnya mirip ikut aktif, tapi tetap
        // mendukung kasus "nama novel sama, ID berbeda, situs sama" (seperti WebNovel, wtr-lab).
        const sameHostDomain = item.domain &&
          getNovelBaseDomain(item.domain) === getNovelBaseDomain(currentHost);
        if (!activeNovelId || sameHostDomain) {
          const itemTitle = (item.novelTitle || getCachedNovelTitle(itemId) || "").toLowerCase().trim();
          const pageTitle = (currentNovel.title || "").toLowerCase().trim();
          if (itemTitle && pageTitle && titlesMatchFuzzy(itemTitle, pageTitle)) {
            aktif[salah] = { to: toVal, cs };
            continue;
          }
          if (sameNovelByUrl(itemId, currentNovel.id || window.location.href)) {
            aktif[salah] = { to: toVal, cs };
            continue;
          }
          if (item.novelUrl && sameNovelByUrl(item.novelUrl, currentNovel.url || window.location.href)) {
            aktif[salah] = { to: toVal, cs };
            continue;
          }
        }
        continue;
      }

      // Kata berbasis domain (tidak ada novelId, tidak global)
      if (domainAllowed && item.domain) {
        const termBaseDomain = getNovelBaseDomain(item.domain);
        if (
          termBaseDomain === pageBaseDomain ||
          (termBaseDomain && pageBaseDomain.endsWith("." + termBaseDomain))
        ) {
          aktif[salah] = { to: toVal, cs };
        }
      }
    }
    return aktif;
  }

  function getHighlightAktif() {
    return GM_getValue("highlight_aktif_v4", true);
  }
  function saveHighlightAktif(val) {
    GM_setValue("highlight_aktif_v4", val);
  }

  function injectHighlightStyle() {
    if (document.getElementById("awr-highlight-style")) return;
    const s = document.createElement("style");
    s.id = "awr-highlight-style";
    s.textContent = [
      "." + HIGHLIGHT_CLASS + " {",
      "  color: #3b82f6 !important;",
      "  font-weight: bold !important;",
      "  cursor: pointer !important;",
      "  background: rgba(59,130,246,0.08) !important;",
      "  border-radius: 2px !important;",
      "  padding: 0 1px !important;",
      "  outline: none !important;",
      "  text-decoration: none !important;",
      "}",
    ].join("\n");
    (document.head || document.documentElement).appendChild(s);
  }

  const NOVEL_SITE_PRESETS = [
    "wtr-lab.com",
    "webnovel.com",
    "novelbin.com",
    "novelbin.net",
    "novelupdates.com",
    "wuxiaworld.com",
    "royalroad.com",
    "scribblehub.com",
    "lightnovelworld.com",
    "mtlnovel.com",
    "novelhall.com",
    "boxnovel.com",
    "readlightnovel.me",
    "noveltranslate.com",
    "readnovelfull.com",
    "zinnovel.com",
    "novelsonline.net",
    "fanfiction.net",
    "archiveofourown.org",
    "wattpad.com",
  ];

  // v52.5: Site-specific kata-unik selector presets
  // Mendukung situs yang membungkus nama dalam span khusus dengan data-hash
  const SITE_KATA_UNIK_PRESETS = {
    "wtr-lab.com": {
      // FIX v53: Hanya targetkan chapter spans menggunakan data-slot='popover-trigger'
      // yang UNIK milik teks chapter. Sidebar glossary native TIDAK memiliki atribut ini,
      // sehingga sidebar tidak ikut diganti oleh applyHashBasedReplacements.
      // isInsideNativeGlossary tetap melindungi KEDUA jenis span dari dihapus oleh replacer reguler.
      glossarySelector: "span[data-slot='popover-trigger'][data-hash]",
      hashAttr: "data-hash",
      label: "wtr-lab (Radix popover — chapter only)"
    },
    "webnovel.com": {
      glossarySelector: "span[data-hash]",
      hashAttr: "data-hash",
      label: "WebNovel"
    },
    "novelbin.com": {
      glossarySelector: "span[data-hash]",
      hashAttr: "data-hash",
      label: "NovelBin"
    },
    "novelfull.com": {
      glossarySelector: "span[data-hash]",
      hashAttr: "data-hash",
      label: "NovelFull"
    }
  };

  function getSiteKataUnikSelector() {
    const baseDomain = getNovelBaseDomain(currentHost);
    for (const domain in SITE_KATA_UNIK_PRESETS) {
      if (baseDomain === domain || baseDomain.endsWith("." + domain)) {
        return SITE_KATA_UNIK_PRESETS[domain];
      }
    }
    // Default fallback: any span with data-hash
    return { glossarySelector: "span[data-hash]", hashAttr: "data-hash", label: "Generic" };
  }

  function isDomainAllowed() {
    const mode = getFilterMode(),
      pageBaseDomain = getNovelBaseDomain(currentHost);
    if (mode === "whitelist") {
      const whitelist = getTargetDomains();
      return whitelist.some((d) => {
        const clean = getNovelBaseDomain(d.trim().toLowerCase());
        return (
          clean &&
          (pageBaseDomain === clean || pageBaseDomain.endsWith("." + clean))
        );
      });
    } else {
      const blacklist = getBlacklistDomains();
      return !blacklist.some((d) => {
        const clean = getNovelBaseDomain(d.trim().toLowerCase());
        return (
          clean &&
          (pageBaseDomain === clean || pageBaseDomain.endsWith("." + clean))
        );
      });
    }
  }

  function hapusSemuaHighlight() {
    const spans = document.querySelectorAll("." + HIGHLIGHT_CLASS),
      parentsToNormalize = new Set();
    spans.forEach((span) => {
      const parent = span.parentNode;
      if (!parent) return;
      const originalText =
        span.getAttribute("data-original") || span.textContent;
      parent.replaceChild(document.createTextNode(originalText), span);
      parentsToNormalize.add(parent);
    });
    parentsToNormalize.forEach((parent) => parent.normalize());
  }

  function restoreAllDirectReplacements() {
    const walker = document.createTreeWalker(
      document.body,
      NodeFilter.SHOW_TEXT,
      null,
      false,
    );
    let node;
    while ((node = walker.nextNode())) {
      if (originalTextMap.has(node)) {
        node.nodeValue = originalTextMap.get(node);
        originalTextMap.delete(node);
      }
      // FIX v54: Restore hash-replaced nodes juga (pakai map terpisah)
      if (hashOriginalTextMap.has(node)) {
        node.nodeValue = hashOriginalTextMap.get(node);
        hashOriginalTextMap.delete(node);
      }
    }
  }

  function jalankanPengganti(forceRebuild = false) {
    if (observer) observer.disconnect();
    const domainAllowed = isDomainAllowed();

    if (!domainAllowed) {
      hapusSemuaHighlight();
      restoreAllDirectReplacements();
      lastProcessedValueMap = new WeakMap();
      if (observer && document.body)
        observer.observe(document.body, {
          childList: true,
          subtree: true,
          characterData: true,
        });
      return;
    }

    const kamus = getKamusAktif(domainAllowed);
    if (Object.keys(kamus).length === 0) {
      hapusSemuaHighlight();
      restoreAllDirectReplacements();
      lastProcessedValueMap = new WeakMap();
      if (observer && document.body)
        observer.observe(document.body, {
          childList: true,
          subtree: true,
          characterData: true,
        });
      return;
    }

    try {
      unwrapFontTags();
    } catch (e) {
      console.warn("Gagal unwrap tag terjemahan:", e);
    }
    if (forceRebuild) {
      hapusSemuaHighlight();
      restoreAllDirectReplacements();
      lastProcessedValueMap = new WeakMap();
      nodeLastReplacedTimeMap = new WeakMap();
      nodeReplaceCountMap = new WeakMap();
      nodeFlickerLockSet = new WeakSet();
    }

    const highlightOn = getHighlightAktif();
    // Selalu re-inject style agar tidak hilang saat SPA navigasi / page reload CSS
    if (highlightOn) { try { injectHighlightStyle(); } catch(_) {} }
    const textNodes = [];
    const walker = document.createTreeWalker(
      document.body,
      NodeFilter.SHOW_TEXT,
      {
        acceptNode: (node) => {
          const textValue = node.nodeValue.normalize("NFC");
          if (
            lastProcessedValueMap.has(node) &&
            lastProcessedValueMap.get(node) === textValue
          )
            return NodeFilter.FILTER_REJECT;
          const tag = node.parentElement && node.parentElement.tagName;
          if (
            !tag ||
            [
              "SCRIPT",
              "STYLE",
              "NOSCRIPT",
              "TEXTAREA",
              "INPUT",
              "BUTTON",
            ].includes(tag)
          )
            return NodeFilter.FILTER_REJECT;
          if (
            node.parentElement.closest("#word-replacer-host") ||
            node.parentElement.classList.contains(HIGHLIGHT_CLASS)
          )
            return NodeFilter.FILTER_REJECT;
          // Catatan: node di dalam span glosari (kata unik) TETAP diproses untuk
          // penggantian teks biasa (nama karakter, dsb). Highlight dimatikan di dalam
          // glosari (lihat diDalamGlosari di bawah) agar tidak merusak elemen popover.
          return NodeFilter.FILTER_ACCEPT;
        },
      },
      false,
    );

    let node;
    while ((node = walker.nextNode())) textNodes.push(node);

    textNodes.forEach((node) => {
      if (!node.parentNode) return;
      let teksAsli = node.nodeValue.normalize("NFC");
      if (originalTextMap.has(node)) {
        teksAsli = originalTextMap.get(node).normalize("NFC");
        node.nodeValue = teksAsli;
      }
      lastProcessedValueMap.set(node, teksAsli);

      let adaPerubahan = false;
      // Urutkan term dari terpanjang ke terpendek agar term panjang dicek duluan
      // Skip hash: entries — mereka ditangani oleh applyHashBasedReplacements()
      const sortedKeys = Object.keys(kamus).filter(k => !k.startsWith("hash:") && !k.startsWith("exact:")).sort((a, b) => b.length - a.length);
      for (const salah of sortedKeys) {
        const entryFlags = kamus[salah].cs ? "gu" : "giu";
        if (
          buildBoundaryRegex(salah, buildRegexPattern(salah), entryFlags).test(
            teksAsli,
          )
        ) {
          adaPerubahan = true;
          break;
        }
      }
      if (!adaPerubahan) {
        originalTextMap.delete(node);
        return;
      }
      if (!originalTextMap.has(node)) originalTextMap.set(node, teksAsli);

      const diDalamGlosari = isInsideNativeGlossary(node),
        actualHighlightOn = highlightOn && !diDalamGlosari;

      if (!actualHighlightOn) {
        let teksBaru = teksAsli;
        // Proses term terpanjang dulu → cegah term pendek "memakan" bagian term panjang
        for (const salah of sortedKeys) {
          const entryFlags = kamus[salah].cs ? "gu" : "giu";
          const _isMulti = /\s/.test(salah);
          teksBaru = teksBaru.replace(
            buildBoundaryRegex(salah, buildRegexPattern(salah), entryFlags),
            (matchStr, ...groups) => {
              // Multi-kata: hitung separator dari split spasi (sesuai pola sederhana)
              // Satu-kata: hitung separator dari split spasi+tanda-hubung (sesuai pola kompleks)
              const numSeps = _isMulti
                ? salah.trim().split(/\s+/).length - 1
                : salah.split(/[\s\-_—–]+/).length - 1;
              return reconstructReplacement(
                kamus[salah].to,
                groups.slice(0, numSeps),
              );
            },
          );
        }
        
        // Anti-flicker rate limiter: skip if node is mutation-locked [1]
        if (nodeFlickerLockSet.has(node)) return;

        // Secure mutation guard & connected DOM check [1]
        if (node.isConnected) {
          const _now = Date.now();
          const _lastT = nodeLastReplacedTimeMap.get(node) || 0;
          if (_now - _lastT < NODE_FLICKER_WINDOW_MS) {
            const _cnt = (nodeReplaceCountMap.get(node) || 0) + 1;
            nodeReplaceCountMap.set(node, _cnt);
            if (_cnt >= NODE_FLICKER_MAX_COUNT) {
              nodeFlickerLockSet.add(node);
              return;
            }
          } else {
            nodeLastReplacedTimeMap.set(node, _now);
            nodeReplaceCountMap.set(node, 1);
          }
          _replacingNow = true;
          node.nodeValue = teksBaru;
          _replacingNow = false;
          lastProcessedValueMap.set(node, teksBaru);
        }
      } else {
        const allKeysPattern = Object.keys(kamus).map((k) => {
          const starts = /^[\p{L}\p{N}]/u.test(k.normalize("NFC")),
            ends = /[\p{L}\p{N}]$/u.test(k.normalize("NFC"));
          return (
            (starts ? "(?<![\\p{L}\\b\\p{N}])" : "") +
            buildRegexPattern(k) +
            (ends ? "(?![\\p{L}\\b\\p{N}])" : "")
          );
        });
        if (allKeysPattern.length === 0) return;

        const regexGabungan = new RegExp(
          "(" + allKeysPattern.join("|") + ")",
          "giu",
        );
        const fragment = document.createDocumentFragment();
        let lastIndex = 0,
          match;

        regexGabungan.lastIndex = 0;
        while ((match = regexGabungan.exec(teksAsli)) !== null) {
          if (match.index > lastIndex) {
            const segmentText = teksAsli.slice(lastIndex, match.index),
              segmentNode = document.createTextNode(segmentText);
            lastProcessedValueMap.set(segmentNode, segmentText);
            fragment.appendChild(segmentNode);
          }

          let penggantinya = match[0];
          for (const salah in kamus) {
            const csFlag = kamus[salah].cs ? "" : "i";
            const subMatch = match[0].match(
              new RegExp("^" + buildRegexPattern(salah) + "$", "u" + csFlag),
            );
            if (subMatch) {
              penggantinya = reconstructReplacement(
                kamus[salah].to,
                subMatch.slice(1, 1 + (salah.split(/[\s\-_—–]+/).length - 1)),
              );
              break;
            }
          }

          const span = document.createElement("span");
          span.className = HIGHLIGHT_CLASS;
          span.textContent = penggantinya;
          span.setAttribute("data-original", match[0]);
          span.title = 'Diganti dari: "' + match[0] + '"';
          span.childNodes.forEach((child) =>
            lastProcessedValueMap.set(child, child.nodeValue),
          );
          fragment.appendChild(span);
          lastIndex = match.index + match[0].length;
        }

        if (lastIndex < teksAsli.length) {
          const segmentText = teksAsli.slice(lastIndex),
            lastSegmentNode = document.createTextNode(segmentText);
          lastProcessedValueMap.set(lastSegmentNode, segmentText);
          fragment.appendChild(lastSegmentNode);
        }

        // Anti-flicker rate limiter: skip if node is mutation-locked [1]
        if (nodeFlickerLockSet.has(node)) return;

        // Secure mutation guard & parent node connection checks [1]
        if (node.isConnected && node.parentNode) {
          const _now = Date.now();
          const _lastT = nodeLastReplacedTimeMap.get(node) || 0;
          if (_now - _lastT < NODE_FLICKER_WINDOW_MS) {
            const _cnt = (nodeReplaceCountMap.get(node) || 0) + 1;
            nodeReplaceCountMap.set(node, _cnt);
            if (_cnt >= NODE_FLICKER_MAX_COUNT) {
              nodeFlickerLockSet.add(node);
              return;
            }
          } else {
            nodeLastReplacedTimeMap.set(node, _now);
            nodeReplaceCountMap.set(node, 1);
          }
          _replacingNow = true;
          try {
            node.parentNode.replaceChild(fragment, node);
          } catch (e) {
            console.warn("Gagal memproses elemen:", e);
          }
          _replacingNow = false;
        }
      }
    });

    // Terapkan penggantian lintas-node setelah walker utama selesai
    if (Object.keys(kamus).length > 0) {
      try { applyMultiNodeReplacements(kamus); } catch(e) {}
      // FIX v51: Juga terapkan penggantian lintas-paragraf
      try { applyMultiBlockReplacements(kamus); } catch(e) {}
      // v52.4: Terapkan penggantian berbasis hash untuk kata unik (data-hash targeting)
      try { applyHashBasedReplacements(kamus); } catch(e) {}
      // FIX v99.12.24b: Terapkan penggantian teks panjang/literal (exact: prefix)
      try { applyExactTextReplacements(kamus); } catch(e) {}
    }

    if (observer && document.body)
      observer.observe(document.body, {
        childList: true,
        subtree: true,
        characterData: true,
      });

    // Zero-delay timeout flush to safely clear queued mutations [1]
    setTimeout(() => {
      _replacingNow = false;
    }, 0);
  }

  // ── Penggantian lintas-node (Cross-node multi-element replacement) ─────────
  // Menangani kata/kalimat panjang yang terpecah oleh elemen inline
  // (span, b, i, em, strong, dll) atau tersebar di beberapa text node satu blok.
  //
  // FIX v51: Perbaikan bug utama:
  // 1. Menggunakan Set modifiedNodes agar node yang sudah diproses tidak diproses ulang
  //    oleh blok parent (div > p: keduanya dipilih, menyebabkan repetisi)
  // 2. Prefix/suffix dihitung dari teks ASLI (origVal) bukan nodeValue terkini
  // 3. Guard _replacingNow mencakup seluruh iterasi, bukan per-match

  // FIX v52: Distribusi teks pengganti di sekitar node glosari (kata unik).
  // Teks glosari dipakai sebagai "anchor" di replacement, sehingga:
  //   - Span glosari beserta tooltip/klik tetap utuh
  //   - Teks di sekitar glosari (non-glossary nodes) mendapat teks pengganti yang tepat
  // Mengembalikan objek { absIdx -> newText } untuk non-glosari, null jika gagal.
  function splitAroundGlossary(replacement, nodeInfos, firstIdx, lastIdx, cmStart, cmEnd, combined) {
    const segments = [];
    for (let i = firstIdx; i <= lastIdx; i++) {
      const ni = nodeInfos[i];
      const segStart = Math.max(ni.start, cmStart);
      const segEnd   = Math.min(ni.start + ni.length, cmEnd);
      const segText  = combined.slice(segStart, segEnd);
      segments.push({ absIdx: i, text: segText, isGlossary: ni.isGlossary });
    }

    const results = {};
    let repPos = 0;

    for (let i = 0; i < segments.length; i++) {
      const seg = segments[i];
      if (!seg.isGlossary) continue;

      // Cari teks glosari di replacement mulai dari posisi saat ini
      const lower = replacement.toLowerCase();
      const foundAt = lower.indexOf(seg.text.toLowerCase(), repPos);
      if (foundAt < 0) return null; // tidak ditemukan → jangan modifikasi

      // Teks antara repPos dan foundAt → non-glosari terakhir sebelum node ini
      const beforeGloss = replacement.slice(repPos, foundAt);
      let prevNGIdx = -1;
      for (let j = i - 1; j >= 0; j--) {
        if (!segments[j].isGlossary) { prevNGIdx = j; break; }
      }
      if (prevNGIdx >= 0) {
        results[segments[prevNGIdx].absIdx] = (results[segments[prevNGIdx].absIdx] || "") + beforeGloss;
      }

      results[seg.absIdx] = null; // penanda: jangan sentuh node glosari ini
      repPos = foundAt + seg.text.length;
    }

    // Sisa replacement → non-glosari terakhir
    let lastNGIdx = -1;
    for (let j = segments.length - 1; j >= 0; j--) {
      if (!segments[j].isGlossary) { lastNGIdx = j; break; }
    }
    if (lastNGIdx >= 0) {
      results[segments[lastNGIdx].absIdx] = (results[segments[lastNGIdx].absIdx] || "") + replacement.slice(repPos);
    } else if (repPos < replacement.length) {
      return null; // tidak ada tempat untuk sisa teks
    }

    return results;
  }

  function applyMultiNodeReplacements(kamus) {
    if (!document.body) return;
    const highlightOn = getHighlightAktif();

    // FIX: Set ini melacak node yang sudah dimodifikasi dalam pemanggilan ini.
    // Mencegah pemrosesan ganda ketika blok parent dan child keduanya dipilih
    // (contoh: <div> parent memproses ulang node yang sudah ditangani <p> child).
    const modifiedNodes = new Set();

    // Hanya elemen blok langsung (tanpa div agar tidak double-process child p)
    // div ditangani di applyMultiBlockReplacements secara terpisah
    const BLOCK_SELECTOR = "p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6, pre, figcaption, dt, dd";
    const blockEls = document.body.querySelectorAll(BLOCK_SELECTOR);

    _replacingNow = true;
    try {
      blockEls.forEach(function(blockEl) {
        if (blockEl.closest("#word-replacer-host")) return;

        // Kumpulkan semua text node yang layak dalam blok ini
        const nodeInfos = [];
        let combined = "";
        const twBlock = document.createTreeWalker(
          blockEl,
          NodeFilter.SHOW_TEXT,
          {
            acceptNode: function(n) {
              const tag = n.parentElement && n.parentElement.tagName;
              if (!tag || ["SCRIPT","STYLE","NOSCRIPT","TEXTAREA","INPUT","BUTTON"].includes(tag))
                return NodeFilter.FILTER_REJECT;
              if (n.parentElement.closest("#word-replacer-host") ||
                  n.parentElement.classList.contains(HIGHLIGHT_CLASS))
                return NodeFilter.FILTER_REJECT;
              // FIX v52: Jangan reject node glosari — sertakan di combined untuk pencocokan frasa.
              // Node glosari tetap tidak diubah teks-nya saat penerapan (splitAroundGlossary).
              // FIX: Skip node yang sudah dimodifikasi di iterasi sebelumnya
              if (modifiedNodes.has(n)) return NodeFilter.FILTER_REJECT;
              return NodeFilter.FILTER_ACCEPT;
            }
          },
          false
        );
        let n2;
        while ((n2 = twBlock.nextNode())) {
          // Gunakan teks ASLI (sebelum walker utama memodifikasi) jika tersedia
          const val = (originalTextMap.has(n2) ? originalTextMap.get(n2) : n2.nodeValue).normalize("NFC");
          // FIX v52: Tandai apakah node ini adalah bagian dari glosari/kata-unik situs
          nodeInfos.push({ node: n2, start: combined.length, length: val.length, origVal: val, isGlossary: isInsideNativeGlossary(n2) });
          combined += val;
        }

        // Hanya proses blok dengan 2+ text node (single node sudah ditangani walker utama)
        if (nodeInfos.length <= 1) return;
        if (!combined.trim()) return;

        // Cari semua kecocokan yang melintas antar node
        const crossMatches = [];
        const sortedCrossTerms = Object.keys(kamus).filter(k => !k.startsWith("hash:") && !k.startsWith("exact:")).sort((a, b) => b.length - a.length);
        for (const term of sortedCrossTerms) {
          const flags = kamus[term].cs ? "gu" : "giu";
          let rx;
          try {
            rx = buildBoundaryRegex(term, buildRegexPattern(term), flags);
          } catch(e) { continue; }
          rx.lastIndex = 0;
          let m;
          while ((m = rx.exec(combined)) !== null) {
            if (m[0].length === 0) { rx.lastIndex++; continue; }
            const mStart = m.index, mEnd = m.index + m[0].length;
            let firstIdx = -1, lastIdx = -1;
            for (let i = 0; i < nodeInfos.length; i++) {
              const ni = nodeInfos[i];
              if (ni.start < mEnd && ni.start + ni.length > mStart) {
                if (firstIdx === -1) firstIdx = i;
                lastIdx = i;
              }
            }
            // Hanya simpan jika melintas ≥2 node
            if (firstIdx !== -1 && lastIdx > firstIdx) {
              const _isMultiTerm = /\s/.test(term);
              const numSeps = _isMultiTerm
                ? term.trim().split(/\s+/).length - 1
                : term.split(/[\s\-_—–]+/).length - 1;
              const replacement = reconstructReplacement(
                kamus[term].to,
                Array.from(m).slice(1, 1 + numSeps)
              );
              crossMatches.push({
                start: mStart, end: mEnd,
                replacement, firstIdx, lastIdx
              });
            }
          }
        }

        if (crossMatches.length === 0) return;

        // Urutkan dari belakang (descending) agar indeks tidak geser saat edit
        crossMatches.sort((a, b) => b.start - a.start);

        // Hapus tumpang tindih
        const nonOverlapping = [];
        let lastUsedStart = Infinity;
        for (const cm of crossMatches) {
          if (cm.end <= lastUsedStart) {
            nonOverlapping.push(cm);
            lastUsedStart = cm.start;
          }
        }

        // Terapkan penggantian lintas-node (dengan dukungan glosari/kata-unik)
        for (const cm of nonOverlapping) {
          const firstNI = nodeInfos[cm.firstIdx];
          const lastNI  = nodeInfos[cm.lastIdx];
          if (nodeFlickerLockSet.has(firstNI.node)) continue;
          if (!firstNI.node.isConnected) continue;

          const hasGlossary = nodeInfos.slice(cm.firstIdx, cm.lastIdx + 1).some(ni => ni.isGlossary);

          if (hasGlossary) {
            // FIX v52: Penerapan sadar-glosari — jangan ubah text node glosari,
            // hanya ubah text node di sekitar glosari (non-glosari).
            const splitRes = splitAroundGlossary(cm.replacement, nodeInfos, cm.firstIdx, cm.lastIdx, cm.start, cm.end, combined);

            // FIX v53 (Hipotesis A): Jika splitAroundGlossary gagal (teks glosari tidak ada di
            // replacement), jangan lewati seluruh match. Terapkan fallback: isi node pertama
            // dengan replacement, kosongkan node antara yang non-glosari, biarkan node glosari utuh.
            if (!splitRes) {
              const prefixLen = Math.max(0, cm.start - firstNI.start);
              const prefix = firstNI.origVal.slice(0, prefixLen);
              const suffixStart = Math.max(0, cm.end - lastNI.start);
              const suffix = lastNI.origVal.slice(suffixStart);
              try {
                if (!originalTextMap.has(firstNI.node))
                  originalTextMap.set(firstNI.node, firstNI.node.nodeValue);
                firstNI.node.nodeValue = prefix + cm.replacement;
                lastProcessedValueMap.set(firstNI.node, prefix + cm.replacement);
                modifiedNodes.add(firstNI.node);
                for (let i = cm.firstIdx + 1; i <= cm.lastIdx; i++) {
                  const ni = nodeInfos[i];
                  if (ni.isGlossary) continue; // lindungi node kata unik
                  if (!ni.node.isConnected) continue;
                  if (!originalTextMap.has(ni.node))
                    originalTextMap.set(ni.node, ni.node.nodeValue);
                  const newVal = i === cm.lastIdx ? suffix : "";
                  ni.node.nodeValue = newVal;
                  lastProcessedValueMap.set(ni.node, newVal);
                  modifiedNodes.add(ni.node);
                }
              } catch(e) {}
              continue;
            }

            for (let i = cm.firstIdx; i <= cm.lastIdx; i++) {
              const ni = nodeInfos[i];
              if (splitRes[i] === null) continue; // node glosari: jangan sentuh
              if (!ni.node.isConnected) continue;
              if (nodeFlickerLockSet.has(ni.node)) continue;

              let newText = splitRes[i] || "";
              // Tambahkan prefix jika node ini adalah node pertama dan bukan glosari
              if (i === cm.firstIdx && !ni.isGlossary) {
                const prefixLen = Math.max(0, cm.start - ni.start);
                newText = ni.origVal.slice(0, prefixLen) + newText;
              }
              // Tambahkan suffix jika node ini adalah node terakhir dan bukan glosari
              if (i === cm.lastIdx && !ni.isGlossary) {
                const suffixStart = Math.max(0, cm.end - ni.start);
                newText = newText + ni.origVal.slice(suffixStart);
              }
              try {
                if (!originalTextMap.has(ni.node))
                  originalTextMap.set(ni.node, ni.node.nodeValue);
                ni.node.nodeValue = newText;
                lastProcessedValueMap.set(ni.node, newText);
                modifiedNodes.add(ni.node);
              } catch(e) {}
            }
          } else {
            // Penerapan biasa (tidak ada node glosari dalam range)
            // FIX: Gunakan origVal (teks asli) untuk menghitung prefix dan suffix
            const prefixLen = Math.max(0, cm.start - firstNI.start);
            const prefix = firstNI.origVal.slice(0, prefixLen);
            const suffixStart = Math.max(0, cm.end - lastNI.start);
            const suffix = lastNI.origVal.slice(suffixStart);

            try {
              if (!originalTextMap.has(firstNI.node))
                originalTextMap.set(firstNI.node, firstNI.node.nodeValue);

              if (highlightOn) {
                firstNI.node.nodeValue = prefix;
                const span = document.createElement("span");
                span.className = HIGHLIGHT_CLASS;
                span.textContent = cm.replacement;
                span.setAttribute("data-original", combined.slice(cm.start, cm.end));
                span.title = "Diganti dari: \"" + combined.slice(cm.start, cm.end) + "\"";
                const parentNode = firstNI.node.parentNode;
                if (parentNode) {
                  parentNode.insertBefore(span, firstNI.node.nextSibling);
                }
                lastProcessedValueMap.set(firstNI.node, prefix);
              } else {
                firstNI.node.nodeValue = prefix + cm.replacement;
                lastProcessedValueMap.set(firstNI.node, prefix + cm.replacement);
              }

              modifiedNodes.add(firstNI.node);

              for (let i = cm.firstIdx + 1; i <= cm.lastIdx; i++) {
                const ni = nodeInfos[i];
                // FIX v53 (Hipotesis B): Jangan kosongkan node kata unik — biarkan
                // applyHashBasedReplacements yang menanganinya.
                if (ni.isGlossary) continue;
                if (!ni.node.isConnected) continue;
                if (!originalTextMap.has(ni.node))
                  originalTextMap.set(ni.node, ni.node.nodeValue);
                const newVal = i === cm.lastIdx ? suffix : "";
                ni.node.nodeValue = newVal;
                lastProcessedValueMap.set(ni.node, newVal);
                modifiedNodes.add(ni.node);
              }
            } catch(e) {}
          }
        }
      });
    } finally {
      _replacingNow = false;
    }
  }

  // ── Penggantian lintas-paragraf (Cross-block multi-paragraph replacement) ──
  // FIX v51: Menangani kata/kalimat yang terpecah di dua baris/paragraf berbeda.
  // Contoh: FROM="Shura tidak" tapi "Shura" di akhir paragraf 1 dan "tidak" di awal paragraf 2.
  function applyMultiBlockReplacements(kamus) {
    if (!document.body) return;
    const highlightOn = getHighlightAktif();

    // Cari kontainer konten utama novel
    const contentSelectors = [
      ".chapter-content", "#chapter-content", ".reading-content",
      ".novel-content", ".content-area", ".chapter-text",
      "[class*='chapter']", "article", ".post-content",
      "#content", "main"
    ];
    let contentEl = null;
    for (const sel of contentSelectors) {
      try {
        const el = document.querySelector(sel);
        if (el && !el.closest("#word-replacer-host")) { contentEl = el; break; }
      } catch(e) {}
    }
    if (!contentEl) return;

    // Ambil semua paragraf langsung dalam kontainer
    const paragraphs = Array.from(
      contentEl.querySelectorAll("p, div > br, li")
    ).filter(el => el.tagName === "P" && !el.closest("#word-replacer-host"));

    if (paragraphs.length < 2) return;

    // Helper: kumpulkan semua text node dari sebuah elemen
    function getTextNodes(el) {
      const nodes = [];
      const tw = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
        acceptNode: function(n) {
          const tag = n.parentElement && n.parentElement.tagName;
          if (!tag || ["SCRIPT","STYLE","NOSCRIPT","TEXTAREA","INPUT","BUTTON"].includes(tag))
            return NodeFilter.FILTER_REJECT;
          if (n.parentElement.closest("#word-replacer-host") ||
              n.parentElement.classList.contains(HIGHLIGHT_CLASS))
            return NodeFilter.FILTER_REJECT;
          // FIX v52: Sertakan node glosari dalam combined untuk pencocokan frasa lintas-paragraf.
          // Node glosari tetap tidak diubah saat penerapan (splitAroundGlossary).
          return NodeFilter.FILTER_ACCEPT;
        }
      }, false);
      let n;
      while ((n = tw.nextNode())) nodes.push(n);
      return nodes;
    }

    // Proses pasangan paragraf: coba hingga 3 paragraf ke depan, lewati yang kosong
    // FIX: tambah spasi batas antar-paragraf di combined agar pencocokan lintas baris berfungsi
    // FIX: gunakan lookAhead untuk melewati paragraf kosong (jarak 2 baris atau lebih)
    for (let bi = 0; bi < paragraphs.length - 1; bi++) {
      const p1Nodes = getTextNodes(paragraphs[bi]);
      if (p1Nodes.length === 0) continue;

      // Cari paragraf berikutnya yang tidak kosong dalam jangkauan lookAhead
      let p2Nodes = null;
      for (let la = 1; la <= Math.min(3, paragraphs.length - 1 - bi); la++) {
        const candidate = getTextNodes(paragraphs[bi + la]);
        if (candidate.length > 0) { p2Nodes = candidate; break; }
      }
      if (!p2Nodes) continue;

      // Gabungkan semua node dari kedua paragraf dengan spasi batas di tengah
      const allNodes = [...p1Nodes, ...p2Nodes];
      const nodeInfos = [];
      let combined = "";

      for (let ni = 0; ni < allNodes.length; ni++) {
        // Sisipkan spasi di batas antar-paragraf agar regex bisa menjembatani
        if (ni === p1Nodes.length) combined += " ";
        const n = allNodes[ni];
        const val = (originalTextMap.has(n) ? originalTextMap.get(n) : n.nodeValue).normalize("NFC");
        // FIX v52: Tandai node glosari agar tidak diubah saat penerapan
        nodeInfos.push({ node: n, start: combined.length, length: val.length, origVal: val, isGlossary: isInsideNativeGlossary(n) });
        combined += val;
      }

      if (!combined.trim()) continue;

      // Cari kecocokan yang hanya melintas antar paragraf
      // (firstIdx dalam p1, lastIdx dalam p2)
      const p1Count = p1Nodes.length;
      const crossMatches = [];
      const sortedTerms = Object.keys(kamus).filter(k => !k.startsWith("hash:") && !k.startsWith("exact:")).sort((a, b) => b.length - a.length);

      for (const term of sortedTerms) {
        const flags = kamus[term].cs ? "gu" : "giu";
        let rx;
        try {
          rx = buildBoundaryRegex(term, buildRegexPattern(term), flags);
        } catch(e) { continue; }
        rx.lastIndex = 0;
        let m;
        while ((m = rx.exec(combined)) !== null) {
          if (m[0].length === 0) { rx.lastIndex++; continue; }
          const mStart = m.index, mEnd = m.index + m[0].length;
          let firstIdx = -1, lastIdx = -1;
          for (let i = 0; i < nodeInfos.length; i++) {
            const ni = nodeInfos[i];
            if (ni.start < mEnd && ni.start + ni.length > mStart) {
              if (firstIdx === -1) firstIdx = i;
              lastIdx = i;
            }
          }
          // Hanya ambil kecocokan yang benar-benar MELINTAS batas paragraf
          // (firstIdx di p1, lastIdx di p2)
          if (firstIdx !== -1 && firstIdx < p1Count && lastIdx >= p1Count) {
            const _isMultiTerm = /\s/.test(term);
            const numSeps = _isMultiTerm
              ? term.trim().split(/\s+/).length - 1
              : term.split(/[\s\-_—–]+/).length - 1;
            const replacement = reconstructReplacement(
              kamus[term].to,
              Array.from(m).slice(1, 1 + numSeps)
            );
            crossMatches.push({ start: mStart, end: mEnd, replacement, firstIdx, lastIdx });
          }
        }
      }

      if (crossMatches.length === 0) continue;

      crossMatches.sort((a, b) => b.start - a.start);
      const nonOverlapping = [];
      let lastUsedStart = Infinity;
      for (const cm of crossMatches) {
        if (cm.end <= lastUsedStart) {
          nonOverlapping.push(cm);
          lastUsedStart = cm.start;
        }
      }

      _replacingNow = true;
      try {
        for (const cm of nonOverlapping) {
          const firstNI = nodeInfos[cm.firstIdx];
          const lastNI  = nodeInfos[cm.lastIdx];
          if (nodeFlickerLockSet.has(firstNI.node)) continue;
          if (!firstNI.node.isConnected) continue;

          const hasGlossary = nodeInfos.slice(cm.firstIdx, cm.lastIdx + 1).some(ni => ni.isGlossary);

          if (hasGlossary) {
            // FIX v52: Penerapan sadar-glosari lintas-paragraf
            const splitRes = splitAroundGlossary(cm.replacement, nodeInfos, cm.firstIdx, cm.lastIdx, cm.start, cm.end, combined);

            // FIX v53 (Hipotesis A): Fallback jika splitAroundGlossary gagal — terapkan pada
            // non-glosari, biarkan node glosari utuh.
            if (!splitRes) {
              const prefixLen = Math.max(0, cm.start - firstNI.start);
              const prefix = firstNI.origVal.slice(0, prefixLen);
              const suffixStart = Math.max(0, cm.end - lastNI.start);
              const suffix = lastNI.origVal.slice(suffixStart);
              try {
                if (!originalTextMap.has(firstNI.node))
                  originalTextMap.set(firstNI.node, firstNI.node.nodeValue);
                firstNI.node.nodeValue = prefix + cm.replacement;
                lastProcessedValueMap.set(firstNI.node, prefix + cm.replacement);
                for (let i = cm.firstIdx + 1; i <= cm.lastIdx; i++) {
                  const ni = nodeInfos[i];
                  if (ni.isGlossary) continue; // lindungi node kata unik
                  if (!ni.node.isConnected) continue;
                  if (!originalTextMap.has(ni.node))
                    originalTextMap.set(ni.node, ni.node.nodeValue);
                  const newVal = i === cm.lastIdx ? suffix : "";
                  ni.node.nodeValue = newVal;
                  lastProcessedValueMap.set(ni.node, newVal);
                }
              } catch(e) {}
              continue;
            }

            for (let i = cm.firstIdx; i <= cm.lastIdx; i++) {
              const ni = nodeInfos[i];
              if (splitRes[i] === null) continue; // node glosari: jangan sentuh
              if (!ni.node.isConnected) continue;
              if (nodeFlickerLockSet.has(ni.node)) continue;

              let newText = splitRes[i] || "";
              if (i === cm.firstIdx && !ni.isGlossary) {
                const prefixLen = Math.max(0, cm.start - ni.start);
                newText = ni.origVal.slice(0, prefixLen) + newText;
              }
              if (i === cm.lastIdx && !ni.isGlossary) {
                const suffixStart = Math.max(0, cm.end - ni.start);
                newText = newText + ni.origVal.slice(suffixStart);
              }
              try {
                if (!originalTextMap.has(ni.node))
                  originalTextMap.set(ni.node, ni.node.nodeValue);
                ni.node.nodeValue = newText;
                lastProcessedValueMap.set(ni.node, newText);
              } catch(e) {}
            }
          } else {
            // Penerapan biasa (tidak ada node glosari dalam range)
            const prefixLen = Math.max(0, cm.start - firstNI.start);
            const prefix = firstNI.origVal.slice(0, prefixLen);
            const suffixStart = Math.max(0, cm.end - lastNI.start);
            const suffix = lastNI.origVal.slice(suffixStart);

            try {
              if (!originalTextMap.has(firstNI.node))
                originalTextMap.set(firstNI.node, firstNI.node.nodeValue);

              if (highlightOn) {
                firstNI.node.nodeValue = prefix;
                const span = document.createElement("span");
                span.className = HIGHLIGHT_CLASS;
                span.textContent = cm.replacement;
                span.setAttribute("data-original", combined.slice(cm.start, cm.end));
                span.title = "Diganti dari: \"" + combined.slice(cm.start, cm.end) + "\"";
                const parentNode = firstNI.node.parentNode;
                if (parentNode) parentNode.insertBefore(span, firstNI.node.nextSibling);
                lastProcessedValueMap.set(firstNI.node, prefix);
              } else {
                firstNI.node.nodeValue = prefix + cm.replacement;
                lastProcessedValueMap.set(firstNI.node, prefix + cm.replacement);
              }

              for (let i = cm.firstIdx + 1; i <= cm.lastIdx; i++) {
                const ni = nodeInfos[i];
                // FIX v53 (Hipotesis B): Jangan kosongkan node kata unik.
                if (ni.isGlossary) continue;
                if (!ni.node.isConnected) continue;
                if (!originalTextMap.has(ni.node))
                  originalTextMap.set(ni.node, ni.node.nodeValue);
                const newVal = i === cm.lastIdx ? suffix : "";
                ni.node.nodeValue = newVal;
                lastProcessedValueMap.set(ni.node, newVal);
              }
            } catch(e) {}
          }
        }
      } finally {
        _replacingNow = false;
      }
    }
  }

  // ── Penggantian teks panjang/literal (Exact-text replacement) v99.12.24c ───
  // Menangani kasus di mana teks sumber berisi tanda baca, pemisah khusus,
  // atau frasa sangat panjang yang sulit dicocokkan dengan regex biasa.
  //
  // Cara pakai: tambahkan entri dengan KEY dimulai "exact:"
  //   "exact:Bab 1, Bagian 2. Keberangkatan" → { to: "Bab 1, Bagian 2. Keberangkatan" }
  //
  // Fitur:
  //   - Pencocokan case-insensitive
  //   - Normalisasi whitespace: spasi berganda, NBSP, zero-width space → disamakan
  //   - Mendukung teks lintas text-node dalam satu blok paragraf
  //   - Frasa terpanjang diproses lebih dulu (paling spesifik menang)
  function applyExactTextReplacements(kamus) {
    if (!document.body) return;

    // Kumpulkan aturan dengan prefix "exact:"
    const exactRules = [];
    for (const key in kamus) {
      if (!key.startsWith("exact:")) continue;
      const pattern = key.slice(6).trim();
      if (!pattern) continue;
      const entry = kamus[key];
      const to = (entry && typeof entry === "object") ? (entry.to || "") : (typeof entry === "string" ? entry : "");
      exactRules.push({ pattern, to });
    }
    if (exactRules.length === 0) return;

    // Paling panjang -> paling pendek (spesifik duluan)
    exactRules.sort(function(a, b) { return b.pattern.length - a.pattern.length; });

    // Bangun regex dari pattern: escape special chars, normalisasi whitespace -> \s+
    // Contoh: "kata, lain?" -> /kata,\s+lain\?/i
    function buildExactRegex(pattern) {
      var escaped = pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      var rxSrc = escaped.replace(/[\s\u00a0\u200b\u200c\u200d\ufeff]+/g, "[\\s\\u00a0\\u200b\\u200c\\u200d\\ufeff]+");
      return new RegExp(rxSrc, "i");
    }

    // FIX Bug 1+3: Helper terapkan satu kecocokan exact ke nodeInfos yang mungkin berisi
    // node glosari (isGlossary:true). Node glosari tidak ditulis -- hanya non-glosari.
    // Mengembalikan combined string yang sudah diperbarui, atau null jika gagal.
    function applyExactMatchToNodes(nodeInfos, combined, origStart, origEnd, ruleTo) {
      var firstIdx = -1, lastIdx = -1;
      for (var i = 0; i < nodeInfos.length; i++) {
        var nfo = nodeInfos[i];
        if (nfo.start < origEnd && nfo.start + nfo.length > origStart) {
          if (firstIdx === -1) firstIdx = i;
          lastIdx = i;
        }
      }
      if (firstIdx === -1) return null;

      var firstNI = nodeInfos[firstIdx];
      var lastNI  = nodeInfos[lastIdx];
      var prefix  = firstNI.origVal.slice(0, Math.max(0, origStart - firstNI.start));
      var suffix  = lastNI.origVal.slice(Math.max(0, origEnd - lastNI.start));

      if (nodeFlickerLockSet.has(firstNI.node) || !firstNI.node.isConnected) return null;

      try {
        // FIX Bug 1: Tulis ke node pertama hanya jika BUKAN node glosari
        if (!firstNI.isGlossary) {
          if (!originalTextMap.has(firstNI.node))
            originalTextMap.set(firstNI.node, firstNI.node.nodeValue);
          firstNI.node.nodeValue = prefix + ruleTo;
          lastProcessedValueMap.set(firstNI.node, prefix + ruleTo);
        }

        for (var j = firstIdx + 1; j <= lastIdx; j++) {
          var jInfo = nodeInfos[j];
          // FIX Bug 1: Skip node glosari -- jangan ubah teks konten span kata-unik
          if (!jInfo.node.isConnected || jInfo.isGlossary) continue;
          if (!originalTextMap.has(jInfo.node))
            originalTextMap.set(jInfo.node, jInfo.node.nodeValue);
          var newVal = j === lastIdx ? suffix : "";
          jInfo.node.nodeValue = newVal;
          lastProcessedValueMap.set(jInfo.node, newVal);
        }

        // Perbarui combined dan posisi nodeInfos agar rule berikutnya akurat
        var delta = ruleTo.length - (origEnd - origStart);
        combined = combined.slice(0, origStart) + ruleTo + combined.slice(origEnd);
        nodeInfos[firstIdx] = {
          node: nodeInfos[firstIdx].node,
          start: nodeInfos[firstIdx].start,
          length: prefix.length + ruleTo.length,
          origVal: prefix + ruleTo,
          isGlossary: nodeInfos[firstIdx].isGlossary
        };
        for (var k = firstIdx + 1; k < nodeInfos.length; k++) {
          var kl = 0, kov = "";
          if (k <= lastIdx) {
            kl = k === lastIdx ? suffix.length : 0;
            kov = k === lastIdx ? suffix : "";
          } else {
            kl = nodeInfos[k].length;
            kov = nodeInfos[k].origVal;
          }
          nodeInfos[k] = {
            node: nodeInfos[k].node,
            start: nodeInfos[k].start + delta,
            length: kl,
            origVal: kov,
            isGlossary: nodeInfos[k].isGlossary
          };
        }
        return combined;
      } catch(e2) { return null; }
    }

    // FIX Bug 1+3: Kumpulkan text node dari elemen (termasuk node glosari untuk pencocokan frasa)
    function collectNodeInfos(el) {
      var infos = [];
      var combined = "";
      var tw2 = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
        acceptNode: function(n) {
          var tag = n.parentElement && n.parentElement.tagName;
          if (!tag || ["SCRIPT","STYLE","NOSCRIPT","TEXTAREA","INPUT","BUTTON"].includes(tag))
            return NodeFilter.FILTER_REJECT;
          if (n.parentElement.closest("#word-replacer-host") ||
              n.parentElement.classList.contains(HIGHLIGHT_CLASS))
            return NodeFilter.FILTER_REJECT;
          // FIX Bug 1: JANGAN reject node glosari -- sertakan dalam combined agar frasa
          // yang melewati/mengandung kata-unik (data-hash span) bisa dicocokkan.
          // isGlossary=true menandai node ini tidak boleh ditulis saat penerapan.
          return NodeFilter.FILTER_ACCEPT;
        }
      }, false);
      var tn;
      while ((tn = tw2.nextNode())) {
        var val = (originalTextMap.has(tn) ? originalTextMap.get(tn) : tn.nodeValue) || "";
        infos.push({ node: tn, start: combined.length, length: val.length, origVal: val, isGlossary: isInsideNativeGlossary(tn) });
        combined += val;
      }
      return { infos: infos, text: combined };
    }

    var BLOCK_SELECTOR = "p, li, td, th, blockquote, h1, h2, h3, h4, h5, h6, pre";

    _replacingNow = true;
    try {
      // -- PASS 1: Intra-block exact replacement (dalam satu blok) ---------------
      document.body.querySelectorAll(BLOCK_SELECTOR).forEach(function(blockEl) {
        if (blockEl.closest("#word-replacer-host")) return;

        var res = collectNodeInfos(blockEl);
        var nodeInfos = res.infos;
        var combined  = res.text;
        if (!combined.trim() || nodeInfos.length === 0) return;

        for (var ri = 0; ri < exactRules.length; ri++) {
          var rule = exactRules[ri];
          var rx;
          try { rx = buildExactRegex(rule.pattern); } catch(e) { continue; }
          var m = rx.exec(combined);
          if (!m) continue;
          var upd = applyExactMatchToNodes(nodeInfos, combined, m.index, m.index + m[0].length, rule.to);
          if (upd !== null) combined = upd;
        }
      });

      // -- FIX Bug 3: PASS 2: Cross-block (lintas paragraf) exact replacement ----
      // Pasangkan paragraf bersebelahan dan cari exact: rule yang melintasi batas antar paragraf.
      var contentSelectors2 = [
        ".chapter-content", "#chapter-content", ".reading-content",
        ".novel-content", ".content-area", ".chapter-text",
        "[class*='chapter']", "article", ".post-content", "#content", "main"
      ];
      var contentEl2 = null;
      for (var ci = 0; ci < contentSelectors2.length; ci++) {
        try {
          var elc = document.querySelector(contentSelectors2[ci]);
          if (elc && !elc.closest("#word-replacer-host")) { contentEl2 = elc; break; }
        } catch(e) {}
      }
      if (contentEl2) {
        var paragraphs2 = Array.from(contentEl2.querySelectorAll("p")).filter(function(el) {
          return !el.closest("#word-replacer-host");
        });
        if (paragraphs2.length >= 2) {
          for (var bi = 0; bi < paragraphs2.length - 1; bi++) {
            var r1 = collectNodeInfos(paragraphs2[bi]);
            if (r1.infos.length === 0) continue;
            // Cari paragraf berikutnya yang tidak kosong (lookAhead maks 3)
            var r2 = null;
            for (var la = 1; la <= Math.min(3, paragraphs2.length - 1 - bi); la++) {
              var cand = collectNodeInfos(paragraphs2[bi + la]);
              if (cand.infos.length > 0) { r2 = cand; break; }
            }
            if (!r2) continue;

            // Gabungkan node dua paragraf dengan spasi pemisah di batas antar paragraf
            var p1Len     = r1.infos.length;
            var combined2 = r1.text + " ";
            var nodeInfos2 = r1.infos.slice();
            var offset2    = combined2.length;
            var r2BaseStart = r2.infos.length > 0 ? r2.infos[0].start : 0;
            for (var ni2idx = 0; ni2idx < r2.infos.length; ni2idx++) {
              var ni2entry = r2.infos[ni2idx];
              nodeInfos2.push({
                node: ni2entry.node,
                start: offset2 + (ni2entry.start - r2BaseStart),
                length: ni2entry.length,
                origVal: ni2entry.origVal,
                isGlossary: ni2entry.isGlossary
              });
            }
            combined2 += r2.text;

            for (var ri2 = 0; ri2 < exactRules.length; ri2++) {
              var rule2 = exactRules[ri2];
              var rx2;
              try { rx2 = buildExactRegex(rule2.pattern); } catch(e) { continue; }
              var m2 = rx2.exec(combined2);
              if (!m2) continue;

              // Hitung firstIdx dan lastIdx di nodeInfos2
              var fIdx = -1, lIdx = -1;
              for (var ii2 = 0; ii2 < nodeInfos2.length; ii2++) {
                var n2i = nodeInfos2[ii2];
                if (n2i.start < m2.index + m2[0].length && n2i.start + n2i.length > m2.index) {
                  if (fIdx === -1) fIdx = ii2;
                  lIdx = ii2;
                }
              }
              if (fIdx === -1) continue;
              // Hanya terapkan jika benar-benar lintas paragraf (fIdx di p1, lIdx di p2)
              if (fIdx >= p1Len || lIdx < p1Len) continue;

              var upd2 = applyExactMatchToNodes(nodeInfos2, combined2, m2.index, m2.index + m2[0].length, rule2.to);
              if (upd2 !== null) combined2 = upd2;
            }
          }
        }
      }
    } finally {
      _replacingNow = false;
    }
  }

  // ── Penggantian berbasis hash (Hash-based glossary span targeting) v52.4 ────
  // Menangani kasus di mana 2+ karakter berbeda ditranslate ke nama yang SAMA
  // (misal: dua tokoh berbeda keduanya disebut "Fugaku").
  //
  // Cara kerja:
  //   - Tambahkan entri di kamus dengan KEY = "hash:NILAI_DATA_HASH"
  //     Contoh: "hash:某城" → { to: "Fugaku Uchiha", novelId: "..." }
  //   - Script akan menemukan semua <span data-hash="某城"> di halaman dan
  //     mengganti teks dalamnya menjadi "Fugaku Uchiha"
  //   - Span itu sendiri (termasuk popover/klik) TIDAK disentuh — hanya teks node
  //   - Untuk menambah rule hash: klik kanan pada kata unik di halaman novel
  //
  // Format key: "hash:" + nilai atribut data-hash secara literal (case-sensitive)
  //
  // v52.6 — POSITIONAL HASH OVERRIDE:
  //   Jika dua karakter berbeda punya data-hash yang SAMA (nama asli identik),
  //   gunakan format "hash:NILAI:INDEX" untuk membedakan per kemunculan:
  //     "hash:某城:0" → { to: "Fugaku Uchiha" }    ← kemunculan pertama (index 0)
  //     "hash:某城:1" → { to: "Fugaku Namikaze" }  ← kemunculan kedua  (index 1)
  //     "hash:某城"   → { to: "Fugaku (default)" } ← fallback jika index tidak ada rule
  function applyHashBasedReplacements(kamus) {
    if (!document.body) return;

    // Kumpulkan entri: default (hash:VAL) dan positional (hash:VAL:INDEX)
    const hashEntries = {};    // { hashVal: entry }  — default rule
    const posEntries  = {};    // { hashVal: { idx: entry, ... } }  — positional rules

    for (const key in kamus) {
      if (!key.startsWith("hash:")) continue;
      const rest = key.slice(5); // hapus prefix "hash:"
      // Cek apakah ada bagian ":INDEX" di akhir (hanya angka)
      const lastColon = rest.lastIndexOf(":");
      if (lastColon !== -1) {
        const maybeIdx = rest.slice(lastColon + 1);
        if (/^\d+$/.test(maybeIdx)) {
          const hashVal = rest.slice(0, lastColon);
          const idx = parseInt(maybeIdx, 10);
          if (hashVal) {
            if (!posEntries[hashVal]) posEntries[hashVal] = {};
            posEntries[hashVal][idx] = kamus[key];
          }
          continue;
        }
      }
      // Default rule tanpa index
      if (rest) hashEntries[rest] = kamus[key];
    }

    const hasDefault    = Object.keys(hashEntries).length > 0;
    const hasPositional = Object.keys(posEntries).length > 0;
    if (!hasDefault && !hasPositional) return;

    // Kumpulkan semua hash values yang perlu diproses
    const allHashVals = new Set([...Object.keys(hashEntries), ...Object.keys(posEntries)]);

    // Cari semua span kata unik yang punya data-hash (gunakan preset situs)
    const sitePreset = getSiteKataUnikSelector();
    const spans = document.querySelectorAll(sitePreset.glossarySelector);

    // Kelompokkan span per hash value (pertahankan urutan DOM = urutan kemunculan)
    const hashSpanMap = {};
    spans.forEach(span => {
      const hash = span.getAttribute("data-hash");
      if (!hash || !allHashVals.has(hash)) return;
      if (!hashSpanMap[hash]) hashSpanMap[hash] = [];
      hashSpanMap[hash].push(span);
    });

    _replacingNow = true;
    try {
      for (const hashVal of allHashVals) {
        const spanList = hashSpanMap[hashVal];
        if (!spanList) continue;

        spanList.forEach((span, idx) => {
          // Prioritas: positional rule (hash:VAL:IDX) > default rule (hash:VAL)
          let entry = null;
          if (posEntries[hashVal] && posEntries[hashVal][idx] !== undefined) {
            entry = posEntries[hashVal][idx];
          } else if (hashEntries[hashVal]) {
            entry = hashEntries[hashVal];
          }
          if (!entry) return;

          const newText = (typeof entry === "object") ? (entry.to || "") : String(entry);
          if (!newText) return;

          // Ganti hanya text node langsung di dalam span (tidak ubah elemen span itu sendiri)
          // FIX v54: Gunakan hashOriginalTextMap (bukan originalTextMap) agar walker utama
          // tidak ikut me-restore node ini ke teks aslinya dan membatalkan penggantian hash.
          span.childNodes.forEach(child => {
            if (child.nodeType === Node.TEXT_NODE && child.nodeValue !== newText) {
              if (!hashOriginalTextMap.has(child)) hashOriginalTextMap.set(child, child.nodeValue);
              child.nodeValue = newText;
              lastProcessedValueMap.set(child, newText);
            }
          });
        });
      }
    } finally {
      _replacingNow = false;
    }
  }

  const TRANSLATIONS = {
    en: {
      flag: "🇺🇸",
      title: "Advanced Word Replacer",
      current_site: "Current site:",
      active: "ACTIVE",
      off: "OFF",
      disable: "Disable",
      enable: "Enable",
      tab_editor: "Editor",
      tab_terms: "Your Terms",
      tab_filter: "Filter",
      tab_recycle: "Recycle Word",
      tab_setting: "Settings",
      tab_cloud: "Cloud Manager",
      tab_config: "Config",
      search_placeholder: "Search old/new words...",
      select_all: "SELECT ALL",
      bulk_delete: "Delete ({0})",
      empty_state: "Dictionary empty / no results found.",
      show_other_terms: "Show Other Novel Terms ({0})",
      hide_other_terms: "Hide Other Novel Terms ({0})",
      other_terms_title: "{0} TERMS ({1})",
      original_text: "Original Text ({0})",
      replacement_text: "Replacement Text ({0})",
      global_replacer: "All Novels (Global Replacer)",
      local_replacer: "This Novel Only",
      this_novel_desc: "This term will only apply to this novel.",
      delete_btn: "Delete",
      close_btn: "Close",
      save_btn: "Save",
      update_btn: "Update",
      suggested_title: "Suggested Misspelled Words:",
      site_manager: "SITE MANAGER (FILTER)",
      mode_label: "Mode:",
      only_whitelist: "Only Whitelist",
      block_blacklist: "Block Blacklist",
      desc_whitelist: "Script will ONLY run on Whitelist sites listed below.",
      desc_blacklist:
        "Script will run on ALL sites, EXCEPT those listed in Blacklist below.",
      new_whitelist_placeholder: "New whitelist domain...",
      new_blacklist_placeholder: "New blacklist domain...",
      script_settings: "REPLACER SCRIPT CONFIG",
      blue_highlight: "Blue Highlight",
      blue_highlight_desc: "Bold and color replaced words in blue",
      restore_defaults: "Restore Defaults",
      restore_desc: "Delete custom data and reset to default",
      reset_data: "Reset Data",
      toast_removed_whitelist: "Site removed from Whitelist: {0}",
      toast_added_whitelist: "Site added to Whitelist: {0}",
      toast_added_blacklist: "Site added to Blacklist: {0}",
      toast_removed_blacklist: "Site removed from Blacklist: {0}",
      toast_deleted: "Rule '{0}' moved to Recycle Word",
      toast_updated: "Word '{0}' updated",
      toast_added: "Word '{0}' added",
      toast_filter_mode: "Filter mode changed to {0}",
      toast_whitelist_deleted: "Whitelist {0} deleted",
      toast_blacklist_deleted: "Blacklist {0} deleted",
      toast_copied: "Sync Key copied to clipboard!",
      toast_sync_connecting: "Connecting & syncing...",
      toast_sync_success: "Sync connection successful!",
      toast_reset_success: "All settings and words successfully reset!",
      alert_both_fields: "Both word fields are required!",
      alert_already_registered: "Site is already registered!",
      alert_enter_key: "Please enter a Sync Key first!",
      alert_same_key: "Sync Key is identical to this device!",
      alert_overwrite_confirm:
        "Connecting will overwrite local data with cloud data. Continue?",
      alert_bulk_delete_confirm:
        "Are you sure you want to move {0} selected rules to Recycle Word?",
      deleted_words_banner: "Deleted {0} words",
      undo_btn: "Undo",
      toast_restored: "Successfully restored {0} words!",
      sure_btn: "Sure?",
      yakin_btn: "Sure?",
      yakin_reset: "⚠️ SURE RESET? CLICK AGAIN",
      replaced_from: "From {0}",
      replaced_to: "To {0}",
      word_salah_placeholder: "Example: man",
      word_benar_placeholder: "Example: man(woman)",
      undo_tooltip: "Undo / Restore",
      confirm_undo_bulk: "Are you sure you want to restore {0} selected rules?",
      confirm_delete_perm_bulk:
        "Are you sure you want to permanently delete {0} selected rules?",
      toast_undone: "Word '{0}' successfully restored!",
      toast_deleted_perm: "Word '{0}' permanently deleted!",
      toast_bulk_undone: "Successfully restored {0} words!",
      toast_bulk_deleted_perm: "Successfully permanently deleted {0} words!",
      bulk_undo: "Restore ({0})",
      bulk_delete_perm: "Delete Perm ({0})",
      cloud_storage_status: "Cloud Storage Status",
      baskets_used: "Baskets (Configs) Used",
      used_of_max: "{0} of {1} Baskets",
      load_config: "Stored Configurations",
      loading_cloud_data: "Connecting to GitHub Gist...",
      no_backups_found: "No backups found on cloud.",
      current_active: "Active Gist State",
      btn_load: "Load",
      toast_config_loaded: "Configuration restored successfully!",
      toast_config_deleted: "Gist disconnected!",
      toast_github_connected: "Connected to GitHub Gist successfully!",
      toast_account_switched: "Logged out from current GitHub account.",
      toast_revision_restored: "Version '{0}' restored successfully!",
      btn_confirm: "Confirm",
      btn_cancel: "Cancel",
      merge_group: "Merge Group",
      merge_group_desc:
        'Select a target group to merge all terms of "{0}" into:',
      merge_group_success:
        'Successfully merged "{0}" into "{1}" ({2} terms moved)!',
      merge_group_err_same: "Cannot merge a group into itself!",
      rename_group_prompt: "Enter new group / novel title:",
      rename_group_success:
        'Successfully renamed group to "{0}" ({1} terms updated)!',
      logout_confirm:
        "Are you sure you want to disconnect your GitHub account? Local data will not be deleted.",
      delete_group_confirm:
        'Are you sure you want to move all terms in "{0}" to the Recycle Bin?',
      delete_group_success:
        'Moved group "{0}" ({1} terms deleted) to Recycle Word!',
      select_custom_group_placeholder: "Enter new custom group name...",
      alert_enter_custom_name: "Please enter a custom group name!",
      make_group_active: "Make This Group Active (Disable Other Groups)",
      suggested_wrong: "Suggested Misspelled Words:",
      cloud_connected_as: "Connected as:",
      cloud_manual_backup_starting: "Creating backup...",
      cloud_manual_backup_success: "Backup completed successfully!",
      cloud_manual_backup_fail_history: "Failed to load recent history.",
      gist_id_copied: "Gist ID copied to clipboard!",
      token_copied: "GitHub Token copied to clipboard!",
      backup_now: "Backup Now",
      logout: "Logout",
      revision_history: "Revision History",
      gist_err_invalid:
        "Gist ID is invalid or inaccessible!\n\nPossible Causes:\n1. Pasted token in Gist ID field.\n2. Incorrect Gist ID/URL.\n3. Token lacks 'gist' scope.\n4. Gist was deleted.",
      conn_err_verify:
        "Connection error verifying account. Please verify that your GitHub Token has 'gist' permissions and is copied correctly without spaces.",
      gist_load_fail:
        "Failed to load GitHub cloud data. Please check connection or token.",
      case_sensitive: "Case sensitive",
      toast_group_disabled:
        "Group novel deactivated (Returning to native site mode)",
      toast_group_activated:
        'Group novel "{0}" successfully activated exclusively',
      delete_revision_confirm:
        'Are you sure you want to delete the backup from "{0}" permanently?',
      reset_confirm_desc:
        "This action will restore default settings, delete custom dictionaries, recycle history, and disconnect GitHub Gist integration.",
      target_category: "Target Category",
      custom_novel_input_placeholder: "Enter new novel name...",
      highlight_enabled: "Highlight enabled",
      highlight_disabled: "Highlight disabled",
      gist_scan_toast: "Scanning your old Gist dictionaries...",
      gist_scan_found: "Your old Gist was found and auto-filled!",
      gist_scan_not_found: "No old Gist detected. Leave empty to create new.",
      warn_no_custom_name: "Please enter a custom novel/group name!",
      auth_token_classic_link:
        "👉 Get GitHub Access Token Here (Classic Token)",
      auth_token_manage_link: "⚙️ Manage Existing Tokens",
      auth_github_token_label: "GitHub Token:",
      auth_gist_id_label: "Gist ID (Optional):",
      auth_connect_btn: "Connect GitHub",
      auth_connecting_btn: "⌛ Connecting...",
      auth_conn_fail_reason:
        "Connection failed during account verification. Please verify that your GitHub Token has 'gist' permissions and is copied correctly without spaces.",
      auth_cloud_err_msg:
        "⚠️ Failed to load GitHub cloud data.<br>Please check your internet connection or token.",
      awr_version_label: "AWR Tools Version",
      editor_example_tip: "Tip: Use | for variations, * for wildcards",
      update_mode_label: "Update Mode",
      update_mode_desc: "Choose how AWR checks for script updates",
      update_mode_auto: "Auto (On Launch)",
      update_mode_manual: "Manual Only",
      btn_check_update: "Check for Updates",
      toast_checking_update: "Checking for updates...",
      toast_already_latest: "You are already using the latest version (v{0})!",
      toast_update_available:
        "Update available! v{0} is available on Greasy Fork.",
      modal_update_title: "Update Script?",
      modal_update_desc:
        "A new version (v{0}) is available on Greasy Fork. Do you want to update now?",
      btn_import_creds_trigger: "Import local credentials file",
      btn_forgot_gist_link: "Search Gists directly on GitHub",
      btn_backup_creds_file: "Save local credentials backup file",
      btn_auto_detect_gist: "Auto-detect Gist configuration",
      btn_cloud_credentials_vault: "Backup credentials inside Gist vault",
      btn_open_gist_github: "Buka Gist di GitHub",
      toast_creds_imported: "Credentials loaded successfully!",
      toast_creds_exported: "Local credentials backup downloaded!",
      toast_creds_backed_up_cloud: "Credentials Vault updated successfully!",
      recycle_auto_delete_title: "Auto-Clean Recycle Bin",
      recycle_auto_delete_desc:
        "Automatically delete terms from Recycle Bin after a set period",
      auto_delete_never: "Never (Manual Only)",
      auto_delete_days: "Days",
      auto_delete_year: "Year",
      toast_auto_delete_changed:
        "Recycle Bin auto-delete period changed to {0}",
      add_novel_presets_btn: "Add Popular Novel Sites to Whitelist",
      toast_novel_presets_added: "Added {0} novel sites to whitelist!",
      toast_novel_presets_already: "All popular novel sites already in whitelist.",
      tab_scanner: "Hash Scanner",
      scanner_title: "Kata Unik (Glossary Spans) on This Page",
      scanner_empty: "No kata-unik spans (span[data-hash]) found on this page.",
      scanner_add_rule: "+ Add Rule",
      scanner_has_rule: "✓ Has Rule",
      scanner_hint: "Click '+ Add Rule' to create a hash: replacement for that character.",
      scanner_del_rule: "🗑 Delete Rule",
      scanner_edit_rule: "✏ Edit",
      scanner_bulk_placeholder: "Replacement text for all selected...",
      scanner_bulk_save: "💾 Save {0} Rules",
      scanner_bulk_select_hint: "Select rows below, then type replacement text to save all at once.",
      scanner_select_all_rows: "Select All",
      scanner_deselect_all: "Deselect All",
      scanner_pin_occurrence: "⚙ Per Occurrence",
      scanner_occurrence_label: "Occurrence #{0}",
      scanner_occurrence_hint: "This hash appears {0}× on page. Assign different names per occurrence to distinguish same-hash characters.",
      scanner_occurrence_context: "Context",
      scanner_pos_save: "💾 Save Positions",
      scanner_pos_saved: "Positional rules saved!",
      scanner_rule_deleted: "Rule '{0}' deleted.",
      scanner_search_placeholder: "Search name or hash...",
      scanner_filter_all: "All",
      scanner_filter_has_rule: "Has Rule",
      scanner_filter_no_rule: "No Rule",
      scanner_filter_conflict: "⚠ Conflict",
      scanner_sort_alpha: "A–Z",
      scanner_sort_count: "Most ×",
      scanner_sort_rule_first: "Rules First",
      scanner_stat_total: "{0} hash",
      scanner_stat_has_rule: "✓ {0}",
      scanner_stat_no_rule: "○ {0}",
      scanner_stat_conflict: "⚠ {0}",
      scanner_conflict_detail: "Appears in different contexts — may be different characters",
      scanner_note_placeholder: "Note (optional)...",
      scanner_note_label: "Note",
      scanner_bulk_del: "🗑 Delete {0}",
      scanner_visible_count: "shown",
      tab_data: "Data",
      export_novel_btn: "Export This Novel's Rules (JSON)",
      export_novel_desc: "Download all replacement rules for the current active novel as a JSON file.",
      import_novel_btn: "Import Rules from JSON",
      import_novel_desc: "Merge rules from a previously exported JSON file into the current novel.",
      export_success: "Exported {0} rules for novel \"{1}\"",
      export_none: "No rules found for this novel to export.",
      import_success: "Imported {0} rules successfully!",
      import_error: "Import failed — invalid JSON file.",
      import_duplicate: "Skipped {0} existing rules, added {1} new rules.",
    },
    id: {
      flag: "🇮🇩",
      title: "Advanced Word Replacer",
      current_site: "Situs saat ini:",
      active: "AKTIF",
      off: "OFF",
      disable: "Matikan",
      enable: "Aktifkan",
      tab_editor: "Editor",
      tab_terms: "Your Terms",
      tab_filter: "Filter",
      tab_recycle: "Recycle Word",
      tab_setting: "Kelola",
      tab_cloud: "Cloud Manager",
      tab_config: "Config",
      search_placeholder: "Cari kata lama/baru...",
      select_all: "PILIH SEMUA",
      bulk_delete: "Hapus ({0})",
      empty_state: "Kamus kosong / tidak ada hasil.",
      show_other_terms: "Tampilkan Istilah Novel Lain ({0})",
      hide_other_terms: "Sembunyikan Istilah Novel Lain ({0})",
      other_terms_title: "TERMS {0} ({1})",
      original_text: "Teks Asli ({0})",
      replacement_text: "Teks Pengganti ({0})",
      global_replacer: "Semua Novel (Global Replacer)",
      local_replacer: "Hanya Novel Ini",
      this_novel_desc: "Istilah ini hanya akan berlaku pada novel ini.",
      delete_btn: "Hapus",
      close_btn: "Tutup",
      save_btn: "Simpan",
      update_btn: "Perbarui",
      suggested_title: "Rekomendasi Kata Salah:",
      site_manager: "SITUS MANAJER (FILTER)",
      mode_label: "Mode:",
      only_whitelist: "Hanya Whitelist",
      block_blacklist: "Blokir Blacklist",
      desc_whitelist:
        "Skrip HANYA akan berjalan pada situs Whitelist di bawah ini.",
      desc_blacklist:
        "Skrip akan berjalan pada SEMUA situs, KECUALI yang terdaftar di Blacklist di bawah ini.",
      new_whitelist_placeholder: "Domain whitelist baru...",
      new_blacklist_placeholder: "Domain blokir baru...",
      script_settings: "PENGATURAN SKRIP REPLACER (CONFIG)",
      blue_highlight: "Highlight Biru",
      blue_highlight_desc: "Berikan warna biru tebal pada kata yang diganti",
      restore_defaults: "Kembalikan Default",
      restore_desc: "Hapus data kustom dan reset ke bawaan",
      reset_data: "Reset Data",
      toast_removed_whitelist: "Situs dihapus dari Whitelist: {0}",
      toast_added_whitelist: "Situs ditambahkan ke Whitelist: {0}",
      toast_added_blacklist: "Situs ditambahkan ke daftar Blokir: {0}",
      toast_removed_blacklist: "Situs dihapus dari daftar Blokir: {0}",
      toast_deleted: "Aturan '{0}' dipindahkan ke Recycle Word",
      toast_updated: "Kata '{0}' diperbarui",
      toast_added: "Kata '{0}' ditambahkan",
      toast_filter_mode: "Mode filter diubah ke {0}",
      toast_whitelist_deleted: "Whitelist {0} dihapus",
      toast_blacklist_deleted: "Blokir {0} dihapus",
      toast_copied: "Kunci Sinkronisasi disalin ke clipboard!",
      toast_sync_connecting: "Menghubungkan & menyinkronkan...",
      toast_sync_success: "Koneksi sinkronisasi berhasil!",
      toast_reset_success: "Semua setelan dan kata berhasil di-reset!",
      alert_both_fields: "Kedua kolom kata wajib diisi!",
      alert_already_registered: "Situs sudah terdaftar!",
      alert_enter_key: "Silakan masukkan Kunci Sinkronisasi terlebih dahulu!",
      alert_same_key: "Kunci Sinkronisasi sama dengan perangkat ini!",
      alert_overwrite_confirm:
        "Menghubungkan perangkat akan menimpa data lokal dengan data awan baru (jika ada). Lanjutkan?",
      alert_bulk_delete_confirm:
        "Yakin ingin memindahkan {0} aturan kata terpilih ke Recycle Word?",
      deleted_words_banner: "Terhapus {0} kata",
      undo_btn: "Urungkan",
      toast_restored: "Berhasil mengembalikan {0} kata!",
      sure_btn: "Yakin?",
      yakin_btn: "Yakin?",
      yakin_reset: "⚠️ YAKIN RESET? KLIK LAGI",
      replaced_from: "From {0}",
      replaced_to: "To {0}",
      word_salah_placeholder: "Contoh: pria",
      word_benar_placeholder: "Contoh: pria(wanita)",
      undo_tooltip: "Urungkan / Kembalikan",
      confirm_undo_bulk:
        "Apakah Anda yakin ingin mengembalikan {0} kata terpilih?",
      confirm_delete_perm_bulk:
        "Apakah Anda yakin ingin menghapus permanen {0} kata terpilih?",
      toast_undone: "Kata '{0}' berhasil dikembalikan!",
      toast_deleted_perm: "Kata '{0}' berhasil dihapus permanen!",
      toast_bulk_undone: "Berhasil mengembalikan {0} kata!",
      toast_bulk_deleted_perm: "Berhasil menghapus permanen {0} kata!",
      bulk_undo: "Urungkan ({0})",
      bulk_delete_perm: "Hapus Permanen ({0})",
      cloud_storage_status: "Status Penyimpanan Cloud",
      baskets_used: "Basket (Config) Terpakai",
      used_of_max: "{0} dari {1} Basket",
      load_config: "Daftar Konfigurasi Tersimpan",
      loading_cloud_data: "Menghubungkan ke GitHub Gist...",
      no_backups_found: "Tidak ada konfigurasi tersimpan di Gist ini.",
      current_active: "Status Gist Aktif",
      btn_load: "Muat",
      toast_config_loaded: "Konfigurasi berhasil dipulihkan!",
      toast_config_deleted: "Gist diputuskan!",
      toast_github_connected: "Berhasil terhubung ke GitHub Gist!",
      toast_account_switched: "Berhasil keluar dari akun GitHub.",
      toast_revision_restored: "Versi '{0}' berhasil dipulihkan!",
      btn_confirm: "Lanjutkan",
      btn_cancel: "Batal",
      merge_group: "Gabungkan Grup",
      merge_group_desc:
        'Pilih grup tujuan untuk menggabungkan semua istilah dari "{0}" ke:',
      merge_group_success:
        'Berhasil menggabungkan "{0}" ke "{1}" ({2} istilah dipindahkan)!',
      merge_group_err_same:
        "Tidak dapat menggabungkan grup ke dirinya sendiri!",
      rename_group_prompt: "Masukkan nama novel / grup kustom baru:",
      rename_group_success:
        'Berhasil mengubah nama grup menjadi "{0}" ({1} kata diperbarui)!',
      logout_confirm:
        "Apakah Anda yakin ingin memutuskan koneksi akun GitHub saat ini? Data lokal Anda tidak akan terhapus.",
      delete_group_confirm:
        'Apakah Anda yakin ingin memindahkan seluruh kata/aturan dalam grup novel "{0}" ke Recycle Bin?',
      delete_group_success:
        'Berhasil memindahkan seluruh grup "{0}" ({1} kata dihapus) ke Recycle Word!',
      select_custom_group_placeholder: "Masukkan nama novel/grup baru...",
      alert_enter_custom_name: "Silakan masukkan nama novel/grup baru!",
      make_group_active: "Jadikan Grup Ini Aktif (Nonaktifkan Grup Lain)",
      suggested_wrong: "Rekomendasi Kata Salah:",
      cloud_connected_as: "Terhubung sebagai:",
      cloud_manual_backup_starting: "Mencadangkan kata ke GitHub...",
      cloud_manual_backup_success: "Kamus kata berhasil dicadangkan!",
      cloud_manual_backup_fail_history: "Gagal mengambil riwayat terbaru.",
      gist_id_copied: "Gist ID disalin ke clipboard!",
      token_copied: "GitHub Token disalin ke clipboard!",
      backup_now: "Cadangkan Sekarang",
      logout: "Logout",
      revision_history: "Riwayat Revisi",
      gist_err_invalid:
        "Gist ID tidak valid atau tidak dapat diakses!\n\nKemungkinan Penyebab:\n1. Menempelkan token di kolom Gist ID secara tidak sengaja.\n2. Gist ID/URL salah.\n3. Token tidak memiliki izin 'gist'.\n4. Gist telah dihapus.",
      conn_err_verify:
        "Terjadi kesalahan koneksi saat memverifikasi akun. Pastikan Token GitHub Anda memiliki izin 'gist' dan disalin dengan benar tanpa spasi tambahan.",
      gist_load_fail:
        "Gagal memuat data cloud GitHub. Silakan periksa koneksi internet atau token Anda.",
      case_sensitive: "Sensitif huruf",
      toast_group_disabled:
        "Grup novel dinonaktifkan (Kembali ke mode alami situs)",
      toast_group_activated:
        'Grup novel "{0}" berhasil diaktifkan secara eksklusif',
      delete_revision_confirm:
        'Apakah Anda yakin ingin menghapus berkas cadangan tanggal "{0}" dari cloud secara permanen?',
      reset_confirm_desc:
        "Tindakan ini akan mengembalikan setelan skrip ke kondisi awal serta menghapus seluruh data kamus kustom, log sampah, dan memutus integrasi cloud GitHub Anda.",
      target_category: "Target Kategori",
      custom_novel_input_placeholder: "Masukkan nama novel baru...",
      highlight_enabled: "Highlight aktif",
      highlight_disabled: "Highlight nonaktif",
      gist_scan_toast: "Memindai kamus Gist lama Anda...",
      gist_scan_found: "Gist lama Anda ditemukan dan diisi secara otomatis!",
      gist_scan_not_found:
        "Tidak ada Gist lama terdeteksi. Silakan biarkan kosong jika ingin membuat baru.",
      warn_no_custom_name: "Silakan masukkan nama novel/grup baru!",
      auth_token_classic_link:
        "👉 Get GitHub Access Token Here (Classic Token)",
      auth_token_manage_link:
        "⚙️ Manage Existing Tokens (Kelola Token Lama Anda)",
      auth_github_token_label: "GitHub Token:",
      auth_gist_id_label: "Gist ID (Optional):",
      auth_connect_btn: "Connect GitHub",
      auth_connecting_btn: "⌛ Menghubungkan...",
      auth_conn_fail_reason:
        "Terjadi kesalahan koneksi saat memverifikasi akun. Pastikan Token GitHub Anda memiliki izin 'gist' dan disalin dengan benar tanpa spasi tambahan.",
      auth_cloud_err_msg:
        "⚠️ Gagal memuat data cloud GitHub.<br>Silakan periksa koneksi internet atau token Anda.",
      awr_version_label: "Versi Skrip AWR",
      editor_example_tip: "Tip: Gunakan | untuk variasi, * untuk wildcard",
      update_mode_label: "Mode Pembaruan",
      update_mode_desc: "Pilih cara AWR memeriksa pembaruan skrip",
      update_mode_auto: "Otomatis (Saat Start)",
      update_mode_manual: "Hanya Manual",
      btn_check_update: "Periksa Pembaruan",
      toast_checking_update: "Memeriksa pembaruan...",
      toast_already_latest:
        "Skrip Anda sudah menggunakan versi terbaru (v{0})!",
      toast_update_available:
        "Pembaruan tersedia! v{0} sudah rilis di Greasy Fork.",
      modal_update_title: "Perbarui Skrip?",
      modal_update_desc:
        "Versi terbaru (v{0}) telah tersedia di Greasy Fork. Apakah Anda ingin memperbarui sekarang?",
      btn_import_creds_trigger: "Impor file kredensial lokal (.json)",
      btn_forgot_gist_link: "Cari data Gist Anda langsung di GitHub",
      btn_backup_creds_file: "Unduh file cadangan kredensial lokal",
      btn_auto_detect_gist: "Deteksi otomatis konfigurasi Gist",
      btn_cloud_credentials_vault: "Cadangkan kredensial login ke Gist",
      btn_open_gist_github: "Buka Gist di GitHub",
      toast_creds_imported: "Kredensial berhasil dimuat!",
      toast_creds_exported: "File cadangan lokal berhasil diunduh!",
      toast_creds_backed_up_cloud: "Kredensial berhasil dicadangkan!",
      recycle_auto_delete_title: "Pembersihan Otomatis Sampah",
      recycle_auto_delete_desc:
        "Hapus otomatis Recycle Bin secara permanen setelah jangka waktu tertentu",
      auto_delete_never: "Jangan Pernah (Manual)",
      auto_delete_days: "Hari",
      auto_delete_year: "Tahun",
      toast_auto_delete_changed:
        "Jangka waktu pembersihan otomatis sampah diubah menjadi {0}",
      add_novel_presets_btn: "Tambah Situs Novel Populer ke Whitelist",
      toast_novel_presets_added: "{0} situs novel berhasil ditambahkan ke whitelist!",
      toast_novel_presets_already: "Semua situs novel populer sudah ada di whitelist.",
      tab_scanner: "Hash Scanner",
      scanner_title: "Kata Unik (Glossary Spans) di Halaman Ini",
      scanner_empty: "Tidak ada span kata unik (span[data-hash]) di halaman ini.",
      scanner_add_rule: "+ Tambah Rule",
      scanner_has_rule: "✓ Ada Rule",
      scanner_hint: "Klik '+ Tambah Rule' untuk buat penggantian hash: bagi karakter itu.",
      scanner_del_rule: "🗑 Hapus Rule",
      scanner_edit_rule: "✏ Edit",
      scanner_bulk_placeholder: "Teks pengganti untuk semua yang dipilih...",
      scanner_bulk_save: "💾 Simpan {0} Rule",
      scanner_bulk_select_hint: "Pilih baris di bawah, lalu ketik teks pengganti untuk menyimpan semua sekaligus.",
      scanner_select_all_rows: "Pilih Semua",
      scanner_deselect_all: "Batal Pilih",
      scanner_pin_occurrence: "⚙ Per Kemunculan",
      scanner_occurrence_label: "Kemunculan ke-{0}",
      scanner_occurrence_hint: "Hash ini muncul {0}× di halaman. Beri nama berbeda per kemunculan untuk membedakan karakter dengan hash yang sama.",
      scanner_occurrence_context: "Konteks",
      scanner_pos_save: "💾 Simpan Posisi",
      scanner_pos_saved: "Rule posisional berhasil disimpan!",
      scanner_rule_deleted: "Rule '{0}' dihapus.",
      scanner_search_placeholder: "Cari nama atau hash...",
      scanner_filter_all: "Semua",
      scanner_filter_has_rule: "Ada Rule",
      scanner_filter_no_rule: "Belum Ada",
      scanner_filter_conflict: "⚠ Konflik",
      scanner_sort_alpha: "A–Z",
      scanner_sort_count: "Terbanyak ×",
      scanner_sort_rule_first: "Ada Rule Dulu",
      scanner_stat_total: "{0} hash",
      scanner_stat_has_rule: "✓ {0}",
      scanner_stat_no_rule: "○ {0}",
      scanner_stat_conflict: "⚠ {0}",
      scanner_conflict_detail: "Muncul di konteks berbeda — kemungkinan karakter berbeda",
      scanner_note_placeholder: "Catatan (opsional)...",
      scanner_note_label: "Catatan",
      scanner_bulk_del: "🗑 Hapus {0}",
      scanner_visible_count: "ditampilkan",
      tab_data: "Data",
      export_novel_btn: "Export Rule Novel Ini (JSON)",
      export_novel_desc: "Unduh semua aturan penggantian untuk novel aktif saat ini sebagai file JSON.",
      import_novel_btn: "Import Rule dari JSON",
      import_novel_desc: "Gabungkan aturan dari file JSON yang sebelumnya diekspor ke novel saat ini.",
      export_success: "Berhasil mengekspor {0} aturan untuk novel \"{1}\"",
      export_none: "Tidak ada aturan yang ditemukan untuk novel ini.",
      import_success: "Berhasil mengimpor {0} aturan!",
      import_error: "Impor gagal — file JSON tidak valid.",
      import_duplicate: "Dilewati {0} aturan duplikat, ditambahkan {1} aturan baru.",
    },
  };

  let core = null;

  function getLang() {
    return GM_getValue("awr_lang_v1", "en");
  }
  function saveLang(langCode) {
    GM_setValue("awr_lang_v1", langCode);
  }

  function t(key, ...args) {
    const lang = getLang();
    const dict = TRANSLATIONS[lang] || TRANSLATIONS["en"];
    const template = dict[key] || TRANSLATIONS["en"][key] || "";
    if (!template) return key;
    return template.replace(/{(\d+)}/g, (match, index) => {
      return typeof args[index] !== "undefined" ? args[index] : match;
    });
  }

  function panggilToast(pesan, tipe = "success") {
    if (!toast) {
      console.log(`[Toast Fallback] ${pesan}`);
      return;
    }
    toast.innerHTML = "";
    const textSpan = document.createElement("span");
    textSpan.textContent = pesan;
    toast.appendChild(textSpan);
    toast.className = "replacer-toast show " + tipe;
    if (toastTimeout) clearTimeout(toastTimeout);
    toastTimeout = setTimeout(() => {
      toast.className = "replacer-toast";
    }, 3000);
  }

  // ── Diagnosa Kata ─────────────────────────────────────────────────────────
  function diagnosisKata(key, itemData) {
    const currentNovel = getNovelContext();
    const activeNovelId = getActiveNovelId();
    const domainAllowed = isDomainAllowed();
    const pageBaseDomain = getNovelBaseDomain(currentHost);

    const toVal = itemData && typeof itemData === "object"
      ? itemData.to || ""
      : itemData || "";
    const isGlobal = !!(itemData && typeof itemData === "object" && itemData.global);
    const storedNovelId   = (itemData && typeof itemData === "object" && itemData.novelId)   ? itemData.novelId   : "";
    const storedNovelTitle= (itemData && typeof itemData === "object" && itemData.novelTitle) ? itemData.novelTitle: "";
    const storedNovelUrl  = (itemData && typeof itemData === "object" && itemData.novelUrl)   ? itemData.novelUrl  : "";
    const storedDomain    = (itemData && typeof itemData === "object" && itemData.domain)     ? itemData.domain    : "";
    const caseSensitive   = !!(itemData && typeof itemData === "object" && itemData.caseSensitive);

    const targetActiveId = activeNovelId ? activeNovelId : currentNovel.id;

    let layer0aMatch = false, layer0bMatch = false;
    let layer1Match  = false, layer2Match  = false, layer3Match = false;

    if (storedNovelId) {
      layer0aMatch = storedNovelId === targetActiveId;
      layer0bMatch = storedNovelId === currentNovel.id;

      const itemTitle  = (storedNovelTitle || getCachedNovelTitle(storedNovelId) || "").toLowerCase().trim();
      const pageTitle  = (currentNovel.title || "").toLowerCase().trim();
      layer1Match = !!(itemTitle && pageTitle && titlesMatchFuzzy(itemTitle, pageTitle));

      try { layer2Match = sameNovelByUrl(storedNovelId, currentNovel.id || window.location.href); } catch(_) {}
      try { layer3Match = storedNovelUrl ? sameNovelByUrl(storedNovelUrl, currentNovel.url || window.location.href) : false; } catch(_) {}
    }

    const novelMatchAny = layer0aMatch || layer0bMatch || layer1Match || layer2Match || layer3Match;

    let isActive = false;
    let activationReason = "";
    if (isGlobal) {
      isActive = true;
      activationReason = "✅ Global — aktif di semua halaman";
    } else if (storedNovelId) {
      if (novelMatchAny && activeNovelId !== "GLOBAL_ONLY") {
        isActive = true;
        if      (layer0aMatch) activationReason = "✅ Aktif — novelId === targetActiveId (Layer 0A)";
        else if (layer0bMatch) activationReason = "✅ Aktif — novelId === currentNovel.id (Layer 0B)";
        else if (layer1Match)  activationReason = "✅ Aktif via Layer 1 — judul novel cocok";
        else if (layer2Match)  activationReason = "✅ Aktif via Layer 2 — novelId URL cocok";
        else if (layer3Match)  activationReason = "✅ Aktif via Layer 3 — novelUrl cocok";
      } else if (activeNovelId === "GLOBAL_ONLY") {
        activationReason = "❌ Tidak aktif — mode GLOBAL_ONLY aktif";
      } else {
        activationReason = "❌ Tidak aktif — tidak ada layer yang cocok dengan novel ini";
      }
    } else if (storedDomain) {
      const termBase = getNovelBaseDomain(storedDomain);
      const domOk = domainAllowed && (termBase === pageBaseDomain || pageBaseDomain.endsWith("." + termBase));
      isActive = domOk;
      activationReason = domOk
        ? "✅ Aktif — domain cocok dan situs diizinkan"
        : "❌ Tidak aktif — domain tidak cocok atau situs tidak diizinkan";
    }

    const scopeLabel = isGlobal ? "🌐 Global" : storedNovelId ? "📖 Novel/Grup Spesifik" : "🏠 Domain Spesifik";
    const statusColor = isActive ? "#34d399" : "#f87171";
    const statusBg    = isActive ? "rgba(16,185,129,0.1)" : "rgba(239,68,68,0.1)";
    const statusBorder= isActive ? "rgba(16,185,129,0.3)" : "rgba(239,68,68,0.3)";

    const srow = (label, value) =>
      `<div style="display:flex!important;gap:8px!important;align-items:baseline!important;padding:4px 0!important;border-bottom:1px solid #1e293b!important;">
         <span style="color:#64748b!important;font-size:10px!important;min-width:130px!important;flex-shrink:0!important;">${label}</span>
         <span style="color:#cbd5e1!important;font-size:11px!important;font-family:monospace!important;word-break:break-all!important;">${value || `<em style="color:#475569!important;">kosong</em>`}</span>
       </div>`;

    const lrow = (label, match) =>
      `<div style="display:flex!important;gap:8px!important;align-items:center!important;padding:3px 0!important;">
         <span style="font-size:12px!important;">${match ? "✅" : "⬜"}</span>
         <span style="color:${match ? "#34d399" : "#64748b"}!important;font-size:10px!important;">${label}</span>
       </div>`;

    const section = (title) =>
      `<div style="font-size:9px!important;font-weight:bold!important;color:#64748b!important;text-transform:uppercase!important;letter-spacing:0.05em!important;margin-top:10px!important;margin-bottom:6px!important;border-top:1px solid #1e293b!important;padding-top:8px!important;">${title}</div>`;

    const bodyHTML = `
      <div style="font-size:11px!important;line-height:1.5!important;max-height:55vh!important;overflow-y:auto!important;padding-right:4px!important;">
        <div style="background:${statusBg}!important;border:1px solid ${statusBorder}!important;border-radius:8px!important;padding:8px 12px!important;margin-bottom:10px!important;font-size:12px!important;font-weight:bold!important;color:${statusColor}!important;">
          ${activationReason || (isActive ? "✅ AKTIF di halaman ini" : "❌ TIDAK AKTIF di halaman ini")}
        </div>

        ${section("📋 Detail Entri")}
        ${srow("Kata Asli (key)", `<strong style="color:#e5e7eb!important;">${key}</strong>`)}
        ${srow("Diganti Dengan", toVal)}
        ${srow("Ruang Lingkup", scopeLabel)}
        ${srow("Case Sensitive", caseSensitive ? "Ya" : "Tidak")}

        ${section("📖 Info Novel Tersimpan")}
        ${srow("novelId tersimpan", storedNovelId)}
        ${srow("novelTitle tersimpan", storedNovelTitle)}
        ${srow("novelUrl tersimpan", storedNovelUrl)}
        ${srow("domain tersimpan", storedDomain)}

        ${section("📍 Konteks Halaman Saat Ini")}
        ${srow("currentNovel.id", currentNovel.id)}
        ${srow("currentNovel.title", currentNovel.title)}
        ${srow("currentNovel.url", currentNovel.url)}
        ${srow("activeNovelId (grup aktif)", activeNovelId || `<em style="color:#475569!important;">tidak ada</em>`)}
        ${srow("targetActiveId", targetActiveId)}
        ${srow("Domain halaman", pageBaseDomain)}
        ${srow("Domain diizinkan", domainAllowed ? "Ya ✅" : "Tidak ❌")}

        ${storedNovelId ? `
        ${section("🔗 Hasil Pencocokan Layer")}
        ${lrow("Layer 0A: novelId === targetActiveId (grup aktif / currentId)", layer0aMatch)}
        ${lrow("Layer 0B: novelId === currentNovel.id (langsung, independen)", layer0bMatch)}
        ${lrow("Layer 1 : Judul novel cocok secara teks", layer1Match)}
        ${lrow("Layer 2 : sameNovelByUrl(novelId, currentId)", layer2Match)}
        ${lrow("Layer 3 : sameNovelByUrl(novelUrl, currentUrl)", layer3Match)}
        ` : ""}
      </div>`;

    window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
      `🔍 Diagnosa Kata: "${key}"`,
      bodyHTML,
      () => {},
    );

    // Ganti tombol konfirmasi di footer untuk menyuntikkan tombol "Perbaiki Semua"
    setTimeout(() => {
      const ov = panel.querySelector(".replacer-confirm-overlay");
      if (!ov) return;
      const footer = ov.querySelector(".replacer-confirm-footer");
      if (footer) {
        footer.innerHTML = `
          <button class="form-btn btn-perbaiki-semua-diag btn-pill-primary" style="background:#1d4ed8!important;border-color:#1d4ed8!important;color:#fff!important;font-size:11px!important;padding:6px 12px!important;margin-right:auto!important;">🔧 Perbaiki Semua</button>
          <button class="form-btn confirm-ok-btn" style="background:#1e293b!important;border-color:#475569!important;color:#e2e8f0!important;font-size:11px!important;padding:6px 12px!important;">Tutup</button>
        `;
        const fixAllBtn = footer.querySelector(".btn-perbaiki-semua-diag");
        const closeBtn = footer.querySelector(".confirm-ok-btn");

        if (fixAllBtn) {
          fixAllBtn.onclick = (e) => {
            e.stopPropagation();
            ov.remove();
            
            const targetId = currentNovel.id;
            const staleId = storedNovelId;
            let count = 0;

            // 1. Remap elements sharing the exact obsolete novelId of this diagnosed item
            if (staleId && targetId && staleId !== targetId) {
              const kamus = core.getKamus();
              for (const k in kamus) {
                const entry = kamus[k];
                if (entry && typeof entry === "object" && entry.novelId === staleId) {
                  entry.novelId = targetId;
                  if (currentNovel.title) entry.novelTitle = currentNovel.title;
                  count++;
                }
              }
              if (count > 0) {
                core.saveKamus(kamus);
                core.simpanKeAwan(kamus);
              }
            }

            // 2. Also run the general perbaikiIdNovel to catch other title-matched groups
            const generalCount = core.perbaikiIdNovel();
            const totalUpdated = Math.max(count, generalCount);

            if (totalUpdated > 0) {
              panggilToast(`✅ Berhasil memetakan ulang ${totalUpdated} novelId usang ke currentNovel.id!`, "success");
              core.jalankanPengganti(true);
              renderTampilan();
            } else {
              panggilToast("Tidak ada novelId usang yang cocok untuk diperbaiki.", "info");
            }
          };
        }
        if (closeBtn) {
          closeBtn.onclick = (e) => {
            e.stopPropagation();
            ov.remove();
          };
        }
      }
    }, 0);
  }

  function renderUnifiedItem(
    k,
    container,
    isFromOther,
    isRecycle,
    terpilih,
    seluruhData,
    parentPane,
  ) {
    const itemData = seluruhData[k];
    const item = document.createElement("div");
    item.className = "word-item flex-row p-box";
    if (isFromOther) item.style.opacity = "0.7";

    const checked = terpilih.includes(k) ? "checked" : "";
    const toVal =
      itemData && typeof itemData === "object"
        ? itemData.to || ""
        : itemData || "";

    let badgeHTML = "";
    if (k.startsWith("hash:")) {
      const hashVal = k.slice(5);
      badgeHTML = `<span class="term-badge" style="background:rgba(234,179,8,0.15)!important;color:#fbbf24!important;border:1px solid rgba(234,179,8,0.3)!important;">🔑 Hash Target: <code style="font-size:10px">${hashVal}</code></span>`;
    } else if (itemData && typeof itemData === "object") {
      if (itemData.global) {
        badgeHTML = `<span class="term-badge global">🌐 ${t("global_replacer")}</span>`;
      } else {
        const cleanNovel =
          core.getCachedNovelTitle(itemData.novelId) ||
          itemData.novelTitle ||
          core.getNovelBaseDomain(itemData.domain) ||
          "Novel";
        badgeHTML = `<span class="term-badge local">${isRecycle ? "📖 " : ""}${t("local_replacer")} (${cleanNovel})</span>`;
      }
    } else {
      badgeHTML = `<span class="term-badge global">🌐 ${t("global_replacer")}</span>`;
    }

    // v99.12.37: Cek apakah rule sedang dinonaktifkan (_off_: prefix di kamus)
    const isDisabledRule = k.startsWith("_off_:");
    const displayKey = isDisabledRule ? k.slice(6) : k;

    const leftActionBtn = isRecycle
      ? `<button class="awr-tooltip-btn modern-icon-btn undo-btn-spec" data-tooltip="${t("undo_tooltip")}">${core.SVGS.undo}</button>`
      : `<button class="awr-tooltip-btn modern-icon-btn edit-btn-spec" data-tooltip="Edit Term">${core.SVGS.pen}</button>`;

    const toggleActiveBtn = !isRecycle
      ? `<button class="awr-tooltip-btn modern-icon-btn awr-toggle-rule-btn" data-key="${k.replace(/"/g, "&quot;")}"
           data-tooltip="${isDisabledRule ? "Aktifkan Rule" : "Nonaktifkan Rule Sementara"}"
           style="font-size:11px!important;opacity:${isDisabledRule ? "0.5" : "1"}!important;">
           ${isDisabledRule ? "🔘" : "✅"}
         </button>`
      : "";

    item.style.opacity = isDisabledRule ? "0.5" : "1";

    item.innerHTML = `
            <input type="checkbox" class="word-checkbox" data-key="${k.replace(/"/g, "&quot;")}" ${checked} style="cursor: pointer !important; margin: 0 4px 0 0 !important; flex-shrink: 0 !important;" />
            ${leftActionBtn}
            <div class="word-pair flex-col" style="gap: 2px !important; min-width: 0 !important; flex: 1 !important; margin-left: 6px;">
                <span class="ellipsis" style="font-size: 12px !important; font-weight: 500 !important; color: ${isDisabledRule ? "#6b7280" : "#e5e7eb"} !important;">
                  <span style="color: #6b7280 !important; font-weight: bold !important;">FROM:</span>
                  ${isDisabledRule ? `<s style="color:#6b7280">${displayKey}</s>` : displayKey}
                </span>
                <span class="ellipsis" style="font-size: 12px !important; font-weight: 500 !important; color: #e5e7eb !important;"><span style="color: #6b7280 !important; font-weight: bold !important;">TO:</span> ${toVal}</span>
                ${badgeHTML}
                ${isDisabledRule ? '<span style="font-size:9px;color:#f87171;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:4px;padding:1px 5px;display:inline-block;margin-top:2px;">⏸ Dinonaktifkan</span>' : ""}
            </div>
            <div class="action-buttons" style="flex-shrink: 0 !important; margin-left: 6px !important; display: flex !important; align-items: center !important; gap: 2px !important;">
                ${toggleActiveBtn}
                ${!isRecycle ? `<button class="awr-tooltip-btn modern-icon-btn diagnose-btn-spec" data-tooltip="Diagnosa Kata" style="font-size: 12px !important; padding: 6px !important;">🔍</button>` : ""}
                <button class="awr-tooltip-btn modern-icon-btn danger delete" data-tooltip="${isRecycle ? "Permanent Delete" : "Delete Term"}">
                    ${core.SVGS.trash}
                </button>
            </div>
        `;

    // v99.12.37: Handler tombol toggle aktif/nonaktif rule
    const toggleRuleBtn = item.querySelector(".awr-toggle-rule-btn");
    if (toggleRuleBtn) {
      toggleRuleBtn.onclick = (e) => {
        e.stopPropagation();
        pushUndoSnapshot(isDisabledRule ? "Aktifkan rule" : "Nonaktifkan rule");
        const tk = core.getKamus();
        if (isDisabledRule) {
          // Aktifkan: hapus prefix _off_:
          const realKey = k.slice(6);
          tk[realKey] = tk[k];
          delete tk[k];
          panggilToast('✅ Rule "' + realKey + '" diaktifkan kembali.', "success");
        } else {
          // Nonaktifkan: tambah prefix _off_:
          tk["_off_:" + k] = tk[k];
          delete tk[k];
          panggilToast('⏸ Rule "' + k + '" dinonaktifkan sementara.', "warn");
        }
        core.saveKamus(tk);
        core.simpanKeAwan(tk);
        core.jalankanPengganti(true);
        renderTampilan();
      };
    }

    item.querySelector(".word-checkbox").onchange = (e) => {
      if (e.target.checked) {
        if (!terpilih.includes(k)) terpilih.push(k);
      } else {
        const idx = terpilih.indexOf(k);
        if (idx !== -1) terpilih.splice(idx, 1);
      }
      parentPane.perbaruiStatusMassal();
    };

    if (isRecycle) {
      item.querySelector(".undo-btn-spec").onclick = (e) => {
        e.stopPropagation();
        window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          "Pulihkan Aturan Kata?",
          `Aturan kata "${k}" akan dipulihkan ke kamus utama Anda.`,
          () => {
            const tempKamus = core.getKamus(),
              deletedWords = core.getDeletedWords();
            if (deletedWords[k]) {
              tempKamus[k] = deletedWords[k];
              delete deletedWords[k];
              core.saveKamus(tempKamus);
              core.saveDeletedWords(deletedWords);
              core.simpanKeAwan(tempKamus, null, deletedWords);
              panggilToast(t("toast_undone", k), "success");
              core.jalankanPengganti(true);
              renderTampilan();
            }
          },
        );
      };
    } else {
      item.querySelector(".edit-btn-spec").onclick = (e) => {
        e.stopPropagation();
        subjekEdit = k;
        tabAktif = "tambah";
        renderTampilan();
      };

      const diagBtn = item.querySelector(".diagnose-btn-spec");
      if (diagBtn) {
        diagBtn.onclick = (e) => {
          e.stopPropagation();
          diagnosisKata(k, itemData);
        };
      }
    }

    item.querySelector(".delete").onclick = (e) => {
      e.stopPropagation();
      if (isRecycle) {
        window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          "Hapus Permanen?",
          `Aturan kata "${k}" akan dihapus permanen secara total. Tindakan ini tidak dapat dibatalkan.`,
          () => {
            const deletedWords = core.getDeletedWords();
            if (deletedWords[k]) {
              delete deletedWords[k];
              core.saveDeletedWords(deletedWords);
              core.simpanKeAwan(null, null, deletedWords);
              panggilToast(t("toast_deleted_perm", k), "warn");
              renderTampilan();
            }
          },
        );
      } else {
        window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          "Hapus Kata?",
          `Aturan kata dari "${k}" ke "${toVal}" akan dipindahkan ke Recycle Bin.`,
          () => {
            const tempKamus = core.getKamus(),
              deletedWords = core.getDeletedWords(),
              backupWord = k,
              backupValue = tempKamus[k];
            delete tempKamus[k];
            core.saveKamus(tempKamus);

            const backupValueCopy = JSON.parse(JSON.stringify(backupValue));
            backupValueCopy.deletedAt = Date.now();
            deletedWords[backupWord] = backupValueCopy;
            core.saveDeletedWords(deletedWords);

            core.simpanKeAwan(tempKamus, null, deletedWords);
            panggilToast(t("toast_deleted", k), "warn");
            core.jalankanPengganti(true);
            renderTampilan();
          },
        );
      }
    };

    container.appendChild(item);
  }

  function ubahJudulNovel(nId, currentTitle) {
    if (!nId) return;

    window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
      "✏️ Ubah Nama Grup",
      t("rename_group_prompt"),
      () => {},
    );

    const overlay = panel.querySelector(".replacer-confirm-overlay");
    if (!overlay) return;

    const bodyEl = overlay.querySelector(".replacer-confirm-body");
    if (bodyEl) {
      bodyEl.innerHTML = `
        <div style="display:flex!important;flex-direction:column!important;gap:10px!important;padding:2px 0!important;">
          <p style="margin:0!important;color:#cbd5e1!important;font-size:13px!important;">${t("rename_group_prompt")}</p>
          <input
            type="text"
            class="form-input rename-group-input"
            value="${(currentTitle || "").replace(/"/g, "&quot;")}"
            placeholder="Nama novel / grup baru..."
            style="width:100%!important;box-sizing:border-box!important;font-size:13px!important;"
          />
        </div>`;
    }

    setTimeout(() => {
      const inp = overlay.querySelector(".rename-group-input");
      if (inp) { inp.focus(); inp.select(); }
    }, 60);

    const confirmBtn = overlay.querySelector(".replacer-confirm-ok");
    if (confirmBtn) {
      confirmBtn.onclick = () => {
        const newTitle = (
          overlay.querySelector(".rename-group-input")?.value || ""
        ).trim();
        if (!newTitle) {
          panggilToast(t("alert_enter_custom_name") || "Masukkan nama baru!", "warn");
          return;
        }
        overlay.remove();

        const tempKamus = core.getKamus();
        let count = 0;
        for (const key in tempKamus) {
          const item = tempKamus[key];
          if (
            item &&
            typeof item === "object" &&
            !item.global &&
            item.novelId === nId
          ) {
            item.novelTitle = newTitle;
            count++;
          }
        }

        core.saveCachedNovelTitle(nId, newTitle);
        core.saveKamus(tempKamus);
        core.simpanKeAwan(tempKamus);

        panggilToast(t("rename_group_success", newTitle, count), "success");
        core.jalankanPengganti(true);
        renderTampilan();
      };
    }

    overlay.querySelector(".rename-group-input")?.addEventListener("keydown", (e) => {
      if (e.key === "Enter") confirmBtn?.click();
      if (e.key === "Escape") overlay.remove();
    });
  }

  function tampilkanMergeGroup(nId, nTitle) {
    if (!nId) return;

    const semuaKamus = core.getKamus();
    const groupIdSet = new Set();
    for (const k in semuaKamus) {
      const item = semuaKamus[k];
      if (item && typeof item === "object" && !item.global && item.novelId) {
        groupIdSet.add(item.novelId);
      }
    }

    if (nTitle && !groupIdSet.has(nId)) groupIdSet.add(nId);

    const groupIds = Array.from(groupIdSet);
    groupIds.sort((a, b) => {
      const ta = core.getCachedNovelTitle(a) || a;
      const tb = core.getCachedNovelTitle(b) || b;
      return ta.localeCompare(tb);
    });

    window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
      t("merge_group"),
      t("merge_group_desc", nTitle || nId),
      () => {},
    );

    const overlay = panel.querySelector(".replacer-confirm-overlay");
    if (!overlay) return;
    const box = overlay.querySelector(".replacer-confirm-box");
    if (!box) return;

    const existingFooter = overlay.querySelector(".replacer-confirm-footer");
    // FIX: sembunyikan GLOBAL_OPTION saat sumber sudah global (tidak perlu memindahkan ke dirinya sendiri)
    const targetSelectOptions = [
      ...(nId !== "GLOBAL" ? [`<option value="GLOBAL_OPTION">🌐 ${t("global_replacer")}</option>`] : []),
      ...groupIds
        .filter((gid) => gid !== nId)
        .map((gid) => {
          const label = core.getCachedNovelTitle(gid) || gid;
          return `<option value="${gid}">📖 ${label}</option>`;
        }),
    ].join("");

    overlay.querySelector(".replacer-confirm-body").innerHTML = `
      <div style="display:flex!important;flex-direction:column!important;gap:8px!important;overflow:visible!important;">
        <div style="font-size:11px!important;color:#94a3b8!important;line-height:1.4!important;">
          Source: <span style="color:#60a5fa!important;font-weight:700!important;">${nTitle || nId}</span>
        </div>
        <label style="font-size:11px!important;color:#cbd5e1!important;font-weight:700!important;">Target Group</label>
        <select class="form-input merge-target-select" style="width:100%!important;">
          ${targetSelectOptions}
        </select>
      </div>
    `;

    if (existingFooter) {
      existingFooter.innerHTML = `
        <button class="form-btn confirm-cancel-btn">${t("btn_cancel")}</button>
        <button class="form-btn btn-pill-primary confirm-ok-btn" style="background:#ef4444!important;border-color:#ef4444!important;">${t("btn_confirm")}</button>
      `;
    }

    const cancelBtn = overlay.querySelector(".confirm-cancel-btn");
    const okBtn = overlay.querySelector(".confirm-ok-btn");

    if (cancelBtn)
      cancelBtn.onclick = (e) => {
        e.stopPropagation();
        overlay.remove();
      };

    if (okBtn)
      okBtn.onclick = (e) => {
        e.stopPropagation();
        const sel = overlay.querySelector(".merge-target-select");
        const targetId = sel ? sel.value : "GLOBAL_OPTION";

        if (targetId === nId) {
          overlay.remove();
          panggilToast(t("merge_group_err_same"), "warn");
          return;
        }

        overlay.remove();
        let movedCount = 0;

        const tempKamus = core.getKamus();

        for (const key in tempKamus) {
          const item = tempKamus[key];
          if (!item || typeof item !== "object") continue;

          // FIX: izinkan penggabungan dari sumber global (nId === "GLOBAL")
          const isSourceMatch = nId === "GLOBAL"
            ? item.global === true
            : (!item.global && item.novelId === nId);

          if (isSourceMatch) {
            if (targetId === "GLOBAL_OPTION") {
              if (item.global) continue; // sudah global
              item.global = true;
              item.novelId = "";
              item.novelTitle = core.getCachedNovelTitle(nTitle) || "";
              item.domain = core.getNovelBaseDomain(core.currentHost);
              movedCount++;
            } else {
              if (!item.global && item.novelId === targetId) continue; // sudah di target
              item.global = false;
              item.novelId = targetId;
              item.novelTitle = core.getCachedNovelTitle(targetId) || targetId;
              item.domain = core.getNovelBaseDomain(core.currentHost);
              movedCount++;
            }
          }
        }

        core.saveKamus(tempKamus);

        if (targetId !== "GLOBAL_OPTION") {
          const label = core.getCachedNovelTitle(targetId) || targetId;
          if (label) core.saveCachedNovelTitle(targetId, label);
        }

        core.simpanKeAwan(tempKamus);
        panggilToast(
          t(
            "merge_group_success",
            nTitle || nId,
            targetId === "GLOBAL_OPTION"
              ? t("global_replacer")
              : core.getCachedNovelTitle(targetId) || targetId,
            movedCount,
          ),
          "success",
        );
        core.jalankanPengganti(true);
        renderTampilan();
      };
  }

  // FIX v52: hilangkanFokusShadow dideklarasikan di outer scope agar bisa diakses
  // oleh renderTampilan dan setupUnifiedWordList. Implementasi sebenarnya di-assign
  // di dalam init() setelah shadow DOM tersedia.
  let hilangkanFokusShadow = function() {};

  function setupUnifiedWordList(pane, isRecycle) {
    pane.innerHTML = `
            <div class="search-action-bar" style="display: flex !important; gap: 6px !important; align-items: center !important; margin-bottom: 8px !important; overflow: visible !important;">
                <input type="text" class="form-input search-input" placeholder="${t("search_placeholder")}" style="flex: 1 !important;" />
                ${
                  isRecycle
                    ? `<button class="form-btn bulk-undo-btn btn-pill-primary" style="display: none !important; width: auto !important; margin-right: 4px !important;">${t("bulk_undo", "0")}</button>
                       <button class="form-btn bulk-delete-perm-btn btn-pill-danger" style="display: none !important; width: auto !important; margin-right: 4px !important;">${t("bulk_delete_perm", "0")}</button>`
                    : `<button class="form-btn bulk-delete-btn" style="display: none !important; width: auto !important; background: #ef4444 !important; color: white !important; padding: 7px 14px !important; font-size: 11px !important; border-radius: 20px !important; border: none !important; cursor: pointer !important; font-weight: bold !important;">${t("bulk_delete", "0")}</button>`
                }
            </div>
            <div class="select-all-row" style="display: flex !important; align-items: center !important; gap: 8px !important; padding: 6px 10px !important; overflow: visible !important;">
                <input type="checkbox" class="select-all-checkbox" style="cursor: pointer !important; margin: 0 !important;" />
                <span>${t("select_all")} (<span class="total-count">0</span>)</span>
            </div>
            <div class="toggle-other-placeholder" style="overflow: visible !important;"></div>
            <div class="word-list" style="overflow: visible !important;"></div>
            <div style="display: flex !important; justify-content: space-between !important; align-items: center !important; margin-top: 10px !important; overflow: visible !important; gap: 6px !important;">
                ${!isRecycle ? `<button class="form-btn btn-perbaiki-id btn-pill" title="Pindai semua kata di kamus dan perbaiki ID novel yang basi agar cocok dengan novel halaman ini (berdasarkan judul)" style="background: #1d4ed8 !important; color: #fff !important; padding: 6px 14px !important; font-size: 11px !important; display: inline-flex; align-items: center; justify-content: center; gap: 5px; white-space: nowrap;">🔧 Perbaiki ID Novel</button>` : `<span></span>`}
                <button class="form-btn close-btn-pane btn-pill" style="background: #374151 !important; color: #d1d5db !important; padding: 6px 16px !important; font-size: 12px !important; display: inline-flex; align-items: center; justify-content: center; gap: 6px;">
                    ${core.SVGS.close} ${t("close_btn")}
                </button>
            </div>
        `;

    const listContainer = pane.querySelector(".word-list");
    const searchInput = pane.querySelector(".search-input");
    const selectAllCheckbox = pane.querySelector(".select-all-checkbox");
    const totalCountSpan = pane.querySelector(".total-count");
    const bulkDeleteBtn = pane.querySelector(".bulk-delete-btn");
    const bulkUndoBtn = pane.querySelector(".bulk-undo-btn");
    const bulkDeletePermBtn = pane.querySelector(".bulk-delete-perm-btn");

    let terpilih = [];
    let visibleKeys = [];

    pane.querySelector(".close-btn-pane").onclick = () => {
      panel.classList.add("hidden");
      hilangkanFokusShadow();
    };

    if (!isRecycle) {
      pane.querySelector(".btn-perbaiki-id").onclick = () => {
        const jumlah = core.perbaikiIdNovel();
        if (jumlah > 0) {
          panggilToast(`✅ ${jumlah} kata berhasil diperbaiki ID novel-nya! Penggantian kata diperbarui.`, "success");
          core.jalankanPengganti(true);
          saringDanTampilkan(searchInput.value.trim().toLowerCase());
        } else {
          panggilToast("Tidak ada kata yang perlu diperbaiki pada novel ini (judul tidak cocok atau ID sudah benar).", "info");
        }
      };
    }

    pane.perbaruiStatusMassal = function () {
      const isAllSelected =
        visibleKeys.length > 0 &&
        visibleKeys.every((k) => terpilih.includes(k));
      selectAllCheckbox.checked = isAllSelected;
      totalCountSpan.textContent = terpilih.length;

      if (isRecycle) {
        if (terpilih.length > 0) {
          bulkUndoBtn.style.display = "block";
          bulkUndoBtn.textContent = t("bulk_undo", terpilih.length);
          bulkDeletePermBtn.style.display = "block";
          bulkDeletePermBtn.textContent = t(
            "bulk_delete_perm",
            terpilih.length,
          );
        } else {
          bulkUndoBtn.style.display = "none";
          bulkDeletePermBtn.style.display = "none";
        }
      } else {
        if (terpilih.length > 0) {
          bulkDeleteBtn.style.display = "block";
          bulkDeleteBtn.textContent = t("bulk_delete", terpilih.length);
        } else {
          bulkDeleteBtn.style.display = "none";
        }
      }
    };

    // v99.12.37: Search diperluas — cocokkan kueri pada key (FROM), toVal (TO),
    // dan nama novel agar user bisa cari berdasarkan teks pengganti atau novel
    function saringDanTampilkan(kueri = "") {
      listContainer.innerHTML = "";
      const seluruhData = isRecycle ? core.getDeletedWords() : core.getKamus();

      const keysFiltered = Object.keys(seluruhData).filter((k) => {
        const item = seluruhData[k];
        const toVal  = item && typeof item === "object" ? item.to || "" : item || "";
        // v99.12.37: cari juga di nama novel / domain
        const novStr = item && typeof item === "object"
          ? (core.getCachedNovelTitle(item.novelId) || item.novelTitle || item.domain || "")
          : "";
        return (
          k.toLowerCase().includes(kueri) ||
          toVal.toLowerCase().includes(kueri) ||
          novStr.toLowerCase().includes(kueri)
        );
      });

      const globalKeys = [];
      const currentLocalKeys = [];
      const otherLocalKeys = [];
      const currentNovel = core.getNovelContext();

      keysFiltered.forEach((k) => {
        const item = seluruhData[k];
        if (item && typeof item === "object") {
          if (item.global) {
            globalKeys.push(k);
          } else if (item.novelId) {
            if (item.novelId === currentNovel.id) {
              currentLocalKeys.push(k);
            } else {
              otherLocalKeys.push(k);
            }
          } else {
            const pageBaseDomain = core.getNovelBaseDomain(core.currentHost);
            const termBaseDomain = core.getNovelBaseDomain(item.domain);
            if (
              termBaseDomain === pageBaseDomain ||
              (termBaseDomain && pageBaseDomain.endsWith("." + termBaseDomain))
            ) {
              currentLocalKeys.push(k);
            } else {
              otherLocalKeys.push(k);
            }
          }
        } else if (typeof item === "string") {
          globalKeys.push(k);
        }
      });

      globalKeys.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
      currentLocalKeys.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));

      visibleKeys = [];
      globalKeys.forEach((k) => visibleKeys.push(k));
      currentLocalKeys.forEach((k) => visibleKeys.push(k));
      if (showOtherTerms) {
        otherLocalKeys.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
        otherLocalKeys.forEach((k) => visibleKeys.push(k));
      }

      if (visibleKeys.length === 0) {
        listContainer.innerHTML = `<div class="empty-state">${t("empty_state")}</div>`;
        pane.perbaruiStatusMassal();
        renderToggleOtherBtn(otherLocalKeys);
        return;
      }

      if (globalKeys.length > 0) {
        const gHeader = document.createElement("div");
        gHeader.setAttribute(
          "style",
          "display:flex!important;align-items:center!important;gap:6px!important;margin:10px 10px 6px 10px!important;overflow:visible!important;",
        );
        if (isRecycle) {
          gHeader.innerHTML = `<span class="btn-pill-primary" style="width:fit-content!important;">Global</span>`;
        } else {
          gHeader.innerHTML = `
            <span class="btn-pill-primary" style="width:fit-content!important;">Global</span>
            <div class="group-menu-container" style="position:relative!important;">
              <button class="group-menu-btn" title="Grup Menu Global">⚙️</button>
              <div class="group-dropdown-menu">
                <button class="group-dropdown-item merge-novel-group-btn" data-id="GLOBAL" data-title="Global">🔗 ${t("merge_group")}</button>
              </div>
            </div>`;
        }
        listContainer.appendChild(gHeader);
        globalKeys.forEach((k) => {
          renderUnifiedItem(
            k,
            listContainer,
            false,
            isRecycle,
            terpilih,
            seluruhData,
            pane,
          );
        });
      }

      if (currentLocalKeys.length > 0) {
        const localHeader = document.createElement("div");
        localHeader.setAttribute(
          "style",
          "display: flex !important; justify-content: space-between !important; align-items: center !important; padding: 10px 10px 6px 10px !important; margin-top: 10px !important; overflow: visible !important;",
        );
        const localTitle = currentNovel.title || "Current Novel";
        const isActiveLocal = isRecycle
          ? true
          : core.getActiveNovelId() === currentNovel.id ||
            !core.getActiveNovelId();

        localHeader.innerHTML = isRecycle
          ? `<span class="btn-pill" style="font-size: 11px !important; font-weight: bold !important;">${localTitle}</span>`
          : `
                        <div style="display: flex !important; align-items: center !important; gap: 6px !important; width: 100% !important; position: relative !important; overflow: visible !important;">
                            <div class="group-menu-container">
                                <button class="group-menu-btn" title="Grup Menu">⚙️</button>
                                <div class="group-dropdown-menu">
                                    <button class="group-dropdown-item toggle-active-novel-btn" data-id="${currentNovel.id}">${isActiveLocal ? "❌ Nonaktifkan" : "🟢 Aktifkan"}</button>
                                    <button class="group-dropdown-item merge-novel-group-btn" data-id="${currentNovel.id}" data-title="${localTitle}">🔗 ${t("merge_group")}</button>
                                    <button class="group-dropdown-item danger delete-novel-group-btn" data-id="${currentNovel.id}" data-title="${localTitle}">🗑 ${t("delete_btn")}</button>
                                </div>
                            </div>
                            <span class="local-novel-title-btn btn-pill" data-id="${currentNovel.id}" title="Klik untuk mengubah nama novel" style="flex: 1 !important; text-align: left !important;">
                                ${localTitle} ✏️ ${isActiveLocal ? "🟢" : ""}
                            </span>
                        </div>
                        ${
                          currentNovel.url
                            ? `
                        <a href="${currentNovel.url}" target="_blank" class="awr-tooltip-btn btn-pill" data-tooltip="Open Book Link" style="border-radius: 20px !important; text-decoration: none !important; margin-left: 6px !important; display: inline-flex; align-items: center; justify-content: center; padding: 4px;">
                            ${core.SVGS.link}
                        </a>`
                            : ""
                        }
                    `;
        listContainer.appendChild(localHeader);
        currentLocalKeys.forEach((k) => {
          renderUnifiedItem(
            k,
            listContainer,
            false,
            isRecycle,
            terpilih,
            seluruhData,
            pane,
          );
        });
      }

      renderToggleOtherBtn(otherLocalKeys);

      if (showOtherTerms && otherLocalKeys.length > 0) {
        // Bug Fix #5: Satukan grup dengan judul novel sama meskipun text-ID berbeda
        const otherGroups = {};
        otherLocalKeys.forEach((k) => {
          const item = seluruhData[k];
          let gId =
            item?.novelId ||
            `domain_${core.getNovelBaseDomain(item.domain) || "unknown"}`;
          let gTitle = (
            core.getCachedNovelTitle(gId) ||
            item?.novelTitle ||
            core.getNovelBaseDomain(item.domain) ||
            "Unknown Novel"
          ).trim();

          // Cari grup yang sudah ada dengan judul yang sama/mirip
          const existingGId = Object.keys(otherGroups).find((eid) => {
            const existTitle = otherGroups[eid].title.toLowerCase();
            const thisTitle  = gTitle.toLowerCase();
            return existTitle === thisTitle || titlesMatchFuzzy(existTitle, thisTitle);
          });

          const groupKey = existingGId || gId;
          if (!otherGroups[groupKey]) otherGroups[groupKey] = { title: gTitle, keys: [] };
          otherGroups[groupKey].keys.push(k);
        });

        Object.keys(otherGroups)
          .sort((a, b) => {
            return otherGroups[a].title.localeCompare(otherGroups[b].title);
          })
          .forEach((gId) => {
            const group = otherGroups[gId];
            group.keys.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" }));
            const isActiveGroup = isRecycle
              ? true
              : core.getActiveNovelId() === gId ||
                (!core.getActiveNovelId() && gId === currentNovel.id);

            const groupHeader = document.createElement("div");
            groupHeader.setAttribute(
              "style",
              "display: flex !important; justify-content: space-between !important; align-items: center !important; padding: 10px 10px 6px 10px !important; margin-top: 10px !important; overflow: visible !important;",
            );

            groupHeader.innerHTML = isRecycle
              ? `<span class="btn-pill" style="font-size: 11px !important; font-weight: bold !important;">${group.title} (${group.keys.length})</span>`
              : `
                            <div style="display: flex !important; align-items: center !important; gap: 6px !important; width: 100% !important; position: relative !important; overflow: visible !important;">
                                <div class="group-menu-container">
                                    <button class="group-menu-btn" title="Grup Menu">⚙️</button>
                                    <div class="group-dropdown-menu">
                                        <button class="group-dropdown-item toggle-active-novel-btn" data-id="${gId}">${isActiveGroup ? "❌ Nonaktifkan" : "🟢 Aktifkan"}</button>
                                        <button class="group-dropdown-item merge-novel-group-btn" data-id="${gId}" data-title="${group.title}">🔗 ${t("merge_group")}</button>
                                        <button class="group-dropdown-item danger delete-novel-group-btn" data-id="${gId}" data-title="${group.title}">🗑 ${t("delete_btn")}</button>
                                    </div>
                                </div>
                                <span class="local-novel-title-btn btn-pill" data-id="${gId}" title="Klik untuk mengubah nama novel" style="flex: 1 !important; text-align: left !important;">
                                    ${group.title} (${group.keys.length}) ${isActiveGroup ? "🟢" : ""}
                                </span>
                            </div>
                        `;
            listContainer.appendChild(groupHeader);
            group.keys.forEach((k) => {
              renderUnifiedItem(
                k,
                listContainer,
                true,
                isRecycle,
                terpilih,
                seluruhData,
                pane,
              );
            });
          });
      }

      if (!isRecycle) {
        pane.querySelectorAll(".group-menu-btn").forEach((btn) => {
          btn.onclick = (e) => {
            e.stopPropagation();
            const dropdown = btn.nextElementSibling;
            pane.querySelectorAll(".group-dropdown-menu").forEach((menu) => {
              if (menu !== dropdown) menu.classList.remove("show");
            });
            dropdown.classList.toggle("show");
          };
        });

        pane.querySelectorAll(".local-novel-title-btn").forEach((btn) => {
          btn.onclick = (e) => {
            e.stopPropagation();
            const nId = btn.getAttribute("data-id");
            const currentTitle =
              core.getCachedNovelTitle(nId) ||
              seluruhData[
                Object.keys(seluruhData).find(
                  (k) => seluruhData[k].novelId === nId,
                )
              ]?.novelTitle ||
              "Novel";
            ubahJudulNovel(nId, currentTitle);
          };
        });

        pane.querySelectorAll(".toggle-active-novel-btn").forEach((btn) => {
          btn.onclick = (e) => {
            e.stopPropagation();
            const nId = btn.getAttribute("data-id");
            const freshActiveId = core.getActiveNovelId();
            const isActive =
              freshActiveId === nId ||
              (!freshActiveId && nId === currentNovel.id);

            if (isActive) {
              core.setActiveNovelId("GLOBAL_ONLY");
              panggilToast(t("toast_group_disabled"), "info");
            } else {
              core.setActiveNovelId(nId);
              panggilToast(
                t(
                  "toast_group_activated",
                  core.getCachedNovelTitle(nId) || nId,
                ),
                "success",
              );
            }
            renderTampilan();
            try { core.jalankanPengganti(true); } catch (_) {}
            setTimeout(() => { try { core.jalankanPengganti(true); } catch (_) {} }, 200);
            setTimeout(() => { try { core.jalankanPengganti(true); } catch (_) {} }, 800);
          };
        });

        pane.querySelectorAll(".merge-novel-group-btn").forEach((btn) => {
          btn.onclick = (e) => {
            e.stopPropagation();
            pane
              .querySelectorAll(".group-dropdown-menu")
              .forEach((menu) => menu.classList.remove("show"));
            const nId = btn.getAttribute("data-id"),
              nTitle = btn.getAttribute("data-title");
            tampilkanMergeGroup(nId, nTitle);
          };
        });

        pane.querySelectorAll(".delete-novel-group-btn").forEach((btn) => {
          btn.onclick = (e) => {
            e.stopPropagation();
            const nId = btn.getAttribute("data-id"),
              nTitle = btn.getAttribute("data-title");
            window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
              t("delete_btn"),
              t("delete_group_confirm", nTitle),
              () => {
                const tempKamus = core.getKamus(),
                  deletedWords = core.getDeletedWords();
                let count = 0;
                for (const salah in tempKamus) {
                  if (tempKamus[salah].novelId === nId) {
                    const itemCopy = JSON.parse(
                      JSON.stringify(tempKamus[salah]),
                    );
                    itemCopy.deletedAt = Date.now();
                    deletedWords[salah] = itemCopy;
                    delete tempKamus[salah];
                    count++;
                  }
                }
                core.saveKamus(tempKamus);
                core.saveDeletedWords(deletedWords);
                try {
                  const cache = JSON.parse(
                    GM_getValue("awr_novel_titles_v2", "{}"),
                  );
                  delete cache[nId];
                  GM_setValue("awr_novel_titles_v2", JSON.stringify(cache));
                } catch (err) {}
                if (core.getActiveNovelId() === nId) core.setActiveNovelId("");
                core.simpanKeAwan(tempKamus, null, deletedWords);
                panggilToast(t("delete_group_success", nTitle, count), "warn");
                core.jalankanPengganti(true);
                renderTampilan();
              },
            );
          };
        });
      }

      pane.perbaruiStatusMassal();
    }

    function renderToggleOtherBtn(otherKeys) {
      const placeholder = pane.querySelector(".toggle-other-placeholder");
      if (!placeholder) return;
      placeholder.innerHTML = "";
      if (otherKeys.length === 0) return;

      const btn = document.createElement("button");
      btn.className = "toggle-other-btn";
      btn.setAttribute(
        "style",
        "background:#1a1d24!important;color:#9ca3af!important;border:1px solid #272a34!important;padding:10px 16px!important;border-radius:20px!important;font-size:11px!important;font-weight:bold!important;cursor:pointer!important;width:calc(100% - 20px)!important;margin:12px 10px!important;transition:all 0.2s ease!important;",
      );
      btn.textContent = showOtherTerms
        ? t("hide_other_terms", otherKeys.length)
        : t("show_other_terms", otherKeys.length);
      btn.onclick = () => {
        showOtherTerms = !showOtherTerms;
        GM_setValue("awr_show_other_terms", showOtherTerms);
        saringDanTampilkan(searchInput.value.toLowerCase().trim());
      };
      placeholder.appendChild(btn);
    }

    selectAllCheckbox.onchange = (e) => {
      if (e.target.checked) {
        visibleKeys.forEach((k) => {
          if (!terpilih.includes(k)) terpilih.push(k);
        });
      } else {
        terpilih = terpilih.filter((k) => !visibleKeys.includes(k));
      }
      pane.querySelectorAll(".word-checkbox").forEach((cb) => {
        cb.checked = e.target.checked;
      });
      pane.perbaruiStatusMassal();
    };

    if (isRecycle) {
      bulkUndoBtn.onclick = () => {
        if (terpilih.length === 0) return;
        window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          "Pulihkan Banyak Kata?",
          `Apakah Anda yakin ingin memulihkan ${terpilih.length} kata terpilih?`,
          () => {
            const tempKamus = core.getKamus(),
              deletedWords = core.getDeletedWords();
            terpilih.forEach((k) => {
              if (deletedWords[k]) {
                tempKamus[k] = deletedWords[k];
                delete deletedWords[k];
              }
            });
            core.saveKamus(tempKamus);
            core.saveDeletedWords(deletedWords);
            core.simpanKeAwan(tempKamus, null, deletedWords);
            panggilToast(t("toast_bulk_undone", terpilih.length), "success");
            terpilih = [];
            core.jalankanPengganti(true);
            renderTampilan();
          },
        );
      };

      bulkDeletePermBtn.onclick = () => {
        if (terpilih.length === 0) return;
        window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          "Hapus Banyak Kata Secara Permanen?",
          `Apakah Anda yakin ingin hapus permanen ${terpilih.length} kata terpilih?`,
          () => {
            const deletedWords = core.getDeletedWords();
            terpilih.forEach((k) => {
              delete deletedWords[k];
            });
            core.saveDeletedWords(deletedWords);
            core.simpanKeAwan(null, null, deletedWords);
            panggilToast(t("toast_bulk_deleted_perm", terpilih.length), "warn");
            terpilih = [];
            renderTampilan();
          },
        );
      };
    } else {
      bulkDeleteBtn.onclick = () => {
        if (terpilih.length === 0) return;
        window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          "Pindahkan Banyak Kata?",
          `Apakah Anda yakin ingin memindahkan ${terpilih.length} kata terpilih ke Recycle Bin?`,
          () => {
            const tempKamus = core.getKamus(),
              deletedWords = core.getDeletedWords();
            terpilih.forEach((k) => {
              const itemCopy = JSON.parse(JSON.stringify(tempKamus[k]));
              itemCopy.deletedAt = Date.now();
              deletedWords[k] = itemCopy;
              delete tempKamus[k];
            });
            core.saveKamus(tempKamus);
            core.saveDeletedWords(deletedWords);
            core.simpanKeAwan(tempKamus, null, deletedWords);
            panggilToast(t("toast_deleted", terpilih.length), "warn");
            terpilih = [];
            core.jalankanPengganti(true);
            renderTampilan();
          },
        );
      };
    }

    searchInput.oninput = (e) =>
      saringDanTampilkan(e.target.value.toLowerCase().trim());
    saringDanTampilkan();
  }

  function renderTampilan() {
    const domainAktif = core.isDomainAllowed();
    const seluruhKamus = core.getKamus();
    const activeLang = getLang();
    const activeFlag = TRANSLATIONS[activeLang]?.flag || "🇺🇸";
    const activeNovelId = core.getActiveNovelId();
    const lastSelectedGroup = GM_getValue(
      "awr_last_selected_group_id_v2",
      "GLOBAL_OPTION",
    );
    const lastCheckboxState = GM_getValue(
      "awr_last_active_group_checkbox_state_v2",
      true,
    );
    const updateMode = GM_getValue("awr_update_mode_v1", "auto");
    const autoDeleteDays = parseInt(
      GM_getValue("awr_recycle_auto_delete_days", "30"),
    );
    const currentNovel = core.getNovelContext();

    const langNames = { en: "English", id: "Indonesia" };
    panel.innerHTML = "";

    const header = document.createElement("div");
    header.className = "replacer-header";
    header.innerHTML = `
            <div class="replacer-header-row" style="display: flex !important; justify-content: space-between !important; align-items: center !important; gap: 8px !important;">
                <span class="replacer-title">🐸 ${t("title")}</span>
                <div class="lang-dropdown-container">
                    <button class="active-lang-btn">${activeFlag} <span style="font-size: 8px !important; line-height: 1 !important;">▼</span></button>
                    <div class="lang-dropdown-menu"></div>
                </div>
                <button class="replacer-close">${core.SVGS.close}</button>
            </div>
            <div class="replacer-host-status">
                <div class="host-info">
                    <span class="host-label">${t("current_site")}</span>
                    <span class="host-name">${core.getNovelBaseDomain(core.currentHost)}</span>
                </div>
                <div class="status-actions">
                    <span class="status-badge ${domainAktif ? "active" : "inactive"}">
                        <span class="status-indicator ${domainAktif ? "active" : "inactive"}"></span>
                        ${domainAktif ? t("active") : t("off")}
                    </span>
                    <button class="toggle-btn ${domainAktif ? "btn-active" : "btn-inactive"}">
                        ${domainAktif ? t("disable") : t("enable")}
                    </button>
                </div>
            </div>
        `;

    const dropdownMenu = header.querySelector(".lang-dropdown-menu");
    const activeBtn = header.querySelector(".active-lang-btn");
    activeBtn.onclick = (e) => {
      e.stopPropagation();
      dropdownMenu.classList.toggle("show");
    };

    Object.keys(TRANSLATIONS).forEach((langKey) => {
      const itemBtn = document.createElement("button");
      itemBtn.className = "lang-dropdown-item";
      if (langKey === activeLang) itemBtn.classList.add("active");
      itemBtn.innerHTML = `<span>${TRANSLATIONS[langKey].flag}</span> <span>${langNames[langKey]}</span>`;
      itemBtn.onclick = (e) => {
        e.stopPropagation();
        saveLang(langKey);
        dropdownMenu.classList.remove("show");
        renderTampilan();
      };
      dropdownMenu.appendChild(itemBtn);
    });

    header.querySelector(".replacer-close").onclick = () => {
      panel.classList.add("hidden");
      hilangkanFokusShadow();
    };

    header.querySelector(".toggle-btn").onclick = () => {
      const normalizedCurrent = core.getNovelBaseDomain(core.currentHost);
      const filterMode = core.getFilterMode();
      if (filterMode === "whitelist") {
        let targets = core.getTargetDomains();
        if (domainAktif) {
          targets = targets.filter(
            (d) => core.getNovelBaseDomain(d) !== normalizedCurrent,
          );
          panggilToast(t("toast_removed_whitelist", normalizedCurrent), "warn");
        } else {
          targets.push(normalizedCurrent);
          panggilToast(
            t("toast_added_whitelist", normalizedCurrent),
            "success",
          );
        }
        core.saveTargetDomains(targets);
        core.simpanKeAwan(null, targets);
      } else {
        let blacklist = core.getBlacklistDomains();
        if (domainAktif) {
          blacklist.push(normalizedCurrent);
          panggilToast(t("toast_added_blacklist", normalizedCurrent), "warn");
        } else {
          blacklist = blacklist.filter(
            (d) => core.getNovelBaseDomain(d) !== normalizedCurrent,
          );
          panggilToast(
            t("toast_removed_blacklist", normalizedCurrent),
            "success",
          );
        }
        core.saveBlacklistDomains(blacklist);
        core.simpanKeAwan();
      }
      core.jalankanPengganti(true);
      renderTampilan();
    };

    panel.appendChild(header);

    // v99.12.37: Stats bar — ringkasan singkat kamus aktif
    (function renderStatsBar() {
      const allKeys   = Object.keys(seluruhKamus);
      const activeKeys = allKeys.filter(k => !k.startsWith("_off_:") && !k.startsWith("hash:") && !/:\d+$/.test(k));
      const disabledCount = allKeys.filter(k => k.startsWith("_off_:")).length;
      const hashCount  = allKeys.filter(k => /^hash:[^:]+$/.test(k)).length;
      const novelName  = currentNovel.title || core.getNovelBaseDomain(core.currentHost) || "—";
      const statsBar   = document.createElement("div");
      statsBar.className = "awr-stats-bar";
      statsBar.style.cssText =
        "background:#0f1117!important;border-bottom:1px solid #1e293b!important;" +
        "padding:4px 14px!important;display:flex!important;align-items:center!important;" +
        "gap:10px!important;flex-wrap:wrap!important;font-size:9px!important;color:#64748b!important;" +
        "line-height:1.4!important;";
      statsBar.innerHTML =
        '<span style="color:#94a3b8;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:120px;" title="' + novelName + '">📖 ' + novelName + '</span>' +
        '<span style="color:#334155">|</span>' +
        '<span><b style="color:#10b981">' + activeKeys.length + '</b> rule aktif</span>' +
        (hashCount > 0 ? '<span style="color:#334155">·</span><span><b style="color:#fbbf24">' + hashCount + '</b> hash</span>' : '') +
        (disabledCount > 0 ? '<span style="color:#334155">·</span><span><b style="color:#f87171">' + disabledCount + '</b> nonaktif</span>' : '') +
        (_undoStack.length > 0
          ? '<button class="awr-undo-bar-btn" style="margin-left:auto!important;background:rgba(99,102,241,0.15)!important;border:1px solid rgba(99,102,241,0.4)!important;' +
            'color:#818cf8!important;border-radius:6px!important;padding:1px 7px!important;font-size:9px!important;cursor:pointer!important;white-space:nowrap!important;">⟲ Undo (' + _undoStack.length + ')</button>'
          : '<span style="margin-left:auto;color:#1e293b;">⟲</span>');
      panel.appendChild(statsBar);
      const undoBtn = statsBar.querySelector(".awr-undo-bar-btn");
      if (undoBtn) undoBtn.onclick = () => popUndoAndRestore();
    })();

    const tabsRow = document.createElement("div");
    tabsRow.className = "replacer-tabs";
    tabsRow.innerHTML = `
            <button class="awr-tooltip-btn tooltip-bottom replacer-tab-btn ${tabAktif === "tambah" ? "active" : ""}" data-tab="tambah" data-tooltip="${t("tab_editor")}">${core.SVGS.pen}</button>
            <button class="awr-tooltip-btn tooltip-bottom replacer-tab-btn ${tabAktif === "daftar" ? "active" : ""}" data-tab="daftar" data-tooltip="${t("tab_terms")}">${core.SVGS.book}</button>
            <button class="awr-tooltip-btn tooltip-bottom replacer-tab-btn ${tabAktif === "recycle" ? "active" : ""}" data-tab="recycle" data-tooltip="${t("tab_recycle")}">${core.SVGS.recycle}</button>
            <button class="awr-tooltip-btn tooltip-bottom replacer-tab-btn ${tabAktif === "scanner" ? "active" : ""}" data-tab="scanner" data-tooltip="${t("tab_scanner")}" style="font-size:14px!important;">🔍</button>
            <button class="awr-tooltip-btn tooltip-bottom replacer-tab-btn ${tabAktif === "setting" ? "active" : ""}" data-tab="setting" data-tooltip="${t("tab_setting")}">${core.SVGS.gear}</button>
        `;

    tabsRow.querySelectorAll(".replacer-tab-btn").forEach((btn) => {
      btn.onclick = () => {
        const targetTab = btn.getAttribute("data-tab");
        if (targetTab === "tambah") subjekEdit = null;
        tabAktif = targetTab;
        renderTampilan();
      };
    });
    panel.appendChild(tabsRow);

    const body = document.createElement("div");
    body.className = "replacer-body";
    panel.appendChild(body);

    if (tabAktif === "daftar") {
      const pane = document.createElement("div");
      pane.className = "tab-pane active";
      body.appendChild(pane);
      setupUnifiedWordList(pane, false);
    } else if (tabAktif === "recycle") {
      const pane = document.createElement("div");
      pane.className = "tab-pane active";
      body.appendChild(pane);
      setupUnifiedWordList(pane, true);
    } else if (tabAktif === "scanner") {
      // v52.7: Hash Scanner — search/filter/sort + stats + conflict + bulk delete + preview + notes
      const pane = document.createElement("div");
      pane.className = "tab-pane active";
      pane.style.cssText = "box-sizing:border-box;min-width:0;overflow:hidden;";
      body.appendChild(pane);

      const sitePreset = core.getSiteKataUnikSelector();
      const allSpans = document.querySelectorAll(sitePreset.glossarySelector + ", span[data-hash]");

      // Kelompokkan per hash value (simpan spans asli untuk context + preview)
      const hashMap = {};
      allSpans.forEach(span => {
        const hash = span.getAttribute(sitePreset.hashAttr || "data-hash");
        if (!hash) return;
        const txt = span.textContent.trim();
        if (!hashMap[hash]) hashMap[hash] = { texts: new Set(), count: 0, spans: [] };
        hashMap[hash].texts.add(txt);
        hashMap[hash].count++;
        hashMap[hash].spans.push(span);
      });
      const allHashKeys = Object.keys(hashMap).sort();

      if (allHashKeys.length === 0) {
        pane.innerHTML = `
          <div style="font-size:11px;font-weight:bold;color:#9ca3af;margin-bottom:6px;border-bottom:1px solid #272a34;padding-bottom:6px;">🔍 ${t("scanner_title")}</div>
          <div style="color:#6b7280;font-size:11px;text-align:center;padding:20px 0;">${t("scanner_empty")}</div>`;
        return;
      }

      // ── State ──────────────────────────────────────────────────────
      // FIX Bug 2: State persisten via _scannerState (scope modul)
      // Tidak direset saat rerenderScanner() -> renderTampilan() dipanggil ulang.
      const bulkSelected = _scannerState.bulkSelected;
      let searchQuery = _scannerState.searchQuery || "";
      let filterMode  = _scannerState.filterMode || "all";
      let sortMode    = _scannerState.sortMode || "alpha";
      // FIX: _liveKamus — referensi kamus yang bisa di-refresh oleh rerenderScanner()
      // agar hasRule, filter "Ada Rule/Belum Ada", dan stats selalu menampilkan data terkini.
      let _liveKamus  = seluruhKamus;

      // ── Helpers ────────────────────────────────────────────────────
      function getSpanContext(span) {
        try {
          const parent = span.parentElement;
          if (!parent) return "";
          const full = parent.textContent || "";
          const spanTxt = span.textContent || "";
          const idx = full.indexOf(spanTxt);
          if (idx === -1) return "";
          const before = full.slice(Math.max(0, idx - 20), idx).replace(/\s+/g, " ").trim();
          const after  = full.slice(idx + spanTxt.length, idx + spanTxt.length + 20).replace(/\s+/g, " ").trim();
          return (before ? "…" + before + " " : "") + "[" + spanTxt + "]" + (after ? " " + after + "…" : "");
        } catch(_) { return ""; }
      }

      function isHashConflict(hash) {
        const sp = hashMap[hash] && hashMap[hash].spans;
        if (!sp || sp.length < 2) return false;
        const ctxSet = new Set(sp.map(s => {
          const p = s.closest("p, li, td, h1, h2, h3, h4, h5, h6");
          return p ? p.textContent.replace(s.textContent, "").trim().slice(0, 40) : "";
        }));
        return ctxSet.size > 1;
      }

      function deleteHashRule(hashVal) {
        const tempKamus = core.getKamus(), deletedWords = core.getDeletedWords();
        const key = "hash:" + hashVal;
        if (tempKamus[key]) {
          deletedWords[key] = tempKamus[key];
          delete tempKamus[key];
          core.saveKamus(tempKamus);
          core.saveDeletedWords(deletedWords);
          core.simpanKeAwan(tempKamus, null, deletedWords);
        }
      }

      function rerenderScanner() {
        // FIX: Jangan hapus pane.innerHTML — shell pencarian (search/filter/sort) dipertahankan
        // agar kolom pencari tetap berfungsi dan tidak kehilangan event binding-nya.
        // renderScannerUI() sudah menangani partial update (hanya list yang di-refresh).
        tabAktif = "scanner";
        if (pane && pane.isConnected) {
          // Refresh kamus agar data terkini (rule baru, hapus rule, dll.) langsung terlihat
          _liveKamus = core.getKamus();
          try {
            renderScannerUI();
          } catch (reErr) {
            console.error("[AWR] rerenderScanner error:", reErr);
            pane.innerHTML = `<div class="awr-scanner-error-state" style="padding:16px;color:#f87171;font-size:11px;text-align:center;line-height:1.6;">
              ⚠️ Gagal memperbarui scanner.<br>
              <span style="color:#64748b;font-size:10px;">${reErr && reErr.message ? reErr.message : "Unknown error"}</span><br>
              <button class="form-btn" style="margin-top:8px;padding:4px 12px!important;font-size:10px!important;border-radius:8px!important;" id="awr-scanner-retry-btn2">🔄 Coba Lagi</button>
            </div>`;
            const rb = pane.querySelector("#awr-scanner-retry-btn2");
            if (rb) rb.onclick = () => { _liveKamus = core.getKamus(); try { renderScannerUI(); } catch(_) {} };
          }
          return;
        }
        // Fallback: pane sudah tidak di DOM — rebuild seluruh panel
        safeRenderTampilan();
      }

      // Preview real-time: highlight span di halaman sementara
      let _previewSpans = [];
      function applyPagePreview(hashes) {
        clearPagePreview();
        (Array.isArray(hashes) ? hashes : [hashes]).forEach(h => {
          (hashMap[h] && hashMap[h].spans || []).forEach(span => {
            _previewSpans.push({ span, oline: span.style.outline, obg: span.style.background, obr: span.style.borderRadius });
            span.style.outline = "2px solid #f59e0b";
            span.style.background = "rgba(245,158,11,0.18)";
            span.style.borderRadius = "3px";
          });
        });
      }
      function clearPagePreview() {
        _previewSpans.forEach(({ span, oline, obg, obr }) => {
          span.style.outline = oline; span.style.background = obg; span.style.borderRadius = obr;
        });
        _previewSpans = [];
      }

      // Hitung stats — dihitung ulang di dalam renderScannerUI() agar selalu fresh
      let statsHasRule = 0, statsNoRule = 0, statsConflict = 0;
      function refreshStats() {
        statsHasRule = 0; statsNoRule = 0; statsConflict = 0;
        allHashKeys.forEach(h => {
          if (_liveKamus["hash:" + h]) statsHasRule++; else statsNoRule++;
          if (isHashConflict(h)) statsConflict++;
        });
      }
      refreshStats();

      function getVisibleKeys() {
        let keys = allHashKeys.filter(h => {
          const info = hashMap[h];
          const allText = Array.from(info.texts).join(" ") + " hash:" + h;
          if (searchQuery && !allText.toLowerCase().includes(searchQuery.toLowerCase())) return false;
          if (filterMode === "has_rule"  && !_liveKamus["hash:" + h]) return false;
          if (filterMode === "no_rule"   &&  _liveKamus["hash:" + h]) return false;
          if (filterMode === "conflict"  && !isHashConflict(h)) return false;
          return true;
        });
        if (sortMode === "count")      keys.sort((a, b) => hashMap[b].count - hashMap[a].count);
        else if (sortMode === "rule_first") keys.sort((a, b) => {
          const ra = !!(_liveKamus["hash:" + a]), rb = !!(_liveKamus["hash:" + b]);
          return ra === rb ? a.localeCompare(b) : (rb ? 1 : -1);
        });
        return keys;
      }

      // ── Render UI (re-usable inner function) ───────────────────────
      function renderScannerUI() {
        try { // v99.12.30: try/catch agar error internal tidak bikin scanner blank
        refreshStats(); // FIX: hitung ulang stats dengan _liveKamus terkini
        const visibleKeys = getVisibleKeys();
        const statsTotal  = allHashKeys.length;

        // FIX v99.12.38: Shell statis (search input, filter, sort) hanya dirender SEKALI.
        // Ini mencegah search field terhapus saat user mengetik — input tidak dihancurkan
        // oleh pane.innerHTML pada setiap re-filter/re-sort.
        if (!pane.querySelector(".awr-sc-search")) {
        pane.innerHTML = `
          <!-- Stats Bar -->
          <div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;margin-bottom:5px;padding-bottom:5px;border-bottom:1px solid #272a34;min-width:0;">
            <span style="font-size:10px;font-weight:bold;color:#9ca3af;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:1;">🔍 ${t("scanner_title")}</span>
            <div style="display:flex;gap:3px;flex-wrap:wrap;margin-left:auto;flex-shrink:0;">
              <span style="font-size:8px;background:#1a1d24;border:1px solid #272a34;border-radius:4px;padding:1px 4px;color:#94a3b8;">${t("scanner_stat_total", statsTotal)}</span>
              <span style="font-size:8px;background:rgba(16,185,129,0.1);border:1px solid rgba(16,185,129,0.3);border-radius:4px;padding:1px 4px;color:#10b981;">${t("scanner_stat_has_rule", statsHasRule)}</span>
              <span style="font-size:8px;background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.3);border-radius:4px;padding:1px 4px;color:#fbbf24;">${t("scanner_stat_no_rule", statsNoRule)}</span>
              ${statsConflict > 0 ? `<span style="font-size:8px;background:rgba(239,68,68,0.1);border:1px solid rgba(239,68,68,0.3);border-radius:4px;padding:1px 4px;color:#f87171;">${t("scanner_stat_conflict", statsConflict)}</span>` : ""}
            </div>
          </div>

          <!-- Search + Sort -->
          <div style="display:flex;gap:4px;margin-bottom:5px;min-width:0;">
            <input class="form-input awr-sc-search" placeholder="${t("scanner_search_placeholder")}" value="${(searchQuery || "").replace(/"/g, "&quot;")}" style="flex:1;min-width:0;font-size:10px!important;padding:3px 7px!important;height:24px!important;min-height:0!important;" />
            <select class="form-input awr-sc-sort" style="width:auto!important;flex-shrink:0;font-size:9px!important;padding:2px 3px!important;height:24px!important;min-height:0!important;background:#1a1d24!important;color:#cbd5e1!important;cursor:pointer!important;">
              <option value="alpha"      ${sortMode==="alpha"?"selected":""}>${t("scanner_sort_alpha")}</option>
              <option value="count"      ${sortMode==="count"?"selected":""}>${t("scanner_sort_count")}</option>
              <option value="rule_first" ${sortMode==="rule_first"?"selected":""}>${t("scanner_sort_rule_first")}</option>
            </select>
          </div>

          <!-- Filter tabs -->
          <div style="display:flex;gap:3px;margin-bottom:5px;flex-wrap:wrap;min-width:0;">
            ${["all","has_rule","no_rule","conflict"].map(fm => {
              const labels = { all: t("scanner_filter_all"), has_rule: t("scanner_filter_has_rule"), no_rule: t("scanner_filter_no_rule"), conflict: t("scanner_filter_conflict") };
              const isA = filterMode === fm;
              return `<button class="form-btn awr-sc-filter" data-filter="${fm}" style="padding:2px 7px!important;font-size:9px!important;border-radius:7px!important;flex-shrink:0!important;${isA?"background:rgba(99,102,241,0.2)!important;border-color:rgba(99,102,241,0.5)!important;color:#818cf8!important;":""}">${labels[fm]}${fm==="conflict"&&statsConflict>0?" ("+statsConflict+")":""}</button>`;
            }).join("")}
          </div>

          <!-- Bulk bar -->
          <div class="awr-bulk-bar" style="display:none;background:rgba(59,130,246,0.08);border:1px solid rgba(59,130,246,0.3);border-radius:7px;padding:6px 8px;margin-bottom:5px;gap:5px;flex-direction:column;min-width:0;box-sizing:border-box;">
            <div style="font-size:9px;color:#60a5fa;">${t("scanner_bulk_select_hint")}</div>
            <div style="display:flex;gap:4px;align-items:center;min-width:0;">
              <input class="form-input awr-bulk-input" placeholder="${t("scanner_bulk_placeholder")}" style="flex:1;min-width:0;font-size:10px!important;padding:3px 6px!important;height:24px!important;min-height:0!important;" />
              <button class="form-btn awr-bulk-save-btn btn-pill-primary" style="padding:2px 7px!important;font-size:9px!important;border-radius:8px!important;white-space:nowrap!important;flex-shrink:0!important;">${t("scanner_bulk_save", 0)}</button>
              <button class="form-btn awr-bulk-del-btn" style="padding:2px 7px!important;font-size:9px!important;border-radius:8px!important;white-space:nowrap!important;flex-shrink:0!important;background:rgba(239,68,68,0.12)!important;color:#f87171!important;border-color:rgba(239,68,68,0.35)!important;">${t("scanner_bulk_del", 0)}</button>
            </div>
          </div>

          <!-- Select-all + count -->
          <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;min-width:0;gap:4px;">
            <span class="awr-scan-visible-count" style="font-size:9px;color:#6b7280;flex-shrink:0;">${visibleKeys.length} ${t("scanner_visible_count")}</span>
            <div style="display:flex;gap:3px;flex-shrink:0;">
              <button class="form-btn awr-scan-selall" style="padding:1px 6px!important;font-size:9px!important;border-radius:7px!important;">${t("scanner_select_all_rows")}</button>
              <button class="form-btn awr-scan-desel" style="padding:1px 6px!important;font-size:9px!important;border-radius:7px!important;display:none;">${t("scanner_deselect_all")}</button>
            </div>
          </div>

          <!-- List -->
          <div class="awr-scanner-list" style="display:flex;flex-direction:column;gap:3px;max-height:290px;overflow-y:auto;overflow-x:hidden;min-width:0;"></div>
        `;

        // Bind search (debounced)
        // v99.12.37 FIX: Guard pane.isConnected — jika panel di-rebuild oleh health monitor
        // saat debounce 250ms sedang berjalan, pane closure sudah mati. Tanpa guard ini,
        // renderScannerUI() berjalan pada pane mati → search tidak berefek → UI "menghilang".
        // FIX: Event search lebih robust — pakai addEventListener agar tidak bisa di-overwrite,
        // dan tambahkan keyup sebagai fallback untuk browser/environment yang tidak fire oninput.
        let _sd = null;
        const _searchEl = pane.querySelector(".awr-sc-search");
        function _onSearchInput(e) {
          const _val = e.target.value;
          const _pos = e.target.selectionStart;
          clearTimeout(_sd);
          _sd = setTimeout(() => {
            if (!pane || !pane.isConnected) return; // Guard: pane mati, batalkan
            searchQuery = _val;
            _scannerState.searchQuery = searchQuery;
            renderScannerUI();
            // Pulihkan fokus & posisi kursor setelah re-render (tanpa mengganggu list scroll)
            const inp = pane.querySelector(".awr-sc-search");
            if (inp && document.activeElement !== inp) {
              inp.focus();
              try { inp.setSelectionRange(_pos, _pos); } catch(_) {}
            }
          }, 150); // Respons lebih cepat: 150ms bukan 250ms
        }
        _searchEl.addEventListener("input", _onSearchInput);
        _searchEl.addEventListener("keyup", function(e) {
          // Fallback: hanya fire jika nilai benar-benar berubah dan input event tidak ter-trigger
          if (e.target.value !== searchQuery) _onSearchInput(e);
        });

        // Bind sort — guard isConnected agar tidak update pane mati
        pane.querySelector(".awr-sc-sort").onchange = (e) => {
          if (!pane || !pane.isConnected) return;
          sortMode = e.target.value; _scannerState.sortMode = sortMode; renderScannerUI();
        };

        // Bind filter tabs — guard isConnected
        pane.querySelectorAll(".awr-sc-filter").forEach(btn => {
          btn.onclick = () => {
            if (!pane || !pane.isConnected) return;
            filterMode = btn.getAttribute("data-filter"); _scannerState.filterMode = filterMode; renderScannerUI();
          };
        });
        } // end if (!shell exists)

        // ── Selalu: ambil referensi elemen & perbarui konten list ──────────
        const listEl       = pane.querySelector(".awr-scanner-list");
        const bulkBar      = pane.querySelector(".awr-bulk-bar");
        const bulkInput    = pane.querySelector(".awr-bulk-input");
        const bulkSaveBtn  = pane.querySelector(".awr-bulk-save-btn");
        const bulkDelBtn   = pane.querySelector(".awr-bulk-del-btn");
        const selAllBtn    = pane.querySelector(".awr-scan-selall");
        const deselBtn     = pane.querySelector(".awr-scan-desel");

        function updateBulkUI() {
          const n = bulkSelected.size;
          if (n > 0) {
            bulkBar.style.display = "flex";
            bulkSaveBtn.textContent = t("scanner_bulk_save", n);
            bulkDelBtn.textContent  = t("scanner_bulk_del", n);
            deselBtn.style.display  = "";
          } else {
            bulkBar.style.display  = "none";
            deselBtn.style.display = "none";
            clearPagePreview();
          }
        }
        updateBulkUI();

        // Preview on bulk input typing
        let _pd = null;
        bulkInput.oninput = () => {
          clearTimeout(_pd); _pd = setTimeout(() => {
            if (bulkInput.value.trim()) applyPagePreview(Array.from(bulkSelected));
            else clearPagePreview();
          }, 200);
        };

        // ── Rows: hanya list yang di-refresh, shell (search/filter/sort) dipertahankan ──
        // Perbarui jumlah visible & active state filter tab tanpa mengganti shell
        const _vc = pane.querySelector(".awr-scan-visible-count");
        if (_vc) _vc.textContent = visibleKeys.length + " " + t("scanner_visible_count");
        pane.querySelectorAll(".awr-sc-filter").forEach(btn => {
          const fm = btn.getAttribute("data-filter");
          const isActive = fm === filterMode;
          btn.style.background    = isActive ? "rgba(99,102,241,0.2)" : "";
          btn.style.borderColor   = isActive ? "rgba(99,102,241,0.5)" : "";
          btn.style.color         = isActive ? "#818cf8" : "";
        });
        listEl.innerHTML = "";
        if (visibleKeys.length === 0) {
          listEl.innerHTML = `<div class="empty-state" style="padding:20px;text-align:center;color:#64748b;font-size:11px;">${t("empty_state")}</div>`;
        }
        visibleKeys.forEach(hash => {
          const info         = hashMap[hash];
          const textsArr     = Array.from(info.texts);
          const hasRule      = !!(_liveKamus["hash:" + hash]);
          const existingRule = hasRule ? _liveKamus["hash:" + hash] : null;
          const existingTo   = existingRule ? (typeof existingRule === "object" ? existingRule.to : existingRule) : "";
          const existingNote = (existingRule && typeof existingRule === "object" && existingRule.note) ? existingRule.note : "";
          const isMultiOcc   = info.count > 1;
          const isConflict   = isHashConflict(hash);
          const posRuleKeys  = Object.keys(_liveKamus).filter(k =>
            k.startsWith("hash:" + hash + ":") && /^\d+$/.test(k.slice(("hash:" + hash + ":").length))
          );
          const isBulkChk    = bulkSelected.has(hash);

          const borderColor = isConflict ? "rgba(239,68,68,0.45)"
            : (hasRule || posRuleKeys.length > 0) ? "rgba(16,185,129,0.3)" : "#272a34";

          const row = document.createElement("div");
          row.style.cssText = `background:#1a1d24;border-radius:7px;padding:5px 7px;border:1px solid ${borderColor};min-width:0;box-sizing:border-box;`;

          row.innerHTML = `
            <div style="display:flex;align-items:center;gap:4px;min-width:0;">
              <input type="checkbox" class="awr-scan-chk" ${isBulkChk?"checked":""} style="flex-shrink:0;cursor:pointer;width:12px;height:12px;" />
              <div style="min-width:0;flex:1;overflow:hidden;">
                <div style="font-size:10px;font-weight:bold;color:${isConflict?"#f87171":"#e2e8f0"};overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${textsArr.join(" / ")}">
                  ${isConflict?`<span title="${t("scanner_conflict_detail")}" style="margin-right:2px;">⚠</span>`:""}${textsArr.join(" / ")}
                </div>
                <div style="font-size:8px;color:#6b7280;margin-top:1px;display:flex;flex-wrap:wrap;gap:3px;align-items:center;min-width:0;overflow:hidden;">
                  <code style="background:#0f1117;padding:1px 3px;border-radius:3px;color:#94a3b8;max-width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block;vertical-align:middle;" title="hash:${hash}">hash:${hash}</code>
                  <span style="flex-shrink:0;">×${info.count}</span>
                  ${hasRule?`<span style="color:#10b981;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:70px;display:inline-block;vertical-align:middle;" title="→ ${existingTo}">→ <b>${existingTo}</b></span>`:""}
                  ${posRuleKeys.length>0?`<span style="color:#22d3ee;flex-shrink:0;">⚙${posRuleKeys.length}</span>`:""}
                  ${existingNote?`<span style="color:#64748b;font-style:italic;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60px;display:inline-block;vertical-align:middle;" title="${existingNote}">📝${existingNote}</span>`:""}
                </div>
              </div>
              <div style="display:flex;gap:2px;flex-shrink:0;align-items:center;">
                <button class="form-btn awr-scan-note-btn" style="padding:1px 4px!important;font-size:11px!important;border-radius:5px!important;line-height:1!important;${existingNote?"background:rgba(100,116,139,0.25)!important;border-color:rgba(100,116,139,0.5)!important;":"background:none!important;border-color:rgba(71,85,105,0.35)!important;"}color:#64748b!important;" title="${t("scanner_note_label")}">📝</button>
                ${isMultiOcc?`<button class="form-btn awr-scan-pin-btn" style="padding:1px 4px!important;font-size:11px!important;border-radius:5px!important;line-height:1!important;background:rgba(34,211,238,0.15)!important;color:#22d3ee!important;border-color:rgba(34,211,238,0.4)!important;" title="${t("scanner_pin_occurrence")}">⚙</button>`:""}
                ${hasRule
                  ?`<button class="form-btn awr-scan-edit-btn" style="padding:1px 4px!important;font-size:10px!important;border-radius:5px!important;background:rgba(16,185,129,0.15)!important;color:#10b981!important;border-color:rgba(16,185,129,0.3)!important;" title="${t("scanner_edit_rule")}">✏</button>
                    <button class="form-btn awr-scan-del-btn" style="padding:1px 4px!important;font-size:10px!important;border-radius:5px!important;background:rgba(239,68,68,0.12)!important;color:#f87171!important;border-color:rgba(239,68,68,0.35)!important;" title="${t("scanner_del_rule")}">🗑</button>`
                  :`<button class="form-btn awr-scan-add-btn" style="padding:1px 5px!important;font-size:9px!important;border-radius:5px!important;white-space:nowrap!important;background:rgba(234,179,8,0.15)!important;color:#fbbf24!important;border-color:rgba(234,179,8,0.3)!important;">${t("scanner_add_rule")}</button>`
                }
              </div>
            </div>
            <!-- Note panel -->
            <div class="awr-note-panel" style="display:none;margin-top:4px;min-width:0;">
              <div style="display:flex;gap:4px;align-items:center;min-width:0;">
                <input type="text" class="form-input awr-note-input" value="${existingNote.replace(/"/g,"&quot;")}" placeholder="${t("scanner_note_placeholder")}" style="flex:1;min-width:0;font-size:10px!important;padding:3px 6px!important;height:24px!important;min-height:0!important;" />
                <button class="form-btn awr-note-save-btn btn-pill-primary" style="padding:2px 7px!important;font-size:9px!important;border-radius:7px!important;flex-shrink:0;">💾</button>
              </div>
            </div>
            <!-- Positional override panel -->
            <div class="awr-pos-panel" style="display:none;margin-top:5px;padding:5px 6px;background:rgba(34,211,238,0.06);border:1px solid rgba(34,211,238,0.25);border-radius:6px;min-width:0;box-sizing:border-box;"></div>
          `;

          // Checkbox
          const chk = row.querySelector(".awr-scan-chk");
          chk.onchange = () => {
            if (chk.checked) { bulkSelected.add(hash); applyPagePreview(Array.from(bulkSelected)); }
            else { bulkSelected.delete(hash); applyPagePreview(Array.from(bulkSelected)); }
            updateBulkUI();
          };

          // Tambah Rule
          const addBtn = row.querySelector(".awr-scan-add-btn");
          if (addBtn) addBtn.onclick = () => {
            clearPagePreview();
            subjekEdit = null; tabAktif = "tambah"; renderTampilan();
            setTimeout(() => {
              const _sr = document.querySelector('#word-replacer-host')?.shadowRoot;
              const iS = _sr?.querySelector(".input-salah"), iB = _sr?.querySelector(".input-benar"), hE = _sr?.querySelector(".awr-hash-hint");
              if (iS) { iS.value = "hash:" + hash; iS.dispatchEvent(new Event("input")); }
              if (iB && !iB.value) iB.value = textsArr[0] || "";
              if (hE) hE.style.display = "block";
            }, 80);
          };

          // Edit Rule
          const editBtn = row.querySelector(".awr-scan-edit-btn");
          if (editBtn) editBtn.onclick = () => { clearPagePreview(); subjekEdit = "hash:" + hash; tabAktif = "tambah"; renderTampilan(); };

          // Hapus Rule
          const delBtn = row.querySelector(".awr-scan-del-btn");
          if (delBtn) delBtn.onclick = () => {
            window.AWR_UI_LIBRARY.tampilkanKonfirmasi(t("scanner_del_rule"), `Yakin hapus rule "hash:${hash}" → "${existingTo}"?`, () => {
              deleteHashRule(hash);
              panggilToast(t("scanner_rule_deleted", "hash:" + hash), "warn");
              core.jalankanPengganti(true); rerenderScanner();
            });
          };

          // Note panel
          const noteBtn   = row.querySelector(".awr-scan-note-btn");
          const notePanel = row.querySelector(".awr-note-panel");
          const noteInput = row.querySelector(".awr-note-input");
          const noteSave  = row.querySelector(".awr-note-save-btn");
          noteBtn.onclick = () => {
            const vis = notePanel.style.display !== "none";
            notePanel.style.display = vis ? "none" : "block";
            if (!vis) setTimeout(() => noteInput.focus(), 50);
          };
          noteSave.onclick = () => {
            const noteVal = noteInput.value.trim();
            const tempKamus = core.getKamus();
            const key = "hash:" + hash;
            if (!tempKamus[key]) { panggilToast("Buat rule dulu sebelum menambah catatan.", "warn"); return; }
            if (typeof tempKamus[key] === "object") {
              if (noteVal) tempKamus[key].note = noteVal; else delete tempKamus[key].note;
            } else {
              tempKamus[key] = { to: tempKamus[key], ...(noteVal ? { note: noteVal } : {}) };
            }
            core.saveKamus(tempKamus);
            panggilToast("Catatan disimpan!", "success");
            rerenderScanner();
          };

          // Per-Kemunculan (Positional Override)
          const pinBtn  = row.querySelector(".awr-scan-pin-btn");
          const posPanel = row.querySelector(".awr-pos-panel");
          if (pinBtn && posPanel) {
            pinBtn.onclick = () => {
              if (posPanel.style.display !== "none") { posPanel.style.display = "none"; return; }
              posPanel.innerHTML = `<div style="font-size:9px;color:#22d3ee;font-weight:bold;margin-bottom:4px;">${t("scanner_occurrence_hint", info.spans.length)}</div>`;
              const curKamus = core.getKamus();
              info.spans.forEach((span, idx) => {
                const posKey  = "hash:" + hash + ":" + idx;
                const posEntry = curKamus[posKey];
                const posVal  = posEntry ? (typeof posEntry === "object" ? posEntry.to : posEntry) : "";
                const ctx     = getSpanContext(span);
                const occRow  = document.createElement("div");
                occRow.style.cssText = "margin-bottom:4px;padding:4px 6px;background:#0f1117;border-radius:5px;border:1px solid #1e293b;min-width:0;box-sizing:border-box;";
                occRow.innerHTML = `
                  <div style="font-size:8px;color:#64748b;margin-bottom:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${ctx}">
                    <b style="color:#22d3ee;">${t("scanner_occurrence_label", idx + 1)}</b>${ctx ? `<span style="margin-left:4px;font-style:italic;">${ctx}</span>` : ""}
                  </div>
                  <div style="display:flex;gap:4px;align-items:center;min-width:0;">
                    <input type="text" class="form-input awr-pos-input" data-poskey="${posKey}" placeholder="${existingTo || (textsArr[0] || "")}" value="${posVal}" style="flex:1;min-width:0;font-size:10px!important;padding:3px 5px!important;height:22px!important;min-height:0!important;" />
                    ${posVal?`<button class="form-btn awr-pos-clear-btn" data-poskey="${posKey}" style="padding:1px 5px!important;font-size:9px!important;border-radius:5px!important;background:rgba(239,68,68,0.12)!important;color:#f87171!important;border-color:rgba(239,68,68,0.3)!important;flex-shrink:0;">✕</button>`:""}
                  </div>`;
                // v99.12.30: Scroll ke span yang sesuai saat user fokus ke input occurrence mana pun
                const posInput = occRow.querySelector(".awr-pos-input");
                posInput.onfocus = () => {
                  // Highlight span occurrence ini
                  clearPagePreview();
                  _previewSpans = [{ span, oline: span.style.outline, obg: span.style.background, obr: span.style.borderRadius }];
                  span.style.outline = "2px solid #22d3ee"; span.style.background = "rgba(34,211,238,0.18)"; span.style.borderRadius = "3px";
                  // Scroll ke span occurrence ini (bukan hanya kemunculan ke-1)
                  if (span.isConnected) {
                    try { span.scrollIntoView({ behavior: "smooth", block: "center" }); } catch(_) {}
                  }
                };
                posInput.oninput = (e) => {
                  clearPagePreview();
                  if (e.target.value) {
                    _previewSpans = [{ span, oline: span.style.outline, obg: span.style.background, obr: span.style.borderRadius }];
                    span.style.outline = "2px solid #22d3ee"; span.style.background = "rgba(34,211,238,0.18)"; span.style.borderRadius = "3px";
                  }
                };
                const clrBtn = occRow.querySelector(".awr-pos-clear-btn");
                if (clrBtn) clrBtn.onclick = () => {
                  const pk = clrBtn.getAttribute("data-poskey");
                  const tk = core.getKamus(); delete tk[pk];
                  core.saveKamus(tk); core.simpanKeAwan(tk);
                  core.jalankanPengganti(true); rerenderScanner();
                };
                posPanel.appendChild(occRow);
              });
              const savePosBtn = document.createElement("button");
              savePosBtn.className = "form-btn btn-pill-primary";
              savePosBtn.style.cssText = "width:100%;padding:3px!important;font-size:9px!important;margin-top:3px!important;box-sizing:border-box!important;";
              savePosBtn.textContent = t("scanner_pos_save");
              // v99.12.30 FIX: savePosBtn.onclick -- scroll ke SEMUA occurrence yang terisi,
              // bukan hanya kemunculan ke-1. Guard if(!_scrollTarget) dihapus agar setiap
              // occurrence dengan nilai dikumpulkan sebagai scroll target. Scroll dilakukan
              // SEBELUM jalankanPengganti agar span belum di-detach dari DOM.
              savePosBtn.onclick = () => {
                // FIX: Kunci posisi scroll halaman sebelum aksi apapun,
                // agar simpan posisi tidak menggeser tampilan halaman novel.
                const _savedScrollX = window.scrollX || window.pageXOffset || 0;
                const _savedScrollY = window.scrollY || window.pageYOffset || 0;

                const tk = core.getKamus();
                posPanel.querySelectorAll(".awr-pos-input").forEach(inp => {
                  // v99.12.30: simpan nilai asli (tanpa trim) agar spasi sebagai pemisah antar kata unik terjaga
                  const pk = inp.getAttribute("data-poskey"), val = inp.value, valTrimmed = val.trim();
                  if (valTrimmed) {
                    tk[pk] = { to: val, global: false, novelId: currentNovel.id, novelTitle: currentNovel.title, novelUrl: currentNovel.url, domain: core.getNovelBaseDomain(core.currentHost), caseSensitive: true };
                  } else delete tk[pk];
                });
                // Simpan & jalankan replacer tanpa scroll
                core.saveKamus(tk); core.simpanKeAwan(tk); core.jalankanPengganti(true);
                clearPagePreview(); panggilToast(t("scanner_pos_saved"), "success"); rerenderScanner();

                // Pulihkan posisi scroll setelah semua operasi selesai
                requestAnimationFrame(() => {
                  window.scrollTo({ top: _savedScrollY, left: _savedScrollX, behavior: "instant" });
                });
              };
              posPanel.appendChild(savePosBtn);
              posPanel.style.display = "block";
            };
          }

          listEl.appendChild(row);
        });

        // Select All / Deselect All
        selAllBtn.onclick = () => {
          visibleKeys.forEach(h => bulkSelected.add(h));
          listEl.querySelectorAll(".awr-scan-chk").forEach(c => { c.checked = true; });
          applyPagePreview(Array.from(bulkSelected)); updateBulkUI();
        };
        deselBtn.onclick = () => {
          bulkSelected.clear();
          listEl.querySelectorAll(".awr-scan-chk").forEach(c => { c.checked = false; });
          clearPagePreview(); updateBulkUI();
        };

        // Bulk Save
        bulkSaveBtn.onclick = () => {
          const toText = (bulkInput.value || "").trim();
          if (!toText) { panggilToast("Teks pengganti tidak boleh kosong!", "warn"); return; }
          if (!bulkSelected.size) { panggilToast("Pilih minimal 1 baris!", "warn"); return; }
          const tk = core.getKamus();
          bulkSelected.forEach(h => {
            const key = "hash:" + h, ex = tk[key];
            tk[key] = { to: toText, global: false, novelId: currentNovel.id, novelTitle: currentNovel.title, novelUrl: currentNovel.url, domain: core.getNovelBaseDomain(core.currentHost), caseSensitive: true, ...(ex && ex.note ? { note: ex.note } : {}) };
          });
          core.saveKamus(tk); core.simpanKeAwan(tk); core.jalankanPengganti(true);
          clearPagePreview(); panggilToast(t("scanner_bulk_save", bulkSelected.size) + " ✓", "success");
          bulkSelected.clear(); rerenderScanner();
        };

        // Bulk Delete
        bulkDelBtn.onclick = () => {
          const toDel = Array.from(bulkSelected).filter(h => seluruhKamus["hash:" + h]);
          if (!toDel.length) { panggilToast("Tidak ada rule untuk dihapus!", "warn"); return; }
          window.AWR_UI_LIBRARY.tampilkanKonfirmasi(t("scanner_bulk_del", toDel.length), `Yakin hapus ${toDel.length} rule hash ke Recycle Bin?`, () => {
            const tk = core.getKamus(), dw = core.getDeletedWords();
            toDel.forEach(h => { const key = "hash:" + h; if (tk[key]) { dw[key] = tk[key]; delete tk[key]; } });
            core.saveKamus(tk); core.saveDeletedWords(dw); core.simpanKeAwan(tk, null, dw); core.jalankanPengganti(true);
            clearPagePreview(); panggilToast(t("scanner_bulk_del", toDel.length) + " ✓", "warn");
            bulkSelected.clear(); rerenderScanner();
          });
        };
        } catch (scanErr) {
          // v99.12.36: Tampilkan pesan ERROR (bukan loading spinner) dengan class khusus.
          // Kelas "awr-scanner-error-state" dikenali health monitor sebagai kondisi yang SUDAH
          // ditangani — tidak perlu memicu recovery, sehingga retry loop TIDAK terjadi.
          console.error("[AWR] renderScannerUI error:", scanErr);
          try {
            pane.innerHTML = `<div class="awr-scanner-error-state" style="padding:16px;color:#f87171;font-size:11px;text-align:center;line-height:1.6;display:flex;flex-direction:column;align-items:center;gap:6px;">
              <span style="font-size:20px;">⚠️</span>
              <b>Gagal memuat scanner</b>
              <span style="color:#64748b;font-size:10px;">${scanErr && scanErr.message ? scanErr.message : "Unknown error"}</span>
              <button class="form-btn" style="margin-top:4px;padding:4px 14px!important;font-size:10px!important;border-radius:8px!important;" id="awr-scanner-retry-btn">🔄 Coba Lagi</button>
            </div>`;
            const retryBtn = pane.querySelector("#awr-scanner-retry-btn");
            if (retryBtn) retryBtn.onclick = () => {
              pane.innerHTML = "";
              try { renderScannerUI(); } catch(e2) {
                pane.innerHTML = `<div class="awr-scanner-error-state" style="padding:16px;color:#f87171;font-size:11px;text-align:center;">⚠️ Scanner tetap gagal: ${e2 && e2.message ? e2.message : "unknown"}<br><span style="color:#64748b;font-size:9px;">Coba refresh halaman jika masalah berlanjut.</span></div>`;
              }
            };
          } catch(_) {}
        }
      } // end renderScannerUI()

      renderScannerUI();

    } else if (tabAktif === "tambah") {
      const pane = document.createElement("div");
      pane.className = "tab-pane active";

      const salahVal = subjekEdit || "";
      const itemData = seluruhKamus[salahVal];
      const benarVal =
        itemData && typeof itemData === "object"
          ? itemData.to || ""
          : itemData || "";

      let initialSelectedGroup =
        subjekEdit && itemData && typeof itemData === "object"
          ? itemData.global
            ? "GLOBAL_OPTION"
            : itemData.novelId || currentNovel.id
          : lastSelectedGroup;

      const isGlobal = initialSelectedGroup === "GLOBAL_OPTION";
      const isNewCustom = initialSelectedGroup === "NEW_CUSTOM";
      const localNovelsMap = {};
      localNovelsMap[currentNovel.id] = currentNovel.title;

      for (const salah in seluruhKamus) {
        const item = seluruhKamus[salah];
        if (item && typeof item === "object" && !item.global && item.novelId) {
          localNovelsMap[item.novelId] =
            item.novelTitle ||
            core.getCachedNovelTitle(item.novelId) ||
            item.novelId;
        }
      }

      if (
        initialSelectedGroup &&
        initialSelectedGroup !== "GLOBAL_OPTION" &&
        !localNovelsMap[initialSelectedGroup]
      ) {
        localNovelsMap[initialSelectedGroup] =
          core.getCachedNovelTitle(initialSelectedGroup) || "Custom Group";
      }

      let selectOptionsHTML = `<option value="GLOBAL_OPTION" ${initialSelectedGroup === "GLOBAL_OPTION" ? "selected" : ""}>🌐 ${t("global_replacer")}</option>`;
      for (const id in localNovelsMap) {
        selectOptionsHTML += `<option value="${id}" ${initialSelectedGroup === id ? "selected" : ""}>📖 ${localNovelsMap[id]}${id === currentNovel.id ? " (Active)" : ""}</option>`;
      }
      selectOptionsHTML += `<option value="NEW_CUSTOM" ${initialSelectedGroup === "NEW_CUSTOM" ? "selected" : ""}>${t("select_custom_group_placeholder")}</option>`;

      const chkActiveState = subjekEdit
        ? activeNovelId === initialSelectedGroup
        : lastCheckboxState;

      pane.innerHTML = `
                <div class="editor-section">
                    <div class="editor-label-row">
                        <span class="editor-label">${t("original_text", salahVal.length)}</span>
                        <div class="editor-pills">
                            <span class="editor-pill btn-add-variation">+ Variation</span>
                            <span class="editor-pill btn-add-wildcard">+ Wild Char</span>
                            <span class="editor-pill btn-add-hash" style="background:rgba(234,179,8,0.2)!important;color:#fbbf24!important;border-color:rgba(234,179,8,0.4)!important;" title="Target kata unik by data-hash (untuk nama yang ditranslate sama)">🔑 Hash Rule</span>
                            <span class="editor-pill btn-add-exact" style="background:rgba(34,197,94,0.15)!important;color:#4ade80!important;border-color:rgba(34,197,94,0.4)!important;" title="Cocokkan teks panjang/literal secara persis (tanpa regex) — cocok untuk kalimat panjang dengan tanda baca">📋 Exact</span>
                        </div>
                    </div>
                    <textarea class="form-input input-salah" placeholder="${t("word_salah_placeholder")}" ${subjekEdit ? "disabled" : ""}>${salahVal}</textarea>
                    <div class="awr-match-preview" style="font-size:9px;color:#64748b;margin-top:3px;min-height:14px;"></div>
                    ${salahVal.startsWith("hash:") ? `<div style="margin-top:6px;padding:8px 10px;background:rgba(234,179,8,0.08);border:1px solid rgba(234,179,8,0.3);border-radius:8px;font-size:11px;color:#fbbf24;">🔑 <b>Hash Rule</b> — Akan mengganti semua <code style="background:rgba(0,0,0,0.3);padding:1px 4px;border-radius:3px;">data-hash="${salahVal.slice(5)}"</code> di halaman ini. Teks pengganti di bawah akan mengisi span kata unik tersebut.</div>` : `<div class="awr-hash-hint" style="display:none;margin-top:6px;padding:8px 10px;background:rgba(234,179,8,0.08);border:1px solid rgba(234,179,8,0.3);border-radius:8px;font-size:11px;color:#fbbf24;">🔑 <b>Hash Rule</b> — Ketik <code style="background:rgba(0,0,0,0.3);padding:1px 4px;border-radius:3px;">hash:NILAI_DATA_HASH</code> untuk menarget span kata unik tertentu. Atau klik kanan pada kata unik di halaman novel.</div>`}
                    <div class="editor-sub-row" style="display: flex !important; justify-content: space-between !important; align-items: center !important; font-size: 10px !important; color: #64748b !important; margin-top: 4px !important; overflow: visible !important;">
                        <span>${t("editor_example_tip")}</span>
                        <label class="case-sensitive-toggle" style="display: inline-flex !important; align-items: center !important; gap: 6px !important; cursor: pointer !important; user-select: none !important;">
                            <input type="checkbox" class="chk-case-sensitive" ${itemData && itemData.caseSensitive ? "checked" : ""} />
                            <span style="color: #94a3b8 !important; font-size: 11px !important;">${t("case_sensitive")}</span>
                        </label>
                    </div>
                </div>
                <div class="editor-section" style="margin-top: 16px;">
                    <span class="editor-label">${t("replacement_text", benarVal.length)}</span>
                    <textarea class="form-input input-benar" placeholder="${t("word_benar_placeholder")}">${benarVal}</textarea>
                </div>
                <div class="editor-section" style="margin-top: 16px; display: flex; flex-direction: column; gap: 6px; overflow: visible !important;">
                    <span class="editor-label">${t("target_category")}:</span>
                    <select class="form-input sel-novel-group">${selectOptionsHTML}</select>
                    <input type="text" class="form-input txt-custom-novel-title" placeholder="${t("custom_novel_input_placeholder")}" style="display: ${isNewCustom ? "block" : "none"}; margin-top: 4px;" />
                    <div class="chk-active-group-container" style="display: ${isGlobal ? "none" : "flex"} !important; align-items: center !important; gap: 6px !important; margin-top: 6px !important;">
                        <input type="checkbox" class="chk-make-group-active" ${chkActiveState ? "checked" : ""} />
                        <span style="font-size: 11px !important; color: #9ca3af !important;">${t("make_group_active")}</span>
                    </div>
                </div>
                <div class="card-box" style="margin-top: 16px; border: 1px dashed #3b82f6 !important; background: rgba(59, 130, 246, 0.05) !important; overflow: visible !important;">
                    <span class="editor-label" style="color: #60a5fa !important; font-weight: bold !important;">🔍 Live Rule Sandbox</span>
                    <div style="font-size: 10px; color: #94a3b8; margin-top: 2px; margin-bottom: 6px;">Test your rules & wildcards live on any custom text below:</div>
                    <textarea class="form-input txt-sandbox-input" placeholder="Type test paragraph here..." style="font-size: 11px !important; min-height: 45px !important; height: 45px !important; resize: vertical !important;"></textarea>
                    <div class="txt-sandbox-output" style="margin-top: 6px; padding: 6px 10px; background: #0f172a; border: 1px solid #334155; border-radius: 6px; font-size: 11px; min-height: 20px; color: #cbd5e1; word-break: break-all;">
                        <span style="color: #64748b; font-style: italic;">Replacement output will show here live...</span>
                    </div>
                </div>
                <div class="editor-footer" style="margin-top: auto; padding-top: 20px; display: flex !important; justify-content: space-between !important; align-items: center !important; gap: 8px !important; overflow: visible !important;">
                    ${subjekEdit ? `<button class="form-btn delete-btn-editor" style="background: #991b1b !important; color: white !important; display: inline-flex; align-items: center; justify-content: center; gap: 6px;">${core.SVGS.trash} ${t("delete_btn")}</button>` : "<div></div>"}
                    <div style="display: flex !important; align-items: center !important; gap: 8px !important; margin-left: auto !important; overflow: visible !important;">
                        <button class="form-btn close-btn-editor">${t("close_btn")}</button>
                        <button class="form-btn simpan-btn btn-pill-primary" style="padding: 8px 20px !important; display: inline-flex; align-items: center; justify-content: center; gap: 6px;">${core.SVGS.disk} ${subjekEdit ? t("update_btn") : t("save_btn")}</button>
                    </div>
                </div>
            `;

      const inputSalah = pane.querySelector(".input-salah");
      const inputBenar = pane.querySelector(".input-benar");
      const selNovelGroup = pane.querySelector(".sel-novel-group");
      const txtCustomNovelTitle = pane.querySelector(".txt-custom-novel-title");

      const txtSandboxInput = pane.querySelector(".txt-sandbox-input");
      const txtSandboxOutput = pane.querySelector(".txt-sandbox-output");
      if (txtSandboxInput && txtSandboxOutput) {
        const updateSandbox = () => {
          const rawSalah = inputSalah.value.trim();
          const rawBenar = inputBenar.value; // v99.12.30: jangan trim — spasi awal/akhir bisa disengaja sebagai pemisah
          const text = txtSandboxInput.value;
          if (!rawSalah || !text) {
            txtSandboxOutput.innerHTML =
              '<span style="color: #64748b; font-style: italic;">Replacement output will show here live...</span>';
            return;
          }
          try {
            const csChecked =
              pane.querySelector(".chk-case-sensitive")?.checked || false;
            const pattern = buildRegexPattern(rawSalah);
            const rx = buildBoundaryRegex(
              rawSalah,
              pattern,
              csChecked ? "gu" : "giu",
            );
            const result = text.replace(rx, (matchStr, ...groups) => {
              const numSeps = rawSalah.split(/[\s\-_—–]+/).length - 1;
              const capturedSeparators = groups.slice(0, numSeps);
              const replaced = reconstructReplacement(
                rawBenar,
                capturedSeparators,
              );
              return `<strong style="color: #3b82f6 !important; text-decoration: underline;">${replaced}</strong>`;
            });
            txtSandboxOutput.innerHTML =
              result ||
              '<span style="color: #64748b; font-style: italic;">Replacement output will show here live...</span>';
          } catch (err) {
            txtSandboxOutput.textContent = "Regex Error: " + err.message;
          }
        };
        txtSandboxInput.oninput = updateSandbox;
        let _matchDebounce = null;
        inputSalah.oninput = () => {
          updateSandbox();
          pane.querySelector(".editor-label").textContent = t(
            "original_text",
            inputSalah.value.length,
          );
          // v99.12.37: Preview jumlah match di halaman saat ini (debounce 400ms)
          clearTimeout(_matchDebounce);
          _matchDebounce = setTimeout(() => {
            const matchEl = pane.querySelector(".awr-match-preview");
            if (!matchEl || subjekEdit) return; // jangan scan saat edit (key disabled)
            const rawTerm = inputSalah.value.trim();
            if (!rawTerm || rawTerm.startsWith("hash:") || rawTerm.startsWith("exact:")) {
              matchEl.textContent = "";
              return;
            }
            try {
              const pattern = buildRegexPattern(rawTerm);
              const rx = buildBoundaryRegex(rawTerm, pattern, "giu");
              const bodyText = document.body ? document.body.innerText : "";
              const hits = (bodyText.match(rx) || []).length;
              matchEl.innerHTML = hits > 0
                ? '<span style="color:#10b981;">● ' + hits + ' match ditemukan di halaman ini</span>'
                : '<span style="color:#f87171;">● Tidak ada match di halaman ini</span>';
            } catch(_) { matchEl.textContent = ""; }
          }, 400);
        };
        inputBenar.oninput = () => {
          updateSandbox();
          pane.querySelectorAll(".editor-label")[1].textContent = t(
            "replacement_text",
            inputBenar.value.length,
          );
        };
      }

      pane.querySelector(".btn-add-variation").onclick = () => {
        inputSalah.value += "|";
        inputSalah.focus();
      };
      pane.querySelector(".btn-add-wildcard").onclick = () => {
        inputSalah.value += "*";
        inputSalah.focus();
      };
      pane.querySelector(".btn-add-hash").onclick = () => {
        if (!inputSalah.value.startsWith("hash:")) {
          inputSalah.value = "hash:" + inputSalah.value;
        }
        const hint = pane.querySelector(".awr-hash-hint");
        if (hint) hint.style.display = "block";
        inputSalah.focus();
      };
      pane.querySelector(".btn-add-exact").onclick = () => {
        if (!inputSalah.value.startsWith("exact:")) {
          inputSalah.value = "exact:" + inputSalah.value;
        }
        inputSalah.focus();
      };
      // Show hash hint live as user types "hash:"
      if (!subjekEdit) {
        inputSalah.addEventListener("input", () => {
          const hint = pane.querySelector(".awr-hash-hint");
          if (hint) hint.style.display = inputSalah.value.startsWith("hash:") ? "block" : "none";
        });
      }

      selNovelGroup.onchange = (e) => {
        const selectedVal = e.target.value;
        if (selectedVal !== "NEW_CUSTOM")
          GM_setValue("awr_last_selected_group_id_v2", selectedVal);
        txtCustomNovelTitle.style.display =
          selectedVal === "NEW_CUSTOM" ? "block" : "none";
        pane.querySelector(".chk-active-group-container").style.display =
          selectedVal === "GLOBAL_OPTION" ? "none" : "flex";
      };

      pane.querySelector(".close-btn-editor").onclick = () => {
        subjekEdit = null;
        tabAktif = "daftar";
        renderTampilan();
      };

      const deleteBtnEditor = pane.querySelector(".delete-btn-editor");
      if (deleteBtnEditor) {
        deleteBtnEditor.onclick = () => {
          window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
            t("delete_btn"),
            t("delete_group_confirm", subjekEdit),
            () => {
              const tempKamus = core.getKamus(),
                deletedWords = core.getDeletedWords();
              // v99.12.37: Simpan snapshot untuk undo sebelum menghapus
              pushUndoSnapshot('Hapus "' + subjekEdit + '"');
              if (subjekEdit) {
                const actKey = Object.keys(tempKamus).find(
                  (k) =>
                    k.trim().toLowerCase() === subjekEdit.trim().toLowerCase(),
                );
                if (actKey) {
                  deletedWords[actKey] = tempKamus[actKey];
                  delete tempKamus[actKey];
                }
              }
              core.saveKamus(tempKamus);
              core.saveDeletedWords(deletedWords);
              core.simpanKeAwan(tempKamus, null, deletedWords);
              panggilToast(t("toast_deleted", subjekEdit), "warn");
              subjekEdit = null;
              tabAktif = "daftar";
              core.jalankanPengganti(true);
              renderTampilan();
            },
          );
        };
      }

      pane.querySelector(".simpan-btn").onclick = () => {
        const csChecked = pane.querySelector(".chk-case-sensitive").checked;
        let rawSalah = inputSalah.value.trim().normalize("NFC");
        // v99.12.30: jangan trim nilai benar — spasi awal/akhir bisa disengaja sebagai pemisah antar kata unik
        // smartSanitizeTerm TIDAK diterapkan ke benar (fungsi itu untuk kata sumber, bukan kata pengganti)
        let benar = inputBenar.value.normalize("NFC");

        rawSalah = (rawSalah.startsWith("hash:") || rawSalah.startsWith("exact:")) ? rawSalah : core.smartSanitizeTerm(rawSalah);
        const selectedVal = selNovelGroup.value,
          isGlobalChecked = selectedVal === "GLOBAL_OPTION";
        // hash: dan exact: entries selalu preserve case (hash: sensitif, exact: dicocokkan case-insensitive tapi key disimpan as-is)
        const salah = (csChecked || rawSalah.startsWith("hash:") || rawSalah.startsWith("exact:")) ? rawSalah : rawSalah.toLowerCase();

        if (!salah || !benar.trim()) { // .trim() hanya untuk cek kosong, tidak untuk menyimpan
          alert(t("alert_both_fields"));
          return;
        }

        // v99.12.37: Deteksi konflik — cek apakah rule baru overlap dengan rule yang ada
        if (!salah.startsWith("hash:") && !salah.startsWith("exact:") && !salah.startsWith("_off_:")) {
          const salahLower = salah.toLowerCase();
          const conflictKeys = Object.keys(seluruhKamus).filter(existKey => {
            if (existKey === subjekEdit) return false; // skip rule yang sedang diedit
            if (existKey.startsWith("hash:") || existKey.startsWith("exact:") || existKey.startsWith("_off_:")) return false;
            const eLower = existKey.toLowerCase();
            return (salahLower.includes(eLower) && eLower !== salahLower) ||
                   (eLower.includes(salahLower) && eLower !== salahLower);
          });
          if (conflictKeys.length > 0) {
            const conflictList = conflictKeys.slice(0, 3).join('", "');
            panggilToast(
              '⚠ Peringatan: Rule ini mungkin overlap dengan "' + conflictList + '"' +
              (conflictKeys.length > 3 ? ' (+' + (conflictKeys.length - 3) + ' lainnya)' : '') +
              '. Rule yang overlap bisa saling mengganggu. Rule tetap disimpan.',
              "warn"
            );
          }
        }
        const tempKamus = core.getKamus();
        let savedNovelId = "",
          savedNovelTitle = "",
          savedNovelUrl = "";

        if (!isGlobalChecked) {
          if (selectedVal === "NEW_CUSTOM") {
            const customTitle = txtCustomNovelTitle.value.trim();
            if (!customTitle) {
              alert(t("warn_no_custom_name"));
              return;
            }
            savedNovelId =
              "custom_novel_" + Math.random().toString(36).substring(2, 11);
            savedNovelTitle = customTitle;
            savedNovelUrl = currentNovel.url;
            core.saveCachedNovelTitle(savedNovelId, savedNovelTitle);
          } else {
            savedNovelId = selectedVal;
            savedNovelTitle =
              core.getCachedNovelTitle(savedNovelId) ||
              localNovelsMap[savedNovelId] ||
              "Novel";
            savedNovelUrl =
              Object.values(tempKamus).find((x) => x.novelId === savedNovelId)
                ?.novelUrl || currentNovel.url;
          }
        }

        // v99.12.37: Simpan snapshot untuk undo sebelum mengubah kamus
        pushUndoSnapshot(subjekEdit ? 'Update "' + subjekEdit + '"' : 'Tambah "' + salah + '"');
        if (subjekEdit) delete tempKamus[subjekEdit];
        tempKamus[salah] = {
          to: benar,
          global: isGlobalChecked,
          novelId: savedNovelId,
          novelTitle: savedNovelTitle,
          novelUrl: savedNovelUrl,
          domain: core.getNovelBaseDomain(core.currentHost),
          caseSensitive: csChecked,
        };

        const chkMakeGroup = pane.querySelector(".chk-make-group-active");
        if (!isGlobalChecked) {
          const isCheckActive = chkMakeGroup && chkMakeGroup.checked;
          GM_setValue("awr_last_selected_group_id_v2", savedNovelId);
          GM_setValue("awr_last_active_group_checkbox_state_v2", isCheckActive);
          if (isCheckActive) core.setActiveNovelId(savedNovelId);
          else core.setActiveNovelId("");
        } else {
          GM_setValue("awr_last_selected_group_id_v2", "GLOBAL_OPTION");
        }

        core.saveKamus(tempKamus);
        core.simpanKeAwan(tempKamus);
        panggilToast(
          subjekEdit ? t("toast_updated", salah) : t("toast_added", salah),
          "success",
        );
        subjekEdit = null;
        tabAktif = "daftar";
        renderTampilan();
        setTimeout(() => {
          core.jalankanPengganti(true);
        }, 50);
      };

      body.appendChild(pane);
    } else if (tabAktif === "setting") {
      const pane = document.createElement("div");
      pane.className = "tab-pane active";
      body.appendChild(pane);

      pane.innerHTML = `
                <div class="setting-sub-tabs-bar" style="display: flex !important; gap: 4px !important; margin-bottom: 12px !important; border-bottom: 1px solid #272a34 !important; padding-bottom: 8px !important; overflow: visible !important; flex-wrap: wrap !important;">
                    <button class="form-btn sub-tab-btn ${settingSubTab === "filter" ? "active" : ""}" data-subtab="filter" style="flex: 1 !important; padding: 5px 8px !important; font-size: 10px !important; border-radius: 12px !important; white-space: nowrap !important; transition: all 0.2s ease !important; cursor: pointer !important; text-align: center !important;">🌐 ${t("tab_filter")}</button>
                    <button class="form-btn sub-tab-btn ${settingSubTab === "config" ? "active" : ""}" data-subtab="config" style="flex: 1 !important; padding: 5px 8px !important; font-size: 10px !important; border-radius: 12px !important; white-space: nowrap !important; transition: all 0.2s ease !important; cursor: pointer !important; text-align: center !important;">⚙️ ${t("tab_config")}</button>
                    <button class="form-btn sub-tab-btn ${settingSubTab === "data" ? "active" : ""}" data-subtab="data" style="flex: 1 !important; padding: 5px 8px !important; font-size: 10px !important; border-radius: 12px !important; white-space: nowrap !important; transition: all 0.2s ease !important; cursor: pointer !important; text-align: center !important;">📦 ${t("tab_data")}</button>
                    <button class="form-btn sub-tab-btn ${settingSubTab === "cloud" ? "active" : ""}" data-subtab="cloud" style="flex: 1 !important; padding: 5px 8px !important; font-size: 10px !important; border-radius: 12px !important; white-space: nowrap !important; transition: all 0.2s ease !important; cursor: pointer !important; text-align: center !important;">☁️ Cloud</button>
                </div>
                <div class="setting-sub-content" style="overflow: visible !important;"></div>
            `;

      const subContent = pane.querySelector(".setting-sub-content");
      pane.querySelectorAll(".sub-tab-btn").forEach((btn) => {
        btn.onclick = (e) => {
          e.stopPropagation();
          settingSubTab = btn.getAttribute("data-subtab");
          renderTampilan();
        };
      });

      if (settingSubTab === "filter") {
        const currentMode = core.getFilterMode(),
          whitelist = core.getTargetDomains(),
          blacklist = core.getBlacklistDomains();

        subContent.innerHTML = `
                    <div style="font-size:11px;font-weight:bold;margin-bottom:8px;color:#9ca3af;border-bottom:1px solid #272a34;padding-bottom:4px;display:flex!important;justify-content:space-between!important;align-items:center!important;overflow:visible!important;">
                        <span>🌐 ${t("site_manager")}</span>
                        <div style="display:flex!important;align-items:center!important;gap:4px!important;">
                            <span style="font-size:9px!important;color:#9ca3af!important;">${t("mode_label")}</span>
                            <select class="form-input select-filter-mode" style="width:auto!important;padding:2px 6px!important;font-size:9px!important;background:#1a1d24!important;border-color:#272a34!important;border-radius:10px!important;color:white!important;height:auto!important;line-height:1!important;cursor:pointer!important;">
                                <option value="whitelist" ${currentMode === "whitelist" ? "selected" : ""}>${t("only_whitelist")}</option>
                                <option value="blacklist" ${currentMode === "blacklist" ? "selected" : ""}>${t("block_blacklist")}</option>
                            </select>
                        </div>
                    </div>
                    <div class="filter-desc" style="font-size:9px!important;color:#9ca3af!important;margin-bottom:10px!important;">${currentMode === "whitelist" ? t("desc_whitelist") : t("desc_blacklist")}</div>
                    ${currentMode === "whitelist" ? `<div style="margin-bottom:8px!important;"><button class="form-btn add-novel-presets-btn" style="width:100%!important;background:rgba(59,130,246,0.1)!important;border:1px dashed #3b82f6!important;color:#60a5fa!important;border-radius:10px!important;padding:6px 10px!important;font-size:10px!important;cursor:pointer!important;">📚 ${t("add_novel_presets_btn")}</button></div>` : ""}
                    <div class="domain-list" style="overflow:visible!important;"></div>
                    <div class="form-group" style="display:flex!important;gap:6px!important;align-items:center!important;"><input type="text" class="form-input input-domain" placeholder="${currentMode === "whitelist" ? t("new_whitelist_placeholder") : t("new_blacklist_placeholder")}" style="flex:1!important;border-radius:10px!important;" /><button type="button" class="form-btn add-domain-btn btn-pill-primary" style="padding:10px 14px!important;border-radius:20px!important;border:none!important;cursor:pointer!important;">➕</button></div>
                `;

        const selectMode = subContent.querySelector(".select-filter-mode");
        selectMode.onchange = (e) => {
          core.saveFilterMode(e.target.value);
          panggilToast(t("toast_filter_mode", e.target.value), "info");
          core.jalankanPengganti(true);
          renderTampilan();
          core.simpanKeAwan();
        };

        const addPresetsBtn = subContent.querySelector(".add-novel-presets-btn");
        if (addPresetsBtn) {
          addPresetsBtn.onclick = () => {
            let list = core.getTargetDomains();
            let added = 0;
            NOVEL_SITE_PRESETS.forEach((site) => {
              if (!list.includes(site)) { list.push(site); added++; }
            });
            if (added > 0) {
              core.saveTargetDomains(list);
              core.simpanKeAwan(null, list);
              core.jalankanPengganti(true);
              renderTampilan();
              panggilToast(t("toast_novel_presets_added", added), "success");
            } else {
              panggilToast(t("toast_novel_presets_already"), "info");
            }
          };
        }

        const listContainer = subContent.querySelector(".domain-list"),
          listData = currentMode === "whitelist" ? whitelist : blacklist;
        if (listData.length === 0) {
          listContainer.innerHTML = `<div class="empty-state">${t("empty_state")}</div>`;
        } else {
          listData.forEach((dom) => {
            const row = document.createElement("div");
            row.className = "domain-item";
            row.innerHTML = `<span>${dom}</span><button class="awr-tooltip-btn modern-icon-btn danger del-dom-btn" data-tooltip="Delete Site">${core.SVGS.trash}</button>`;
            row.querySelector(".del-dom-btn").onclick = () => {
              window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
                "Hapus Domain Filter?",
                `Apakah Anda yakin ingin menghapus domain "${dom}"?`,
                () => {
                  if (currentMode === "whitelist") {
                    let list = core.getTargetDomains().filter((d) => d !== dom);
                    core.saveTargetDomains(list);
                    panggilToast(t("toast_whitelist_deleted", dom), "warn");
                    core.simpanKeAwan(null, list);
                  } else {
                    let list = core
                      .getBlacklistDomains()
                      .filter((d) => d !== dom);
                    core.saveBlacklistDomains(list);
                    panggilToast(t("toast_blacklist_deleted", dom), "warn");
                    core.simpanKeAwan();
                  }
                  core.jalankanPengganti(true);
                  renderTampilan();
                },
              );
            };
            listContainer.appendChild(row);
          });
        }

        subContent.querySelector(".add-domain-btn").onclick = (e) => {
          // Hentikan bubbling ke elemen lain di shadow DOM
          e.stopPropagation();
          e.preventDefault();

          // Re-query setiap klik agar tidak gunakan referensi DOM yang usang
          const inputEl = subContent.querySelector(".input-domain");
          if (!inputEl) return;

          // Bersihkan karakter tak terlihat (zero-width, non-breaking space, dll)
          const rawVal = (inputEl.value || "")
            .replace(/[\u200b\u200c\u200d\ufeff\u00a0\u2060]+/g, "")
            .trim()
            .toLowerCase();

          // UPDATE: Jika textbox kosong → otomatis gunakan domain halaman yang sedang dibuka
          const targetDomain = rawVal || core.getNovelBaseDomain(core.currentHost);

          if (!targetDomain) {
            panggilToast("Domain tidak terdeteksi. Buka halaman website terlebih dahulu.", "warn");
            return;
          }

          const normalizedText = core.getNovelBaseDomain(targetDomain);

          if (!normalizedText) {
            panggilToast("Domain tidak valid. Contoh: example.com", "warn");
            if (rawVal) inputEl.focus();
            return;
          }

          if (currentMode === "whitelist") {
            let list = core.getTargetDomains();
            if (list.some((d) => core.getNovelBaseDomain(d) === normalizedText)) {
              panggilToast(t("alert_already_registered"), "warn");
              return;
            }
            list.push(normalizedText);
            core.saveTargetDomains(list);
            core.simpanKeAwan(null, list);
            panggilToast(
              rawVal
                ? "\u2705 \"" + normalizedText + "\" ditambahkan ke whitelist."
                : "\u2705 Halaman ini (\"" + normalizedText + "\") diizinkan.",
              "success"
            );
          } else {
            let list = core.getBlacklistDomains();
            if (list.some((d) => core.getNovelBaseDomain(d) === normalizedText)) {
              panggilToast(t("alert_already_registered"), "warn");
              return;
            }
            list.push(normalizedText);
            core.saveBlacklistDomains(list);
            core.simpanKeAwan();
            panggilToast(
              rawVal
                ? "\ud83d\udeab \"" + normalizedText + "\" ditambahkan ke blacklist."
                : "\ud83d\udeab Halaman ini (\"" + normalizedText + "\") diblokir.",
              "warn"
            );
          }

          inputEl.value = "";
          core.jalankanPengganti(true);
          // Tunda sedikit render agar panel tidak berkedip saat status domain berubah
          setTimeout(() => { try { renderTampilan(); } catch(err) {} }, 50);
        };
      } else if (settingSubTab === "config") {
        subContent.innerHTML = `
                    <div class="setting-row">
                        <div class="setting-info"><span class="setting-title">${t("blue_highlight")}</span><span class="setting-desc">${t("blue_highlight_desc")}</span></div>
                        <label class="switch"><input type="checkbox" class="chk-highlight" ${core.getHighlightAktif() ? "checked" : ""} /><span class="slider"></span></label>
                    </div>
                    <div class="setting-row">
                        <div class="setting-info"><span class="setting-title">${t("recycle_auto_delete_title")}</span><span class="setting-desc">${t("recycle_auto_delete_desc")}</span></div>
                        <select class="form-input select-recycle-auto-delete" style="width:auto!important;padding:2px 6px!important;font-size:11px!important;background:#1a1d24!important;border-color:#272a34!important;border-radius:10px!important;color:white!important;height:auto!important;line-height:1!important;cursor:pointer!important;">
                            <option value="0" ${autoDeleteDays === 0 ? "selected" : ""}>${t("auto_delete_never")}</option>
                            <option value="7" ${autoDeleteDays === 7 ? "selected" : ""}>7 ${t("auto_delete_days")}</option>
                            <option value="30" ${autoDeleteDays === 30 ? "selected" : ""}>30 ${t("auto_delete_days")}</option>
                            <option value="90" ${autoDeleteDays === 90 ? "selected" : ""}>90 ${t("auto_delete_days")}</option>
                            <option value="365" ${autoDeleteDays === 365 ? "selected" : ""}>1 ${t("auto_delete_year")}</option>
                        </select>
                    </div>
                    <div class="setting-row" style="margin-top:20px;border-top:1px dashed #334155;padding-top:15px;overflow:visible!important;">
                        <div class="setting-info"><span class="setting-title" style="color:#ef4444;">${t("restore_defaults")}</span><span class="setting-desc">${t("restore_desc")}</span></div>
                        <button class="form-btn reset-config-btn btn-pill-danger" style="padding:5px 12px!important;border-radius:20px!important;">${t("reset_data")}</button>
                    </div>
                    <div class="setting-row" style="margin-top:20px;border-top:1px dashed #334155;padding-top:15px;user-select:none;">
                        <div class="setting-info">
                            <span class="setting-title">${t("awr_version_label")}</span>
                            <span class="setting-desc" style="font-family:monospace;font-weight:bold;color:#10b981;">v${core.version} (Stable)</span>
                        </div>
                    </div>
                    <div class="setting-row">
                        <div class="setting-info"><span class="setting-title">${t("update_mode_label")}</span><span class="setting-desc">${t("update_mode_desc")}</span></div>
                        <select class="form-input select-update-mode" style="width:auto!important;padding:2px 6px!important;font-size:11px!important;background:#1a1d24!important;border-color:#272a34!important;border-radius:10px!important;color:white!important;height:auto!important;line-height:1!important;cursor:pointer!important;">
                            <option value="auto" ${updateMode === "auto" ? "selected" : ""}>${t("update_mode_auto")}</option><option value="manual" ${updateMode === "manual" ? "selected" : ""}>${t("update_mode_manual")}</option>
                        </select>
                    </div>
                    <div style="margin-top:8px;display:flex;justify-content:flex-end;"><button class="form-btn check-update-now-btn btn-pill-primary" style="padding:5px 12px!important;font-size:10px!important;border-radius:12px!important;display:inline-flex;align-items:center;justify-content:center;gap:6px;">${core.SVGS.refresh} ${t("btn_check_update")}</button></div>
                `;

        subContent.querySelector(".chk-highlight").onchange = (e) => {
          core.saveHighlightAktif(e.target.checked);
          panggilToast(
            e.target.checked ? t("highlight_enabled") : t("highlight_disabled"),
            "info",
          );
          core.jalankanPengganti(true);
        };

        const selectAutoDelete = subContent.querySelector(
          ".select-recycle-auto-delete",
        );
        if (selectAutoDelete) {
          selectAutoDelete.onchange = (e) => {
            const val = e.target.value;
            GM_setValue("awr_recycle_auto_delete_days", val);
            panggilToast(
              t(
                "toast_auto_delete_changed",
                val === "0"
                  ? t("auto_delete_never")
                  : val + " " + t("auto_delete_days"),
              ),
              "success",
            );
          };
        }

        const selectUpdateMode = subContent.querySelector(
          ".select-update-mode",
        );
        if (selectUpdateMode) {
          selectUpdateMode.onchange = (e) => {
            GM_setValue("awr_update_mode_v1", e.target.value);
            panggilToast(
              t(
                "toast_filter_mode",
                e.target.value === "auto"
                  ? t("update_mode_auto")
                  : t("update_mode_manual"),
              ),
              "info",
            );
          };
        }

        subContent.querySelector(".check-update-now-btn").onclick = (e) => {
          e.stopPropagation();
          core.checkForUpdates(true);
        };

        subContent.querySelector(".reset-config-btn").onclick = () => {
          window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
            t("restore_defaults"),
            "⚠️ RESET LENGKAP akan menghapus SEMUA data lokal:\n\n• Semua kata kustom dan grup novel\n• Semua pengaturan domain (filter/whitelist/blacklist)\n• Semua preferensi tampilan & konfigurasi\n\nBackup cloud GitHub Gist Anda TIDAK akan terpengaruh dan kredensial login tetap tersimpan.\n\nLanjutkan?",
            () => {
              // Hapus semua kunci GM lokal kecuali kredensial cloud
              const ALL_LOCAL_KEYS = [
                "kamus_kata_v5", "kamus_kata_v4",
                "target_domains_v4", "blacklist_domains_v1", "filter_mode_v1",
                "highlight_aktif_v4", "awr_deleted_words_v1",
                "awr_novel_titles_v2", "awr_active_novel_id_v1",
                "awr_lang_v1", "awr_show_other_terms",
                "awr_last_selected_group_id_v2", "awr_last_active_group_checkbox_state_v2",
                "awr_recycle_auto_delete_days", "awr_update_mode_v1",
              ];
              ALL_LOCAL_KEYS.forEach(k => GM_setValue(k, null));

              // Terapkan nilai default setelah reset
              const defaultKamus = {
                silahkan: { to: "silakan", global: true, domain: "wikipedia.org" },
                wikipedia: { to: "Ensiklopedia Bebas", global: true, domain: "wikipedia.org" },
                salah: { to: "keliru", global: true, domain: "detik.com" },
              };
              GM_setValue("kamus_kata_v5", JSON.stringify(defaultKamus));
              GM_setValue("target_domains_v4", JSON.stringify([
                "wtr-lab.com","webnovel.com","novelbin.com","novelbin.net",
                "novelupdates.com","wuxiaworld.com","royalroad.com","scribblehub.com",
                "lightnovelworld.com","mtlnovel.com","novelhall.com","boxnovel.com",
                "readlightnovel.me","noveltranslate.com","readnovelfull.com",
                "zinnovel.com","novelsonline.net","fanfiction.net",
                "archiveofourown.org","wattpad.com"
              ]));
              GM_setValue("blacklist_domains_v1", JSON.stringify(["google.com", "facebook.com", "youtube.com"]));
              GM_setValue("filter_mode_v1", "whitelist");
              GM_setValue("highlight_aktif_v4", true);
              GM_setValue("awr_deleted_words_v1", "{}");
              GM_setValue("awr_novel_titles_v2", "{}");
              GM_setValue("awr_update_mode_v1", "auto");
              GM_setValue("awr_recycle_auto_delete_days", "30");

              // Reset variabel in-memory
              _activeNovelIdCache = null;
              showOtherTerms = false;

              panggilToast("✅ Reset lengkap selesai! Semua pengaturan lokal telah dikembalikan ke default. Backup cloud tidak terpengaruh.", "info");
              core.jalankanPengganti(true);
              renderTampilan();
            },
          );
        };
      } else if (settingSubTab === "data") {
        // v52.5: Export/Import per-novel rules
        const activeNovelId = core.getActiveNovelId();
        const currentNovel = core.getNovelContext();
        const novelLabel = currentNovel.title || currentNovel.id || "novel ini";

        subContent.innerHTML = `
          <div style="font-size:11px;font-weight:bold;color:#9ca3af;margin-bottom:10px;border-bottom:1px solid #272a34;padding-bottom:6px;">📦 ${t("tab_data")}</div>

          <div style="background:#1a1d24;border-radius:10px;padding:10px 12px;margin-bottom:8px;border:1px solid #272a34;">
            <div style="font-size:11px;font-weight:bold;color:#e2e8f0;margin-bottom:4px;">⬇️ ${t("export_novel_btn")}</div>
            <div style="font-size:9px;color:#6b7280;margin-bottom:8px;">${t("export_novel_desc")}</div>
            <div style="font-size:9px;color:#94a3b8;margin-bottom:6px;">Novel aktif: <b style="color:#60a5fa;">${novelLabel}</b>${activeNovelId ? ` <code style="background:#0f1117;padding:1px 4px;border-radius:3px;font-size:8px;">${activeNovelId.slice(0,16)}...</code>` : " (global)"}</div>
            <button class="form-btn awr-export-novel-btn btn-pill-primary" style="width:100%!important;padding:7px!important;font-size:10px!important;border-radius:10px!important;">⬇️ ${t("export_novel_btn")}</button>
          </div>

          <div style="background:#1a1d24;border-radius:10px;padding:10px 12px;border:1px solid #272a34;">
            <div style="font-size:11px;font-weight:bold;color:#e2e8f0;margin-bottom:4px;">⬆️ ${t("import_novel_btn")}</div>
            <div style="font-size:9px;color:#6b7280;margin-bottom:8px;">${t("import_novel_desc")}</div>
            <input type="file" class="awr-import-file-input" accept=".json,application/json" style="display:none;" />
            <button class="form-btn awr-import-novel-btn" style="width:100%!important;padding:7px!important;font-size:10px!important;border-radius:10px!important;background:rgba(234,179,8,0.1)!important;color:#fbbf24!important;border-color:rgba(234,179,8,0.3)!important;">⬆️ ${t("import_novel_btn")}</button>
          </div>
        `;

        // Export handler
        subContent.querySelector(".awr-export-novel-btn").onclick = () => {
          const kamus = core.getKamus();
          const toExport = {};
          for (const key in kamus) {
            const entry = kamus[key];
            const entryNovelId = entry && typeof entry === "object" ? entry.novelId : null;
            const isGlobal = entry && typeof entry === "object" ? !!entry.global : false;
            // Include: entries matching active novel OR global entries (if no active novel, export all)
            if (!activeNovelId) {
              toExport[key] = entry;
            } else if (entryNovelId === activeNovelId || key.startsWith("hash:")) {
              toExport[key] = entry;
            }
          }
          const count = Object.keys(toExport).length;
          if (count === 0) { panggilToast(t("export_none"), "warn"); return; }

          const payload = JSON.stringify({ novelId: activeNovelId, novelTitle: novelLabel, exportedAt: new Date().toISOString(), entries: toExport }, null, 2);
          const blob = new Blob([payload], { type: "application/json" });
          const url = URL.createObjectURL(blob);
          const a = document.createElement("a");
          a.href = url;
          a.download = "awr_" + (novelLabel.replace(/[^a-zA-Z0-9]+/g, "_").slice(0, 30) || "novel") + "_rules.json";
          a.click();
          URL.revokeObjectURL(url);
          panggilToast(t("export_success", count, novelLabel), "success");
        };

        // Import handler
        const fileInput = subContent.querySelector(".awr-import-file-input");
        subContent.querySelector(".awr-import-novel-btn").onclick = () => fileInput.click();
        fileInput.onchange = (e) => {
          const file = e.target.files[0];
          if (!file) return;
          const reader = new FileReader();
          reader.onload = (ev) => {
            try {
              const data = JSON.parse(ev.target.result);
              const imported = data.entries || data; // support both wrapped and raw
              if (typeof imported !== "object" || imported === null) throw new Error("invalid");
              const kamus = core.getKamus();
              let added = 0, skipped = 0;
              for (const key in imported) {
                if (kamus[key]) { skipped++; continue; }
                // If entry has no novelId and activeNovelId exists, assign it
                let entry = imported[key];
                if (activeNovelId && entry && typeof entry === "object" && !entry.novelId && !entry.global) {
                  entry = Object.assign({}, entry, { novelId: activeNovelId });
                }
                kamus[key] = entry;
                added++;
              }
              core.saveKamus(kamus);
              core.jalankanPengganti(true);
              core.simpanKeAwan();
              const msg = skipped > 0 ? t("import_duplicate", skipped, added) : t("import_success", added);
              panggilToast(msg, "success");
              fileInput.value = "";
            } catch (err) {
              panggilToast(t("import_error"), "error");
              fileInput.value = "";
            }
          };
          reader.readAsText(file);
        };
      } else if (settingSubTab === "cloud") {
        const token = core.getGitHubToken(),
          gistId = core.getGistId();
        if (!token || !gistId) {
          subContent.innerHTML = `
                        <div class="flex-col" style="gap:10px!important;overflow:visible!important;">
                            <div style="font-size:11px;color:#9ca3af;line-height:1.4;">Hubungkan ke akun GitHub pribadi Anda untuk menyimpan cadangan kata secara aman dan gratis di **Private Gist** milik Anda sendiri.</div>
                            <div class="flex-col" style="margin:4px 0 8px 0;gap:6px!important;"><a href="https://github.com/settings/tokens/new?scopes=gist&description=AWR-Replacer-Sync-Token" target="_blank" style="color:#60a5fa!important;font-size:11px!important;text-decoration:underline!important;font-weight:bold!important;">${t("auth_token_classic_link")}</a><a href="https://github.com/settings/tokens" target="_blank" style="color:#34d399!important;font-size:11px!important;text-decoration:underline!important;font-weight:bold!important;">${t("auth_token_manage_link")}</a></div>
                            <div class="editor-section" style="overflow:visible!important;"><span class="editor-label">${t("auth_github_token_label")}</span><div class="input-wrapper-box" style="overflow:visible!important;"><input type="password" class="form-input txt-github-token" placeholder="ghp_xxxxxxxxxxxxxxxxxxxx" style="border:none!important;background:transparent!important;outline:none!important;width:100%!important;border-radius:10px!important;padding-right:35px!important;box-sizing:border-box!important;" /><button class="toggle-token-visibility-btn" style="position:absolute!important;right:8px!important;background:none!important;border:none!important;color:#9ca3af!important;cursor:pointer!important;font-size:14px!important;padding:0!important;display:flex!important;align-items:center!important;outline:none!important;">👁️</button></div></div>
                            <div class="editor-section" style="margin-top:8px;overflow:visible!important;"><div style="display:flex!important;justify-content:space-between!important;align-items:center!important;margin-bottom:4px!important;overflow:visible!important;"><span class="editor-label" style="margin:0!important;">${t("auth_gist_id_label")}</span><button class="awr-tooltip-btn modern-icon-btn btn-auto-detect-gist" data-tooltip="${t("btn_auto_detect_gist")}" style="padding:3px!important;border-radius:6px!important;display:none;background:transparent!important;">${core.SVGS.radar}</button></div><input type="text" class="form-input txt-gist-id" placeholder="Biarkan kosong jika ingin membuat baru" style="border-radius:10px!important;" value="${gistId}" /></div>
                            <div style="margin-top:15px;display:flex!important;justify-content:space-between!important;align-items:center!important;overflow:visible!important;gap:6px;">
                                <input type="file" class="file-import-creds" style="display:none;" accept=".json,.txt,.js" />
                                <div style="display:flex;gap:6px;">
                                    <button class="awr-tooltip-btn modern-icon-btn btn-import-creds-trigger" data-tooltip="${t("btn_import_creds_trigger")}">${core.SVGS.folder}</button>
                                    <button class="awr-tooltip-btn modern-icon-btn btn-backup-creds-trigger" data-tooltip="${t("btn_backup_creds_file")}">${core.SVGS.disk}</button>
                                </div>
                                <button class="form-btn btn-connect-github-action" style="background:#10b981!important;border-radius:20px!important;color:white!important;">${t("auth_connect_btn")}</button>
                            </div>
                            <div style="margin-top:12px;border-top:1px dashed #334155!important;padding-top:10px!important;display:flex!important;flex-direction:column!important;gap:6px!important;overflow:visible!important;"><span class="editor-label">🔑 Helper & Recovery:</span><div style="display:flex!important;gap:6px!important;overflow:visible!important;"><button class="awr-tooltip-btn modern-icon-btn btn-forgot-gist-link" data-tooltip="${t("btn_forgot_gist_link")}" style="flex:1!important;">${core.SVGS.link}</button></div></div>
                        </div>
                    `;

          const tokenInput = subContent.querySelector(".txt-github-token"),
            gistInput = subContent.querySelector(".txt-gist-id"),
            visibilityBtn = subContent.querySelector(
              ".toggle-token-visibility-btn",
            ),
            autoDetectBtn = subContent.querySelector(".btn-auto-detect-gist"),
            fileInput = subContent.querySelector(".file-import-creds"),
            importTrigger = subContent.querySelector(
              ".btn-import-creds-trigger",
            ),
            forgotGistBtn = subContent.querySelector(".btn-forgot-gist-link"),
            backupTrigger = subContent.querySelector(
              ".btn-backup-creds-trigger",
            );

          visibilityBtn.onclick = (e) => {
            e.stopPropagation();
            const isPass = tokenInput.type === "password";
            tokenInput.type = isPass ? "text" : "password";
            visibilityBtn.textContent = isPass ? "🙈" : "👁️";
          };

          const updateAutoDetectVisibility = () => {
            autoDetectBtn.style.display =
              tokenInput.value.trim().length >= 35 ? "block" : "none";
          };
          tokenInput.addEventListener("input", updateAutoDetectVisibility);
          updateAutoDetectVisibility();

          autoDetectBtn.onclick = async (e) => {
            e.preventDefault();
            const tokenVal = tokenInput.value.trim();
            if (!tokenVal) return;
            panggilToast(t("gist_scan_toast"), "info");
            const profile = await verifyGitHubToken(tokenVal);
            if (!profile) {
              panggilToast(
                "Token verification failed! Please verify token has gist permissions.",
                "warn",
              );
              return;
            }
            cachedUserProfile = profile;
            const foundId = await core.findExistingGist(tokenVal);
            if (foundId) {
              gistInput.value = foundId;
              panggilToast(t("gist_scan_found"), "success");
            } else {
              panggilToast(t("gist_scan_not_found"), "warn");
            }
          };

          importTrigger.onclick = (e) => {
            e.preventDefault();
            fileInput.click();
          };
          fileInput.onchange = (e) => {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = (evt) => {
              try {
                const teks = evt.target.result;
                let importToken = "", importGistId = "";

                // Coba parse sebagai JSON dulu
                try {
                  const creds = JSON.parse(teks);
                  if (creds.token) importToken = creds.token;
                  if (creds.gistId) importGistId = creds.gistId;
                } catch (_) {
                  // Parse sebagai file .js / .txt format AWR
                  const matchToken = teks.match(/var\s+AWR_TOKEN\s*=\s*["\']([^\"\']*)["\'];?/);
                  const matchGist  = teks.match(/var\s+AWR_GIST_ID\s*=\s*["\']([^\"\']*)["\'];?/);
                  if (matchToken) importToken   = matchToken[1];
                  if (matchGist)  importGistId  = matchGist[1];
                }

                if (importToken) {
                  tokenInput.value = importToken;
                  updateAutoDetectVisibility();
                }
                if (importGistId) gistInput.value = importGistId;

                if (importToken || importGistId) {
                  panggilToast(t("toast_creds_imported"), "success");
                } else {
                  alert("File kredensial tidak valid atau format tidak dikenali!");
                }
              } catch (err) {
                alert("Gagal membaca file kredensial!");
              }
            };
            reader.readAsText(file);
          };

          if (backupTrigger) {
            backupTrigger.onclick = (e) => {
              e.preventDefault();
              core.exportCredentials();
              panggilToast(t("toast_creds_exported"), "success");
            };
          }

          forgotGistBtn.onclick = (e) => {
            e.preventDefault();
            window.open("https://gist.github.com/", "_blank");
          };

          subContent.querySelector(".btn-connect-github-action").onclick =
            async (e) => {
              const btn = e.currentTarget,
                originalText = btn.textContent,
                inputToken = tokenInput.value.trim();
              let inputGistId = gistInput.value.trim();

              if (!inputToken) {
                alert("Silakan masukkan Token Akses GitHub terlebih dahulu!");
                return;
              }
              btn.disabled = true;
              btn.textContent = t("auth_connecting_btn");
              btn.style.opacity = "0.7";
              panggilToast(t("toast_sync_connecting"), "info");

              const profile = await verifyGitHubToken(inputToken);
              if (!profile) {
                alert(t("auth_conn_fail_reason"));
                btn.disabled = false;
                btn.textContent = originalText;
                btn.style.opacity = "1";
                return;
              }
              cachedUserProfile = profile;

              if (!inputGistId) {
                const discoveredGistId = await findExistingGist(inputToken);
                if (discoveredGistId) {
                  inputGistId = discoveredGistId;
                  panggilToast(t("gist_scan_found"), "success");
                }
              } else {
                inputGistId = extractGistId(inputGistId);
              }

              try {
                let details = null;
                if (inputGistId) {
                  details = await core.fetchGistDetails(
                    inputToken,
                    inputGistId,
                  );
                  if (!details) {
                    alert(t("gist_err_invalid"));
                    btn.disabled = false;
                    btn.textContent = originalText;
                    btn.style.opacity = "1";
                    return;
                  }
                } else {
                  const payload = {
                    kamus: core.getKamus(),
                    domains: core.getTargetDomains(),
                    blacklist: core.getBlacklistDomains(),
                    filterMode: core.getFilterMode(),
                    novelTitles: JSON.parse(
                      GM_getValue("awr_novel_titles_v2", "{}"),
                    ),
                    deletedWords: core.getDeletedWords(),
                  };
                  const newGistId = await core.createGist(inputToken, payload);
                  if (newGistId) {
                    inputGistId = newGistId;
                    details = await core.fetchGistDetails(
                      inputToken,
                      inputGistId,
                    );
                  } else {
                    alert("Gagal membuat Gist baru.");
                    btn.disabled = false;
                    btn.textContent = originalText;
                    btn.style.opacity = "1";
                    return;
                  }
                }

                cachedGistDetails = details;
                core.saveGitHubCredentials(inputToken, inputGistId);
                panggilToast(t("toast_github_connected"), "success");
                if (pane && pane.parentNode)
                  renderCloudGistManager(pane, details);
              } catch (err) {
                alert(t("auth_conn_fail_reason"));
                btn.disabled = false;
                btn.textContent = originalText;
                btn.style.opacity = "1";
              }
            };
        } else {
          if (cachedGistDetails && cachedUserProfile) {
            renderCloudGistManager(subContent, cachedGistDetails);
          } else {
            subContent.innerHTML = `<div class="cloud-loading-spinner" style="display:flex!important;flex-direction:column!important;align-items:center!important;justify-content:center!important;padding:30px!important;gap:10px!important;"><span style="font-size:24px!important;animation:spin 1s linear infinite!important;display:inline-block!important;">⏳</span><span style="font-size:11px!important;color:#9ca3af!important;">${t("loading_cloud_data")}</span></div>`;
            Promise.all([
              verifyGitHubToken(token),
              core.fetchGistDetails(token, gistId),
            ])
              .then(([profile, details]) => {
                if (profile && details) {
                  cachedUserProfile = profile;
                  cachedGistDetails = details;
                  if (pane && pane.parentNode) {
                    renderCloudGistManager(subContent, details);
                  }
                } else {
                  throw new Error("Verification failed");
                }
              })
              .catch(() => {
                pane.innerHTML = `<div style="color:#ef4444!important;font-size:11px!important;text-align:center!important;padding:20px!important;line-height:1.4!important;display:flex!important;flex-direction:column!important;gap:10px!important;">${t("auth_cloud_err_msg")}<div><button class="form-btn btn-force-switch btn-pill-danger" style="padding: 5px 14px !important;">Logout</button></div></div>`;
                pane.querySelector(".btn-force-switch").onclick = () => {
                  core.saveGitHubCredentials("", "");
                  cachedGistDetails = null;
                  cachedUserProfile = null;
                  panggilToast(t("toast_account_switched"), "info");
                  renderTampilan();
                };
              });
          }
        }
      }
    }
  }

  window.AWR_UI_LIBRARY = {
    tampilkanKonfirmasi: function (
      judul,
      deskripsi,
      onConfirm,
      onCancel = null,
      options = {},
    ) {

      if (!panel || !panel.querySelector) {
        console.warn(
          "AWR_UI_LIBRARY.tampilkanKonfirmasi skipped: panel is not ready",
        );
        return;
      }
      const existing = panel.querySelector(".replacer-confirm-overlay");
      if (existing) existing.remove();

      const confirmLabel = options.confirmLabel || t("btn_confirm");
      const cancelLabel = options.cancelLabel || t("btn_cancel");
      const confirmColor = options.confirmColor || "#ef4444";

      const overlay = document.createElement("div");
      overlay.className = "replacer-confirm-overlay";
      overlay.innerHTML = `
                <div class="replacer-confirm-box">
                    <div class="replacer-confirm-header">${judul}</div>
                    <div class="replacer-confirm-body" style="white-space: pre-line !important;">${deskripsi}</div>
                    <div class="replacer-confirm-footer">
                        <button class="form-btn confirm-cancel-btn">${cancelLabel}</button>
                        <button class="form-btn btn-pill-primary confirm-ok-btn" style="background: ${confirmColor} !important; border-color: ${confirmColor} !important;">${confirmLabel}</button>
                    </div>
                </div>
            `;
      overlay.querySelector(".confirm-cancel-btn").onclick = (e) => {
        e.stopPropagation();
        overlay.remove();
        if (onCancel) onCancel();
      };
      overlay.querySelector(".confirm-ok-btn").onclick = (e) => {
        e.stopPropagation();
        overlay.remove();
        onConfirm();
      };
      panel.appendChild(overlay);
    },

    init: function (coreAPI) {
      core = coreAPI;

      const hostElement = document.createElement("div");
      hostElement.id = "word-replacer-host";
      hostElement.setAttribute(
        "style",
        "position: fixed !important; top: 0 !important; left: 0 !important; width: 100vw !important; height: 100vh !important; z-index: 999999999 !important; pointer-events: none !important;",
      );
      document.body.appendChild(hostElement);

      const shadow = hostElement.attachShadow({ mode: "open" });

      ["keydown", "keyup", "keypress"].forEach((eventType) => {
        shadow.addEventListener(eventType, (e) => { e.stopPropagation(); });
      });

      ["mousedown", "mouseup", "click", "dblclick", "pointerdown", "pointerup",
       "contextmenu"].forEach((eventType) => {
        shadow.addEventListener(eventType, (e) => { e.stopPropagation(); });
      });

      ["focusin", "focusout"].forEach((eventType) => {
        shadow.addEventListener(eventType, (e) => { e.stopPropagation(); });
      });

      shadow.addEventListener("click", (e) => {
        const langDropdown = shadow.querySelector(".lang-dropdown-menu");
        if (
          langDropdown &&
          langDropdown.classList.contains("show") &&
          !e.target.closest(".lang-dropdown-container")
        ) {
          langDropdown.classList.remove("show");
        }
        const groupMenus = shadow.querySelectorAll(".group-dropdown-menu");
        groupMenus.forEach((menu) => {
          if (menu.classList.contains("show")) {
            const container = menu.closest(".group-menu-container");
            if (!container || !container.contains(e.target))
              menu.classList.remove("show");
          }
        });
      });

      const style = document.createElement("style");
      style.textContent = `
                .replacer-wrapper { font-family: system-ui, sans-serif !important; color: #f1f5f9 !important; overflow: visible !important; pointer-events: none !important; }
                .replacer-panel { position: fixed !important; bottom: 0 !important; left: 0 !important; z-index: 999999999 !important; width: 380px !important; height: 60vh !important; background: #0f172a !important; border: 1px solid #334155 !important; border-radius: 16px 16px 0 0 !important; box-shadow: 0 -10px 25px rgba(0,0,0,.6) !important; display: flex !important; flex-direction: column !important; overflow: visible !important; transition: transform .3s ease !important; pointer-events: auto !important; }
                .replacer-panel.hidden { transform: translateX(-100%) !important; pointer-events: none !important; }
                .replacer-launcher { position: fixed !important; bottom: 24px !important; left: 24px !important; z-index: 100000000 !important; background: #1e293b !important; color: #f8fafc !important; border: 1px solid #475569 !important; border-radius: 9999px !important; padding: 10px 18px !important; font-size: 13px !important; font-weight: 700 !important; cursor: pointer !important; box-shadow: 0 10px 15px -3px rgba(0,0,0,.4) !important; display: flex !important; align-items: center !important; gap: 8px !important; pointer-events: auto !important; }
                .replacer-header { background: #1e293b !important; color: #f8fafc !important; padding: 14px 16px !important; display: flex !important; flex-direction: column !important; border-bottom: 1px solid #334155 !important; }
                .replacer-title { font-size: 15px !important; font-weight: 800 !important; color: #f8fafc !important; }
                .replacer-close { background: none !important; border: none !important; color: #94a3b8 !important; font-size: 18px !important; cursor: pointer !important; display: flex !important; align-items: center !important; }
                .replacer-host-status { background: #0f172a !important; border: 1px solid #334155 !important; border-radius: 12px !important; padding: 10px 14px !important; margin-top: 10px !important; display: flex !important; justify-content: space-between !important; align-items: center !important; gap: 12px !important; font-size: 11px !important; }
                .host-info { display: flex !important; flex-direction: column !important; gap: 2px !important; min-width: 0 !important; flex: 1 !important; }
                .host-label { color: #64748b !important; font-size: 9px !important; font-weight: 800 !important; text-transform: uppercase !important; }
                .host-name { font-family: monospace !important; font-weight: 700 !important; color: #cbd5e1 !important; font-size: 11px !important; white-space: nowrap !important; overflow: hidden !important; text-overflow: ellipsis !important; }
                .status-actions { display: flex !important; align-items: center !important; gap: 8px !important; }
                .status-badge { padding: 4px 10px !important; border-radius: 9999px !important; font-size: 10px !important; font-weight: 700 !important; display: inline-flex !important; align-items: center !important; gap: 6px !important; }
                .status-badge.active { background: rgba(16,185,129,0.1) !important; color: #34d399 !important; border: 1px solid rgba(16,185,129,0.3) !important; }
                .status-badge.inactive { background: rgba(239,68,68,0.1) !important; color: #f87171 !important; border: 1px solid rgba(239,68,68,0.3) !important; }
                .status-indicator { width: 6px !important; height: 6px !important; border-radius: 50% !important; display: inline-block !important; }
                .status-indicator.active { background-color: #10b981 !important; }
                .status-indicator.inactive { background-color: #ef4444 !important; }
                .toggle-btn { background: #1e293b !important; border: 1px solid #475569 !important; color: #cbd5e1 !important; padding: 5px 12px !important; border-radius: 8px !important; font-size: 11px !important; font-weight: 600 !important; cursor: pointer !important; }
                .replacer-tabs { background: #1e293b !important; border-bottom: 1px solid #334151 !important; display: flex !important; padding: 0 16px !important; gap: 16px !important; overflow: visible !important; }
                .replacer-tab-btn { background: none !important; border: none !important; padding: 12px 10px !important; color: #94a3b8 !important; cursor: pointer !important; position: relative !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; flex: 1 !important; transition: color 0.2s ease !important; border-bottom: 2.5px solid transparent !important; }
                .replacer-tab-btn.active { color: #3b82f6 !important; border-bottom-color: #3b82f6 !important; }
                .sub-tab-btn.active { background: #2563eb !important; border-color: #3b82f6 !important; color: #ffffff !important; }
                .awr-tooltip-btn { position: relative !important; }
                .awr-tooltip-btn::after { content: attr(data-tooltip) !important; position: absolute !important; bottom: 125% !important; left: 50% !important; transform: translateX(-50%) translateY(5px) !important; background: #1e293b !important; color: #f1f5f9 !important; padding: 5px 10px !important; border-radius: 6px !important; font-size: 10px !important; font-weight: 500 !important; white-space: nowrap !important; opacity: 0 !important; visibility: hidden !important; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1) !important; box-shadow: 0 4px 12px rgba(0,0,0,0.5) !important; border: 1px solid #334155 !important; pointer-events: none !important; z-index: 100000040 !important; }
                .awr-tooltip-btn:hover::after { opacity: 1 !important; visibility: visible !important; transform: translateX(-50%) translateY(0) !important; }
                .awr-tooltip-btn.tooltip-bottom::after { top: 125% !important; bottom: auto !important; transform: translateX(-50%) translateY(-5px) !important; }
                .awr-tooltip-btn.tooltip-bottom:hover::after { transform: translateX(-50%) translateY(0) !important; }
                .replacer-body { flex: 1 !important; overflow-y: auto !important; background: #0f172a !important; display: flex !important; flex-direction: column !important; padding-bottom: 20px !important; }
                .tab-pane { display: none !important; padding: 16px !important; flex-direction: column !important; flex: 1 !important; overflow-y: auto !important; max-height: calc(60vh - 150px) !important; }
                .tab-pane.active { display: flex !important; }
                .card-box { background: #1e293b !important; border: 1px solid #334155 !important; border-radius: 10px !important; padding: 10px 12px !important; margin-bottom: 8px !important; overflow: visible !important; }
                .editor-label { font-size: 12px !important; font-weight: 600 !important; color: #94a3b8 !important; }
                .form-input { width: 100% !important; box-sizing: border-box !important; padding: 8px 12px !important; font-size: 13px !important; border: 1px solid #475569 !important; border-radius: 8px !important; background: #0f172a !important; color: #ffffff !important; }
                .select.form-input { appearance: none !important; background-image: url("data:image/svg+xml;utf8,<svg fill='%2394a3b8' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>") !important; background-repeat: no-repeat !important; background-position: right 10px center !important; background-size: 18px !important; padding-right: 32px !important; cursor: pointer !important; }
                .form-btn { background: #1e293b !important; border: 1px solid #475569 !important; color: #e2e8f0 !important; border-radius: 20px !important; padding: 8px 16px !important; font-size: 12px !important; font-weight: 600 !important; cursor: pointer !important; }
                .form-btn:hover { background: #334155 !important; border-color: #3b82f6 !important; color: #ffffff !important; }
                .btn-pill-primary { background: #2563eb !important; border: 1px solid #3b82f6 !important; color: #ffffff !important; border-radius: 20px !important; padding: 8px 16px !important; font-size: 12px !important; font-weight: 700 !important; cursor: pointer !important; }
                .word-item { display: flex !important; justify-content: space-between !important; align-items: center !important; padding: 10px 12px !important; border-bottom: 1px solid #334155 !important; }
                .word-item:last-child { border-bottom: none !important; }
                .word-pair { display: flex !important; flex-direction: column !important; min-width: 0 !important; flex: 1 !important; gap: 2px !important; }
                .word-pair span { font-size: 12px !important; }
                .flex-col { display: flex !important; flex-direction: column !important; }
                .flex-row { display: flex !important; align-items: center !important; }
                .flex-between { display: flex !important; justify-content: space-between !important; align-items: center !important; gap: 8px !important; overflow: visible !important; }
                .input-wrapper-box { position: relative !important; display: flex !important; align-items: center !important; background: #0f172a !important; border: 1px solid #475569 !important; border-radius: 8px !important; box-sizing: border-box !important; overflow: visible !important; }
                .lang-dropdown-container { position: relative !important; display: inline-block; }
                .active-lang-btn { background: #1e293b !important; border: 1px solid #475569 !important; color: #f8fafc !important; padding: 4px 8px !important; border-radius: 8px !important; font-size: 11px !important; cursor: pointer !important; display: flex !important; align-items: center !important; gap: 4px !important; }
                .lang-dropdown-menu { display: none !important; position: absolute !important; right: 0 !important; top: 110% !important; margin-top: 4px !important; background: #1e293b !important; border: 1px solid #334155 !important; border-radius: 8px !important; box-shadow: 0 10px 25px rgba(0,0,0,0.5) !important; z-index: 100000002 !important; min-width: 120px !important; }
                .lang-dropdown-menu.show { display: block !important; }
                .lang-dropdown-item { width: 100% !important; background: none !important; border: none !important; color: #cbd5e1 !important; padding: 8px 12px !important; font-size: 11px !important; text-align: left !important; cursor: pointer !important; }
                .group-menu-container { position: relative !important; display: inline-flex !important; align-items: center !important; z-index: 100 !important; }
                .group-menu-btn { background: none !important; border: none !important; color: #94a3b8 !important; font-size: 14px !important; cursor: pointer !important; padding: 4px 6px !important; display: flex !important; align-items: center !important; justify-content: center !important; }
                .group-dropdown-menu { display: none !important; position: absolute !important; left: 0 !important; top: 100% !important; margin-top: 4px !important; background: #1e293b !important; border: 1px solid #334155 !important; border-radius: 8px !important; box-shadow: 0 10px 20px rgba(0,0,0,0.5) !important; z-index: 100000002 !important; min-width: 170px !important; }
                .group-dropdown-menu.show { display: block !important; }
                .group-dropdown-item { width: 100% !important; background: none !important; border: none !important; color: #cbd5e1 !important; padding: 8px 12px !important; font-size: 11px !important; text-align: left !important; cursor: pointer !important; }
                .group-dropdown-item.danger { color: #f87171 !important; }
                .switch { position: relative !important; display: inline-block !important; width: 36px !important; height: 20px !important; flex-shrink: 0 !important; }
                .switch input { opacity: 0 !important; width: 0 !important; height: 0 !important; }
                .slider { position: absolute !important; cursor: pointer !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; background-color: #334155 !important; transition: .3s !important; border-radius: 20px !important; }
                .slider::before { position: absolute !important; content: "" !important; height: 14px !important; width: 14px !important; left: 3px !important; bottom: 3px !important; background-color: white !important; transition: .3s !important; border-radius: 50% !important; }
                input:checked+.slider { background-color: #2563eb !important; }
                input:checked+.slider::before { transform: translateX(16px) !important; }
                .setting-row { display: flex !important; justify-content: space-between !important; align-items: center !important; padding: 8px 0 !important; gap: 12px !important; overflow: visible !important; }
                .setting-info { display: flex !important; flex-direction: column !important; flex: 1 !important; }
                .setting-title { font-size: 12px !important; font-weight: 600 !important; color: #ffffff !important; }
                .setting-desc { font-size: 10px !important; color: #94a3b8 !important; }
                .modern-icon-btn { background: #1e293b !important; border: 1px solid #334155 !important; color: #94a3b8 !important; cursor: pointer !important; padding: 6px !important; border-radius: 8px !important; display: inline-flex !important; align-items: center !important; justify-content: center !important; }
                .modern-icon-btn:hover { background: #334155 !important; color: #ffffff !important; border-color: #3b82f6 !important; }
                .modern-icon-btn.danger:hover { background: rgba(239, 68, 68, 0.1) !important; color: #f87171 !important; border-color: #ef4444 !important; }
                .editor-section { display: flex; flex-direction: column; gap: 4px; }
                .editor-label-row { display: flex; justify-content: space-between; align-items: center; }
                .editor-pills { display: flex; gap: 4px; }
                .editor-pill { background: #1e293b; border: 1px solid #334155; color: #94a3b8; font-size: 10px; padding: 2px 6px; border-radius: 4px; cursor: pointer; }
                .editor-pill:hover { color: #fff; border-color: #3b82f6; }
                .editor-sub-row { display: flex; justify-content: space-between; align-items: center; font-size: 11px; color: #64748b; margin-top: 4px; }
                .domain-item { display: flex !important; justify-content: space-between !important; align-items: center !important; padding: 6px 10px !important; background: #1e293b !important; border: 1px solid #334155 !important; border-radius: 8px !important; margin-bottom: 4px !important; gap: 8px !important; }
                .domain-item span { flex: 1 !important; font-size: 11px !important; color: #e2e8f0 !important; overflow: hidden !important; text-overflow: ellipsis !important; white-space: nowrap !important; min-width: 0 !important; }
                .domain-item .modern-icon-btn { flex-shrink: 0 !important; }
                .replacer-confirm-overlay { position: absolute !important; top: 0 !important; left: 0 !important; width: 100% !important; height: 100% !important; background: rgba(15, 23, 42, 0.8) !important; backdrop-filter: blur(4px) !important; z-index: 100000050 !important; display: flex !important; align-items: center !important; justify-content: center !important; pointer-events: auto !important; box-sizing: border-box !important; padding: 12px !important; }
                .replacer-confirm-box { background: #1e293b !important; border: 1px solid #334155 !important; border-radius: 12px !important; padding: 16px !important; width: 100% !important; max-width: 100% !important; max-height: 85% !important; overflow-y: auto !important; box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.6) !important; display: flex !important; flex-direction: column !important; gap: 12px !important; animation: modalSlideIn 0.2s cubic-bezier(0.16, 1, 0.3, 1) !important; box-sizing: border-box !important; }
                .replacer-confirm-header { font-size: 13px !important; font-weight: 800 !important; color: #f8fafc !important; border-bottom: 1px solid #334155 !important; padding-bottom: 8px !important; text-transform: uppercase !important; letter-spacing: 0.05em !important; }
                .replacer-confirm-body { font-size: 11px !important; color: #cbd5e1 !important; line-height: 1.5 !important; }
                .replacer-confirm-footer { display: flex !important; justify-content: flex-end !important; gap: 8px !important; margin-top: 4px !important; }
                .replacer-toast { position: fixed !important; bottom: 20px !important; right: 20px !important; background: #1e293b !important; border: 1px solid #334155 !important; border-left: 4px solid #3b82f6 !important; color: #ffffff !important; padding: 10px 16px !important; border-radius: 8px !important; font-size: 12px !important; font-weight: 600 !important; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5) !important; z-index: 100000010 !important; display: none !important; align-items: center !important; gap: 8px !important; max-width: 300px !important; pointer-events: auto !important; }
                .replacer-toast.show { display: flex !important; animation: toastSlideIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards !important; }
                .replacer-toast.success { border-left-color: #10b981 !important; }
                .replacer-toast.warn { border-left-color: #f59e0b !important; }
                .replacer-toast.info { border-left-color: #3b82f6 !important; }
                .replacer-tooltip-container { position: fixed !important; background: #0f172a !important; border: 1px solid #1e3a5f !important; border-radius: 10px !important; padding: 0 !important; box-shadow: 0 12px 28px -4px rgba(0,0,0,0.7), 0 0 0 1px rgba(59,130,246,0.15) !important; z-index: 100000005 !important; pointer-events: auto !important; display: none !important; flex-direction: column !important; gap: 0 !important; min-width: 170px !important; max-width: 250px !important; overflow: hidden !important; }
                .replacer-tooltip-container.visible { display: flex !important; }
                .tooltip-main-body { display: flex !important; align-items: center !important; gap: 6px !important; padding: 10px 12px !important; flex-wrap: wrap !important; }
                .tooltip-original-badge { font-size: 11px !important; color: #64748b !important; text-decoration: line-through !important; word-break: break-all !important; }
                .tooltip-arrow-icon { color: #3b82f6 !important; font-size: 13px !important; flex-shrink: 0 !important; font-weight: bold !important; }
                .tooltip-replaced-badge { font-size: 12px !important; font-weight: 700 !important; color: #60a5fa !important; background: rgba(59,130,246,0.12) !important; padding: 2px 7px !important; border-radius: 5px !important; word-break: break-all !important; }
                .tooltip-footer { border-top: 1px solid #1e293b !important; padding: 6px 10px !important; display: flex !important; justify-content: flex-end !important; background: #060f1e !important; }
                .tooltip-edit-btn { background: #1e293b !important; border: 1px solid #334155 !important; color: #94a3b8 !important; font-size: 10px !important; padding: 3px 10px !important; border-radius: 6px !important; cursor: pointer !important; font-weight: 600 !important; letter-spacing: 0.02em !important; }
                .tooltip-edit-btn:hover { color: #fff !important; border-color: #3b82f6 !important; background: #1e3a5f !important; }
                @keyframes modalSlideIn { from { transform: scale(0.95) translateY(10px) !important; opacity: 0 !important; } to { transform: scale(1) translateY(0) !important; opacity: 1 !important; } }
                @keyframes toastSlideIn { from { transform: translateY(100px) scale(0.9) !important; opacity: 0 !important; } to { transform: translateY(0) scale(1) !important; opacity: 1 !important; } }
            `;
      shadow.appendChild(style);

      const wrapper = document.createElement("div");
      wrapper.className = "replacer-wrapper";
      shadow.appendChild(wrapper);

      const tooltipEl = document.createElement("div");
      tooltipEl.className = "replacer-tooltip-container";
      wrapper.appendChild(tooltipEl);

      toast = document.createElement("div");
      toast.className = "replacer-toast";
      wrapper.appendChild(toast);

      const launcher = document.createElement("button");
      launcher.className = "replacer-launcher";
      launcher.innerHTML = `
          <span style="display:flex;align-items:center;justify-content:center;">
              <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="width:15px;height:15px;">
                  <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/>
                  <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
              </svg>
          </span>
          <span style="line-height:1;">AWR Tools</span>
      `;
      wrapper.appendChild(launcher);

      panel = document.createElement('div');
      panel.className = 'replacer-panel hidden';
      wrapper.appendChild(panel);

      launcher.onclick = () => {
          panel.classList.toggle('hidden');
          if (!panel.classList.contains('hidden')) {
              subjekEdit = null; safeRenderTampilan();
          } else {
              hilangkanFokusShadow();
          }
      };

      // FIX: Pastikan launcher selalu ada di shadow DOM, tidak hilang saat DOM berubah
      // Beberapa situs secara agresif menghapus elemen asing; ini mencegah itu.
      (function guardLauncher() {
        const launcherGuard = new MutationObserver(() => {
          if (!wrapper.contains(launcher)) {
            wrapper.appendChild(launcher);
          }
          if (!wrapper.contains(panel)) {
            wrapper.appendChild(panel);
          }
        });
        launcherGuard.observe(wrapper, { childList: true });
      })();

      let activeHoveredTarget = null, tooltipHideTimeout = null;
      document.body.addEventListener('mouseover', (e) => {
          const host = e.target.closest('#word-replacer-host');
          if (host) {
              if (tooltipHideTimeout) { clearTimeout(tooltipHideTimeout); tooltipHideTimeout = null; }
              return;
          }
          const target = e.target.closest('.' + HIGHLIGHT_CLASS);
          if (target) {
              if (isInsideNativeGlossary(target)) return;
              if (tooltipHideTimeout) { clearTimeout(tooltipHideTimeout); tooltipHideTimeout = null; }
              activeHoveredTarget = target;
              const original = target.getAttribute('data-original') || target.textContent, replacement = target.textContent;
              tampilkanTooltip(target, original, replacement);
          }
      }, false);

      document.body.addEventListener('mouseout', () => {
          if (tooltipHideTimeout) clearTimeout(tooltipHideTimeout);
          tooltipHideTimeout = setTimeout(() => { sembunyikanTooltip(); activeHoveredTarget = null; }, 350);
      }, false);

      document.body.addEventListener('click', (e) => {
          if (e.target.closest('#word-replacer-host')) return;
          const isMobileClick = e.clientX === 0 && e.clientY === 0;
          const diffX = isMobileClick ? 0 : Math.abs(e.clientX - startX);
          const diffY = isMobileClick ? 0 : Math.abs(e.clientY - startY);
          const selection = window.getSelection() ? window.getSelection().toString().trim() : '';

          const isInteractiveElement = e.target.closest('a, button, input, textarea, select, summary, [role="button"], [role="link"], [role="menuitem"]');
          const classNameStr = typeof e.target.className === 'string' ? e.target.className : '';
          const idStr = typeof e.target.id === 'string' ? e.target.id : '';
          const hasClickableKeywords = /more|less|expand|collapse|toggle|show|hide|btn|button|click/i.test(classNameStr + ' ' + idStr);

          const isGlossaryOrHighlight = isInsideNativeGlossary(e.target) || e.target.closest('.' + HIGHLIGHT_CLASS);
          const isInteractive = isInteractiveElement || hasClickableKeywords || isGlossaryOrHighlight;

          if (diffX > 6 || diffY > 6 || selection.length > 0 || isInteractive) return;

          if (panel.classList.contains('hidden')) {
              subjekEdit = null; panel.classList.remove('hidden'); safeRenderTampilan();
          } else {
              panel.classList.add('hidden'); hilangkanFokusShadow();
          }
      }, false);

      document.body.addEventListener('touchstart', (e) => { if (e.touches?.[0]) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; } }, { passive: true, capture: true });
      document.body.addEventListener('mousedown', (e) => { startX = e.clientX; startY = e.clientY; }, true);

      // v52.4 + v99.12.36: Klik kanan pada kata unik (span[data-hash])
      // PERILAKU BARU:
      //   • Hash SUDAH punya rule (kata sudah diganti) → tampilkan daftar per kemunculan
      //     dengan tombol revert individual/semua (bukan lagi redirect ke editor)
      //   • Hash BELUM punya rule → tetap buka editor pre-filled "hash:XXXX" (perilaku lama)
      document.body.addEventListener('contextmenu', (e) => {
        if (e.target.closest('#word-replacer-host')) return;
        const glossarySpan = e.target.closest('span[data-hash].text-patch, span[data-slot="popover-trigger"][data-hash]');
        if (!glossarySpan) return;
        const hashVal = glossarySpan.getAttribute('data-hash');
        if (!hashVal) return;
        e.preventDefault();

        const kamus = core.getKamus();
        const hashKey = 'hash:' + hashVal;
        const existingRule = kamus[hashKey];
        const replacedTo = existingRule
          ? (typeof existingRule === 'object' ? existingRule.to : existingRule)
          : null;

        // ── Belum ada rule → perilaku lama (buka editor) ──────────────────────
        if (!replacedTo) {
          const currentText = glossarySpan.textContent.trim();
          subjekEdit = null;
          tabAktif = 'tambah';
          panel.classList.remove('hidden');
          safeRenderTampilan();
          setTimeout(() => {
            const iSalah = shadow.querySelector('.input-salah');
            const iBenar = shadow.querySelector('.input-benar');
            const hintEl = shadow.querySelector('.awr-hash-hint');
            if (iSalah) { iSalah.value = 'hash:' + hashVal; iSalah.dispatchEvent(new Event('input')); }
            if (iBenar && !iBenar.value) iBenar.value = currentText;
            if (hintEl) hintEl.style.display = 'block';
            panggilToast('🔑 Hash rule siap: data-hash="' + hashVal + '" (isi teks pengganti lalu simpan)', 'info');
          }, 80);
          return;
        }

        // ── Sudah ada rule → tampilkan daftar per kemunculan ─────────────────
        panel.classList.remove('hidden');

        // Kumpulkan semua span dengan hash yang sama di halaman
        const allMatchingSpans = Array.from(
          document.querySelectorAll(
            'span[data-hash="' + hashVal + '"].text-patch, ' +
            'span[data-slot="popover-trigger"][data-hash="' + hashVal + '"]'
          )
        );

        // Cari index kemunculan yang diklik
        const clickedIdx = allMatchingSpans.indexOf(glossarySpan);

        // Hapus overlay lama jika masih ada
        const existingOverlay = panel.querySelector('.awr-revert-overlay');
        if (existingOverlay) existingOverlay.remove();

        const overlay = document.createElement('div');
        overlay.className = 'awr-revert-overlay';
        overlay.style.cssText =
          'position:absolute!important;top:0!important;left:0!important;' +
          'width:100%!important;height:100%!important;' +
          'background:rgba(15,23,42,0.88)!important;backdrop-filter:blur(4px)!important;' +
          'z-index:100000050!important;display:flex!important;align-items:center!important;' +
          'justify-content:center!important;box-sizing:border-box!important;padding:12px!important;' +
          'pointer-events:auto!important;';

        const box = document.createElement('div');
        box.style.cssText =
          'background:#1e293b!important;border:1px solid #334155!important;' +
          'border-radius:12px!important;padding:14px!important;width:100%!important;' +
          'max-height:88%!important;overflow-y:auto!important;' +
          'box-shadow:0 20px 25px -5px rgba(0,0,0,0.6)!important;' +
          'display:flex!important;flex-direction:column!important;gap:10px!important;' +
          'box-sizing:border-box!important;animation:modalSlideIn 0.2s cubic-bezier(0.16,1,0.3,1)!important;';

        // Teks asli dari hash dapat diketahui via hashOriginalTextMap (WeakMap modul)
        // hashOriginalTextMap.get(span) → teks sebelum diganti AWR
        function getOrigTxt(span) {
          const fromMap = hashOriginalTextMap.get(span);
          return fromMap || span.getAttribute('data-awr-orig') || null;
        }

        // Cari teks asli mewakili hash ini (dari span pertama yang ada datanya)
        let knownOrigSample = null;
        for (const sp of allMatchingSpans) {
          const o = getOrigTxt(sp);
          if (o) { knownOrigSample = o; break; }
        }
        const origLabel = knownOrigSample
          ? '<b style="color:#60a5fa;">' + knownOrigSample + (allMatchingSpans.length > 1 ? ', …' : '') + '</b>'
          : '<span style="color:#64748b;">(teks asli tidak tersimpan di sesi ini)</span>';

        box.innerHTML =
          '<div style="font-size:13px!important;font-weight:800!important;color:#f8fafc!important;' +
          'border-bottom:1px solid #334155!important;padding-bottom:8px!important;letter-spacing:0.02em;">' +
          '↩ Kembalikan Kemunculan</div>' +

          '<div style="font-size:10px!important;color:#94a3b8!important;line-height:1.6;">' +
          'Hash: <code style="background:#0f1117;padding:1px 4px;border-radius:3px;color:#fbbf24;">' + hashKey + '</code><br>' +
          'Teks asli: ' + origLabel + '<br>' +
          'Diganti menjadi: <b style="color:#10b981;">' + replacedTo + '</b><br>' +
          (clickedIdx >= 0 ? 'Kemunculan ke-<b style="color:#e2e8f0;">' + (clickedIdx + 1) + '</b><br>' : '') +
          'Total kemunculan di halaman: <b style="color:#e2e8f0;">' + allMatchingSpans.length + '</b>' +
          '</div>' +

          '<div style="font-size:11px!important;color:#e2e8f0!important;font-weight:600!important;">' +
          'Pilih kemunculan yang ingin dikembalikan ke teks asli:</div>' +

          '<div class="awr-occ-list" style="display:flex;flex-direction:column;gap:5px;' +
          'max-height:220px;overflow-y:auto;padding-right:2px;"></div>' +

          '<div style="display:flex;gap:6px;justify-content:space-between;flex-wrap:wrap;margin-top:2px;">' +
          '<button class="form-btn awr-revert-all-btn" style="padding:5px 12px!important;font-size:10px!important;' +
          'border-radius:10px!important;background:rgba(239,68,68,0.12)!important;color:#f87171!important;' +
          'border-color:rgba(239,68,68,0.35)!important;">↩ Kembalikan Semua</button>' +
          '<div style="display:flex;gap:5px;">' +
          '<button class="form-btn awr-edit-rule-btn" style="padding:5px 12px!important;font-size:10px!important;' +
          'border-radius:10px!important;background:rgba(59,130,246,0.12)!important;color:#60a5fa!important;' +
          'border-color:rgba(59,130,246,0.35)!important;">✏️ Edit Rule</button>' +
          '<button class="form-btn awr-close-overlay-btn" style="padding:5px 12px!important;' +
          'font-size:10px!important;border-radius:10px!important;">✕ Tutup</button>' +
          '</div>' +
          '</div>';

        const occList = box.querySelector('.awr-occ-list');

        // Helper: konteks singkat di sekitar span
        function getSpanCtx(span) {
          try {
            const parent = span.parentElement;
            if (!parent) return '';
            const full = parent.textContent || '';
            const txt  = span.textContent || '';
            const idx  = full.indexOf(txt);
            if (idx === -1) return '';
            const before = full.slice(Math.max(0, idx - 25), idx).replace(/\s+/g, ' ').trim();
            const after  = full.slice(idx + txt.length, idx + txt.length + 25).replace(/\s+/g, ' ').trim();
            return (before ? '…' + before + ' ' : '') + '[' + txt + ']' + (after ? ' ' + after + '…' : '');
          } catch (_) { return ''; }
        }

        // Highlight span di halaman saat hover
        let _hlStore = [];
        function hlClear() {
          _hlStore.forEach(function(h) {
            h.s.style.outline = h.o; h.s.style.background = h.b; h.s.style.borderRadius = h.r;
          });
          _hlStore = [];
        }
        function hlSpan(span) {
          hlClear();
          _hlStore = [{ s: span, o: span.style.outline, b: span.style.background, r: span.style.borderRadius }];
          span.style.outline = '2px solid #ef4444';
          span.style.background = 'rgba(239,68,68,0.18)';
          span.style.borderRadius = '3px';
          try { span.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
        }

        allMatchingSpans.forEach(function(span, idx) {
          const occKey    = hashKey + ':' + idx;
          const posEntry  = kamus[occKey];
          const posVal    = posEntry ? (typeof posEntry === 'object' ? posEntry.to : posEntry) : null;
          const origTxt   = getOrigTxt(span); // teks asli sebelum diganti (null jika tidak tahu)
          const dispTxt   = span.textContent.trim(); // teks saat ini (sudah diganti)
          const ctx       = getSpanCtx(span);
          const isReverted = posVal !== null && posVal !== replacedTo;

          const row = document.createElement('div');
          row.style.cssText =
            'background:#0f1117!important;border-radius:7px!important;padding:7px 9px!important;' +
            'border:1px solid ' + (isReverted ? 'rgba(34,197,94,0.3)' : '#1e293b') + '!important;' +
            'display:flex!important;align-items:flex-start!important;gap:7px!important;cursor:pointer!important;';

          const statusBadge = isReverted
            ? '<span style="font-size:8px;background:rgba(34,197,94,0.15);border:1px solid rgba(34,197,94,0.3);' +
              'color:#4ade80;border-radius:4px;padding:1px 4px;flex-shrink:0;">✓ sudah revert</span>'
            : '';

          row.innerHTML =
            '<div style="min-width:0;flex:1;">' +
              '<div style="font-size:10px;font-weight:bold;color:#22d3ee;display:flex;align-items:center;gap:5px;">' +
                'Kemunculan ke-' + (idx + 1) + statusBadge +
              '</div>' +
              (ctx
                ? '<div style="font-size:9px;color:#64748b;margin-top:2px;overflow:hidden;' +
                  'text-overflow:ellipsis;white-space:nowrap;" title="' + ctx.replace(/"/g, '&quot;') + '">' + ctx + '</div>'
                : '') +
              (origTxt
                ? '<div style="font-size:9px;color:#94a3b8;margin-top:1px;">Asli: <b style="color:#60a5fa;">' + origTxt + '</b>' +
                  (posVal ? ' → Override: <b style="color:#10b981;">' + posVal + '</b>' : '') + '</div>'
                : '<div style="font-size:9px;color:#475569;margin-top:1px;font-style:italic;">Teks asli tidak diketahui (buka halaman ulang untuk memuat)</div>') +
            '</div>' +
            '<div style="display:flex;flex-direction:column;gap:3px;flex-shrink:0;">' +
              '<button class="form-btn awr-revert-one-btn" data-idx="' + idx + '" style="padding:2px 8px!important;' +
                'font-size:9px!important;border-radius:7px!important;white-space:nowrap;' +
                'background:rgba(239,68,68,0.12)!important;color:#f87171!important;' +
                'border-color:rgba(239,68,68,0.35)!important;">' +
                (origTxt ? '↩ Ke "' + origTxt.slice(0, 12) + (origTxt.length > 12 ? '…' : '') + '"' : '↩ Revert') +
              '</button>' +
              // Tombol Hapus Override hanya muncul jika kemunculan ini punya positional override
              (posEntry
                ? '<button class="form-btn awr-del-override-btn" data-idx="' + idx + '" style="padding:2px 8px!important;' +
                    'font-size:9px!important;border-radius:7px!important;white-space:nowrap;' +
                    'background:rgba(245,158,11,0.12)!important;color:#fbbf24!important;' +
                    'border-color:rgba(245,158,11,0.35)!important;" title="Hapus positional override untuk kemunculan ke-' + (idx+1) + ' saja. Kemunculan ini akan kembali mengikuti rule utama.">' +
                    '🗑 Hapus Override' +
                  '</button>'
                : '') +
            '</div>';

          row.addEventListener('mouseenter', function() { hlSpan(span); });
          row.addEventListener('mouseleave', hlClear);

          const revertBtn = row.querySelector('.awr-revert-one-btn');
          revertBtn.onclick = function(ev) {
            ev.stopPropagation();
            const tk = core.getKamus();
            const currentNovel = core.getNovelContext();

            if (origTxt) {
              // Teks asli diketahui → set positional override ke teks asli
              tk[occKey] = {
                to: origTxt,
                global: false,
                novelId: currentNovel.id,
                novelTitle: currentNovel.title,
                novelUrl: currentNovel.url,
                domain: core.getNovelBaseDomain(core.currentHost),
                caseSensitive: true
              };
              core.saveKamus(tk);
              core.simpanKeAwan(tk);
              core.jalankanPengganti(true);
              hlClear();
              overlay.remove();
              panggilToast('↩ Kemunculan ke-' + (idx + 1) + ' dikembalikan ke teks asli.', 'success');
            } else {
              // Teks asli tidak diketahui → hapus positional override jika ada,
              // atau beri tahu user bahwa teks asli tidak tersedia di sesi ini
              if (tk[occKey]) {
                delete tk[occKey];
                core.saveKamus(tk);
                core.simpanKeAwan(tk);
                core.jalankanPengganti(true);
                hlClear();
                overlay.remove();
                panggilToast('↩ Override kemunculan ke-' + (idx + 1) + ' dihapus.', 'success');
              } else {
                panggilToast(
                  '⚠ Teks asli tidak tersedia di sesi ini. Muat ulang halaman agar AWR mencatat teks aslinya, lalu coba lagi.',
                  'warn'
                );
              }
            }
          };

          // Handler tombol Hapus Override (hanya ada jika posEntry !== null)
          const delOverrideBtn = row.querySelector('.awr-del-override-btn');
          if (delOverrideBtn) {
            delOverrideBtn.onclick = function(ev) {
              ev.stopPropagation();
              const tk = core.getKamus();
              if (tk[occKey]) {
                delete tk[occKey];
                core.saveKamus(tk);
                core.simpanKeAwan(tk);
                core.jalankanPengganti(true);
                hlClear();
                overlay.remove();
                panggilToast(
                  '🗑 Override kemunculan ke-' + (idx + 1) + ' dihapus. ' +
                  'Kemunculan ini kembali mengikuti rule utama ("' + replacedTo + '").',
                  'success'
                );
              }
            };
          }

          occList.appendChild(row);
        });

        // Tombol Kembalikan Semua
        box.querySelector('.awr-revert-all-btn').onclick = function() {
          const tk = core.getKamus();
          const currentNovel = core.getNovelContext();
          let countReverted = 0;
          let countUnknown  = 0;

          allMatchingSpans.forEach(function(span, idx) {
            const origTxt = getOrigTxt(span);
            if (origTxt) {
              tk[hashKey + ':' + idx] = {
                to: origTxt,
                global: false,
                novelId: currentNovel.id,
                novelTitle: currentNovel.title,
                novelUrl: currentNovel.url,
                domain: core.getNovelBaseDomain(core.currentHost),
                caseSensitive: true
              };
              countReverted++;
            } else {
              countUnknown++;
            }
          });

          core.saveKamus(tk);
          core.simpanKeAwan(tk);
          core.jalankanPengganti(true);
          hlClear();
          overlay.remove();

          if (countUnknown > 0) {
            panggilToast(
              '↩ ' + countReverted + ' kemunculan dikembalikan. ' + countUnknown + ' kemunculan tidak bisa diproses (teks asli tidak tersedia — muat ulang halaman).',
              'warn'
            );
          } else {
            panggilToast('↩ Semua ' + countReverted + ' kemunculan dikembalikan ke teks asli.', 'success');
          }
        };

        box.querySelector('.awr-close-overlay-btn').onclick = function() { hlClear(); overlay.remove(); };

        // Tombol Edit Rule: tutup overlay lalu buka editor pre-filled dengan rule hash ini
        box.querySelector('.awr-edit-rule-btn').onclick = function() {
          hlClear();
          overlay.remove();
          // Buka tab editor, pre-fill dengan hashKey dan teks pengganti saat ini
          subjekEdit = null;
          tabAktif = 'tambah';
          panel.classList.remove('hidden');
          safeRenderTampilan();
          setTimeout(function() {
            const iSalah = shadow.querySelector('.input-salah');
            const iBenar = shadow.querySelector('.input-benar');
            const hintEl = shadow.querySelector('.awr-hash-hint');
            if (iSalah) {
              iSalah.value = hashKey;
              iSalah.dispatchEvent(new Event('input'));
            }
            if (iBenar) {
              // Pre-fill dengan teks pengganti saat ini agar mudah diedit
              iBenar.value = replacedTo;
            }
            if (hintEl) hintEl.style.display = 'block';
            panggilToast('✏️ Edit rule "' + hashKey + '" — ubah teks pengganti lalu klik Simpan.', 'info');
          }, 80);
        };

        overlay.appendChild(box);
        panel.appendChild(overlay);
      }, false);

      function tampilkanTooltip(target, original, replacement) {
          tooltipEl.innerHTML = `
              <div class="tooltip-main-body">
                  <span class="tooltip-original-badge">${original}</span>
                  <span class="tooltip-arrow-icon">→</span>
                  <span class="tooltip-replaced-badge">${replacement}</span>
              </div>
              <div class="tooltip-footer">
                  <button class="tooltip-edit-btn">✏️ ${t('tab_editor')}</button>
              </div>
          `;
          tooltipEl.classList.add('visible');
          const tooltipWidth = tooltipEl.getBoundingClientRect().width || 230;
          const tooltipHeight = tooltipEl.getBoundingClientRect().height || 80;

          const rect = target.getBoundingClientRect();
          let top = rect.top - tooltipHeight - 8, left = rect.left + (rect.width / 2) - (tooltipWidth / 2);

          if (top < 10) top = rect.bottom + 8;
          if (left < 10) left = 10;
          if (left + tooltipWidth > window.innerWidth - 10) left = window.innerWidth - tooltipWidth - 10;

          tooltipEl.style.top = top + 'px'; tooltipEl.style.left = left + 'px';

          tooltipEl.querySelector('.tooltip-edit-btn').onclick = (e) => {
              e.stopPropagation(); subjekEdit = original; tabAktif = 'tambah';
              panel.classList.remove('hidden'); safeRenderTampilan(); sembunyikanTooltip();
          };
      }

      function sembunyikanTooltip() { tooltipEl.classList.remove('visible'); }
      // FIX v52: Assign implementasi sebenarnya setelah shadow tersedia
      hilangkanFokusShadow = function() { if (shadow && shadow.activeElement) { shadow.activeElement.blur(); } };

      // ── v99.12.30: Jalankan UI Health Monitor setelah semua komponen siap ──
      // Memulai sistem watchdog yang cek panel setiap 2.5 detik dan auto-recover
      // jika blank/kosong tanpa menutup panel. Juga menjaga agar hostElement,
      // wrapper, dan panel tidak terlepas dari DOM akibat situs yang agresif.
      startUIHealthMonitor(shadow, wrapper, hostElement);
  }
};

function renderCloudGistManager(container, gistDetails) {
  const token = core.getGitHubToken(), gistId = core.getGistId(), ownerName = gistDetails?.owner?.login || cachedUserProfile?.login || "GitHub User";
  const avatarUrl = gistDetails?.owner?.avatar_url || cachedUserProfile?.avatar_url || "";

  container.innerHTML = `
      <div class="flex-col card-box" style="gap: 8px !important; margin-bottom: 12px !important;">
          <div class="flex-between" style="font-size: 11px !important;">
              <span style="color: #9ca3af !important; font-weight: 500 !important; display: flex; align-items: center; gap: 6px;">
                  ${avatarUrl ? `<img src="${avatarUrl}" style="width: 20px; height: 20px; border-radius: 50%; border: 1px solid #334155;" />` : ''}
                  ${t('cloud_connected_as')}
              </span>
              <span style="color: #38bdf8 !important; font-weight: bold !important;">🐱 @${ownerName}</span>
          </div>
          <div class="flex-between" style="font-size: 11px !important; border-top: 1px dashed #334155 !important; padding-top: 8px !important;">
              <span style="color: #9ca3af !important; flex-shrink: 0 !important;">Gist ID:</span>
              <span class="mono-font txt-ellipsis" style="color: #60a5fa !important; flex: 1 !important; text-align: right !important; margin-right: 6px !important;" title="${gistId}">
                  ${gistId}
              </span>
              <button class="action-btn copy-gist-id-btn" style="padding: 2px !important; background: none; border: none; cursor: pointer;" title="Copy Gist ID">📋</button>
              <a href="https://gist.github.com/${ownerName}/${gistId}" target="_blank" class="awr-tooltip-btn" data-tooltip="${t('btn_open_gist_github')}" style="text-decoration: none; font-size: 11px; margin-left: 2px;">🔗</a>
          </div>
          <div class="flex-between" style="font-size: 11px !important;">
              <span style="color: #9ca3af !important; flex-shrink: 0 !important;">${t('auth_github_token_label')}</span>
              <input type="password" readonly class="txt-copyable-token mono-font" value="${token}" style="background: transparent !important; border: none !important; color: #cbd5e1 !important; font-size: 11px !important; flex: 1 !important; text-align: right !important; outline: none !important; width: 100px !important; margin-right: 6px !important;" />
              <button class="action-btn toggle-token-view-btn" style="background: none !important; border: none !important; cursor: pointer !important; padding: 2px !important; color: #94a3b8 !important;" title="Show/Hide Token">👁️</button>
              <button class="action-btn copy-token-btn" style="background: none !important; border: none !important; cursor: pointer !important; padding: 2px !important;" title="Copy Token">📋</button>
          </div>
          <div class="flex-between" style="margin-top: 6px !important; border-top: 1px dashed #334155 !important; padding-top: 8px !important;">
              <button class="form-btn manual-upload-btn btn-pill-primary" style="padding: 5px 14px !important;">
                  ${t('backup_now')}
              </button>
              <button class="form-btn btn-backup-vault-cloud btn-pill" style="padding: 5px 14px !important;">
                  💾 Simpan Login Lokal
              </button>
              <button class="form-btn btn-switch-github btn-pill" style="padding: 5px 14px !important; color: #ef4444 !important; border-color: #ef4444 !important;">
                  ${t('logout')}
              </button>
          </div>
      </div>

      <div style="font-size: 11px !important; font-weight: bold !important; margin-bottom: 8px !important; color: #94a3b8 !important; border-bottom: 1px solid #334155 !important; padding-bottom: 4px !important; text-transform: uppercase !important; letter-spacing: 0.05em !important;">
          🕒 ${t('revision_history')}
      </div>

      <div class="cloud-baskets-list flex-col" style="gap: 6px !important; max-height: 180px !important; overflow-y: auto !important; padding-right: 4px !important;">
      </div>
  `;

  container.querySelector('.copy-gist-id-btn').onclick = () => {
      navigator.clipboard.writeText(gistId);
      panggilToast(t('gist_id_copied'), "success");
  };

  container.querySelector('.copy-token-btn').onclick = () => {
      navigator.clipboard.writeText(token);
      panggilToast(t('token_copied'), "success");
  };

  const tInput = container.querySelector('.txt-copyable-token');
  const viewBtn = container.querySelector('.toggle-token-view-btn');
  viewBtn.onclick = () => {
      if (tInput.type === 'password') {
          tInput.type = 'text'; viewBtn.textContent = '🙈';
      } else {
          tInput.type = 'password'; viewBtn.textContent = '👁️';
      }
  };

  container.querySelector('.btn-backup-vault-cloud').onclick = async () => {
      const tanggal = new Date().toLocaleString("id-ID");
      const isi = [
          "// AWR Credentials - Advanced Word Replacer",
          "// Dibuat: " + tanggal,
          "// JANGAN bagikan file ini kepada siapapun!",
          "",
          "var AWR_TOKEN = " + JSON.stringify(token) + ";",
          "var AWR_GIST_ID = " + JSON.stringify(gistId) + ";",
          ""
      ].join("\n");

      // Coba showSaveFilePicker agar user bisa memilih lokasi penyimpanan
      if (typeof window.showSaveFilePicker === "function") {
          try {
              const handle = await window.showSaveFilePicker({
                  suggestedName: "awr_credentials.txt",
                  types: [
                      { description: "File Teks (.txt)", accept: { "text/plain": [".txt"] } },
                      { description: "File JavaScript (.js)", accept: { "text/javascript": [".js"] } },
                  ],
              });
              const writable = await handle.createWritable();
              await writable.write(isi);
              await writable.close();
              panggilToast("✅ Kredensial login berhasil disimpan!", "success");
              return;
          } catch (err) {
              if (err.name === "AbortError") return; // user membatalkan dialog
              panggilToast("Gagal menyimpan: " + err.message + ". Mengunduh ke Downloads...", "warn");
          }
      }

      // Fallback: browser tidak mendukung dialog — unduh ke folder Downloads
      const blob = new Blob([isi], { type: "text/javascript" });
      const url = URL.createObjectURL(blob);
      const a = document.createElement("a");
      a.href = url;
      a.download = "awr_credentials.js";
      document.body.appendChild(a);
      a.click();
      document.body.removeChild(a);
      URL.revokeObjectURL(url);
      panggilToast("⚠️ Browser tidak mendukung dialog lokasi. File diunduh ke folder Downloads.", "info");
  };

  container.querySelector('.manual-upload-btn').onclick = async () => {
      const listContainer = container.querySelector('.cloud-baskets-list');
      const originalHTML = listContainer.innerHTML;
      listContainer.innerHTML = `
          <div class="cloud-loading-spinner flex-col" style="align-items: center !important; justify-content: center !important; padding: 30px !important; gap: 10px !important;">
              <span style="font-size: 24px !important; animation: spin 1s linear infinite !important; display: inline-block !important;">⏳</span>
              <span style="font-size: 11px !important; color: #94a3b8 !important;">${t('cloud_manual_backup_starting')}</span>
          </div>
      `;
      panggilToast(t('cloud_manual_backup_starting'), "info");
      await core.simpanKeAwan(null, null, null, true);

      const freshDetails = await core.fetchGistDetails(token, gistId);
      if (freshDetails) {
          panggilToast(t('cloud_manual_backup_success'), "success");
          renderCloudGistManager(container, freshDetails);
      } else {
          listContainer.innerHTML = originalHTML;
          panggilToast(t('cloud_manual_backup_fail_history'), "warn");
      }
  };

  container.querySelector('.btn-switch-github').onclick = () => {
      window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
          t('logout'),
          t('logout_confirm'),
          () => {
              core.saveGitHubCredentials("", "");
              cachedGistDetails = null;
              cachedUserProfile = null;
              panggilToast(t('toast_account_switched'), 'info');
              renderTampilan();
          }
      );
  };

  const listContainer = container.querySelector('.cloud-baskets-list');
  if (!gistDetails || !gistDetails.files) {
      listContainer.innerHTML = `<div class="empty-state">${t('no_backups_found')}</div>`;
      return;
  }

  const backupFiles = Object.keys(gistDetails.files)
      .filter(name => name.startsWith('backup_') && name.endsWith('.json'))
      .sort()
      .reverse();

  if (backupFiles.length === 0) {
      listContainer.innerHTML = `<div class="empty-state">${t('no_backups_found')}</div>`;
      return;
  }

  backupFiles.forEach((filename) => {
      const friendlyName = parseBackupFilename(filename);
      const fileData = gistDetails.files[filename];

      const item = document.createElement('div');
      item.className = 'flex-between bg-box p-box';
      item.setAttribute('style', 'background: #1e293b !important; border: 1px solid #334155 !important; border-radius: 8px !important; padding: 8px 12px !important; margin-bottom: 4px !important;');

      item.innerHTML = `
          <div class="flex-col" style="min-width: 0 !important; flex: 1 !important; gap: 2px !important;">
              <span class="mono-font txt-ellipsis" style="font-size: 11px !important; font-weight: bold !important; color: #f1f5f9 !important;" title="${filename}">
                  ${friendlyName}
              </span>
          </div>
          <div class="flex-row" style="gap: 4px !important; flex-shrink: 0 !important;">
              <button class="form-btn restore-revision-btn btn-pill-primary" style="padding: 4px 10px !important; font-size: 10px !important; border-radius: 12px !important;" title="Muat & Timpa Kamus Aktif">${t('btn_load')}</button>
              <button class="form-btn merge-revision-btn btn-pill-primary" style="padding: 4px 10px !important; font-size: 10px !important; border-radius: 12px !important; background: #10b981 !important; border-color: #10b981 !important;" title="Gabungkan dengan Kamus Aktif">➕ Gabung</button>
              <button class="form-btn delete-revision-btn btn-pill" style="background: none !important; border: 1px solid #ef4444 !important; color: #f87171 !important; padding: 4px 8px !important; font-size: 10px !important; border-radius: 12px !important;" title="Hapus cadangan ini">🗑️</button>
          </div>
      `;

      item.querySelector('.restore-revision-btn').onclick = () => {
          window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
              t('btn_load'),
              t('alert_overwrite_confirm'),
              async () => {
                  panggilToast(t('toast_sync_connecting'), 'info');
                  try {
                      let contentText = fileData.content;
                      if (!contentText && fileData.raw_url) {
                          const rawRes = await gmFetch(fileData.raw_url);
                          if (rawRes.ok) contentText = await rawRes.text();
                      }
                      if (contentText) {
                          const parsedData = JSON.parse(contentText);
                          if (parsedData) {
                              if (parsedData.kamus) GM_setValue("kamus_kata_v5", JSON.stringify(parsedData.kamus));
                              if (parsedData.domains) GM_setValue("target_domains_v4", JSON.stringify(parsedData.domains));
                              if (parsedData.blacklist) GM_setValue("blacklist_domains_v1", JSON.stringify(parsedData.blacklist));
                              if (parsedData.filterMode) GM_setValue("filter_mode_v1", parsedData.filterMode);
                              if (parsedData.deletedWords) GM_setValue("awr_deleted_words_v1", JSON.stringify(parsedData.deletedWords));
                              if (parsedData.novelTitles) GM_setValue("awr_novel_titles_v2", JSON.stringify(parsedData.novelTitles));
                              // Pulihkan semua pengaturan tambahan dari backup
                              if (typeof parsedData.highlightAktif !== "undefined") GM_setValue("highlight_aktif_v4", !!parsedData.highlightAktif);
                              if (parsedData.lang) GM_setValue("awr_lang_v1", parsedData.lang);
                              if (parsedData.updateMode) GM_setValue("awr_update_mode_v1", parsedData.updateMode);
                              if (parsedData.recycleAutoDeleteDays !== undefined) GM_setValue("awr_recycle_auto_delete_days", String(parsedData.recycleAutoDeleteDays));
                              if (typeof parsedData.showOtherTerms !== "undefined") {
                                  GM_setValue("awr_show_other_terms", !!parsedData.showOtherTerms);
                                  showOtherTerms = !!parsedData.showOtherTerms;
                              }
                              // Kamus dipulihkan; tidak mengubah grup aktif yang dipilih pengguna
                              _activeNovelIdCache = null;
                          }
                          panggilToast(t('toast_revision_restored', friendlyName), 'success');
                          core.jalankanPengganti(true);
                          renderTampilan();
                      } else {
                          alert("Gagal memuat isi berkas cadangan!");
                      }
                  } catch (err) {
                      console.error(err);
                      alert("Koneksi gagal saat memulihkan cadangan.");
                  }
              }
          );
      };

      item.querySelector('.merge-revision-btn').onclick = () => {
          // Tanyakan mode remap sebelum menggabungkan
          window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
              "Gabungkan Kamus — Remap ID?",
              "Pilih cara penanganan ID grup kata dari backup ini:\n\n• Klik [Ya] → Paksa remap semua ID ke grup yang sedang aktif sekarang.\n• Klik [Tidak] → Pertahankan ID asli dari backup (cocok untuk backup dari akun/perangkat berbeda).",
              async () => {
                  // Tombol YA → remap paksa ke grup aktif
                  await _doMergeRevision(fileData, parsedData => core.mergeKamus(parsedData.kamus, parsedData.novelTitles || {}, "force"), friendlyName);
              },
              async () => {
                  // Tombol TIDAK → pertahankan ID asli (auto-smart match tetap jalan)
                  await _doMergeRevision(fileData, parsedData => core.mergeKamus(parsedData.kamus, parsedData.novelTitles || {}, "keep"), friendlyName);
              },
              { confirmLabel: "Ya, Remap Semua", cancelLabel: "Tidak, ID Asli" }
          );
      };

      async function _doMergeRevision(fileData, mergeFn, label) {
          panggilToast(t('toast_sync_connecting'), 'info');
          try {
              let contentText = fileData.content;
              if (!contentText && fileData.raw_url) {
                  const rawRes = await gmFetch(fileData.raw_url);
                  if (rawRes.ok) contentText = await rawRes.text();
              }
              if (contentText) {
                  const parsedData = JSON.parse(contentText);
                  if (parsedData && parsedData.kamus) {
                      if (parsedData.novelTitles && typeof parsedData.novelTitles === "object") {
                          try {
                              const localTitles = JSON.parse(GM_getValue("awr_novel_titles_v2", "{}"));
                              const merged = Object.assign({}, localTitles, parsedData.novelTitles);
                              GM_setValue("awr_novel_titles_v2", JSON.stringify(merged));
                          } catch (_) {}
                      }
                      const mergedCount = mergeFn(parsedData);
                      // Invalidate local active novel cache during configuration updates [1]
                      _activeNovelIdCache = null;
                      panggilToast(`Selesai! Berhasil menggabungkan ${mergedCount} kata kustom dari backup "${label}"!`, 'success');
                      core.jalankanPengganti(true);
                      renderTampilan();
                  } else {
                      alert("File cadangan tidak memiliki database kamus yang valid!");
                  }
              } else {
                  alert("Gagal mengambil data dari awan Gist!");
              }
          } catch (err) {
              console.error(err);
              alert("Koneksi bermasalah saat menggabungkan kamus.");
          }
      }

      const delRevBtn = item.querySelector('.delete-revision-btn');
      delRevBtn.onclick = (e) => {
          e.stopPropagation();
          window.AWR_UI_LIBRARY.tampilkanKonfirmasi(
              t('delete_btn'),
              t('delete_revision_confirm', friendlyName),
              async () => {
                  panggilToast("Menghapus berkas cadangan dari GitHub Gist...", "info");
                  try {
                      const response = await gmFetch(`https://api.github.com/gists/${gistId}`, {
                          method: "PATCH",
                          headers: {
                              "Authorization": `token ${token}`,
                              "Accept": "application/vnd.github.v3+json"
                          },
                          body: JSON.stringify({
                              files: {
                                  [filename]: null
                              }
                          })
                      });
                      if (response.ok || response.status === 200) {
                          panggilToast("Berkas cadangan berhasil dihapus!", "success");
                          const freshDetails = await core.fetchGistDetails(token, gistId);
                          cachedGistDetails = freshDetails;
                          renderCloudGistManager(container, freshDetails);
                      } else {
                          alert("Gagal menghapus berkas cadangan dari GitHub Gist. Status: " + response.status);
                      }
                  } catch (err) {
                      console.error(err);
                      alert("Koneksi gagal saat mencoba menghapus berkas cadangan.");
                  }
              }
          );
      };

      listContainer.appendChild(item);
  });
}

// Debounced MutationObserver with revert-detection & loop guard
observer = new MutationObserver((mutations) => {
  if (_replacingNow) return;

  const isInsidePanel = (target) => {
    const el = target.nodeType === Node.TEXT_NODE ? target.parentElement : target;
    return !!(el && el.closest && el.closest("#word-replacer-host"));
  };

  let hasRevert = false;
  let hasHashRevert = false; // FIX v54: revert pada hash span perlu re-apply LEBIH CEPAT
  let hasNewContent = false;

  for (const m of mutations) {
    if (isInsidePanel(m.target)) continue;

    if (m.type === "characterData") {
      const node = m.target;
      if (lastProcessedValueMap.has(node)) {
        // Node ini pernah kita proses — cek apakah nilainya berubah (revert oleh situs)
        const cachedVal = lastProcessedValueMap.get(node);
        const currentVal = node.nodeValue;
        if (cachedVal !== currentVal) {
          // Situs mengembalikan teks ke aslinya → hapus cache agar walker memproses ulang
          lastProcessedValueMap.delete(node);
          // FIX v54: Deteksi apakah node ini ada di dalam hash span (React-managed)
          // → gunakan debounce 0ms agar lebih cepat dari React reconciliation
          const pe = node.parentElement;
          if (pe && pe.dataset && pe.dataset.hash &&
              (pe.getAttribute('data-slot') === 'popover-trigger' ||
               (pe.classList && pe.classList.contains('text-patch')))) {
            hasHashRevert = true;
          } else {
            hasRevert = true;
          }
        }
        // Nilai sama = mutasi dari kita sendiri, skip
      } else {
        hasNewContent = true;
      }
    } else {
      hasNewContent = true;
    }
  }

  if (hasHashRevert) {
    // FIX v54: Hash span di-reset oleh React → re-apply SEGERA (0ms) untuk mengalahkan reconciliation
    if (replacerTimeout) clearTimeout(replacerTimeout);
    replacerTimeout = setTimeout(() => { jalankanPengganti(false); }, 0);
  } else if (hasRevert) {
    // Re-apply cepat (50ms) untuk reverts biasa yang terdeteksi
    if (replacerTimeout) clearTimeout(replacerTimeout);
    replacerTimeout = setTimeout(() => { jalankanPengganti(false); }, 50);
  } else if (hasNewContent) {
    // Konten baru (elemen baru dimuat): debounce normal 300ms
    if (replacerTimeout) clearTimeout(replacerTimeout);
    replacerTimeout = setTimeout(() => { jalankanPengganti(false); }, 300);
  }
});

// v52.5: IntersectionObserver — tangkap konten lazy-load yang masuk viewport
(function setupIntersectionObserver() {
  if (!("IntersectionObserver" in window)) return;
  let ioTimeout = null;
  const seenElements = new WeakSet();
  const io = new IntersectionObserver((entries) => {
    const hasNewVisible = entries.some(e => e.isIntersecting && !seenElements.has(e.target));
    if (!hasNewVisible) return;
    entries.forEach(e => { if (e.isIntersecting) seenElements.add(e.target); });
    if (ioTimeout) clearTimeout(ioTimeout);
    ioTimeout = setTimeout(() => { try { jalankanPengganti(false); } catch (ex) {} }, 400);
  }, { rootMargin: "200px 0px", threshold: 0.01 });

  function observeContentBlocks() {
    const selectors = [
      "article", "section", ".chapter-content", ".content", ".novel-content",
      ".reading-content", ".chapter-c", ".text-chapter", ".chapter-entity",
      "[class*='chapter']", "[class*='content']", "[class*='novel']", "main p"
    ];
    const visited = new WeakSet();
    selectors.forEach(sel => {
      try {
        document.querySelectorAll(sel).forEach(el => {
          if (!visited.has(el) && !el.closest("#word-replacer-host")) {
            visited.add(el);
            io.observe(el);
          }
        });
      } catch (_) {}
    });
  }

  observeContentBlocks();
  window.addEventListener("awr-urlchange", () => { setTimeout(observeContentBlocks, 800); });
})();

// SPA Navigation Support
(function patchHistoryForSPA() {
  let lastUrl = location.href;

  function onUrlChange() {
    const newUrl = location.href;
    if (newUrl === lastUrl) return;
    lastUrl = newUrl;
    setTimeout(() => { try { jalankanPengganti(true); } catch (e) {} }, 400);
    setTimeout(() => { try { jalankanPengganti(true); } catch (e) {} }, 1200);
  }

  ["pushState", "replaceState"].forEach((method) => {
    const original = history[method];
    history[method] = function () {
      const result = original.apply(this, arguments);
      window.dispatchEvent(new Event("awr-urlchange"));
      return result;
    };
  });

  window.addEventListener("popstate", onUrlChange);
  window.addEventListener("awr-urlchange", onUrlChange);
})();

async function init() {
  if (!document.body) { setTimeout(init, 50); return; }

  try { injectHighlightStyle(); } catch (e) { console.warn("Gagal inject highlight style:", e); }

  try { jalankanPengganti(true); } catch (e) { console.error("Gagal penggantian kata:", e); }
  try { bersihkanRecycleBinOtomatis(); } catch (e) { console.error("Gagal pembersihan Recycle Bin otomatis:", e); }

  const updateMode = GM_getValue("awr_update_mode_v1", "auto");
  if (updateMode === "auto") { checkForUpdates(false); }

  if (window.self === window.top) {
      try {
          const coreAPI = {
              version: SCRIPT_VERSION,
              SVGS: SVGS,
              currentHost: currentHost,
              smartSanitizeTerm: smartSanitizeTerm,
              getNovelBaseDomain: getNovelBaseDomain,
              getCachedNovelTitle: getCachedNovelTitle,
              saveCachedNovelTitle: saveCachedNovelTitle,
              getGitHubToken: getGitHubToken,
              getGistId: getGistId,
              saveGitHubCredentials: saveGitHubCredentials,
              findExistingGist: findExistingGist,
              fetchGistDetails: fetchGistDetails,
              createGist: createGist,
              simpanKeAwan: simpanKeAwan,
              sinkronisasiDariAwan: sinkronisasiDariAwan,
              getActiveNovelId: getActiveNovelId,
              setActiveNovelId: setActiveNovelId,
              getDeletedWords: getDeletedWords,
              saveDeletedWords: saveDeletedWords,
              getKamus: getKamus,
              saveKamus: saveKamus,
              getFilterMode: getFilterMode,
              saveFilterMode: saveFilterMode,
              getTargetDomains: getTargetDomains,
              saveTargetDomains: saveTargetDomains,
              getBlacklistDomains: getBlacklistDomains,
              saveBlacklistDomains: saveBlacklistDomains,
              getHighlightAktif: getHighlightAktif,
              saveHighlightAktif: saveHighlightAktif,
              isDomainAllowed: isDomainAllowed,
              jalankanPengganti: jalankanPengganti,
              getNovelContext: getNovelContext,
              checkForUpdates: checkForUpdates,
              exportCredentials: exportCredentials,
              mergeKamus: mergeKamus,
              perbaikiIdNovel: perbaikiIdNovel,
              getSiteKataUnikSelector: getSiteKataUnikSelector
          };
          window.AWR_UI_LIBRARY.init(coreAPI);
      } catch (e) { console.error("Gagal memuat Floating UI Library:", e); }

      try { await sinkronisasiDariAwan(true); } catch (e) { console.error("Gagal sinkronisasi awan Gist:", e); }
  }
}

init();
})();