/**
 * KI-Shield Proxy v2.2.3 — Network Layer
 * 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.
 */

// injected.js — Network-Layer, läuft im MAIN world von claude.ai / chatgpt.com / gemini.google.com
//
// Aufgabe: Kein erkannter Klartext verlässt den Browser. Wir hooken die Ausgangs-
// kanäle (fetch, XMLHttpRequest, sendBeacon) und ersetzen bekannte PII durch
// Pseudonyme, bevor der Request rausgeht. Antworten (SSE-Stream) werden lokal
// re-identifiziert.
//
// Sicherheits-Grundhaltung: FAIL-CLOSED.
//   - Ist die Erkennung tot (PANIC) oder die Lizenz ungültig, werden Chat-API-
//     Requests HART blockiert statt Klartext durchzulassen.
//   - Bodys, die wir NICHT als Text lesen können (Datei-Uploads: FormData / Binär-
//     Blob), können wir nicht auf PII prüfen → wir warnen den Nutzer sichtbar
//     (kishield-warn), statt still durchzuwinken. (Text-Prompts sind immer lesbar
//     und werden vollständig pseudonymisiert.)
//
// Kommunikation mit content.js (ISOLATED world) via window.postMessage. Jede
// Nachricht trägt `mark: MARK` — ein leichter Filter gegen fremdes Cross-Talk
// (andere Extensions / Seiten-Skripte). Er ist KEINE starke Authentifizierung
// (postMessage ist im MAIN world mitlesbar); die eigentliche Sicherheit liegt in
// der fail-closed-Haltung: eine gefälschte Nachricht kann Requests höchstens
// BLOCKIEREN (DoS), aber keinen zusätzlichen Klartext freisetzen.

