/**
 * KI-Shield Proxy v2.2.0 — Thin-Client
 * Copyright (c) 2026 KI-Shield UG (haftungsbeschraenkt)
 * HRB 524511 · Amtsgericht Jena · kishieldguard.de
 *
 * Reverse Engineering, Disassembly, Wiederverwendung des Codes
 * oder kommerzielle Nutzung ohne schriftliche Genehmigung untersagt.
 */

// content.js — UI-Layer des Thin-Clients (Claude + ChatGPT + Gemini).
// Die PII-Erkennung läuft SERVERSEITIG im KI-Shield-Proxy; dieser Layer zeigt
// das Confirm-Modal, verwaltet das Mapping und re-identifiziert Antworten.

(async () => {
  const PLUGIN_NAME = 'KI-Shield Proxy';
  const Core = globalThis.KIShieldCore;
  if (!Core) throw new Error('KIShieldCore wurde nicht geladen');
  const VERSION = chrome.runtime.getManifest().version;
  const ENABLED_KEY = 'kishield_enabled';
  const LIVE_DETECTION_KEY = 'kishield_live_detection';
  let PROTECTION_ENABLED = true;
  let LIVE_DETECTION_ENABLED = false;
  let detectSequence = 0;
  // Marke auf allen postMessages zwischen content.js (ISOLATED) und injected.js
  // (MAIN). Leichter Filter gegen fremdes Cross-Talk — KEINE starke Auth
  // (postMessage ist im MAIN world mitlesbar); die eigentliche Sicherheit liegt in
  // der fail-closed-Haltung von injected.js.
  const MARK = 'kishield-v1';

  console.log(
    `%c[${PLUGIN_NAME}] Plugin geladen auf %c${location.hostname}`,
    'color: #6366f1; font-weight: bold;',
    'color: #1e293b; font-weight: normal;'
  );
  console.log(`[${PLUGIN_NAME}] Version ${VERSION} — Multi-Plattform (Claude + ChatGPT + Gemini)`);

  // ─── Netzwerk-Layer-Kommunikation + sichtbare Warnungen ─────────────
  function postNet(type, extra) {
    try { window.postMessage(Object.assign({ mark: MARK, type }, extra || {}), '*'); } catch (_) {}
  }
  function approveNetworkSend(originalText, maskedText) {
    return new Promise((resolve) => {
      const approvalId = 'approval-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
      const timer = setTimeout(() => {
        window.removeEventListener('message', onAck);
        resolve(false);
      }, 1200);
      const onAck = (event) => {
        if (event.source !== window || !event.data || event.data.mark !== MARK) return;
        if (event.data.type !== 'kishield-send-approved-ack' || event.data.approvalId !== approvalId) return;
        clearTimeout(timer);
        window.removeEventListener('message', onAck);
        resolve(true);
      };
      window.addEventListener('message', onAck);
      postNet('kishield-send-approved', {
        approvalId,
        candidateTexts: [...new Set([originalText, maskedText].filter(Boolean))],
        expiresAt: Date.now() + Core.SEND_APPROVAL_TTL_MS,
      });
    });
  }
  // Vollflächiges Banner für Zustände, die der Nutzer WISSEN muss (Erkennung tot,
  // Upload ungeprüft). color rot = blockiert/hart, amber = Hinweis.
  function showBanner(msg, color, autoHideMs) {
    try {
      let b = document.getElementById('kishield-zk-banner');
      if (!b) {
        b = document.createElement('div');
        b.id = 'kishield-zk-banner';
        b.style.cssText = 'position:fixed;top:0;left:0;right:0;z-index:2147483647;padding:10px 16px;' +
          'font:600 13px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;text-align:center;' +
          'box-shadow:0 2px 8px rgba(0,0,0,.15);color:#fff;';
        (document.body || document.documentElement).appendChild(b);
      }
      b.style.background = color || '#dc2626';
      b.textContent = msg;
      b.style.display = 'block';
      b.dataset.kishieldPersistent = autoHideMs ? '0' : '1';
      if (b._kishieldTimer) clearTimeout(b._kishieldTimer);
      if (autoHideMs) {
        b._kishieldTimer = setTimeout(() => {
          b.style.display = 'none';
          showProtectionStatus();
        }, autoHideMs);
      }
    } catch (_) {}
  }
  function hidePersistentBanner() {
    try {
      const b = document.getElementById('kishield-zk-banner');
      if (b && b.dataset.kishieldPersistent === '1') b.style.display = 'none';
    } catch (_) {}
  }
  // Warnungen aus injected.js (z.B. nicht prüfbarer Datei-Upload an die Chat-API).
  function handleNetworkWarn(d) {
    if (d && d.reason === 'upload-blocked') {
      showBanner('KI-Shield: Ungeprüfter Original-Upload blockiert. Bitte die Datei erneut auswählen, damit KI-Shield sie lokal ausliest und schützt.', '#dc2626', 7000);
    } else if (d && d.reason === 'upload-unscanned') {
      showBanner('KI-Shield: Ein Datei-Upload/Anhang wurde NICHT auf PII geprüft.', '#b45309', 7000);
    } else if (d && d.reason === 'approval-missing') {
      showBanner('KI-Shield: Der Netzwerk-Layer hat einen Send ohne passende Kurzzeitfreigabe blockiert. Bitte erneut senden oder die Seite neu laden.', '#dc2626', 8000);
    }
  }
  // injected.js meldet über 'kishield-hook-failed', falls sich der Netzwerk-Hook
  // NICHT installieren ließ. Dann ist kein Netzwerk-Schutz aktiv → fail-closed:
  // content.js blockiert Sends UI-seitig (siehe handleSendAttempt).
  // Gilt für alle drei Browser: seit der Firefox-Messung vom 30.07.2026 läuft
  // world:"MAIN" @document_start auch dort zuverlässig vor den Seitenskripten,
  // der frühere Sonderweg firefox-early.js ist entfallen.
  let NETWORK_HOOK_FAILED = false;
  // Letzter Mapping-Fehler seit Seitenladen, ausschließlich als inhaltsfreie
  // Strukturdiagnose. Prompt, Entitätstexte und Pseudonyme werden nie übernommen.
  let lastMaskDiagnostics = null;
  let activeExtractionJobId = null;

  // ─── Thin-Client: Mapping-Verwaltung + Proxy-Masking-Brücke ─────────
  // Die PII-Erkennung läuft NICHT mehr im Browser (kein Recognizer-Code, kein
  // Namens-Dictionary im Bundle). content.js schickt den Text beim Senden über
  // den Service Worker an den KI-Shield-Proxy (/api/v1/pseudonymize) und erhält
  // maskierten Text + Mapping zurück. Das Mapping lebt in zwei Maps:
  //   FORWARD: Original → Pseudonym   (Body-Maskierung in injected.js)
  //   REVERSE: Pseudonym → Original   (Re-Identifikation der Antwort im DOM)
  const MAPPING_TTL_MS = Core.MAPPING_TTL_MS;
  const FORWARD = new Map();
  const REVERSE = new Map();
  let mappingExpiryTimer = null;
  // Eine Conversation wird als gemeinsame Mapping-Generation gespeichert. Das
  // FRÜHESTE Ablaufdatum gewinnt: ein späterer Maskierungslauf darf bestehende
  // Mappings niemals über createdAt + 24h hinaus verlängern. Neuere Einträge
  // dürfen dadurch früher verfallen; sicherheitstechnisch ist das fail-closed.
  let mappingExpiresAt = 0;
  function clearInMemoryMappings(removeStored) {
    if (mappingExpiryTimer) clearTimeout(mappingExpiryTimer);
    mappingExpiryTimer = null;
    mappingExpiresAt = 0;
    FORWARD.clear();
    REVERSE.clear();
    postNet('kishield-mappings-clear');
    if (removeStored && extensionAlive()) {
      chrome.storage.local.remove(`mappings_${getConversationId()}`).catch(() => {});
    }
  }
  function scheduleMappingExpiry(expiresAt) {
    const now = Date.now();
    const safeExpiry = Math.min(
      Number(expiresAt) || (now + MAPPING_TTL_MS),
      now + MAPPING_TTL_MS
    );
    mappingExpiresAt = mappingExpiresAt > now
      ? Math.min(mappingExpiresAt, safeExpiry)
      : safeExpiry;
    if (mappingExpiryTimer) clearTimeout(mappingExpiryTimer);
    const delay = mappingExpiresAt - Date.now();
    if (delay <= 0) {
      clearInMemoryMappings(true);
      return;
    }
    mappingExpiryTimer = setTimeout(() => clearInMemoryMappings(true), delay);
  }
  function mappingCreatedAt() {
    return mappingExpiresAt > 0
      ? Math.max(1, mappingExpiresAt - MAPPING_TTL_MS)
      : Date.now();
  }
  function rememberMapping(forwardObj, expiresAt) {
    let accepted = 0;
    for (const [orig, pseudo] of Object.entries(forwardObj || {})) {
      if (typeof orig === 'string' && typeof pseudo === 'string' && orig && pseudo) {
        // FIRST-WINS — exakt wie injected.js (PSEUDOS.has-Check). Sonst könnten
        // Vorschau/Storage einen anderen Platzhalter zeigen als der Network-
        // Layer tatsächlich sendet (Server vergibt pro Session neue Nummern).
        if (!FORWARD.has(orig)) FORWARD.set(orig, pseudo);
        if (!REVERSE.has(pseudo)) REVERSE.set(pseudo, orig);
        accepted++;
      }
    }
    if (accepted) scheduleMappingExpiry(expiresAt);
  }
  function forwardAsObject() {
    const o = {};
    for (const [k, v] of FORWARD.entries()) o[k] = v;
    return o;
  }
  // Wendet FORWARD auf einen Text an — EXAKT der Algorithmus, mit dem
  // injected.js den Request-Body maskiert (längste Originale zuerst, global).
  // Wird für die Modal-Vorschau benutzt, damit Vorschau ≡ gesendeter Body gilt
  // (mask.masked vom Server kann abweichen, wenn der Server für ein bekanntes
  // Original in einer neuen Session einen NEUEN Platzhalter geprägt hat —
  // first-wins gewinnt auf dem Draht).
  function applyForward(text, mappings) {
    let result = text;
    const source = mappings instanceof Map
      ? [...mappings.entries()]
      : Object.entries(mappings || forwardAsObject());
    const sorted = source.sort((a, b) => b[0].length - a[0].length);
    for (const [orig, pseudo] of sorted) {
      if (result.includes(orig)) result = result.split(orig).join(pseudo);
    }
    return result;
  }
  function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
  // Reiner String-Ersatz Pseudonym → Original (Fallback der DOM-Re-ID).
  // Negativer Lookahead (?!\d) wie in injected.js: PERSON_5 darf nicht
  // INNERHALB von PERSON_562 matchen.
  function reidentifyString(s) {
    let out = String(s == null ? '' : s);
    const keys = [...REVERSE.keys()].sort((a, b) => b.length - a.length);
    for (const p of keys) {
      if (out.indexOf(p) === -1) continue;
      const re = new RegExp(escapeRegExp(p) + '(?!\\d)', 'g');
      out = out.replace(re, () => REVERSE.get(p));
    }
    return out;
  }
  // Erste Fundstelle eines Pseudonyms, hinter der KEINE Ziffer folgt (sonst
  // ist es der Anfang eines längeren Pseudonyms, z.B. PERSON_5 in PERSON_562).
  function findPseudoPos(hay, p, from) {
    let i = hay.indexOf(p, from || 0);
    while (i !== -1 && /\d/.test(hay.charAt(i + p.length))) i = hay.indexOf(p, i + 1);
    return i;
  }

  // Neue, noch URL-lose Chats ('plat:new') bekommen eine stabile Zufalls-
  // Session, damit nicht die Erstnachrichten ALLER neuen Chats dieselbe
  // Server-Session (und damit denselben Pseudonym-Pool) teilen.
  const PENDING_SESSION_ID = (crypto.randomUUID && crypto.randomUUID()) ||
    ([...crypto.getRandomValues(new Uint8Array(16))].map((b) => b.toString(16).padStart(2, '0')).join(''));
  function proxySessionId() {
    const cid = getConversationId();
    return cid.endsWith(':new') ? cid.replace(/:new$/, ':' + PENDING_SESSION_ID) : cid;
  }

  // ─── Manuelles PII-Tagging (client-seitig, Weg 1) ───────────────────
  // FN markieren: Nutzer markiert ein Wort → wird maskiert (eigenes Pseudonym).
  // FP deaktivieren: „Nie als PII" → Wort wird nicht erkannt (allowlist an den Proxy).
  // Dauerhaft in diesem Browserprofil (chrome.storage.local); Proxy/Web-App unberührt.
  const MANUAL = new Map(); // text → { type, pseudo }
  const NEVER = new Set();  // text (nie als PII)
  let manualCounter = 0;
  function replaceNeverTerms(values) {
    NEVER.clear();
    for (const term of Core.sanitizeNeverTerms(values)) NEVER.add(term);
  }
  function loadTagging() {
    try {
      chrome.storage.local.get(['kishield_manual_terms', 'kishield_never_terms', 'kishield_manual_counter'])
        .then((r) => {
          for (const [t, v] of Object.entries((r && r.kishield_manual_terms) || {})) {
            if (v && v.pseudo) MANUAL.set(t, v);
          }
          replaceNeverTerms((r && r.kishield_never_terms) || []);
          manualCounter = (r && r.kishield_manual_counter) || MANUAL.size;
        }).catch(() => {});
    } catch (_) {}
  }
  function saveTagging() {
    const m = {}; for (const [t, v] of MANUAL) m[t] = v;
    try {
      chrome.storage.local.set({
        kishield_manual_terms: m, kishield_never_terms: [...NEVER], kishield_manual_counter: manualCounter,
      }).catch(() => {});
    } catch (_) {}
  }
  function allowlistArray() { return [...NEVER]; }
  function addManualTag(text, type) {
    if (!text) return;
    NEVER.delete(text);
    const chosenType = Core.ALL_PII_TYPES.includes(type) ? type : 'PERSON';
    if (!MANUAL.has(text)) {
      manualCounter++;
      MANUAL.set(text, { type: chosenType, pseudo: Core.manualPseudo(chosenType, manualCounter) });
    }
    saveTagging();
  }
  function addNeverTag(text) { if (!text) return; MANUAL.delete(text); NEVER.add(text); saveTagging(); }
  function removeTag(text) { MANUAL.delete(text); NEVER.delete(text); saveTagging(); }
  function isTagged(text) { return MANUAL.has(text) || NEVER.has(text); }

  // Server-Entities + manuelle Terme kombinieren (never entfernen) — für Highlight/Modal.
  // NEVER wird POSITIONSBASIERT gefiltert: Der Nutzer selektiert oft nur einen Teil
  // einer Server-Entity (z.B. per Doppelklick „Rene" statt „Rene Bringezu"). Ein
  // exakter String-Vergleich würde da nicht greifen. Deshalb lösen wir jeden
  // NEVER-Begriff im aktuellen Text zu Bereichen auf und werfen jede Server-Entity
  // raus, die sich damit überschneidet (case-insensitiv als zusätzlicher Fallback).
  function combineEntities(text, serverEntities) {
    const lower = text.toLowerCase();
    const neverRanges = [];
    for (const nv of NEVER) {
      if (!nv) continue;
      const n = nv.toLowerCase();
      let idx = lower.indexOf(n);
      while (idx !== -1) { neverRanges.push([idx, idx + n.length]); idx = lower.indexOf(n, idx + n.length); }
    }
    const isNevered = (e) =>
      NEVER.has(e.text) ||
      neverRanges.some(([s, en]) => s < e.end && en > e.start);
    const out = (serverEntities || []).filter((e) => !isNevered(e));
    for (const [term, v] of MANUAL) {
      if (NEVER.has(term)) continue;
      let idx = text.indexOf(term);
      while (idx !== -1) {
        const s = idx, en = idx + term.length;
        if (!out.some((e) => s < e.end && en > e.start)) {
          out.push({ entity_type: v.type, text: term, start: s, end: en, manual: true });
        }
        idx = text.indexOf(term, idx + term.length);
      }
    }
    return out;
  }
  // Manuelle Terme in die Masking-Map aufnehmen (client-Pseudonym) — vor dem Send.
  function applyManualToMapping(text, target) {
    const destination = target || FORWARD;
    for (const [term, v] of MANUAL) {
      if (NEVER.has(term)) continue;
      const alreadyPresent = destination instanceof Map
        ? destination.has(term)
        : Object.prototype.hasOwnProperty.call(destination, term);
      if (text.includes(term) && !alreadyPresent) {
        if (destination instanceof Map) {
          destination.set(term, v.pseudo);
          if (destination === FORWARD && !REVERSE.has(v.pseudo)) REVERSE.set(v.pseudo, term);
        } else {
          destination[term] = v.pseudo;
        }
      }
    }
  }

  // Zwischenspeicher der zuletzt SERVER-erkannten Entities, damit ein Tag-Change
  // das Highlight sofort neu berechnen kann, ohne den Proxy erneut zu fragen.
  let lastServerEntities = [];
  let lastDetectText = '';
  function refreshHighlight() {
    if (!PROTECTION_ENABLED || !LIVE_DETECTION_ENABLED) return;
    const el = findChatInput();
    if (!el) return;
    const text = getInputText(el).trim();
    const ents = combineEntities(text, (text === lastDetectText) ? lastServerEntities : []);
    updateBadge(ents);
    lastEntities = ents; lastInputEl = el;
    renderHighlightOverlay(el, ents);
  }

  function feedbackTypeForTerm(term) {
    const lower = String(term || '').toLowerCase();
    const entity = lastServerEntities.find((item) => {
      if (!item || typeof item.text !== 'string' || !Core.ALL_PII_TYPES.includes(item.entity_type)) return false;
      const entityText = item.text.toLowerCase();
      return entityText === lower || entityText.includes(lower) || lower.includes(entityText);
    });
    return entity ? entity.entity_type : null;
  }

  function reportFalsePositive(entityText, currentType) {
    if (!currentType) return;
    try {
      chrome.runtime.sendMessage({
        type: 'kishield-feedback-fp',
        entity_text: entityText,
        current_type: currentType,
      }, (response) => {
        if (chrome.runtime.lastError || !response || !response.ok) {
          console.warn(`[${PLUGIN_NAME}] Fehlalarm konnte nicht gemeldet werden.`);
        }
      });
    } catch (_) {}
  }

  // ─── Tag-Popup bei Textselektion ────────────────────────────────────
  // Eine Teilselektion (z.B. Doppelklick „Rene") auf den vollen, tatsächlich
  // gemeinten Begriff auflösen: einen bereits getaggten Term, sonst die volle
  // Server-Entity. So meinen Anzeige, Aufheben, Highlight UND die (exakte)
  // Server-Allowlist beim Senden denselben Begriff — sonst würde die volle
  // Entity trotz „Nie als PII" weiter maskiert.
  function resolveTerm(selText) {
    if (MANUAL.has(selText) || NEVER.has(selText)) return selText;
    for (const t of NEVER) if (t.includes(selText) || selText.includes(t)) return t;
    for (const t of MANUAL.keys()) if (t.includes(selText) || selText.includes(t)) return t;
    for (const e of lastServerEntities) {
      if (e && e.text && (e.text.includes(selText) || selText.includes(e.text))) return e.text;
    }
    return selText;
  }
  function hideTagPopup() { const p = document.getElementById('kishield-tag-popup'); if (p) p.remove(); }
  function showTagPopup(selRaw, rect) {
    hideTagPopup();
    const selText = resolveTerm(selRaw);
    const marked = MANUAL.has(selText); const never = NEVER.has(selText);
    const detectedType = feedbackTypeForTerm(selText);
    const p = document.createElement('div');
    p.id = 'kishield-tag-popup';
    p.style.cssText = 'position:fixed;top:0;left:0;visibility:hidden;z-index:2147483647;background:#fff;' +
      'border:1px solid #e2e8f0;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,.16);padding:8px;' +
      "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;font-size:12px;width:230px;" +
      'max-height:calc(100vh - 16px);overflow-y:auto;box-sizing:border-box;';
    const shown = selText.length > 30 ? selText.slice(0, 30) + '…' : selText;
    const btn = (act, label, style) => '<button data-act="' + act + '" style="display:block;width:100%;text-align:left;' +
      'padding:7px 9px;border:none;border-radius:5px;cursor:pointer;margin-bottom:4px;font:inherit;' + style + '">' + label + '</button>';
    let body = '<div style="color:#64748b;margin-bottom:6px;word-break:break-word;">„' + escHtml(shown) + '"</div>';
    if (marked) body += btn('remove', '✓ als PII markiert — aufheben', 'background:#ede9fe;color:#5b21b6;font-weight:600;');
    else if (never) body += btn('remove', '🚫 nie als PII — wieder zulassen', 'background:#f1f5f9;color:#475569;');
    else {
      const typeOptions = Core.ALL_PII_TYPES.map((type) =>
        '<option value="' + type + '">' + escHtml(Core.PII_NAMES[type] || type) + '</option>'
      ).join('');
      body += '<label for="kishield-manual-type" style="display:block;color:#475569;margin:2px 0 4px;">PII-Typ</label>' +
        '<select id="kishield-manual-type" style="width:100%;padding:6px;border:1px solid #cbd5e1;border-radius:5px;margin-bottom:5px;font:inherit;">' +
        typeOptions + '</select>';
      body += btn('mark', '🛡️ Als PII markieren', 'background:#6366f1;color:#fff;font-weight:600;');
      body += btn(
        'never',
        detectedType ? 'Nie als PII + Fehlalarm melden' : 'Nie als PII behandeln',
        'background:#f1f5f9;color:#475569;'
      );
    }
    p.innerHTML = body;
    p.addEventListener('mousedown', (e) => e.stopPropagation());
    p.querySelectorAll('button').forEach((b) => b.addEventListener('click', () => {
      const a = b.getAttribute('data-act');
      if (a === 'mark') {
        const select = p.querySelector('#kishield-manual-type');
        addManualTag(selText, select ? select.value : 'PERSON');
      }
      else if (a === 'never') {
        addNeverTag(selText);
        reportFalsePositive(selText, detectedType);
      }
      else removeTag(selText);
      hideTagPopup();
      refreshHighlight();
    }));
    document.body.appendChild(p);
    const panelRect = p.getBoundingClientRect();
    const position = Core.positionFloatingPanel(
      rect,
      { width: panelRect.width, height: panelRect.height },
      { width: window.innerWidth, height: window.innerHeight }
    );
    p.style.top = position.top + 'px';
    p.style.left = position.left + 'px';
    p.dataset.placement = position.placement;
    p.style.visibility = 'visible';
  }
  function setupTaggingUI() {
    document.addEventListener('mouseup', (event) => {
      if (Core.isTagPopupInteraction(event.target)) return;
      setTimeout(() => {
        const inputEl = findChatInput();
        if (!inputEl) return;
        const sel = window.getSelection();
        if (!sel || sel.isCollapsed || sel.rangeCount === 0) { hideTagPopup(); return; }
        const selText = sel.toString().trim();
        if (selText.length < 2 || selText.length > 80) { hideTagPopup(); return; }
        const a = sel.anchorNode;
        const aEl = a && (a.nodeType === Node.ELEMENT_NODE ? a : a.parentNode);
        if (!aEl || !inputEl.contains(aEl)) { hideTagPopup(); return; }
        let rect; try { rect = sel.getRangeAt(0).getBoundingClientRect(); } catch (_) { return; }
        showTagPopup(selText, rect);
      }, 10);
    }, true);
    document.addEventListener('mousedown', (e) => {
      if (!(e.target.closest && e.target.closest('#kishield-tag-popup'))) hideTagPopup();
    }, true);
    window.addEventListener('scroll', hideTagPopup, true);
  }

  // Maskierung über den Service Worker — der einzige Ort, der den Proxy CORS-
  // seitig erreichen darf. FAIL-CLOSED: jede Fehlantwort blockiert den Send.
  function proxyMask(text) {
    return new Promise((resolve) => {
      let done = false;
      const finish = (r) => { if (!done) { done = true; resolve(r); } };
      try {
        chrome.runtime.sendMessage(
          {
            type: 'kishield-mask',
            text,
            session_id: proxySessionId(),
            excluded: Array.from(DISABLED_TYPES),
            allowlist: allowlistArray(),
          },
          (resp) => {
            if (chrome.runtime.lastError) {
              finish({ ok: false, reason: 'sw_unreachable', error: chrome.runtime.lastError.message });
              return;
            }
            finish(resp || { ok: false, reason: 'no_response' });
          }
        );
      } catch (e) {
        finish({ ok: false, reason: 'exception', error: String((e && e.message) || e) });
      }
      // Sicherheitsnetz: keine Antwort vom Service Worker → fail-closed.
      setTimeout(() => finish({ ok: false, reason: 'sw_timeout' }), 12000);
    });
  }

  // Kompatibilität (#17): engine/license.js bleibt im Paket und kann von älteren
  // Installationen weiter referenziert werden. Der kritische Bootstrap importiert
  // das Legacy-Modul aber nicht mehr; Zugang und Servergültigkeit werden über den
  // bestehenden API-Key sowie die fail-closed Proxy-Antwort geprüft.

  // ─── Lizenz-Status (fail-closed) ────────────────────────────────────
  // LICENSED steuert, ob überhaupt an den KI-Chat gesendet werden darf. Ohne
  // gültige Lizenz wird der Versand blockiert (kein Klartext-Leak, Zahlungshebel).
  let LICENSED = false;
  function broadcastLicense() {
    // injected.js (MAIN world) blockiert AI-API-Requests, wenn nicht lizenziert.
    try { window.postMessage({ mark: MARK, type: 'kishield-license', licensed: LICENSED }, '*'); } catch (_) {}
  }
  async function refreshLicense() {
    // Onboarding-Modell: Zugang = vorhandener kp-Key (aus Trial/Abo). Die echte
    // Gültigkeit (Trial abgelaufen / Abo gekündigt) setzt sich SERVERSEITIG durch —
    // ein abgelaufener kp-Key lässt proxyMask fehlschlagen → fail-closed-Block.
    // LICENSED ist hier nur das grobe UI-Gate „Zugang überhaupt eingerichtet".
    try {
      const r = await chrome.storage.local.get('kishield_api_key');
      LICENSED = !!(r && r.kishield_api_key);
    } catch (_) { LICENSED = false; }
    broadcastLicense();
    return LICENSED;
  }
  // NICHT blockierend: die Lizenzprüfung kann kishieldguard.de kontaktieren und
  // dabei hängen. Früher hing daran die GESAMTE restliche Initialisierung —
  // inklusive setupSendHooks() weiter unten. Folge: keine Abfang-Hooks → Sends
  // gingen UNGEPRÜFT (Klartext) raus. Jetzt läuft die Lizenz im Hintergrund; der
  // Status wird per broadcastLicense an injected.js gemeldet (bis dahin fail-closed).
  refreshLicense();
  // Popup kann per storage-Änderung (Key aktiviert) einen Refresh anstoßen.
  try {
    if (chrome.storage && chrome.storage.onChanged) {
      chrome.storage.onChanged.addListener((changes, area) => {
        if (area === 'local' && changes.kishield_api_key) {
          refreshLicense();
        }
      });
    }
  } catch (_) {}
  // Nachrichten von injected.js (MAIN world): Lizenz-Nachfrage (nach eigenem
  // Re-Load) oder Warnung (nicht prüfbarer Datei-Upload an die Chat-API).
  window.addEventListener('message', (e) => {
    if (e.source !== window || !e.data || e.data.mark !== MARK) return;
    if (e.data.type === 'kishield-license-query') broadcastLicense();
    else if (e.data.type === 'kishield-protection-query') broadcastProtection();
    else if (e.data.type === 'kishield-health' && e.data.nonce) {
      const finish = pendingHealthChecks.get(e.data.nonce);
      if (finish) finish(e.data);
    }
    else if (e.data.type === 'kishield-warn') handleNetworkWarn(e.data);
    else if (e.data.type === 'kishield-hook-failed') {
      NETWORK_HOOK_FAILED = true;
      showBanner('KI-Shield: Netzwerk-Schutz konnte in diesem Browser nicht starten — Senden gesperrt. Bitte Erweiterung neu laden oder Chrome nutzen.');
    }
  });

  // ─── Mapping-Speicherung ────────────────────────────────────────────
  // Pseudonym-Mappings (Original → Pseudonym) liegen pro Conversation lokal und
  // werden nach einer festen Speicherfrist automatisch entfernt. Das Leseschema
  // akzeptiert weiterhin das rohe v2.0.1-Format, damit Bestandskunden beim Update
  // keine laufende Re-Identifikation verlieren.
  function mappingRecord(mappings, createdAt) {
    return { createdAt: createdAt || Date.now(), mappings: mappings || {} };
  }
  function readMappingRecord(value) {
    if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
    if (value.mappings && typeof value.mappings === 'object' && !Array.isArray(value.mappings)) {
      const createdAt = Number(value.createdAt) || 0;
      return {
        createdAt,
        expired: !createdAt || Date.now() - createdAt > MAPPING_TTL_MS,
        mappings: value.mappings,
        legacy: false,
      };
    }
    return { createdAt: Date.now(), expired: false, mappings: value, legacy: true };
  }

  // ─── PII-Kategorien-Schalter (wie KIShieldGuard) ───────────────────
  // Der Nutzer kann im Popup einzelne PII-Kategorien abschalten; deaktivierte
  // Typen werden dann nicht mehr erkannt/ersetzt. Alle 42 Kategorien sind frei
  // schaltbar; Standard bleibt Vollschutz und das Abschalten braucht Bestätigung.
  // Die Liste kommt aus chrome.storage.local (vom Popup gesetzt).
  let DISABLED_TYPES = new Set();
  function applyDisabled(list) {
    DISABLED_TYPES = new Set(Core.sanitizeDisabledTypes(list));
    showProtectionStatus();
  }
  function broadcastProtection() {
    postNet('kishield-enabled', { enabled: PROTECTION_ENABLED });
  }
  function showProtectionStatus() {
    if (!PROTECTION_ENABLED) {
      showBanner('KI-Shield ist pausiert — Nachrichten und Dokumente werden nicht durch KI-Shield geprüft.', '#b91c1c');
      return;
    }
    if (DISABLED_TYPES.size > 0) {
      showBanner(
        `KI-Shield: ${DISABLED_TYPES.size} Schutzarten/Kategorien deaktiviert — im Popup „Vollschutz aktivieren“.`,
        '#9a6700'
      );
      return;
    }
    hidePersistentBanner();
  }
  try {
    const settings = await chrome.storage.local.get([
      ENABLED_KEY, LIVE_DETECTION_KEY, 'kishield_disabled_types',
    ]);
    PROTECTION_ENABLED = Core.protectionEnabled(settings[ENABLED_KEY]);
    LIVE_DETECTION_ENABLED = Core.liveDetectionEnabled(settings[LIVE_DETECTION_KEY]);
    applyDisabled(settings.kishield_disabled_types);
  } catch (_) {
    PROTECTION_ENABLED = true;
    LIVE_DETECTION_ENABLED = false;
    applyDisabled([]);
  }
  broadcastProtection();
  try {
    if (chrome.storage && chrome.storage.onChanged) {
      chrome.storage.onChanged.addListener((changes, area) => {
        if (area !== 'local') return;
        if (changes.kishield_disabled_types) applyDisabled(changes.kishield_disabled_types.newValue);
        if (changes.kishield_never_terms) {
          replaceNeverTerms(changes.kishield_never_terms.newValue);
          refreshHighlight();
        }
        if (changes[ENABLED_KEY]) {
          PROTECTION_ENABLED = Core.protectionEnabled(changes[ENABLED_KEY].newValue);
          broadcastProtection();
          showProtectionStatus();
        }
        if (changes[LIVE_DETECTION_KEY]) {
          LIVE_DETECTION_ENABLED = Core.liveDetectionEnabled(changes[LIVE_DETECTION_KEY].newValue);
          detectSequence++;
          if (!LIVE_DETECTION_ENABLED) {
            lastServerEntities = [];
            lastDetectText = '';
            updateBadge([]);
            clearHighlightOverlay();
          }
        }
      });
    }
  } catch (_) {}
  // Deaktivierte Kategorien gehen als excluded_entities an den Proxy mit; der
  // Server unterstützt seit 2026-07-17 alle 42 Typen als Ausschluss.

  // ─── Conversation-ID aus URL extrahieren ───────────────────────────
  // Pro Plattform eigenes Pfad-Schema UND Plattform-Präfix. Verhindert, dass
  // Mappings einer Claude-Conversation auf einer gleichnamigen ChatGPT-/Gemini-
  // Conversation geladen werden (Storage-Key-Kollision → Fremd-Klartext im DOM).
  function getConversationId() {
    const h = location.hostname;
    const p = location.pathname;
    let plat = 'x';
    let id = 'new';
    if (h === 'claude.ai' || h.endsWith('.claude.ai')) {
      plat = 'claude';
      const m = p.match(/\/chat\/([0-9a-f-]+)/i);
      if (m) id = m[1];
    } else if (h === 'chatgpt.com' || h.endsWith('.chatgpt.com') || h === 'chat.openai.com') {
      plat = 'chatgpt';
      const m = p.match(/\/c\/([0-9a-f-]+)/i);
      if (m) id = m[1];
    } else if (h === 'gemini.google.com' || h.endsWith('.gemini.google.com')) {
      plat = 'gemini';
      const m = p.match(/\/(?:app|gem)\/([\w-]+)/i);
      if (m) id = m[1];
    }
    return plat + ':' + id;
  }

  // ─── Mappings persistieren / laden (lokal, unverschlüsselt, mit TTL) ─
  function isContextInvalidated(err) {
    const msg = err && err.message ? err.message : String(err);
    return msg.includes('Extension context invalidated') || msg.includes('context invalidated');
  }

  /**
   * True wenn die Extension noch lebt. Nach Plugin-Reload ist chrome.runtime.id nicht mehr lesbar
   * — wir können dann gar nicht erst auf chrome.storage zugreifen, sonst wirft der Aufruf einen
   * uncaught Error, den Chrome trotz try/catch in der Fehler-Liste anzeigt.
   */
  function extensionAlive() {
    try {
      return !!(chrome && chrome.runtime && chrome.runtime.id);
    } catch {
      return false;
    }
  }

  async function saveMappings() {
    if (!extensionAlive()) return;
    const cid = getConversationId();
    if (FORWARD.size === 0) return;
    try {
      await chrome.storage.local.set({
        [`mappings_${cid}`]: mappingRecord(forwardAsObject(), mappingCreatedAt()),
      });
    } catch (e) {
      if (isContextInvalidated(e)) return;
      console.warn(`[${PLUGIN_NAME}] saveMappings:`, e);
    }
  }

  async function loadMappings() {
    if (!extensionAlive()) return;
    const cid = getConversationId();
    try {
      const data = await chrome.storage.local.get(`mappings_${cid}`);
      const stored = data[`mappings_${cid}`];
      const record = readMappingRecord(stored);
      if (!record) return;
      if (record.expired) {
        await chrome.storage.local.remove(`mappings_${cid}`);
        return;
      }
      const expiresAt = record.createdAt + MAPPING_TTL_MS;
      rememberMapping(record.mappings, expiresAt);
      if (record.legacy) {
        await chrome.storage.local.set({
          [`mappings_${cid}`]: mappingRecord(record.mappings, record.createdAt),
        });
      }
      if (FORWARD.size > 0) {
        // An injected.js syncen, damit der Network-Layer alte Mappings auch nutzt.
        try {
          window.postMessage({
            mark: MARK,
            type: 'kishield-mappings',
            entries: forwardAsObject(),
            expiresAt,
          }, '*');
        } catch (_) {}
        console.log(`[${PLUGIN_NAME}] Mappings geladen für Conversation ${cid}: ${FORWARD.size} Einträge`);
      }
    } catch (e) {
      if (isContextInvalidated(e)) return;
      console.warn(`[${PLUGIN_NAME}] loadMappings:`, e);
    }
  }

  /**
   * Replay 2026-07-04: ALLE gespeicherten Mappings dieser Plattform zusätzlich an
   * den Network-Layer geben (nur Ersetzungsrichtung). Grund: ChatGPTs Anon-API ist
   * zustandslos — der Client sendet u.U. die komplette bisherige Konversation aus
   * seinem Speicher erneut, und dort steht der KLARTEXT früherer Nachrichten. Hat
   * der Network-Layer nur die Mappings der aktuellen Conversation, geht alte PII
   * beim Resend ungeschützt raus. Das Mapping der aktiven Conversation bleibt
   * davon unberührt.
   */
  async function replayPlatformMappingsToNetwork() {
    if (!extensionAlive()) return;
    try {
      const platPrefix = 'mappings_' + getConversationId().split(':')[0] + ':';
      const all = await chrome.storage.local.get(null);
      const replayRecords = [];
      const expiredKeys = [];
      const legacyMigrations = {};
      let replayedMappings = 0;
      for (const [key, val] of Object.entries(all || {})) {
        if (!key.startsWith(platPrefix)) continue;
        const record = readMappingRecord(val);
        if (!record) continue;
        if (record.expired) {
          expiredKeys.push(key);
          continue;
        }
        if (record.legacy) {
          legacyMigrations[key] = mappingRecord(record.mappings, record.createdAt);
        }
        // Pro Eintrag prüfen — nur String→String in die Ersetzungstabelle, damit
        // kaputte/fremde Storage-Objekte nicht ungeprüft eingespült werden.
        const cleanEntries = {};
        for (const [orig, pseudo] of Object.entries(record.mappings)) {
          if (typeof orig === 'string' && typeof pseudo === 'string' && orig && pseudo) {
            cleanEntries[orig] = pseudo;
          }
        }
        if (Object.keys(cleanEntries).length) {
          replayRecords.push({
            entries: cleanEntries,
            expiresAt: record.createdAt + MAPPING_TTL_MS,
          });
          replayedMappings += Object.keys(cleanEntries).length;
        }
      }
      if (expiredKeys.length) await chrome.storage.local.remove(expiredKeys);
      if (Object.keys(legacyMigrations).length) {
        await chrome.storage.local.set(legacyMigrations);
      }
      for (const replay of replayRecords) {
        window.postMessage({
          mark: MARK,
          type: 'kishield-mappings',
          entries: replay.entries,
          expiresAt: replay.expiresAt,
        }, '*');
      }
      if (replayedMappings > 0) {
        console.log(`[${PLUGIN_NAME}] Plattform-Mappings an Network-Layer repliziert: ${replayedMappings}`);
      }
    } catch (e) {
      if (!isContextInvalidated(e)) console.warn(`[${PLUGIN_NAME}] replayPlatformMappings:`, e);
    }
  }

  // Ebenfalls nicht blockierend (Storage-Zugriffe, best-effort) — dürfen die
  // Hook-Installation weiter unten nicht aufhalten.
  loadMappings();
  replayPlatformMappingsToNetwork();

  // Popup-Gesundheitsprüfung: beide isolierten Laufzeitebenen müssen antworten.
  const pendingHealthChecks = new Map();
  function pingNetworkLayer() {
    return new Promise((resolve) => {
      const nonce = 'health-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
      const timer = setTimeout(() => {
        pendingHealthChecks.delete(nonce);
        resolve(false);
      }, 900);
      pendingHealthChecks.set(nonce, (details) => {
        clearTimeout(timer);
        pendingHealthChecks.delete(nonce);
        resolve(details || null);
      });
      postNet('kishield-health-query', { nonce });
    });
  }
  chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
    if (
      message &&
      message.type === 'kishield-extract-progress' &&
      message.jobId === activeExtractionJobId
    ) {
      if (message.phase === 'ocr-init') {
        const stage = String(message.stage || '');
        if (stage === 'timeout' || stage === 'error') {
          showBanner(
            stage === 'timeout'
              ? 'KI-Shield Dokument: Lokale OCR-Engine antwortet nicht — Verarbeitung sicher abgebrochen.'
              : 'KI-Shield Dokument: Lokale OCR-Engine konnte nicht gestartet werden.',
            '#dc2626',
            0
          );
        } else if (stage === 'ready') {
          showBanner(
            'KI-Shield Dokument: Lokale OCR-Engine bereit — alle bildbasierten Seiten werden gelesen …',
            '#4f46e5',
            0
          );
        } else {
          const progress = Math.max(0, Math.min(100, Math.round((Number(message.progress) || 0) * 100)));
          showBanner(
            `KI-Shield Dokument: Lokale OCR-Engine wird vorbereitet${progress ? ` (${progress} %)` : ''} …`,
            '#4f46e5',
            0
          );
        }
        return false;
      }
      const page = Math.max(1, Number(message.page) || 1);
      const total = Math.max(page, Number(message.totalPages) || page);
      const phase = message.ocr ? 'OCR' : 'Text';
      showBanner(
        `KI-Shield Dokument: ${phase} – Seite ${page.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} wird lokal gelesen …`,
        '#4f46e5',
        0
      );
      return false;
    }
    if (!message || message.type !== 'kishield-health-query') return false;
    pingNetworkLayer().then((network) => sendResponse({
      content: true,
      network: !!network,
      networkDiagnostics: network && network.diagnostics ? network.diagnostics : null,
      maskDiagnostics: lastMaskDiagnostics,
      enabled: PROTECTION_ENABLED,
      liveDetection: LIVE_DETECTION_ENABLED,
      mappings: FORWARD.size,
      disabledTypes: DISABLED_TYPES.size,
      version: VERSION,
    }));
    return true;
  });

  // ─── Plattform-Detection ────────────────────────────────────────────
  const PLATFORM = (() => {
    const h = location.hostname;
    if (h === 'claude.ai' || h.endsWith('.claude.ai')) return 'claude';
    if (h === 'chatgpt.com' || h === 'chat.openai.com' || h.endsWith('.chatgpt.com')) return 'chatgpt';
    if (h === 'gemini.google.com' || h.endsWith('.gemini.google.com')) return 'gemini';
    return 'unknown';
  })();

  const PLATFORM_LABELS = {
    claude:  { service: 'claude.ai',         ai: 'Claude'  },
    chatgpt: { service: 'chatgpt.com',       ai: 'ChatGPT' },
    gemini:  { service: 'gemini.google.com', ai: 'Gemini'  },
    unknown: { service: location.hostname,   ai: 'der KI'  },
  };
  const PLATFORM_LABEL = PLATFORM_LABELS[PLATFORM] || PLATFORM_LABELS.unknown;
  console.log(`[${PLUGIN_NAME}] Plattform erkannt: ${PLATFORM} (${PLATFORM_LABEL.service})`);

  // ─── Hilfsfunktionen ────────────────────────────────────────────────
  // Liest den Text aus einem Eingabefeld unabhängig vom Element-Typ.
  // <textarea>/<input>: .value
  // contenteditable <div>: .innerText — NICHT .textContent. textContent
  // verkettet Zeilen ohne Umbruch ("Zeile1Zeile2"), der echte Request-Body enthält
  // aber "Zeile1\nZeile2". Dann würde der erkannte PII-String nicht im gesendeten
  // Body auffindbar sein → keine Ersetzung → Klartext-Leak. innerText bildet die
  // Zeilenumbrüche ab und liegt damit näher an dem, was die Seite tatsächlich sendet.
  function getInputText(el) {
    if (!el) return '';
    const tag = el.tagName;
    if (tag === 'TEXTAREA' || tag === 'INPUT') return el.value || '';
    const it = el.innerText;
    return (typeof it === 'string' ? it : (el.textContent || '')) || '';
  }

  function findChatInput() {
    if (PLATFORM === 'chatgpt') {
      return (
        document.querySelector('#prompt-textarea') ||
        document.querySelector('div[contenteditable="true"][data-virtualkeyboard="true"]') ||
        document.querySelector('.ProseMirror[contenteditable="true"]') ||
        document.querySelector('div[contenteditable="true"][role="textbox"]') ||
        document.querySelector('div[contenteditable="true"]') ||
        document.querySelector('textarea[data-id="root"]') ||
        document.querySelector('textarea[name="prompt"]') ||
        document.querySelector('textarea[placeholder]') ||
        document.querySelector('form textarea') ||
        document.querySelector('main textarea') ||
        document.querySelector('textarea')
      );
    }
    if (PLATFORM === 'gemini') {
      return (
        document.querySelector('rich-textarea .ql-editor') ||
        document.querySelector('div.ql-editor[contenteditable="true"]') ||
        document.querySelector('.ql-editor[contenteditable="true"]') ||
        document.querySelector('div[contenteditable="true"][role="textbox"]') ||
        document.querySelector('div[contenteditable="true"]') ||
        document.querySelector('textarea[aria-label*="Prompt" i]') ||
        document.querySelector('textarea')
      );
    }
    // Claude (Default)
    return (
      document.querySelector('[data-testid="chat-input"]') ||
      document.querySelector('div[contenteditable="true"][role="textbox"]') ||
      document.querySelector('.tiptap.ProseMirror') ||
      document.querySelector('div[contenteditable="true"]')
    );
  }

  function findSendButton() {
    if (PLATFORM === 'chatgpt') {
      return (
        document.querySelector('[data-testid="send-button"]') ||
        document.querySelector('[data-testid="composer-send-button"]') ||
        document.querySelector('button[aria-label*="Send" i]') ||
        document.querySelector('button[aria-label*="Prompt" i]') ||
        document.querySelector('button[type="submit"]')
      );
    }
    if (PLATFORM === 'gemini') {
      return (
        document.querySelector('button[aria-label*="Send" i]') ||
        document.querySelector('button[aria-label*="Senden" i]') ||
        document.querySelector('button.send-button') ||
        document.querySelector('button[mat-icon-button][aria-label]') ||
        document.querySelector('button[type="submit"]')
      );
    }
    return (
      document.querySelector('button[aria-label*="Send" i]') ||
      document.querySelector('button[aria-label*="Senden" i]') ||
      document.querySelector('button[type="submit"]') ||
      document.querySelector('[data-testid*="send"]')
    );
  }

  // HTML-Escape für alles, was aus User-Text/Netzwerk stammt und via innerHTML in
  // Plugin-UI gerendert wird. Verhindert, dass getippter Text wie <img onerror=…>
  // im Bestätigungs-Modal als Markup ausgeführt wird.
  function escHtml(s) {
    return String(s == null ? '' : s)
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/"/g, '&quot;')
      .replace(/'/g, '&#39;');
  }

  function colorForType(type) {
    const map = {
      PERSON: { bg: '#ede9fe', fg: '#5b21b6' },
      EMAIL: { bg: '#dbeafe', fg: '#1e40af' },
      PHONE_NUMBER: { bg: '#dcfce7', fg: '#166534' },
      IBAN: { bg: '#fef3c7', fg: '#92400e' },
      DATE_OF_BIRTH: { bg: '#fce7f3', fg: '#9d174d' },
      DATE_TIME: { bg: '#fce7f3', fg: '#9d174d' },
      ADDRESS: { bg: '#fed7aa', fg: '#9a3412' },
      CREDIT_CARD: { bg: '#fee2e2', fg: '#991b1b' },
      ORGANIZATION: { bg: '#e0e7ff', fg: '#3730a3' },
      HEALTH_DATA: { bg: '#fee2e2', fg: '#991b1b' },
    };
    return map[type] || { bg: '#f1f5f9', fg: '#334155' };
  }

  function debounce(fn, delay) {
    let t;
    return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); };
  }

  // Live-Erkennung für die Vorschau: fragt bei einer Tipp-Pause DEINEN Proxy
  // (/api/v1/detect, NICHT den KI-Anbieter). Nur Erkennung, keine Maskierung,
  // kein Erkennungscode im Client. Best-effort — Fehler bleiben still.
  function proxyDetect(text) {
    return new Promise((resolve) => {
      let done = false;
      const finish = (r) => { if (!done) { done = true; resolve(r); } };
      try {
        chrome.runtime.sendMessage(
          { type: 'kishield-detect', text, excluded: Array.from(DISABLED_TYPES), allowlist: allowlistArray() },
          (resp) => {
            if (chrome.runtime.lastError) { finish({ ok: false }); return; }
            finish(resp || { ok: false });
          }
        );
      } catch (_) { finish({ ok: false }); }
      setTimeout(() => finish({ ok: false }), 8000);
    });
  }

  // ─── Phase 2: Inline-Highlight via Overlay-Layer ────────────────────
  function ensureHighlightOverlay() {
    let overlay = document.getElementById('kishield-zk-highlight-overlay');
    if (overlay) return overlay;
    overlay = document.createElement('div');
    overlay.id = 'kishield-zk-highlight-overlay';
    overlay.style.cssText = `
      position: fixed;
      top: 0; left: 0;
      pointer-events: none;
      z-index: 2147483645;
      width: 0; height: 0;
    `;
    document.body.appendChild(overlay);
    return overlay;
  }

  /**
   * Berechnet DOMRects für jede detected entity im Editor-Text.
   * Nutzt Range-API + getClientRects (mehrere Rects bei Zeilenumbruch).
   * Vorkommnis-Zählung statt Offsets (innerText-Zeilenumbruch-Versatz-Fix).
   */
  function findEntityRects(inputEl, entities) {
    const detText = getInputText(inputEl);

    const nodes = [];
    let stream = '';
    const walker = document.createTreeWalker(inputEl, NodeFilter.SHOW_TEXT);
    let n;
    while ((n = walker.nextNode())) {
      nodes.push({ node: n, start: stream.length });
      stream += n.nodeValue;
    }

    function locate(pos) {
      for (let i = nodes.length - 1; i >= 0; i--) {
        if (nodes[i].start <= pos) return { node: nodes[i].node, offset: pos - nodes[i].start };
      }
      return null;
    }

    const result = [];
    for (const entity of entities) {
      const needle = entity.text;
      if (!needle) continue;

      let occ = 0, p = -1;
      while ((p = detText.indexOf(needle, p + 1)) !== -1 && p < entity.start) occ++;

      let sp = -1;
      for (let i = 0; i <= occ; i++) {
        sp = stream.indexOf(needle, sp + 1);
        if (sp === -1) break;
      }
      if (sp === -1) continue;

      const startLoc = locate(sp);
      const endLoc = locate(sp + needle.length);
      if (!startLoc || !endLoc) continue;

      try {
        const range = document.createRange();
        range.setStart(startLoc.node, startLoc.offset);
        range.setEnd(endLoc.node, Math.min(endLoc.offset, endLoc.node.nodeValue.length));
        const rects = Array.from(range.getClientRects());
        result.push({ entity, rects });
      } catch (_) { /* skip */ }
    }
    return result;
  }

  function renderHighlightOverlay(inputEl, entities) {
    const overlay = ensureHighlightOverlay();
    overlay.innerHTML = '';
    if (!inputEl || !entities || entities.length === 0) return;
    const data = findEntityRects(inputEl, entities);
    for (const { entity, rects } of data) {
      const c = colorForType(entity.entity_type);
      for (const r of rects) {
        if (r.width < 1 || r.height < 1) continue;
        const span = document.createElement('div');
        span.style.cssText = `
          position: fixed;
          top: ${r.top}px; left: ${r.left}px;
          width: ${r.width}px; height: ${r.height}px;
          background: ${c.bg};
          border-bottom: 2px solid ${c.fg};
          border-radius: 2px; opacity: 0.55; pointer-events: none;
        `;
        span.title = `${entity.entity_type}: ${entity.text}`;
        overlay.appendChild(span);
      }
    }
  }

  function clearHighlightOverlay() {
    const overlay = document.getElementById('kishield-zk-highlight-overlay');
    if (overlay) overlay.innerHTML = '';
  }

  let lastEntities = [];
  let lastInputEl = null;
  function rerenderHighlights() {
    if (lastInputEl && lastEntities.length > 0) renderHighlightOverlay(lastInputEl, lastEntities);
  }
  window.addEventListener('scroll', rerenderHighlights, true);
  window.addEventListener('resize', rerenderHighlights);

  // ─── Floating Badge (UI) ────────────────────────────────────────────
  // Pro Hostname eigener Storage-Key, damit eine auf Gemini gezogene Position
  // nicht auf claude.ai oder chatgpt.com unter Sidebar/Header verschwindet.
  const BADGE_POS_KEY_LEGACY = 'kishield-badge-pos';
  const BADGE_POS_KEY = 'kishield-badge-pos-' + location.hostname;

  function applyBadgePosition(badge, pos) {
    // pos: { left, top } in px (viewport-koordinaten). Wenn null → default bottom-right.
    if (pos && Number.isFinite(pos.left) && Number.isFinite(pos.top)) {
      const maxLeft = Math.max(0, window.innerWidth - 60);
      const maxTop = Math.max(0, window.innerHeight - 40);
      const left = Math.min(Math.max(0, pos.left), maxLeft);
      const top = Math.min(Math.max(0, pos.top), maxTop);
      badge.style.left = left + 'px';
      badge.style.top = top + 'px';
      badge.style.right = 'auto';
      badge.style.bottom = 'auto';
    } else {
      badge.style.left = 'auto';
      badge.style.top = 'auto';
      badge.style.right = '24px';
      badge.style.bottom = '24px';
    }
  }

  function loadBadgePosition(badge) {
    try {
      // Per-Host autoritativ, kein Fallback auf globalen Legacy-Key:
      // dadurch startet jede Plattform mit Default rechts-unten,
      // bis der User dort selbst eine Position setzt.
      chrome.storage.local.get([BADGE_POS_KEY], (res) => {
        applyBadgePosition(badge, res && res[BADGE_POS_KEY]);
      });
    } catch (_) { /* storage nicht verfügbar → default */ }
  }

  function saveBadgePosition(pos) {
    try { chrome.storage.local.set({ [BADGE_POS_KEY]: pos }); } catch (_) {}
  }

  function resetBadgePosition(badge) {
    try { chrome.storage.local.remove([BADGE_POS_KEY, BADGE_POS_KEY_LEGACY]); } catch (_) {}
    applyBadgePosition(badge, null);
  }

  function makeBadgeDraggable(badge, handle) {
    let dragging = false;
    let pointerId = null;
    let startX = 0, startY = 0;
    let originLeft = 0, originTop = 0;
    let moved = false;

    // touch-action verhindert dass der Browser Scroll/Zoom auf der Handle macht
    handle.style.touchAction = 'none';

    const onDown = (e) => {
      if (e.pointerType === 'mouse' && e.button !== 0) return;
      dragging = true;
      moved = false;
      pointerId = e.pointerId;
      const rect = badge.getBoundingClientRect();
      originLeft = rect.left;
      originTop = rect.top;
      startX = e.clientX;
      startY = e.clientY;
      badge.style.left = originLeft + 'px';
      badge.style.top = originTop + 'px';
      badge.style.right = 'auto';
      badge.style.bottom = 'auto';
      try { handle.setPointerCapture(pointerId); } catch (_) {}
      e.preventDefault();
      e.stopPropagation();
    };

    const onMove = (e) => {
      if (!dragging || e.pointerId !== pointerId) return;
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
      const newLeft = Math.min(Math.max(0, originLeft + dx), window.innerWidth - badge.offsetWidth);
      const newTop = Math.min(Math.max(0, originTop + dy), window.innerHeight - badge.offsetHeight);
      badge.style.left = newLeft + 'px';
      badge.style.top = newTop + 'px';
      e.preventDefault();
      e.stopPropagation();
    };

    const onUp = (e) => {
      if (!dragging || e.pointerId !== pointerId) return;
      dragging = false;
      try { handle.releasePointerCapture(pointerId); } catch (_) {}
      pointerId = null;
      if (!moved) return;
      const left = parseFloat(badge.style.left) || 0;
      const top = parseFloat(badge.style.top) || 0;
      saveBadgePosition({ left, top });
    };

    // capture: true damit Angular/CDK-Listener (Gemini) das Event nicht abfangen können
    handle.addEventListener('pointerdown', onDown, { capture: true });
    handle.addEventListener('pointermove', onMove, { capture: true });
    handle.addEventListener('pointerup', onUp, { capture: true });
    handle.addEventListener('pointercancel', onUp, { capture: true });
    // Fallback falls Pointer-Capture nicht greift
    document.addEventListener('pointermove', onMove, { capture: true });
    document.addEventListener('pointerup', onUp, { capture: true });
  }

  function createBadge() {
    if (document.getElementById('kishield-zk-badge')) {
      return document.getElementById('kishield-zk-badge');
    }
    const badge = document.createElement('div');
    badge.id = 'kishield-zk-badge';
    badge.style.cssText = `
      position: fixed;
      bottom: 24px;
      right: 24px;
      background: white;
      border: 1px solid #e2e8f0;
      border-radius: 10px;
      padding: 10px 14px;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      font-size: 12px;
      color: #1e293b;
      box-shadow: 0 6px 20px rgba(0,0,0,0.10);
      z-index: 2147483647;
      max-width: 340px;
      min-width: 200px;
      display: none;
      pointer-events: auto;
      user-select: none;
    `;
    badge.innerHTML = `
      <div id="kishield-zk-handle" title="Ziehen zum Verschieben — Doppelklick: Position zurücksetzen" style="display:flex;align-items:center;gap:6px;font-weight:600;color:#6366f1;margin-bottom:6px;cursor:move;">
        <img src="${chrome.runtime.getURL('icons/icon48.png')}" alt="" style="width:16px;height:16px;border-radius:50%;display:block;flex:none;">
        KI-Shield Platzhalter
        <span style="margin-left:auto;color:#cbd5e1;font-size:14px;line-height:1;letter-spacing:-1px;">⋮⋮</span>
      </div>
      <div id="kishield-zk-status" style="font-size:11px;color:#64748b;">Bereit — keine PII erkannt</div>
      <div id="kishield-zk-entities" style="margin-top:6px;line-height:1.6;"></div>
    `;
    document.body.appendChild(badge);
    const handle = badge.querySelector('#kishield-zk-handle');
    if (handle) {
      makeBadgeDraggable(badge, handle);
      // Doppelklick auf Drag-Griff → Position auf Default (rechts-unten) zurücksetzen.
      // Notausgang falls Badge unter Sidebar/Header einer Plattform unsichtbar geworden ist.
      handle.addEventListener('dblclick', (e) => {
        e.preventDefault();
        e.stopPropagation();
        resetBadgePosition(badge);
      }, { capture: true });
    }
    loadBadgePosition(badge);
    // Bei Resize Position clampen, damit Badge nicht außerhalb des Viewports landet
    window.addEventListener('resize', () => {
      if (badge.style.left && badge.style.left !== 'auto') {
        const left = parseFloat(badge.style.left) || 0;
        const top = parseFloat(badge.style.top) || 0;
        applyBadgePosition(badge, { left, top });
      }
    });
    return badge;
  }

  function updateBadge(entities) {
    const badge = createBadge();
    const status = document.getElementById('kishield-zk-status');
    const list = document.getElementById('kishield-zk-entities');

    if (!entities || entities.length === 0) {
      status.textContent = 'Bereit — keine PII erkannt';
      list.innerHTML = '';
      badge.style.display = 'none';
      return;
    }

    badge.style.display = 'block';
    status.textContent = `${entities.length} PII erkannt — wird vor Send pseudonymisiert`;

    const byType = {};
    for (const e of entities) {
      byType[e.entity_type] = (byType[e.entity_type] || 0) + 1;
    }

    list.innerHTML = Object.entries(byType)
      .map(([type, count]) => {
        const c = colorForType(type);
        return `<span style="display:inline-block;background:${c.bg};color:${c.fg};padding:3px 8px;border-radius:5px;margin:2px 3px 2px 0;font-size:11px;font-weight:500;">${escHtml(type)} × ${count}</span>`;
      })
      .join('');
  }

  // ─── Phase 3: Confirm-Modal ─────────────────────────────────────────
  function showConfirmModal(entities, originalText, anonymizedText, onProceed, onCancel) {
    const existing = document.getElementById('kishield-zk-modal');
    if (existing) existing.remove();

    const overlay = document.createElement('div');
    overlay.id = 'kishield-zk-modal';
    overlay.style.cssText = `
      position: fixed;
      inset: 0;
      background: rgba(15, 23, 42, 0.55);
      z-index: 2147483646;
      display: flex;
      align-items: center;
      justify-content: center;
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    `;

    const card = document.createElement('div');
    card.style.cssText = `
      background: white;
      border-radius: 12px;
      padding: 24px;
      max-width: 460px;
      width: 90%;
      box-shadow: 0 20px 60px rgba(0,0,0,0.25);
    `;

    const byType = {};
    for (const e of entities) {
      byType[e.entity_type] = (byType[e.entity_type] || 0) + 1;
    }
    const pillsHtml = Object.entries(byType)
      .map(([type, count]) => {
        const c = colorForType(type);
        return `<span style="display:inline-block;background:${c.bg};color:${c.fg};padding:4px 10px;border-radius:6px;margin:3px 4px 3px 0;font-size:12px;font-weight:500;">${escHtml(type)} × ${count}</span>`;
      })
      .join('');

    const examplesHtml = entities.slice(0, 5).map(e => {
      const c = colorForType(e.entity_type);
      const shown = e.text.length > 60 ? e.text.slice(0, 60) + '…' : e.text;
      return `<li style="margin:4px 0;font-size:12px;color:#475569;"><span style="background:${c.bg};color:${c.fg};padding:1px 6px;border-radius:4px;font-size:11px;font-weight:500;">${escHtml(e.entity_type)}</span> "${escHtml(shown)}"</li>`;
    }).join('');

    card.innerHTML = `
      <div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
        <img src="${chrome.runtime.getURL('icons/icon48.png')}" alt="KI ShieldGuard" style="width:34px;height:34px;border-radius:50%;display:block;flex:none;">

        <div>
          <div style="font-weight:600;font-size:15px;color:#1e293b;">KI-Shield: ${entities.length} PII erkannt</div>
          <div style="font-size:11px;color:#64748b;">vor dem Senden an ${PLATFORM_LABEL.service}</div>
        </div>
      </div>

      <div style="margin:16px 0 12px;font-size:13px;color:#334155;">
        Die folgenden personenbezogenen Daten wurden vom KI-Shield-Proxy (AVV, Zero-Knowledge) durch Pseudonyme ersetzt. ${PLATFORM_LABEL.ai} sieht nur die Pseudonyme. Die Antwort wird lokal wieder aufgelöst.
      </div>

      <div style="margin-bottom:12px;">${pillsHtml}</div>

      <ul style="margin:0;padding:0 0 0 18px;list-style:disc;">${examplesHtml}</ul>
      ${entities.length > 5 ? `<div style="font-size:11px;color:#94a3b8;margin-top:4px;">… und ${entities.length - 5} weitere</div>` : ''}

      <div style="margin-top:14px;background:#f8fafc;border:1px solid #e2e8f0;border-radius:6px;padding:10px;">
        <div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px;color:#475569;margin-bottom:6px;">Was ${PLATFORM_LABEL.ai} sieht:</div>
        <div id="kishield-zk-preview" style="font-size:12px;color:#1e293b;line-height:1.5;font-family:ui-monospace,Menlo,Consolas,monospace;word-break:break-word;max-height:120px;overflow-y:auto;"></div>
      </div>

      <div style="display:flex;gap:10px;margin-top:20px;justify-content:flex-end;">
        <button id="kishield-zk-cancel" style="padding:8px 16px;border:1px solid #e2e8f0;background:white;color:#475569;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;">Abbrechen</button>
        <button id="kishield-zk-proceed" style="padding:8px 16px;border:none;background:#6366f1;color:white;border-radius:6px;cursor:pointer;font-size:13px;font-weight:500;">Pseudonymisieren &amp; Senden</button>
      </div>
    `;

    overlay.appendChild(card);
    document.body.appendChild(overlay);

    // Pseudonym-Preview rendern: Pseudonyme farbig markieren im Vorschau-Text
    try {
      const preview = document.getElementById('kishield-zk-preview');
      if (preview && anonymizedText) {
        // Erst escapen (User-Text!), dann Pseudonyme (PERSON_NNN, IBAN_NNN etc.)
        // einfärben. Pseudonyme bestehen nur aus A-Z/_/Ziffern → vom Escape
        // unberührt, die Regex matcht weiterhin.
        const html = escHtml(anonymizedText).replace(
          /([A-ZÄÖÜ][A-ZÄÖÜ_]*_\d+)/g,
          (m) => {
            const type = m.split('_')[0];
            const c = colorForType(type === 'GEBURT' ? 'DATE_OF_BIRTH' :
                                    type === 'TELEFON' ? 'PHONE_NUMBER' :
                                    type === 'ADRESSE' ? 'ADDRESS' :
                                    type === 'ORT' ? 'ADDRESS' :
                                    type === 'ORG' ? 'ORGANIZATION' :
                                    type);
            return `<span style="background:${c.bg};color:${c.fg};padding:1px 6px;border-radius:3px;font-weight:500;">${m}</span>`;
          }
        );
        preview.innerHTML = html;
      }
    } catch (_) { /* preview ist nice-to-have */ }

    document.getElementById('kishield-zk-cancel').addEventListener('click', () => {
      overlay.remove();
      onCancel && onCancel();
    });
    document.getElementById('kishield-zk-proceed').addEventListener('click', () => {
      overlay.remove();
      onProceed && onProceed();
    });
    overlay.addEventListener('click', (e) => {
      if (e.target === overlay) {
        overlay.remove();
        onCancel && onCancel();
      }
    });
  }

  // ─── Phase 3: Send-Hook (Network-Layer-Architektur seit Phase 5) ────
  let bypassNextSend = false;

  async function handleSendAttempt(inputEl, triggerType, retrigger) {
    if (!PROTECTION_ENABLED) return false;
    // FAIL-CLOSED (Firefox): kein aktiver Netzwerk-Schutz → gar nicht erst senden.
    if (NETWORK_HOOK_FAILED) {
      showBanner('KI-Shield: Netzwerk-Schutz nicht aktiv — Senden gesperrt.', '#dc2626', 6000);
      return true;
    }
    let text = '';
    try { text = getInputText(inputEl).trim(); } catch (_) { text = ''; }
    if (!text) return false;

    // Text-Limit des Proxys (50k Zeichen) — fail-closed statt gekürzt senden.
    if (text.length > 45000) {
      showBanner('KI-Shield: Nachricht zu lang für die PII-Prüfung (max. ~45.000 Zeichen) — Senden blockiert.', '#dc2626', 7000);
      return true;
    }

    // Serverseitige Erkennung + Pseudonymisierung in EINEM Roundtrip. Die
    // Vorschau ist EXAKT der Text, den der Proxy geliefert hat, und das Mapping
    // dazu geht unverändert an den Network-Layer — kein zweiter Lauf, keine
    // Off-by-one-Gefahr.
    let mask;
    try {
      mask = await proxyMask(text);
    } catch (err) {
      mask = { ok: false, reason: 'exception', error: String((err && err.message) || err) };
    }
    if (!mask || !mask.ok) {
      // FAIL-CLOSED: Ohne erfolgreiche Maskierung wird NICHT gesendet.
      const reason = (mask && mask.reason) || 'unbekannt';
      if (reason === 'map_failed') {
        const diagnostics = (mask && mask.diagnostics) || {};
        lastMaskDiagnostics = {
          at: Date.now(),
          stage: diagnostics.stage === 'mapping_validation' ? diagnostics.stage : 'mapping_validation',
          source: diagnostics.source === 'server' ? 'server' : 'legacy_reconstruction',
          inputLength: Math.max(0, Number(diagnostics.inputLength) || 0),
          outputLength: Math.max(0, Number(diagnostics.outputLength) || 0),
          entityCount: Math.max(0, Number(diagnostics.entityCount) || 0),
          mappingCount: Math.max(0, Number(diagnostics.mappingCount) || 0),
        };
      }
      console.error(
        `[${PLUGIN_NAME}] proxyMask fehlgeschlagen (${reason}) — Senden blockiert:`,
        mask && mask.error,
        lastMaskDiagnostics || ''
      );
      const msg = reason === 'no_key'
        ? 'KI-Shield: Kein API-Zugang hinterlegt — bitte im Plugin-Popup aktivieren. Senden gesperrt.'
        : reason === 'map_failed'
          ? 'KI-Shield: PII erkannt, aber die Schutzabbildung war inkonsistent — Senden sicher blockiert. Bitte erneut versuchen; bei Wiederholung den Plugin-Status öffnen.'
          : 'KI-Shield: PII-Prüfung nicht erreichbar — Senden blockiert. Bitte erneut versuchen.';
      showBanner(msg, '#dc2626', 7000);
      return true;
    }
    lastServerEntities = Array.isArray(mask.entities) ? mask.entities : [];
    lastDetectText = text;

    // Server-erkannte + manuell markierte PII kombinieren (never-Terme sind schon
    // via allowlist ausgefiltert).
    const entities = combineEntities(text, mask.entities || []);
    if (entities.length === 0) {
      if (!(await approveNetworkSend(text, mask.masked || text))) {
        showBanner('KI-Shield: Netzwerk-Schutz bestätigt die Sendefreigabe nicht — Senden blockiert. Bitte Seite neu laden.', '#dc2626', 7000);
        return true;
      }
      console.log(`[${PLUGIN_NAME}] Send via ${triggerType}: keine PII, durchgelassen`);
      return false;
    }

    console.log(`[${PLUGIN_NAME}] Send via ${triggerType}: ${entities.length} PII abgefangen, Modal wird gezeigt`);
    updateBadge(entities);

    // Vorschau zunächst in einer lokalen Pending-Map aufbauen. Erst eine
    // ausdrückliche Bestätigung übernimmt sie in RAM/Storage; „Abbrechen“ darf
    // weder alte TTLs verlängern noch ungesendete Mappings hinterlassen.
    const pendingMappings = forwardAsObject();
    for (const [orig, pseudo] of Object.entries(mask.forward || {})) {
      if (typeof orig !== 'string' || typeof pseudo !== 'string' || !orig || !pseudo) continue;
      if (!Object.prototype.hasOwnProperty.call(pendingMappings, orig)) pendingMappings[orig] = pseudo;
    }
    applyManualToMapping(text, pendingMappings);
    const previewText = applyForward(text, pendingMappings);

    showConfirmModal(
      entities,
      text,
      previewText,
      async () => {
        // Guard: Wurde der Text NACH der Prüfung geändert (weitergetippt,
        // eingefügt), würde der Retrigger UNGEPRÜFTEN Inhalt senden → abbrechen.
        let nowText = '';
        try { nowText = getInputText(inputEl).trim(); } catch (_) { nowText = ''; }
        if (nowText !== text) {
          showBanner('KI-Shield: Der Text wurde nach der Prüfung geändert — bitte erneut auf Senden klicken.', '#b45309', 6000);
          return;
        }

        // Erst jetzt — nach Nutzerbestätigung und Text-Guard — übernehmen.
        // first-wins bewahrt ältere Pseudonyme; deren frühere TTL bleibt erhalten.
        rememberMapping(pendingMappings);
        const allMappings = forwardAsObject();

        // ACK-GATING: Gesendet wird erst, wenn injected.js den Empfang der
        // Mappings bestätigt hat (syncId-Echo, injected.js:74). Ohne Ack KEIN
        // Retrigger — ein toter MAIN-world-Hook würde sonst Klartext
        // durchlassen. Ersetzt den früheren blinden 250-ms-Timer.
        const syncId = 'ks-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
        const acked = new Promise((resolve) => {
          const to = setTimeout(() => { cleanup(); resolve(false); }, 1500);
          const onMsg = (ev) => {
            if (ev.source !== window || !ev.data || ev.data.mark !== MARK) return;
            if (ev.data.type === 'kishield-mappings-ack' && ev.data.syncId === syncId) { cleanup(); resolve(true); }
          };
          const cleanup = () => { clearTimeout(to); window.removeEventListener('message', onMsg); };
          window.addEventListener('message', onMsg);
        });
        window.postMessage({
          mark: MARK,
          type: 'kishield-mappings',
          entries: allMappings,
          syncId,
          expiresAt: mappingExpiresAt,
        }, '*');
        console.log(`[${PLUGIN_NAME}] Mappings via postMessage an Network-Layer gesendet:`, Object.keys(allMappings).length);

        await saveMappings();

        if (!(await acked)) {
          showBanner('KI-Shield: Netzwerk-Schutz antwortet nicht — Senden blockiert. Bitte Seite neu laden (F5).', '#dc2626', 7000);
          return;
        }
        if (!(await approveNetworkSend(text, previewText))) {
          showBanner('KI-Shield: Kurzlebige Sendefreigabe wurde nicht bestätigt — Senden blockiert. Bitte erneut versuchen.', '#dc2626', 7000);
          return;
        }

        // Editor bleibt KLARTEXT — der Network-Layer ersetzt im Request-Body.
        bypassNextSend = true;
        retrigger();
        setTimeout(() => { bypassNextSend = false; }, 500);
      },
      () => {
        console.log(`[${PLUGIN_NAME}] Send abgebrochen durch User`);
      }
    );
    return true;
  }

  // showFailureModal entfernt — wird durch Network-Layer-Architektur (Phase 5) nicht mehr benötigt.
  // Editor bleibt unangetastet, fetch-Hook macht Pseudonymisierung im Body.

  // WICHTIG (Thin-Client): Die Maskierung ist jetzt ein ECHTER Netzwerk-
  // Roundtrip. Ein preventDefault() nach dem ersten await käme zu spät — der
  // Event-Dispatch wäre längst durch und die Seite hätte den Klartext bereits
  // gesendet, BEVOR die Mappings im Network-Layer ankommen. Deshalb wird jedes
  // echte User-Send-Event mit Text SYNCHRON geblockt; nach erfolgreicher
  // Maskierung wird synthetisch re-triggert (bypassNextSend-One-Shot).
  let SEND_IN_FLIGHT = false;

  async function runGuardedSend(inputEl, triggerType, retrigger) {
    if (SEND_IN_FLIGHT) {
      showBanner('KI-Shield: PII-Prüfung läuft — einen Moment …', '#b45309', 2000);
      return;
    }
    SEND_IN_FLIGHT = true;
    try {
      const intercepted = await handleSendAttempt(inputEl, triggerType, retrigger);
      if (!intercepted) {
        // Keine PII (bzw. nichts zu tun) → Original-Send synthetisch auslösen.
        bypassNextSend = true;
        retrigger();
        setTimeout(() => { bypassNextSend = false; }, 500);
      }
    } catch (err) {
      // FAIL-CLOSED: unerwarteter Fehler → Send bleibt blockiert.
      console.error(`[${PLUGIN_NAME}] Send-Hook (${triggerType}) Fehler — blockiert:`, err);
      showBanner('KI-Shield: Interner Fehler — Senden blockiert. Bitte Seite neu laden.', '#dc2626', 6000);
    } finally {
      SEND_IN_FLIGHT = false;
    }
  }

  function setupSendHooks() {
    document.addEventListener('click', (e) => {
      if (!PROTECTION_ENABLED) return;
      // One-Shot: NUR das synthetische Retrigger-Event (isTrusted=false) darf
      // ungeprüft durch und konsumiert das Flag. Jedes ECHTE User-Event läuft
      // durch die volle Prüfung.
      if (bypassNextSend && !e.isTrusted) { bypassNextSend = false; return; }

      const btn = e.target.closest('button');
      if (!btn) return;

      const looksLikeSend =
        (btn.getAttribute('aria-label') || '').match(/send|senden/i) ||
        btn.getAttribute('type') === 'submit' ||
        (btn.getAttribute('data-testid') || '').match(/send/i);

      if (!looksLikeSend) return;

      const inputEl = findChatInput();
      if (!inputEl) return;

      let text = '';
      try { text = getInputText(inputEl).trim(); } catch (_) { text = ''; }
      if (!text) return; // leeres Feld → nichts zu prüfen, durchlassen

      // SYNCHRON blocken — VOR jedem await (siehe Kommentar über runGuardedSend).
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
      runGuardedSend(inputEl, 'click', () => {
        // Zwischen diesem Klick und dem Retrigger liegen ein Server-Roundtrip
        // und der Bestätigungsdialog — mehrere Sekunden. Die Seite kann ihren
        // Send-Button in der Zeit neu gerendert haben; `btn` zeigt dann auf ein
        // Element, das nicht mehr im Dokument hängt. Dessen click() feuert zwar,
        // bubbelt aber nirgendwohin: kein Send, keine Fehlermeldung, der Nutzer
        // klickt erneut und landet wieder im Dialog.
        // Deshalb frisch suchen — wie es der Enter-Pfad unten längst tut — und
        // die alte Referenz nur als Rückfall verwenden.
        const fresh = findSendButton();
        const target = (fresh && fresh.isConnected) ? fresh : btn;
        if (!target || !target.isConnected) {
          console.warn(`[${PLUGIN_NAME}] Retrigger: Send-Button nicht im Dokument ` +
            `(frisch=${!!fresh}, gecacht-verbunden=${!!(btn && btn.isConnected)}) — Send verpufft.`);
        }
        if (target) target.click();
      });
    }, true);

    document.addEventListener('keydown', (e) => {
      if (!PROTECTION_ENABLED) return;
      // One-Shot: NUR das synthetische Retrigger-Event (isTrusted=false) darf
      // ungeprüft durch und konsumiert das Flag.
      if (bypassNextSend && !e.isTrusted) { bypassNextSend = false; return; }
      if (e.key !== 'Enter' || e.shiftKey || e.altKey || e.ctrlKey || e.metaKey) return;

      const inputEl = findChatInput();
      if (!inputEl) return;
      if (e.target !== inputEl && !inputEl.contains(e.target)) return;

      let text = '';
      try { text = getInputText(inputEl).trim(); } catch (_) { text = ''; }
      if (!text) return; // leeres Feld → nichts zu prüfen, durchlassen

      // SYNCHRON blocken — VOR jedem await (siehe Kommentar über runGuardedSend).
      e.preventDefault();
      e.stopPropagation();
      e.stopImmediatePropagation();
      runGuardedSend(inputEl, 'enter', () => {
        // Gemini verarbeitet einen synthetischen Enter zunächst intern und löst
        // danach asynchron selbst einen Klick auf den Send-Button aus. Das
        // frühere One-Shot-Bypass wurde bereits vom Enter verbraucht, sodass
        // dieser Folge-Klick erneut im Bestätigungsdialog landete. Nach der
        // Freigabe klicken wir deshalb bei Gemini direkt den Send-Button; der
        // vorhandene One-Shot-Bypass wird exakt von diesem Klick konsumiert.
        const sendButton = findSendButton();
        const strategy = Core.sendRetriggerStrategy(PLATFORM, 'enter', !!sendButton);
        if (strategy === 'button-click') {
          sendButton.click();
          return;
        }
        const evt = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true });
        inputEl.dispatchEvent(evt);
      });
    }, true);

    console.log(`[${PLUGIN_NAME}] Send-Hooks aktiv (click + enter, capture: true, synchroner Block)`);
  }

  // ─── Phase 4: Re-Identifikation der Antworten (mit Tooltip-Highlight) ─
  function getPseudonymList() {
    // Längste zuerst, damit "PERSON_001" vor "PERSON_" matched
    return [...REVERSE.keys()].sort((a, b) => b.length - a.length);
  }

  function isInsidePluginUI(node) {
    if (!node) return false;
    const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
    if (!el) return false;
    if (el.id === 'kishield-zk-badge' || el.id === 'kishield-zk-modal') return true;
    if (el.closest && (el.closest('#kishield-zk-badge') || el.closest('#kishield-zk-modal'))) return true;
    return false;
  }

  function isReidWrapper(node) {
    if (!node) return false;
    const el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
    return !!(el && el.hasAttribute && el.hasAttribute('data-kishield-reid'));
  }

  function reidentifyTextNode(node) {
    if (node.nodeType !== Node.TEXT_NODE) return;
    if (!node.parentElement) return;
    if (isInsidePluginUI(node)) return;
    if (isReidWrapper(node)) return; // schon gewrappt, nicht erneut

    // Wichtig: parent.normalize() führt benachbarte TextNodes zu einem zusammen.
    // ChatGPT/Gemini streamen Pseudonyme oft als mehrere LLM-Tokens
    // (z.B. "ADRESSE_" und "515" als zwei TextNode-Siblings) — ohne diesen
    // Schritt würde keiner der TextNodes alleine das volle Pseudonym matchen.
    try { node.parentElement.normalize(); } catch (_) {}

    // Nach normalize() kann unser ursprünglicher node in einem Sibling
    // absorbiert worden sein. Dann triggert die normalize-Operation selbst
    // eine characterData-Mutation auf dem überlebenden Sibling — der wird
    // durch den Observer separat erneut behandelt.
    if (!node.isConnected) return;

    const original = node.nodeValue;
    if (!original) return;

    const pseudos = getPseudonymList();
    if (pseudos.length === 0) return;

    // Schnellprüfung
    let hasAny = false;
    for (const p of pseudos) {
      if (original.indexOf(p) !== -1) { hasAny = true; break; }
    }
    if (!hasAny) return;

    // DocumentFragment aufbauen mit Text + Spans
    const fragment = document.createDocumentFragment();
    let remaining = original;

    while (remaining.length > 0) {
      let earliestPos = -1;
      let earliestPseudo = null;

      for (const p of pseudos) {
        const pos = findPseudoPos(remaining, p);
        if (pos !== -1 && (earliestPos === -1 || pos < earliestPos)) {
          earliestPos = pos;
          earliestPseudo = p;
        }
      }

      if (earliestPos === -1) {
        fragment.appendChild(document.createTextNode(remaining));
        break;
      }

      if (earliestPos > 0) {
        fragment.appendChild(document.createTextNode(remaining.slice(0, earliestPos)));
      }

      const originalValue = REVERSE.get(earliestPseudo);
      const span = document.createElement('span');
      span.setAttribute('data-kishield-reid', earliestPseudo);
      span.title = `KI-Shield: Original-Wert lokal eingesetzt. ${PLATFORM_LABEL.ai} sah hier "${earliestPseudo}".`;
      span.style.cssText = 'border-bottom: 1.5px dotted #6366f1; cursor: help; text-decoration: none;';
      span.textContent = originalValue;
      fragment.appendChild(span);

      remaining = remaining.slice(earliestPos + earliestPseudo.length);
    }

    try {
      node.parentElement.replaceChild(fragment, node);
    } catch (e) {
      // Fallback: einfacher Text-Replace falls DOM-Manipulation fehlschlägt
      node.nodeValue = reidentifyString(original);
    }
  }

  function reidentifySubtree(rootNode) {
    if (rootNode.nodeType !== Node.ELEMENT_NODE) return;
    if (isInsidePluginUI(rootNode)) return;
    if (isReidWrapper(rootNode)) return;

    const walker = document.createTreeWalker(rootNode, NodeFilter.SHOW_TEXT, {
      acceptNode: (n) => {
        const parent = n.parentElement;
        if (!parent) return NodeFilter.FILTER_REJECT;
        if (isInsidePluginUI(parent)) return NodeFilter.FILTER_REJECT;
        if (isReidWrapper(parent)) return NodeFilter.FILTER_REJECT;
        return NodeFilter.FILTER_ACCEPT;
      }
    });

    // Sammle Knoten erst, dann mutiere — sonst bricht der Walker
    const nodes = [];
    let node;
    while ((node = walker.nextNode())) {
      nodes.push(node);
    }
    for (const n of nodes) {
      reidentifyTextNode(n);
    }
  }

  function setupReidentificationObserver() {
    const observer = new MutationObserver((mutations) => {
      if (REVERSE.size === 0) return;

      for (const mut of mutations) {
        if (mut.type === 'characterData') {
          reidentifyTextNode(mut.target);
        } else if (mut.type === 'childList') {
          for (const node of mut.addedNodes) {
            if (node.nodeType === Node.TEXT_NODE) {
              reidentifyTextNode(node);
            } else if (node.nodeType === Node.ELEMENT_NODE) {
              reidentifySubtree(node);
            }
          }
        }
      }
    });

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

    setTimeout(() => reidentifySubtree(document.body), 1000);

    // Periodic Re-Sweep: alle 2 Sekunden den ganzen Body durchscannen.
    // Fängt Edge-Cases wo Pseudonyme zwischen non-text-Elementen verteilt sind
    // (z.B. <strong>ADRESSE_</strong><strong>515</strong>) und normalize()
    // sie nicht zusammenführen kann — diese passieren nicht beim normalen
    // ChatGPT-Streaming aber bei Markdown-Rendering möglich.
    setInterval(() => {
      if (REVERSE.size === 0) return;
      reidentifySubtree(document.body);
    }, 2000);

    console.log(`[${PLUGIN_NAME}] Re-Identifikations-Observer aktiv (mit normalize + periodic sweep)`);
  }

  // ─── Eingabefeld verbinden (Live-Erkennung + Highlight) ─────────────
  function attachToInput(inputEl) {
    if (inputEl.dataset.kishieldHooked === '1') return;
    inputEl.dataset.kishieldHooked = '1';
    console.log(`[${PLUGIN_NAME}] Eingabefeld verbunden:`, inputEl);

    let lastText = '';
    const runDetection = async () => {
      const sequence = ++detectSequence;
      if (!PROTECTION_ENABLED || !LIVE_DETECTION_ENABLED) {
        updateBadge([]);
        clearHighlightOverlay();
        lastEntities = [];
        return;
      }
      const text = getInputText(inputEl).trim();
      if (text === lastText) return;
      lastText = text;
      // Sehr kurze Entwürfe nicht hochladen (spart Proxy-Calls, kaum PII möglich).
      if (text.length < 4) { updateBadge([]); clearHighlightOverlay(); lastEntities = []; return; }
      try {
        const r = await proxyDetect(text);
        if (sequence !== detectSequence || !PROTECTION_ENABLED || !LIVE_DETECTION_ENABLED) return;
        const serverEnts = (r && r.ok && r.entities) ? r.entities : [];
        lastServerEntities = serverEnts; lastDetectText = text;
        const entities = combineEntities(text, serverEnts);
        updateBadge(entities);
        lastEntities = entities;
        lastInputEl = inputEl;
        renderHighlightOverlay(inputEl, entities);
      } catch (_) { /* Live-Vorschau ist best-effort */ }
    };

    // Nur bei Tipp-PAUSE (700 ms) ein Proxy-Call, nicht pro Tastendruck.
    const debouncedRun = debounce(runDetection, 700);
    inputEl.addEventListener('input', debouncedRun);
    inputEl.addEventListener('keyup', debouncedRun);
    inputEl.addEventListener('paste', () => setTimeout(debouncedRun, 50));
    const mo = new MutationObserver(debouncedRun);
    mo.observe(inputEl, { childList: true, subtree: true, characterData: true });
    debouncedRun();
  }

  // ─── Datei-Uploads: abfangen → Text lokal extrahieren → einfügen ────────
  // Der Thin-Client darf keine Original-Datei an die KI lassen (die Server-
  // Maskierung greift nur auf TEXT). Also fangen wir Drag&Drop, Büroklammer-
  // Upload und Bild-Einfügen ab, BEVOR die Datei die KI-Seite erreicht, lesen
  // den Text im Browser aus (pdf.js/mammoth/OCR im Offscreen-Dokument) und
  // fügen ihn ins Eingabefeld ein → er durchläuft die normale Maskierung.
  // FAIL-CLOSED: Extraktion fehlgeschlagen / Typ unbekannt → geblockt. Es gibt
  // bewusst KEINE feste Datei- oder Seitenobergrenze. Große Dateien laufen als
  // kleine Nachrichten zum Offscreen-Dokument, große Texte als Chatabschnitte.
  const MESSAGE_MAX_TEXT_CHARS = 45000;
  const DOCUMENT_PART_CHARS = 38000;
  const FILE_STREAM_CHUNK_BYTES = 256 * 1024;
  let activeDocument = null;

  function bytesToBase64(bytes) {
    let binary = '';
    for (let offset = 0; offset < bytes.byteLength; offset += 32768) {
      binary += String.fromCharCode(...bytes.subarray(offset, offset + 32768));
    }
    return btoa(binary);
  }

  async function checkedRuntimeMessage(message) {
    const response = await chrome.runtime.sendMessage(message);
    if (!response || !response.ok) {
      throw new Error((response && response.error) || 'keine Antwort der Erweiterung');
    }
    return response;
  }

  async function sendFileChunks(file, jobId) {
    let sent = 0;
    let nextProgress = 10;
    const sendBytes = async (bytes) => {
      for (let offset = 0; offset < bytes.byteLength; offset += FILE_STREAM_CHUNK_BYTES) {
        const part = bytes.subarray(offset, offset + FILE_STREAM_CHUNK_BYTES);
        await checkedRuntimeMessage({
          type: 'kishield-extract-chunk',
          jobId,
          chunk: bytesToBase64(part),
        });
        sent += part.byteLength;
        const percent = file.size ? Math.floor((sent / file.size) * 100) : 100;
        if (percent >= nextProgress || sent === file.size) {
          showBanner(
            `KI-Shield Dokument: ${Math.min(100, percent)} % lokal eingelesen …`,
            '#4f46e5',
            0
          );
          nextProgress = Math.min(100, percent + 10);
        }
      }
    };

    if (file.stream && typeof file.stream === 'function') {
      const reader = file.stream().getReader();
      try {
        while (true) {
          const item = await reader.read();
          if (item.done) break;
          await sendBytes(item.value instanceof Uint8Array ? item.value : new Uint8Array(item.value));
        }
      } finally {
        try { reader.releaseLock(); } catch (_) {}
      }
      return;
    }

    for (let offset = 0; offset < file.size; offset += FILE_STREAM_CHUNK_BYTES) {
      const buffer = await file.slice(offset, offset + FILE_STREAM_CHUNK_BYTES).arrayBuffer();
      await sendBytes(new Uint8Array(buffer));
    }
  }

  function showDirectExtractionProgress(message) {
    if (!message || typeof message !== 'object') return;
    if (message.phase === 'ocr-init') {
      const stage = String(message.stage || '');
      if (stage === 'timeout' || stage === 'error') {
        showBanner(
          stage === 'timeout'
            ? 'KI-Shield Dokument: Lokale OCR-Engine antwortet nicht — Verarbeitung sicher abgebrochen.'
            : 'KI-Shield Dokument: Lokale OCR-Engine konnte nicht gestartet werden.',
          '#dc2626',
          0
        );
      } else if (stage === 'ready') {
        showBanner(
          'KI-Shield Dokument: Lokale OCR-Engine bereit — alle bildbasierten Seiten werden gelesen …',
          '#4f46e5',
          0
        );
      } else {
        const progress = Math.max(
          0,
          Math.min(100, Math.round((Number(message.progress) || 0) * 100))
        );
        showBanner(
          `KI-Shield Dokument: Lokale OCR-Engine wird vorbereitet${progress ? ` (${progress} %)` : ''} …`,
          '#4f46e5',
          0
        );
      }
      return;
    }

    const page = Math.max(1, Number(message.page) || 1);
    const total = Math.max(page, Number(message.totalPages) || page);
    const phase = message.phase === 'ocr' || message.ocr ? 'OCR' : 'Text';
    showBanner(
      `KI-Shield Dokument: ${phase} – Seite ${page.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} wird lokal gelesen …`,
      '#4f46e5',
      0
    );
  }

  async function extractFileLocally(file) {
    const directExtractor = self.KIShieldDocumentExtractor;
    if (
      directExtractor &&
      typeof directExtractor.supports === 'function' &&
      directExtractor.supports(file)
    ) {
      return directExtractor.extract(file, {
        onProgress: showDirectExtractionProgress,
      });
    }

    const jobId = 'document-' + Date.now().toString(36) + '-' +
      Math.random().toString(36).slice(2);
    let started = false;
    activeExtractionJobId = jobId;
    try {
      await checkedRuntimeMessage({
        type: 'kishield-extract-start',
        jobId,
        name: file.name,
        mime: file.type,
        size: file.size,
      });
      started = true;
      await sendFileChunks(file, jobId);
      const finished = await checkedRuntimeMessage({
        type: 'kishield-extract-finish',
        jobId,
      });
      const pageCount = Math.max(0, Number(finished.pageCount) || 0);
      const pages = Array.from({ length: pageCount }, (_, index) => ({
        number: index + 1,
        text: '',
        source: 'text',
      }));
      const batchCount = Math.max(0, Number(finished.batchCount) || 0);
      for (let index = 0; index < batchCount; index++) {
        const batch = await checkedRuntimeMessage({
          type: 'kishield-extract-result',
          jobId,
          index,
        });
        for (const fragment of Array.isArray(batch.pages) ? batch.pages : []) {
          const pageIndex = Math.floor(Number(fragment.number)) - 1;
          if (pageIndex < 0 || pageIndex >= pages.length) {
            throw new Error('ungueltige Seitennummer im Extraktionsergebnis');
          }
          pages[pageIndex].text += String(fragment.text || '');
          if (fragment.source === 'ocr') pages[pageIndex].source = 'ocr';
        }
      }
      return {
        pages,
        pageCount,
        ocrPageCount: Math.max(0, Number(finished.ocrPageCount) || 0),
      };
    } finally {
      activeExtractionJobId = null;
      if (started) {
        try {
          await chrome.runtime.sendMessage({
            type: 'kishield-extract-release',
            jobId,
          });
        } catch (_) {}
      }
    }
  }

  function insertExtractedText(text) {
    const el = findChatInput();
    if (!el) { showBanner('KI-Shield: Kein Eingabefeld gefunden — Datei wurde nicht eingefügt (und nicht hochgeladen).', '#dc2626', 7000); return false; }
    try { el.focus(); } catch (_) {}
    let ok = false;
    try { ok = document.execCommand('insertText', false, text); } catch (_) {}
    if (!ok) { showBanner('KI-Shield: Extrahierter Text konnte nicht eingefügt werden — bitte manuell hineinkopieren.', '#dc2626', 8000); }
    return ok;
  }

  function showDocumentPreview(fileName, text, details) {
    return new Promise((resolve) => {
      const old = document.getElementById('kishield-document-preview');
      if (old) old.remove();
      const overlay = document.createElement('div');
      overlay.id = 'kishield-document-preview';
      overlay.setAttribute('role', 'dialog');
      overlay.setAttribute('aria-modal', 'true');
      overlay.style.cssText = 'position:fixed;inset:0;z-index:2147483647;background:rgba(15,23,42,.58);' +
        'display:flex;align-items:center;justify-content:center;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;';
      const shownText = text.length > 5000 ? text.slice(0, 5000) + '\n\n… Vorschau gekürzt …' : text;
      const wordCount = text.trim().split(/\s+/).filter(Boolean).length;
      const pageCount = Math.max(1, Number(details && details.pageCount) || 1);
      const ocrPageCount = Math.max(0, Number(details && details.ocrPageCount) || 0);
      const partCount = Math.max(1, Number(details && details.partCount) || 1);
      const detailText =
        pageCount.toLocaleString('de-DE') + ' Seite' + (pageCount === 1 ? '' : 'n') +
        (ocrPageCount ? ' · OCR auf ' + ocrPageCount.toLocaleString('de-DE') + ' Seite' + (ocrPageCount === 1 ? '' : 'n') : '') +
        ' · ' + partCount.toLocaleString('de-DE') + ' sicher' + (partCount === 1 ? 'er Abschnitt' : 'e Abschnitte');
      overlay.innerHTML =
        '<div style="width:min(620px,92vw);max-height:86vh;overflow:auto;background:#fff;border-radius:14px;padding:22px;box-shadow:0 24px 70px rgba(0,0,0,.3);">' +
          '<div style="font-size:16px;font-weight:700;color:#123c2d;">Dokument-Vorschau</div>' +
          '<div style="font-size:12px;color:#64748b;margin:4px 0 14px;">' + escHtml(fileName) + ' · ' +
            wordCount.toLocaleString('de-DE') + ' Wörter · ' + text.length.toLocaleString('de-DE') + ' Zeichen<br>' +
            escHtml(detailText) + '</div>' +
          '<div style="font-size:13px;color:#334155;margin-bottom:10px;">Das gesamte Dokument wurde lokal extrahiert. Große Dokumente werden vollständig in sichere Abschnitte zerlegt. Du sendest jeden Abschnitt weiterhin selbst; vor jedem Senden erfolgt die normale PII-Prüfung.</div>' +
          '<pre style="white-space:pre-wrap;word-break:break-word;max-height:45vh;overflow:auto;background:#f7faf8;border:1px solid #d9e5df;border-radius:8px;padding:12px;font:12px/1.5 ui-monospace,Menlo,monospace;color:#1f2937;">' +
            escHtml(shownText) + '</pre>' +
          '<div style="display:flex;justify-content:flex-end;gap:9px;margin-top:16px;">' +
            '<button id="kishield-document-cancel" style="padding:8px 14px;border:1px solid #cbd5e1;border-radius:7px;background:#fff;color:#475569;cursor:pointer;">Abbrechen</button>' +
            '<button id="kishield-document-insert" style="padding:8px 14px;border:0;border-radius:7px;background:#087a52;color:#fff;font-weight:650;cursor:pointer;">' +
              (partCount > 1 ? 'Abschnitt 1 sicher einfügen' : 'Text sicher einfügen') +
            '</button>' +
          '</div>' +
        '</div>';
      const finish = (accepted) => {
        overlay.remove();
        resolve(accepted);
      };
      overlay.querySelector('#kishield-document-cancel').addEventListener('click', () => finish(false));
      overlay.querySelector('#kishield-document-insert').addEventListener('click', () => finish(true));
      overlay.addEventListener('click', (event) => {
        if (event.target === overlay) finish(false);
      });
      document.body.appendChild(overlay);
      overlay.querySelector('#kishield-document-insert').focus();
    });
  }

  function removeDocumentNavigator() {
    const panel = document.getElementById('kishield-document-navigator');
    if (panel) panel.remove();
  }

  function renderDocumentNavigator() {
    removeDocumentNavigator();
    if (!activeDocument) return;
    const total = activeDocument.parts.length;
    const nextIndex = activeDocument.nextIndex;
    const panel = document.createElement('div');
    panel.id = 'kishield-document-navigator';
    panel.style.cssText =
      'position:fixed;left:18px;bottom:18px;z-index:2147483646;width:min(360px,calc(100vw - 36px));' +
      'box-sizing:border-box;background:#fff;border:1px solid #cbd5e1;border-radius:12px;padding:13px 14px;' +
      'box-shadow:0 16px 45px rgba(15,23,42,.22);font:13px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#1f2937;';
    const complete = nextIndex >= total;
    panel.innerHTML =
      '<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:10px;">' +
        '<div><div style="font-weight:750;color:#123c2d;">KI-Shield Dokument</div>' +
          '<div style="font-size:12px;color:#64748b;word-break:break-word;">' + escHtml(activeDocument.fileName) + '</div></div>' +
        '<button id="kishield-document-close" title="Dokument verwerfen" style="border:0;background:transparent;color:#64748b;font-size:18px;line-height:1;cursor:pointer;">×</button>' +
      '</div>' +
      '<div style="margin-top:9px;font-size:12px;color:#475569;">' +
        (complete
          ? 'Alle ' + total.toLocaleString('de-DE') + ' Abschnitte wurden eingefügt.'
          : nextIndex.toLocaleString('de-DE') + ' von ' + total.toLocaleString('de-DE') + ' Abschnitten eingefügt.') +
      '</div>' +
      (complete ? '' :
        '<button id="kishield-document-next" style="width:100%;margin-top:9px;padding:8px 10px;border:0;border-radius:7px;background:#087a52;color:#fff;font-weight:700;cursor:pointer;">' +
          'Abschnitt ' + (nextIndex + 1).toLocaleString('de-DE') + ' von ' + total.toLocaleString('de-DE') + ' einfügen' +
        '</button>');
    panel.querySelector('#kishield-document-close').addEventListener('click', () => {
      activeDocument = null;
      removeDocumentNavigator();
      showBanner('KI-Shield: Dokumentabschnitte aus dem Browser-Speicher entfernt.', '#64748b', 4000);
    });
    const nextButton = panel.querySelector('#kishield-document-next');
    if (nextButton) {
      nextButton.addEventListener('click', () => {
        if (activeDocument) insertDocumentPart(activeDocument.nextIndex, false);
      });
    }
    document.body.appendChild(panel);
  }

  function documentPartPayload(documentState, index) {
    const header =
      '[Dokument „' + documentState.fileName + '“ – Abschnitt ' +
      (index + 1) + ' von ' + documentState.parts.length + ']\n\n';
    return header + documentState.parts[index];
  }

  function insertDocumentPart(index, allowExistingText) {
    if (!activeDocument || index < 0 || index >= activeDocument.parts.length) return false;
    const input = findChatInput();
    if (!input) {
      showBanner('KI-Shield: Kein Eingabefeld gefunden. Der Dokumentabschnitt bleibt lokal vorgemerkt.', '#dc2626', 7000);
      return false;
    }
    const existingText = getInputText(input).trim();
    if (!allowExistingText && existingText) {
      showBanner('KI-Shield: Bitte den aktuellen Abschnitt zuerst senden oder das Eingabefeld leeren. Danach „Nächsten Abschnitt“ wählen.', '#b45309', 8000);
      return false;
    }
    const payload = documentPartPayload(activeDocument, index);
    if (existingText.length + payload.length > MESSAGE_MAX_TEXT_CHARS) {
      showBanner('KI-Shield: Der bereits eingegebene Text lässt für diesen Dokumentabschnitt nicht genug Platz. Bitte zuerst senden oder leeren.', '#dc2626', 8000);
      return false;
    }
    if (!insertExtractedText(payload)) return false;
    activeDocument.nextIndex = index + 1;
    renderDocumentNavigator();
    showBanner(
      'KI-Shield: Abschnitt ' + (index + 1).toLocaleString('de-DE') + ' von ' +
      activeDocument.parts.length.toLocaleString('de-DE') +
      ' eingefügt — vor dem Senden wird PII geprüft.',
      '#059669',
      7000
    );
    return true;
  }

  async function handleOneFile(file) {
    if (!file || !PROTECTION_ENABLED) return;
    showBanner(
      'KI-Shield Dokument: „' + file.name + '" (' +
      (file.size / 1048576).toLocaleString('de-DE', { maximumFractionDigits: 1 }) +
      ' MB) wird vollständig lokal gelesen …',
      '#4f46e5',
      0
    );
    let res;
    try {
      if (!extensionAlive()) throw new Error('Extension neu geladen');
      res = await extractFileLocally(file);
    } catch (e) {
      showBanner('KI-Shield: Extraktion nicht möglich (' + String((e && e.message) || e) + ') — Datei blockiert, kein Upload.', '#dc2626', 8000); return;
    }
    const fullText = Core.formatDocumentPages(res.pages);
    if (!fullText.trim()) {
      showBanner('KI-Shield: In „' + file.name + '" wurde kein Text gefunden (evtl. reines Bild ohne Schrift). Upload blockiert.', '#b45309', 8000); return;
    }
    const parts = Core.splitDocumentPages(res.pages, DOCUMENT_PART_CHARS);
    showBanner(
      'KI-Shield Dokument: ' + res.pageCount.toLocaleString('de-DE') +
      ' Seiten vollständig gelesen — lokale Vorschau bereit.',
      '#087a52',
      5000
    );
    if (!(await showDocumentPreview(file.name, fullText, {
      pageCount: res.pageCount,
      ocrPageCount: res.ocrPageCount,
      partCount: parts.length,
    }))) {
      showBanner('KI-Shield: Dokument verworfen — nichts eingefügt und nichts hochgeladen.', '#64748b', 4000);
      return;
    }
    activeDocument = { fileName: file.name, parts, nextIndex: 0 };
    renderDocumentNavigator();
    insertDocumentPart(0, true);
  }

  async function handleFiles(fileList) {
    const files = Array.from(fileList || []).filter(Boolean);
    for (const f of files) {
      try { await handleOneFile(f); } catch (_) { /* nächste Datei */ }
    }
  }

  function setupUploadBlock() {
    const dtHasFiles = (dt) => {
      if (!dt) return false;
      if (dt.files && dt.files.length) return true;
      try { return Array.from(dt.types || []).includes('Files'); } catch (_) { return false; }
    };
    // dragenter/dragover: preventDefault ist nötig, damit das drop-Event bei UNS
    // landet (sonst greift der native Upload der Seite). dropEffect=copy zeigt an,
    // dass die Datei angenommen (und lokal verarbeitet) wird.
    const onDragFiles = (e) => {
      if (!PROTECTION_ENABLED) return;
      if (!dtHasFiles(e.dataTransfer)) return;
      e.preventDefault(); e.stopPropagation();
      try { e.stopImmediatePropagation(); } catch (_) {}
      try { e.dataTransfer.dropEffect = 'copy'; } catch (_) {}
    };
    document.addEventListener('dragenter', onDragFiles, true);
    document.addEventListener('dragover', onDragFiles, true);
    document.addEventListener('drop', (e) => {
      if (!PROTECTION_ENABLED) return;
      if (!dtHasFiles(e.dataTransfer)) return;
      // Seiten-eigenen Upload unterbinden, dann selbst extrahieren.
      e.preventDefault(); e.stopPropagation();
      try { e.stopImmediatePropagation(); } catch (_) {}
      const files = (e.dataTransfer && e.dataTransfer.files) ? e.dataTransfer.files : null;
      if (files && files.length) handleFiles(files);
    }, true);
    // Zwischenablage: (a) Bild/Datei → extrahieren; (b) LANGER Text → als normalen
    // Text einfügen. Grund für (b): Claude/ChatGPT wandeln langen eingefügten Text
    // automatisch in einen ANHANG ("PASTED") um, der die Maskierung umgeht. Wir
    // fügen ihn stattdessen als normalen Editor-Text ein → wird beim Senden regulär
    // geprüft und maskiert.
    document.addEventListener('paste', (e) => {
      if (!PROTECTION_ENABLED) return;
      const cd = e.clipboardData;
      if (!cd) return;
      if (cd.files && cd.files.length) {
        e.preventDefault(); e.stopPropagation();
        try { e.stopImmediatePropagation(); } catch (_) {}
        handleFiles(cd.files);
        return;
      }
      let text = '';
      try { text = cd.getData('text/plain') || ''; } catch (_) { text = ''; }
      if (text.length >= 800) {  // Schwelle unter der üblichen Anhang-Umwandlung
        const el = findChatInput();
        if (el && (el === e.target || (el.contains && el.contains(e.target)))) {
          e.preventDefault(); e.stopPropagation();
          try { e.stopImmediatePropagation(); } catch (_) {}
          try { el.focus(); } catch (_) {}
          let inserted = false;
          try { inserted = document.execCommand('insertText', false, text); } catch (_) {}
          if (!inserted) {
            showBanner('KI-Shield: Langer Text konnte nicht sicher als normaler Text eingefügt werden — bitte in kleineren Abschnitten einfügen (sonst wird er als ungeprüfter Anhang behandelt).', '#dc2626', 8000);
          }
        }
      }
    }, true);
    // Büroklammer-Button (<input type=file>): Datei greifen, Upload verhindern
    // (value leeren + Event stoppen), dann selbst extrahieren.
    document.addEventListener('change', (e) => {
      if (!PROTECTION_ENABLED) return;
      const t = e.target;
      if (t && t.tagName === 'INPUT' && t.type === 'file' && t.files && t.files.length) {
        const files = Array.from(t.files);
        try { t.value = ''; } catch (_) {}
        e.preventDefault(); e.stopPropagation();
        try { e.stopImmediatePropagation(); } catch (_) {}
        handleFiles(files);
      }
    }, true);
    console.log(`[${PLUGIN_NAME}] Datei-Schutz aktiv: Uploads werden abgefangen und lokal ausgelesen (Drag&Drop, Paste, File-Input).`);
  }

  // ─── Bootstrap ───────────────────────────────────────────────────────
  loadTagging();
  setupSendHooks();
  setupUploadBlock();
  setupReidentificationObserver();
  setupTaggingUI();

  const tryAttach = () => {
    const input = findChatInput();
    if (!input) return false;
    attachToInput(input);
    return true;
  };

  // Robustes Attach: ChatGPT ersetzt sein Composer-Element beim Re-Rendern — und
  // #prompt-textarea ist inzwischen ein contenteditable <div> statt <textarea>, das
  // oft OHNE URL-Wechsel neu erzeugt wird. Ein einmaliges Attach reicht daher nicht,
  // sonst wird das neue Feld nie gehookt (→ keine Erkennung → Klartext).
  // Wir rufen tryAttach() bei JEDER DOM-Mutation auf; attachToInput() ist via
  // dataset.kishieldHooked idempotent, hängt sich also nur an neue, noch nicht
  // verbundene Felder an. Ein aus dem DOM entferntes Feld stoppt sein Polling selbst.
  tryAttach();

  let lastUrl = location.href;
  let lastCid = getConversationId();
  new MutationObserver(() => {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      const newCid = getConversationId();
      if (lastCid.endsWith(':new') && !newCid.endsWith(':new') && FORWARD.size > 0 && extensionAlive()) {
        // Erstnachricht eines neuen Chats: Mappings vom volatilen ':new'-
        // Schlüssel auf die echte Conversation-ID migrieren — sonst wäre die
        // Re-Identifikation nach einem Reload verloren (verwaister Storage-Key).
        try {
          chrome.storage.local.set({
            [`mappings_${newCid}`]: mappingRecord(forwardAsObject(), mappingCreatedAt()),
          }).catch(() => {});
          chrome.storage.local.remove(`mappings_${lastCid}`).catch(() => {});
        } catch (_) {}
      } else {
        loadMappings();
      }
      lastCid = newCid;
    }
    tryAttach();
  }).observe(document.body, { childList: true, subtree: true });

  console.log(`[${PLUGIN_NAME}] Bootstrap komplett. Phase 3 (Pre-Send) + Phase 4 (Re-ID) aktiv.`);
})();