(() => {
  'use strict';

  const TAG = '[KI-Shield Injected]';
  const MARK = 'kishield-v1';
  const Core = globalThis.KIShieldCore;
  if (!Core) throw new Error('KIShieldCore wurde nicht geladen');
  console.log('%c' + TAG + ' aktiv im MAIN world', 'color: #6366f1; font-weight: bold;');

  // ─── Zustand ────────────────────────────────────────────────────────
  const PSEUDOS = new Map();   // original  → pseudonym
  const REVERSE = new Map();   // pseudonym → original
  const PSEUDO_EXPIRIES = new Map();
  let mappingExpiryTimer = null;
  let LICENSED = false;
  let LICENSE_KNOWN = false;   // erst blockieren, wenn content.js den Status kennt
  let PANIC = false;           // Erkennung tot → alle Chat-Sends blockieren
  let ENABLED = true;          // Bestandskunden bleiben ohne neuen Storage-Wert geschützt
  const SEND_APPROVALS = [];
  const NETWORK_DIAGNOSTICS = {
    approvalsRegistered: 0,
    approvalsMatched: 0,
    approvalBlocks: 0,
    geminiBackgroundAllowed: 0,
    lastBlock: null,
  };

  function diagnosticSnapshot() {
    return {
      approvalsRegistered: NETWORK_DIAGNOSTICS.approvalsRegistered,
      approvalsMatched: NETWORK_DIAGNOSTICS.approvalsMatched,
      approvalBlocks: NETWORK_DIAGNOSTICS.approvalBlocks,
      geminiBackgroundAllowed: NETWORK_DIAGNOSTICS.geminiBackgroundAllowed,
      pendingApprovals: SEND_APPROVALS.length,
      lastBlock: NETWORK_DIAGNOSTICS.lastBlock
        ? Object.assign({}, NETWORK_DIAGNOSTICS.lastBlock)
        : null,
    };
  }

  function recordApprovalBlock(transport, bodyText) {
    NETWORK_DIAGNOSTICS.approvalBlocks++;
    NETWORK_DIAGNOSTICS.lastBlock = {
      at: Date.now(),
      platform: Core.platformHost(location.hostname) || 'unknown',
      transport,
      bodyLength: String(bodyText == null ? '' : bodyText).length,
      pendingApprovals: SEND_APPROVALS.length,
    };
  }

  function scheduleNextMappingExpiry() {
    if (mappingExpiryTimer) clearTimeout(mappingExpiryTimer);
    mappingExpiryTimer = null;
    if (PSEUDO_EXPIRIES.size === 0) return;
    const nextExpiry = Math.min(...PSEUDO_EXPIRIES.values());
    mappingExpiryTimer = setTimeout(() => {
      mappingExpiryTimer = null;
      pruneMappings();
    }, Math.max(0, nextExpiry - Date.now()));
    if (mappingExpiryTimer && typeof mappingExpiryTimer.unref === 'function') {
      mappingExpiryTimer.unref();
    }
  }

  function pruneMappings() {
    const now = Date.now();
    for (const [orig, expiresAt] of PSEUDO_EXPIRIES) {
      if (expiresAt > now) continue;
      const pseudo = PSEUDOS.get(orig);
      PSEUDOS.delete(orig);
      PSEUDO_EXPIRIES.delete(orig);
      if (pseudo && REVERSE.get(pseudo) === orig) REVERSE.delete(pseudo);
    }
    scheduleNextMappingExpiry();
  }

  function clearAllMappings() {
    if (mappingExpiryTimer) clearTimeout(mappingExpiryTimer);
    mappingExpiryTimer = null;
    PSEUDOS.clear();
    REVERSE.clear();
    PSEUDO_EXPIRIES.clear();
  }

  function pruneApprovals() {
    const now = Date.now();
    for (let i = SEND_APPROVALS.length - 1; i >= 0; i--) {
      if (!SEND_APPROVALS[i] || SEND_APPROVALS[i].expiresAt <= now || SEND_APPROVALS[i].usesLeft <= 0) {
        SEND_APPROVALS.splice(i, 1);
      }
    }
  }

  function consumeApproval(bodyText) {
    pruneApprovals();
    const index = SEND_APPROVALS.findIndex((approval) =>
      Core.bodyContainsApprovedText(bodyText, approval.candidateTexts)
    );
    if (index < 0) return false;
    SEND_APPROVALS[index].usesLeft--;
    NETWORK_DIAGNOSTICS.approvalsMatched++;
    console.log(TAG + ' Kurzzeitfreigabe passt zum Request [body=' +
      String(bodyText == null ? '' : bodyText).length + ' Zeichen].');
    pruneApprovals();
    return true;
  }

  function approvalDecision(url, method, bodyText) {
    let required = Core.requiresSendApproval(url, location.hostname, method);
    const geminiStream = Core.platformHost(location.hostname) === 'gemini' &&
      /\/StreamGenerate(?:\?|$)/i.test(String(url || ''));
    if (required && geminiStream && !Core.geminiStreamRequestCarriesPrompt(bodyText)) {
      required = false;
    }
    const geminiBatch = Core.platformHost(location.hostname) === 'gemini' &&
      /\/batchexecute(?:\?|$)/i.test(String(url || ''));
    const matched = (required || geminiBatch) ? consumeApproval(bodyText) : false;
    if ((geminiBatch || geminiStream) && !required && !matched) {
      NETWORK_DIAGNOSTICS.geminiBackgroundAllowed++;
    }
    return { required, matched };
  }

  // ─── Nachrichten von content.js ─────────────────────────────────────
  function msgOk(e) {
    return e.source === window && e.data && e.data.mark === MARK;
  }
  function notify(type, extra) {
    try { window.postMessage(Object.assign({ mark: MARK, type }, extra || {}), '*'); } catch (_) {}
  }

  window.addEventListener('message', (e) => {
    if (!msgOk(e)) return;
    const d = e.data;
    switch (d.type) {
      case 'kishield-mappings': {
        const fresh = d.entries || {};
        const expiresAt = Math.min(
          Number(d.expiresAt) || (Date.now() + Core.MAPPING_TTL_MS),
          Date.now() + Core.MAPPING_TTL_MS
        );
        let added = 0;
        if (expiresAt > Date.now()) {
          for (const [orig, pseudo] of Object.entries(fresh)) {
            if (typeof orig !== 'string' || typeof pseudo !== 'string') continue;
            if (!orig || !pseudo) continue;
            if (!PSEUDOS.has(orig)) {
              PSEUDOS.set(orig, pseudo);
              REVERSE.set(pseudo, orig);
              added++;
            }
            PSEUDO_EXPIRIES.set(orig, Math.min(PSEUDO_EXPIRIES.get(orig) || expiresAt, expiresAt));
          }
          pruneMappings();
        }
        if (added > 0) console.log(TAG + ' Mappings +' + added + ', total ' + PSEUDOS.size);
        // ACK-Gating: content.js sendet den Chat-Request erst nach dieser
        // Bestätigung — ohne Ack wird der Send abgebrochen (kein Klartext-Risiko
        // durch verlorene Mapping-Nachrichten).
        if (d.syncId && expiresAt > Date.now()) {
          notify('kishield-mappings-ack', { syncId: d.syncId, total: PSEUDOS.size });
        }
        break;
      }
      case 'kishield-mappings-clear': {
        clearAllMappings();
        break;
      }
      case 'kishield-license':
        LICENSED = !!d.licensed;
        LICENSE_KNOWN = true;
        break;
      case 'kishield-enabled':
        ENABLED = d.enabled !== false;
        if (!ENABLED) SEND_APPROVALS.splice(0);
        break;
      case 'kishield-send-approved': {
        const expiresAt = Math.min(
          Number(d.expiresAt) || 0,
          Date.now() + Core.SEND_APPROVAL_TTL_MS
        );
        const candidateTexts = [...new Set((d.candidateTexts || [])
          .filter((text) => typeof text === 'string' && text.length > 0))];
        if (d.approvalId && expiresAt > Date.now() && candidateTexts.length) {
          SEND_APPROVALS.push({
            approvalId: String(d.approvalId),
            candidateTexts,
            expiresAt,
            // Ein Plattform-Send kann wenige inhaltlich identische Requests auslösen
            // (Retry/Stream-Aufbau); jeder muss denselben geprüften Text enthalten.
            usesLeft: 20,
          });
          pruneApprovals();
          NETWORK_DIAGNOSTICS.approvalsRegistered++;
          console.log(TAG + ' Kurzzeitfreigabe registriert [kandidaten=' +
            candidateTexts.length + ', ttl=' + Math.max(0, expiresAt - Date.now()) + 'ms].');
          notify('kishield-send-approved-ack', { approvalId: d.approvalId });
        }
        break;
      }
      case 'kishield-health-query':
        pruneMappings();
        notify('kishield-health', {
          nonce: d.nonce,
          enabled: ENABLED,
          approvals: SEND_APPROVALS.length,
          mappings: PSEUDOS.size,
          diagnostics: diagnosticSnapshot(),
        });
        break;
      case 'kishield-panic':
        PANIC = true;
        console.warn(TAG + ' PANIC empfangen — Chat-Sends werden blockiert (Erkennung nicht verfügbar).');
        break;
    }
  });

  // Nach eigenem (Re-)Load den Lizenzstatus von content.js anfordern.
  notify('kishield-license-query');
  notify('kishield-protection-query');

  // ─── String-Werkzeug ────────────────────────────────────────────────
  function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }

  function pseudonymizeText(text) {
    pruneMappings();
    if (!text || PSEUDOS.size === 0) return text;
    let result = text;
    // Längste Originale zuerst, damit "Herr Müller" vor "Müller" ersetzt wird.
    const sorted = [...PSEUDOS.entries()].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 reidentifyText(text) {
    pruneMappings();
    if (!text || REVERSE.size === 0) return text;
    let result = text;
    // Längste Pseudonyme zuerst. Zusätzlich negativer Lookahead (?!\d), damit
    // PERSON_1 nicht INNERHALB von PERSON_12 matcht (Kollisions-Schutz).
    const sorted = [...REVERSE.entries()].sort((a, b) => b[0].length - a[0].length);
    for (const [pseudo, orig] of sorted) {
      if (result.indexOf(pseudo) === -1) continue;
      const re = new RegExp(escapeRegExp(pseudo) + '(?!\\d)', 'g');
      result = result.replace(re, () => orig);
    }
    return result;
  }

  function deepReplaceStrings(obj, fn, depth) {
    if (depth === undefined) depth = 0;
    if (depth > 40) return obj; // Rekursions-Backstop; Chat-JSON ist nie so tief (früher 12 → fail-open bei tiefer PII)
    if (typeof obj === 'string') return fn(obj);
    if (Array.isArray(obj)) return obj.map((item) => deepReplaceStrings(item, fn, depth + 1));
    if (obj && typeof obj === 'object') {
      const result = {};
      for (const [k, v] of Object.entries(obj)) result[k] = deepReplaceStrings(v, fn, depth + 1);
      return result;
    }
    return obj;
  }

  // Wandelt einen (lesbaren) Body-String in seinen pseudonymisierten Gegenwert.
  // Gibt null zurück, wenn nichts zu ändern war.
  function pseudonymizeBody(bodyText) {
    if (!bodyText) return null;

    // Pfad A: URL-encoded form body (Gemini: f.req=...&at=...)
    const looksLikeForm = bodyText.includes('=') && !/^[\s[{"]/.test(bodyText);
    if (looksLikeForm) {
      try {
        const params = new URLSearchParams(bodyText);
        const newParams = new URLSearchParams();
        let changed = false;
        for (const [k, v] of params.entries()) {
          let nv = pseudonymizeText(v);
          if (v.length > 1 && (v[0] === '[' || v[0] === '{')) {
            try {
              const inner = JSON.parse(v);
              const innerStr = JSON.stringify(deepReplaceStrings(inner, pseudonymizeText, 0));
              if (innerStr !== v) nv = innerStr;
            } catch (_) {}
          }
          if (nv !== v) changed = true;
          newParams.append(k, nv);
        }
        if (changed) return { text: newParams.toString(), approach: 'form' };
      } catch (_) {}
    }

    // Pfad B: JSON
    try {
      const json = JSON.parse(bodyText);
      const candidate = JSON.stringify(deepReplaceStrings(json, pseudonymizeText, 0));
      if (candidate !== bodyText) return { text: candidate, approach: 'json' };
      return null; // sauberes JSON ohne PII → unverändert lassen
    } catch (_) {}

    // Pfad C: Plaintext
    const candidate = pseudonymizeText(bodyText);
    if (candidate !== bodyText) return { text: candidate, approach: 'plain' };
    return null;
  }

  // ─── URL-Klassifikation ─────────────────────────────────────────────
  function isPluginPlatformHost(url) {
    try {
      const parsed = new URL(String(url), location.href);
      return Core.platformHost(parsed.hostname) === Core.platformHost(location.hostname);
    } catch (_) {
      return false;
    }
  }

  function isAiApiUrl(url) {
    return Core.isAiApiUrl(url, location.hostname);
  }

  // 403-Antwort für hart blockierte Requests (fail-closed).
  function blockedResponse(reason) {
    return new Response(
      JSON.stringify({ error: 'KI-Shield: Request blockiert — ' + reason }),
      { status: 403, statusText: 'Blocked by KI-Shield', headers: { 'Content-Type': 'application/json' } }
    );
  }
  function blockReason() {
    if (PANIC) return 'PII-Erkennung nicht verfügbar (Plugin neu laden). Zum Schutz vor Datenabfluss gesperrt.';
    if (LICENSE_KNOWN && !LICENSED) return 'keine gültige Lizenz — bitte im Plugin reaktivieren.';
    return null;
  }

  // ─── Body-Extraktion (fetch) ────────────────────────────────────────
  // Liefert { text, unreadable }. `unreadable` = Body existiert, ist aber kein Text
  // (Datei-Upload) → wir können ihn nicht auf PII prüfen.
  async function extractFetchBody(input, init) {
    if (init && init.body !== undefined && init.body !== null) {
      const b = init.body;
      if (typeof b === 'string') return { text: b, unreadable: false };
      if (b instanceof URLSearchParams) return { text: b.toString(), unreadable: false };
      if (b instanceof ArrayBuffer) return { text: safeDecode(b), unreadable: false };
      if (ArrayBuffer.isView(b)) return { text: safeDecode(b.buffer), unreadable: false };
      if (typeof Blob !== 'undefined' && b instanceof Blob) {
        if (/^(text\/|application\/(json|.*x-www-form-urlencoded|.*\+json))/i.test(b.type || '')) {
          try { return { text: await b.text(), unreadable: false }; } catch (_) {}
        }
        return { text: null, unreadable: true };
      }
      if (typeof FormData !== 'undefined' && b instanceof FormData) return { text: null, unreadable: true };
      if (typeof ReadableStream !== 'undefined' && b instanceof ReadableStream) return { text: null, unreadable: true };
      return { text: null, unreadable: true };
    }
    if (input instanceof Request) {
      try { return { text: await input.clone().text(), unreadable: false }; }
      catch (_) { return { text: null, unreadable: true }; }
    }
    return { text: null, unreadable: false }; // kein Body (GET)
  }
  function safeDecode(buf) { try { return new TextDecoder().decode(buf); } catch (_) { return ''; } }

  // ─── fetch-Override (Architektur der funktionierenden v0.5.8) ───────
  // WICHTIG: Wir merken uns das NATIVE fetch und rufen es am Ende DIREKT auf —
  // NICHT die fetch-Kette der Seite. Dadurch ist unser Hook der INNERSTE: die
  // Maskierung ist der letzte Schritt vor dem Netzwerk, und ein Seiten-Wrapper
  // (ChatGPTs o) kann sie nicht mehr rückgängig machen. Der frühere getter/setter-
  // Umbau machte uns zum ÄUSSERSTEN Wrapper → ChatGPTs Wrapper baute den Request
  // danach aus dem Original-Body neu → Klartext. Nur Chrome+ChatGPT war betroffen.
  const NATIVE_FETCH = window.fetch.bind(window);

  async function safeFetchOverride(input, init) {
    // URL robust extrahieren. Ein URL-Objekt hat .href (NICHT .url) — früher fiel
    // das auf '' zurück → isAiApiUrl false → ALLE Gates (PANIC/Lizenz/Pseudonym)
    // umgangen (fail-open). String(input) deckt URL & andere stringifizierbare
    // Inputs ab (USVString-Semantik der Fetch-Spec).
    const url = typeof input === 'string'
      ? input
      : (input instanceof Request ? input.url
        : (typeof URL !== 'undefined' && input instanceof URL ? input.href
          : ((input && (input.url || input.href)) || (input != null ? String(input) : ''))));

    const isApi = isAiApiUrl(url);
    const method = (init && init.method) || (input instanceof Request ? input.method : 'GET');

    if (isPluginPlatformHost(url)) {
      console.log(TAG + ' fetch ' + method + ' ' + url + '  [isAiApi=' + isApi + ', map=' + PSEUDOS.size + ']');
    }

    if (!ENABLED) return NATIVE_FETCH(input, init);

    if (isApi) {
      // FAIL-CLOSED: Erkennung tot oder Lizenz ungültig → hart blockieren.
      const reason = blockReason();
      if (reason) {
        console.warn(TAG + ' Request blockiert: ' + reason + '  ' + url);
        return blockedResponse(reason);
      }

      let modifiedInit = init;
      try {
        const { text, unreadable } = await extractFetchBody(input, init);
        if (unreadable) {
          // FAIL-CLOSED: Datei-Upload / nicht lesbarer Body kann nicht auf PII
          // geprüft werden → HART BLOCKIEREN, damit kein ungeprüftes Original an
          // die KI geht. (Dokument-Schutz mit Browser-OCR folgt separat.)
          console.warn(TAG + ' Nicht lesbarer Body an Chat-API — BLOCKIERT: ' + url);
          notify('kishield-warn', { reason: 'upload-blocked', url });
          return blockedResponse('Datei-Upload wird im Schutzmodus blockiert.');
        } else if (text) {
          const approval = approvalDecision(url, method, text);
          if (approval.required && !approval.matched) {
            recordApprovalBlock('fetch', text);
            console.warn(TAG + ' Chat-Send ohne passende kurzlebige Freigabe — BLOCKIERT: ' + url);
            notify('kishield-warn', { reason: 'approval-missing', url });
            return blockedResponse('keine passende Sendefreigabe.');
          }
          const pseudo = pseudonymizeBody(text);
          if (pseudo) {
            console.log(TAG + ' fetch-Body pseudonymisiert (' + pseudo.approach + ')  ' + url +
              '  [' + text.length + ' → ' + pseudo.text.length + ' bytes]');
            // input (auch ein Request-Objekt) UNVERÄNDERT lassen; nur init.body
            // überschreiben. Da direkt danach das NATIVE fetch aufgerufen wird,
            // gewinnt dieser Body — kein Seiten-Wrapper kann ihn mehr ersetzen.
            modifiedInit = withBody(init, pseudo.text);
          }
        }
      } catch (e) {
        // Interner Hook-Fehler: NICHT still den Original-Klartext an die Chat-API
        // schicken. Fail-closed blockieren.
        console.warn(TAG + ' Body-Verarbeitung fehlgeschlagen — blockiert (fail-closed):', e);
        return blockedResponse('interner Fehler bei der PII-Prüfung.');
      }

      let origResponse;
      try {
        origResponse = await NATIVE_FETCH(input, modifiedInit);
      } catch (err) {
        console.warn(TAG + ' fetch failed, propagating', err);
        throw err;
      }
      return streamWrap(url, origResponse);
    }

    // Nicht-API-Request: unverändert ans native fetch.
    return NATIVE_FETCH(input, init);
  }

  // init mit neuem Body; Content-Length aus evtl. mitgegebenen Headers streichen
  // (fetch berechnet sie selbst — ein stale Wert würde den Request beschädigen).
  function withBody(init, body) {
    const out = Object.assign({}, init || {}, { body });
    if (out.headers) {
      try {
        const h = new Headers(out.headers);
        h.delete('content-length');
        out.headers = h;
      } catch (_) {}
    }
    return out;
  }

  // Response-Stream re-identifizieren (Pseudonyme → Klartext, lokal).
  function streamWrap(url, origResponse) {
    if (!(isAiApiUrl(url) && REVERSE.size > 0 && origResponse && origResponse.body)) return origResponse;
    try {
      const newStream = new ReadableStream({
        async start(controller) {
          const reader = origResponse.body.getReader();
          const decoder = new TextDecoder();
          const encoder = new TextEncoder();
          let buffer = '';
          let lookback = '';
          const PSEUDO_TAIL_RE = /[A-ZÄÖÜ][A-ZÄÖÜ_]*_\d*$/;
          const MAX_LOOKBACK = 64;
          try {
            while (true) {
              const { done, value } = await reader.read();
              if (done) {
                buffer += decoder.decode(); // Multibyte-Flush
                const tail = lookback + buffer;
                if (tail) controller.enqueue(encoder.encode(reidentifyText(tail)));
                controller.close();
                break;
              }
              buffer += decoder.decode(value, { stream: true });
              const lastNl = buffer.lastIndexOf('\n');
              if (lastNl >= 0) {
                const complete = lookback + buffer.slice(0, lastNl + 1);
                buffer = buffer.slice(lastNl + 1);
                let reIded = reidentifyText(complete);
                const m = reIded.match(PSEUDO_TAIL_RE);
                if (m && m[0].length <= MAX_LOOKBACK) {
                  lookback = m[0];
                  reIded = reIded.slice(0, reIded.length - m[0].length);
                } else {
                  lookback = '';
                }
                if (reIded) controller.enqueue(encoder.encode(reIded));
              }
            }
          } catch (e) {
            console.warn(TAG + ' Stream-Re-ID error:', e);
            try { controller.error(e); } catch (_) {}
          }
        },
        cancel(reason) { try { origResponse.body.cancel(reason); } catch (_) {} },
      });
      return new Response(newStream, {
        status: origResponse.status,
        statusText: origResponse.statusText,
        headers: origResponse.headers,
      });
    } catch (e) {
      console.warn(TAG + ' Response-Wrap fehlgeschlagen, gebe Original zurück:', e);
      return origResponse;
    }
  }

  // Einfache Zuweisung (wie die funktionierende v0.5.8). Überschreibt ein Seiten-
  // Wrapper window.fetch später, kapselt er UNSEREN Hook (prev = wir) — wir bleiben
  // in der Kette und maskieren als letzter Schritt vor dem nativen fetch.
  // KEIN catch→erneutes-NATIVE_FETCH: interne Fehler werden in safeFetchOverride
  // fail-closed als 403 behandelt; ein echter Netzwerkfehler wird propagiert (nicht
  // mit Klartext erneut gesendet).
  window.fetch = function (input, init) {
    return safeFetchOverride(input, init);
  };
  console.log(TAG + ' fetch-Hook installiert (innerster Hook, ruft natives fetch direkt).');

  // ─── XHR-Override (Gemini, alte ChatGPT-Versionen) ──────────────────
  try {
    const origOpen = XMLHttpRequest.prototype.open;
    const origSend = XMLHttpRequest.prototype.send;

    XMLHttpRequest.prototype.open = function (method, url, ...rest) {
      try { this._kishieldUrl = url; this._kishieldMethod = method; } catch (_) {}
      return origOpen.call(this, method, url, ...rest);
    };

    XMLHttpRequest.prototype.send = function (body) {
      if (!ENABLED) return origSend.call(this, body);
      let url = '';
      let method = 'GET';
      try { url = this._kishieldUrl || ''; } catch (_) {}
      try { method = this._kishieldMethod || 'GET'; } catch (_) {}
      const isApi = isAiApiUrl(url);

      if (isApi) {
        // FAIL-CLOSED: blockieren, indem der Request GAR NICHT rausgeht und die
        // Seite einen Fehler-Event bekommt (statt Klartext zu senden).
        const reason = blockReason();
        if (reason) {
          console.warn(TAG + ' XHR blockiert: ' + reason + '  ' + url);
          const self = this;
          setTimeout(() => {
            try {
              self.dispatchEvent(new Event('error'));
              self.dispatchEvent(new Event('loadend'));
            } catch (_) {}
          }, 0);
          return; // origSend NICHT aufrufen
        }

        try {
          let bodyStr = null;
          if (typeof body === 'string') bodyStr = body;
          else if (body instanceof URLSearchParams) bodyStr = body.toString();
          else if (body instanceof ArrayBuffer) bodyStr = safeDecode(body);
          else if (ArrayBuffer.isView(body)) bodyStr = safeDecode(body.buffer);

          if (bodyStr !== null) {
            const approval = bodyStr ? approvalDecision(url, method, bodyStr) : { required: false, matched: false };
            if (approval.required && !approval.matched) {
              recordApprovalBlock('xhr', bodyStr);
              console.warn(TAG + ' XHR ohne passende kurzlebige Freigabe — BLOCKIERT: ' + url);
              notify('kishield-warn', { reason: 'approval-missing', url });
              const self = this;
              setTimeout(() => {
                try { self.dispatchEvent(new Event('error')); self.dispatchEvent(new Event('loadend')); } catch (_) {}
              }, 0);
              return;
            }
            const pseudo = pseudonymizeBody(bodyStr);
            if (pseudo) {
              console.log(TAG + ' XHR-Body pseudonymisiert (' + pseudo.approach + ')  ' + url +
                '  [' + bodyStr.length + ' → ' + pseudo.text.length + ' bytes]');
              return origSend.call(this, pseudo.text);
            }
          } else if (body != null) {
            // FAIL-CLOSED: Datei-Upload (Blob/FormData) nicht auf PII prüfbar →
            // BLOCKIEREN (synthetischer Fehler, kein origSend) statt ungeprüft senden.
            console.warn(TAG + ' XHR-Body nicht lesbar (' + (body.constructor && body.constructor.name) + ') — BLOCKIERT: ' + url);
            notify('kishield-warn', { reason: 'upload-blocked', url });
            const self = this;
            setTimeout(() => { try { self.dispatchEvent(new Event('error')); self.dispatchEvent(new Event('loadend')); } catch (_) {} }, 0);
            return;
          }
        } catch (e) {
          // Fail-closed: bei internem Fehler den Chat-Request NICHT im Klartext senden.
          console.warn(TAG + ' XHR-Hook-Fehler — blockiert (fail-closed):', e);
          const self = this;
          setTimeout(() => { try { self.dispatchEvent(new Event('error')); } catch (_) {} }, 0);
          return;
        }
      }
      return origSend.call(this, body);
    };

    console.log(TAG + ' XHR-Hook installiert.');
  } catch (e) {
    console.warn(TAG + ' XHR-Hook-Installation fehlgeschlagen:', e);
  }

  // ─── sendBeacon-Override (Telemetrie-Kanal absichern) ───────────────
  // sendBeacon kann keine Antwort empfangen, wird also nie für den eigentlichen
  // Chat-Send benutzt. Wir pseudonymisieren nur lesbare Bodys als Defense-in-Depth
  // (falls die Seite den Prompt in ein Analytics-Beacon packt). Fail-open bei Fehler.
  try {
    if (navigator.sendBeacon) {
      const origBeacon = navigator.sendBeacon.bind(navigator);
      navigator.sendBeacon = function (url, data) {
        if (!ENABLED) return origBeacon(url, data);
        try {
          if (isAiApiUrl(url) && PSEUDOS.size > 0 && data != null) {
            let str = null;
            if (typeof data === 'string') str = data;
            else if (data instanceof URLSearchParams) str = data.toString();
            if (str !== null) {
              const pseudo = pseudonymizeBody(str);
              if (pseudo) return origBeacon(url, pseudo.text);
            }
          }
        } catch (_) {}
        return origBeacon(url, data);
      };
      console.log(TAG + ' sendBeacon-Hook installiert.');
    }
  } catch (e) {
    console.warn(TAG + ' sendBeacon-Hook fehlgeschlagen:', e);
  }
})();
