/**
 * KI-Shield Proxy — Service Worker = Proxy-Client.
 *
 * Thin-Client-Architektur: Die PII-Erkennung findet NICHT mehr im Browser statt,
 * sondern serverseitig im KI-Shield-Compliance-Proxy. Dieser Service Worker ist
 * der einzige Ort, von dem aus der Proxy aufgerufen werden darf — die CORS-
 * Allowlist des Proxys lässt Seiten-/Content-Script-Origins nicht zu, ein
 * Service Worker mit host_permissions ist davon befreit.
 *
 * Ablauf:
 *   content.js  ──runtime.sendMessage({type:'kishield-mask', text})──▶  hier
 *   hier  ──POST /api/v1/pseudonymize (X-API-Key)──▶  Proxy
 *   Proxy  ──{pseudonymized, pii_found}──▶  hier
 *   hier baut das Mapping (orig↔pseudo) lokal und gibt es an content.js zurück,
 *   das es via postMessage an injected.js weiterreicht (Body-Maskierung + Re-ID).
 *
 * FAIL-CLOSED: Bei fehlendem Token, Timeout, Netzwerkfehler, non-2xx oder wenn
 * das Mapping nicht rekonstruierbar ist, wird {ok:false, reason} zurückgegeben —
 * content.js blockiert dann den Send (kein Klartext an die KI).
 *
 * Der API-Token liegt NICHT im Quellcode (nichts zu extrahieren), sondern kommt
 * kurzlebig aus dem Lizenz-/Onboarding-Flow in chrome.storage.local.
 */

// Chrome lädt diese Datei als Service Worker (importScripts vorhanden).
// Safari lädt sie als Skript einer Background-PAGE — dort gibt es kein
// importScripts; shared/core.js ist bereits per <script> geladen
// (siehe safari-background.html).
if (typeof importScripts === 'function') importScripts('shared/core.js');

const Core = self.KIShieldCore;
if (!Core) throw new Error('KIShieldCore wurde nicht geladen');
const PLUGIN_NAME = 'KI-Shield Proxy';
const BUILD = chrome.runtime.getManifest().version;
const PROXY_URL = 'https://kishieldguard.de/api/v1/pseudonymize';
const DETECT_URL = 'https://kishieldguard.de/api/v1/detect';
const FEEDBACK_URL = 'https://kishieldguard.de/api/v1/pii-feedback';
const MASK_TIMEOUT_MS = 8000;
const STORAGE_TOKEN_KEY = 'kishield_api_key';
const STORAGE_ENABLED_KEY = 'kishield_enabled';

async function isProtectionEnabled() {
  try {
    const result = await chrome.storage.local.get(STORAGE_ENABLED_KEY);
    return Core.protectionEnabled(result[STORAGE_ENABLED_KEY]);
  } catch (_) {
    return true;
  }
}

/**
 * Rekonstruiert das Mapping (Original ↔ Pseudonym) aus der Proxy-Antwort.
 *
 * Der Proxy berechnet das Mapping intern, verwirft es aber in der Response
 * (`pseudonymized, _ = ...`). Wir bauen es hier per STRIKTEM sequenziellen
 * Parse nach: `pseudonymized` MUSS exakt gap0+PH1+gap1+PH2+…+tail sein — es
 * wird nichts gesucht, nur an fester Position verglichen. Jede Abweichung
 * wirft → der Aufrufer blockiert den Send (fail-closed statt falscher Map).
 *
 * Härtungen (adversarial review 05.07.2026):
 *  - Offsets: Python zählt Codepoints, JS UTF-16 — Offsets werden konvertiert
 *    und gegen ent.text verifiziert (Emoji vor PII verschiebt sonst die Anker).
 *  - Überlappende Entities werden zu EINEM Span verschmolzen (der Server
 *    ersetzt verschmolzene Treffer mit EINEM Platzhalter).
 *  - Ziffern-Ambiguität: folgt im Original direkt eine Ziffer auf die PII
 *    ("Max123"), wird der Platzhalter-Ziffernteil per Backtracking so gewählt,
 *    dass der nachfolgende Anker exakt passt.
 */
function reconstructMapping(originalText, pseudonymized, piiFound) {
  const forward = {}; // original → pseudonym
  const reverse = {}; // pseudonym → original
  // Platzhalter-Form: PRAEFIX_<Ziffern>; Praefix ggf. mit Unterstrich
  // (HEALTH_DATA_5). Lazy Praefix, damit bei direkt angrenzendem zweitem
  // Platzhalter nicht zu viel gegriffen wird.
  const PLACEHOLDER_RE = /^([A-ZÄÖÜ_]+?_)(\d+)/;

  // Codepoint→UTF-16-Offsetkarte (Identität, solange keine Astral-Zeichen).
  const cpToU16 = [0];
  for (const ch of originalText) cpToU16.push(cpToU16[cpToU16.length - 1] + ch.length);
  const u16 = (cp) => (cp >= 0 && cp < cpToU16.length ? cpToU16[cp] : originalText.length);

  // Entities normalisieren: Offsets konvertieren + gegen ent.text verifizieren,
  // sortieren, Überlappungen zu einem Span verschmelzen.
  const spans = [];
  for (const ent of [...piiFound].sort((a, b) => a.start - b.start)) {
    let s = u16(ent.start);
    let e = u16(ent.end);
    if (originalText.slice(s, e) !== ent.text) {
      // Offsets evtl. bereits UTF-16 gezählt? Direkt probieren, sonst fail-closed.
      if (originalText.slice(ent.start, ent.end) === ent.text) { s = ent.start; e = ent.end; }
      else throw new Error('Entity-Offsets passen nicht zum Text (fail-closed)');
    }
    const last = spans[spans.length - 1];
    if (last && s < last.e) last.e = Math.max(last.e, e);
    else spans.push({ s, e });
  }

  let po = 0; // Cursor im Originaltext
  let pp = 0; // Cursor im pseudonymisierten Text
  for (let i = 0; i < spans.length; i++) {
    const sp = spans[i];
    const gap = originalText.slice(po, sp.s);
    if (pseudonymized.slice(pp, pp + gap.length) !== gap) {
      throw new Error('Anker-Abweichung vor Platzhalter (fail-closed)');
    }
    pp += gap.length;

    const m = pseudonymized.slice(pp).match(PLACEHOLDER_RE);
    if (!m) throw new Error('Platzhalter nicht gefunden (fail-closed)');
    const prefix = m[1];
    const digits = m[2];
    // Backtracking über die Ziffernlänge: längste Variante, bei der der
    // NÄCHSTE Anker exakt anschließt, gewinnt.
    const nextAnchorStart = sp.e;
    const nextAnchorEnd = (i + 1 < spans.length) ? spans[i + 1].s : originalText.length;
    const nextGap = originalText.slice(nextAnchorStart, nextAnchorEnd);
    let placeholder = null;
    for (let k = digits.length; k >= 1; k--) {
      const cand = prefix + digits.slice(0, k);
      const after = pp + cand.length;
      if (pseudonymized.slice(after, after + nextGap.length) === nextGap) { placeholder = cand; break; }
    }
    if (!placeholder) throw new Error('Kein konsistenter Platzhalter-Schnitt (fail-closed)');

    const origSpan = originalText.slice(sp.s, sp.e);
    if (!origSpan) throw new Error('Leerer Original-Span (fail-closed)');
    forward[origSpan] = placeholder;
    reverse[placeholder] = origSpan;
    pp += placeholder.length;
    po = sp.e;
  }
  if (pseudonymized.slice(pp) !== originalText.slice(po)) {
    throw new Error('Rest-Anker weicht ab (fail-closed)');
  }
  return { forward, reverse };
}

/**
 * Übernimmt das seit 17.07.2026 vom Proxy gelieferte autoritative Mapping.
 *
 * Die Werte werden nicht blind vertraut: Entitäten und Mapping müssen als
 * Multimenge übereinstimmen, jedes Mapping muss bijektiv sein und die
 * Ersetzungen an den gemeldeten Codepoint-Offsets müssen bytegenau den
 * pseudonymisierten Antworttext ergeben. Jede Abweichung bleibt fail-closed.
 *
 * `null` bedeutet ausschließlich: alter Proxy ohne `mappings`-Feld. Dann darf
 * der kompatible sequenzielle Rekonstruktionsparser oben verwendet werden.
 */
function mappingFromServer(originalText, pseudonymized, piiFound, serverMappings) {
  if (!Array.isArray(serverMappings)) return null;
  if (serverMappings.length !== piiFound.length) {
    throw new Error('Server-Mapping-Anzahl passt nicht zu den Entitäten (fail-closed)');
  }

  const entityCounts = new Map();
  const mappingKey = (text, type) => JSON.stringify([text, type]);
  for (const entity of piiFound) {
    const key = mappingKey(entity && entity.text, entity && entity.type);
    entityCounts.set(key, (entityCounts.get(key) || 0) + 1);
  }

  const serverForwardMap = new Map();
  const serverReverseMap = new Map();
  for (const entry of serverMappings) {
    const original = entry && entry.original;
    const pseudonym = entry && entry.pseudonym;
    const type = entry && entry.type;
    if (
      typeof original !== 'string' || !original ||
      typeof pseudonym !== 'string' || !pseudonym ||
      typeof type !== 'string' || !type ||
      original === pseudonym
    ) {
      throw new Error('Server-Mapping enthält ungültige Werte (fail-closed)');
    }

    const key = mappingKey(original, type);
    const remaining = entityCounts.get(key) || 0;
    if (remaining < 1) {
      throw new Error('Server-Mapping passt nicht zu den Entitäten (fail-closed)');
    }
    entityCounts.set(key, remaining - 1);

    if (serverForwardMap.has(original) && serverForwardMap.get(original) !== pseudonym) {
      throw new Error('Original besitzt widersprüchliche Pseudonyme (fail-closed)');
    }
    if (serverReverseMap.has(pseudonym) && serverReverseMap.get(pseudonym) !== original) {
      throw new Error('Pseudonym ist nicht eindeutig (fail-closed)');
    }
    serverForwardMap.set(original, pseudonym);
    serverReverseMap.set(pseudonym, original);
  }
  if ([...entityCounts.values()].some((count) => count !== 0)) {
    throw new Error('Server-Mapping deckt die Entitäten nicht ab (fail-closed)');
  }

  // Python meldet Codepoint-Offsets, JavaScript schneidet in UTF-16-Einheiten.
  const cpToU16 = [0];
  for (const ch of originalText) cpToU16.push(cpToU16[cpToU16.length - 1] + ch.length);
  const normalizedEntities = piiFound.map((entity, index) => {
    let start = cpToU16[entity.start];
    let end = cpToU16[entity.end];
    if (
      !Number.isInteger(start) || !Number.isInteger(end) ||
      (
        originalText.slice(start, end) !== entity.text &&
        originalText.slice(start, end).replace(/\u00a0/g, ' ') !== entity.text
      )
    ) {
      // Rückwärtskompatibilität, falls ein Proxy bereits UTF-16-Offsets liefert.
      start = entity.start;
      end = entity.end;
    }
    if (
      !Number.isInteger(start) || !Number.isInteger(end) ||
      start < 0 || end <= start ||
      (
        originalText.slice(start, end) !== entity.text &&
        originalText.slice(start, end).replace(/\u00a0/g, ' ') !== entity.text
      )
    ) {
      throw new Error('Server-Entity-Offsets passen nicht zum Text (fail-closed)');
    }
    return {
      start,
      end,
      sourceText: originalText.slice(start, end),
      serverText: entity.text,
      index,
    };
  });

  // PDF-Text enthält regelmäßig geschützte Leerzeichen (U+00A0). Der Proxy
  // normalisiert sie in Entity-Text und Mapping längenerhaltend zu U+0020.
  // Für die tatsächliche Maskierung/Re-ID muss das Mapping trotzdem den exakten
  // Browser-Span verwenden. Mehrdeutige Varianten bleiben strikt fail-closed.
  const forwardMap = new Map();
  const reverseMap = new Map();
  for (const entity of normalizedEntities) {
    const pseudonym = serverForwardMap.get(entity.serverText);
    if (!pseudonym) {
      throw new Error('Server-Mapping deckt die Entitäten nicht ab (fail-closed)');
    }
    if (forwardMap.has(entity.sourceText) && forwardMap.get(entity.sourceText) !== pseudonym) {
      throw new Error('Original besitzt widersprüchliche Pseudonyme (fail-closed)');
    }
    if (reverseMap.has(pseudonym) && reverseMap.get(pseudonym) !== entity.sourceText) {
      throw new Error('Pseudonym ist nicht eindeutig (fail-closed)');
    }
    forwardMap.set(entity.sourceText, pseudonym);
    reverseMap.set(pseudonym, entity.sourceText);
  }

  let expected = originalText;
  normalizedEntities
    .sort((a, b) => (b.start - a.start) || (a.index - b.index))
    .forEach((entity) => {
      const pseudonym = forwardMap.get(entity.sourceText);
      expected = expected.slice(0, entity.start) + pseudonym + expected.slice(entity.end);
    });
  if (expected !== pseudonymized) {
    throw new Error('Server-Mapping reproduziert den pseudonymisierten Text nicht (fail-closed)');
  }

  return {
    forward: Object.fromEntries(forwardMap),
    reverse: Object.fromEntries(reverseMap),
  };
}

/** Kurzlebiger API-Token aus dem Lizenz-Flow (nie hardcoded). */
async function getApiToken() {
  try {
    const r = await chrome.storage.local.get(STORAGE_TOKEN_KEY);
    return (r && r[STORAGE_TOKEN_KEY]) || null;
  } catch (_) {
    return null;
  }
}

/** Maskiert Text über den Proxy und liefert das rekonstruierte Mapping. */
async function maskText(text, sessionId, excluded, allowlist) {
  if (!(await isProtectionEnabled())) {
    return { ok: false, reason: 'paused', error: 'Schutz ist pausiert' };
  }
  if (typeof text !== 'string' || !text.trim()) {
    return { ok: false, reason: 'empty', error: 'kein Text' };
  }
  const token = await getApiToken();
  if (!token) {
    return { ok: false, reason: 'no_key', error: 'Kein API-Token hinterlegt' };
  }

  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), MASK_TIMEOUT_MS);
  try {
    const body = { text, language: 'de' };
    // Der Proxy erlaubt für session_id nur [a-zA-Z0-9_.-]; unsere Conversation-
    // IDs sind 'plat:id' → Doppelpunkt (und alles andere) auf '_' normalisieren.
    // Deterministisch pro Conversation, damit Pseudonyme konsistent bleiben.
    if (sessionId) body.session_id = String(sessionId).replace(/[^a-zA-Z0-9_.\-]/g, '_');
    // Vom Nutzer bewusst deaktivierte Kategorien (alle 42 sind serverseitig
    // ausschließbar; Standard bleibt Vollschutz).
    if (Array.isArray(excluded) && excluded.length) body.excluded_entities = excluded;
    // „Nie markieren" (False Positives): diese Werte NIE als PII behandeln.
    if (Array.isArray(allowlist) && allowlist.length) body.allowlist = allowlist;

    const resp = await fetch(PROXY_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': token },
      body: JSON.stringify(body),
      signal: ctrl.signal,
    });

    if (!resp.ok) {
      return { ok: false, reason: 'http_' + resp.status, error: 'Proxy HTTP ' + resp.status + ' [' + BUILD + ']' };
    }

    const data = await resp.json();
    const pseudonymized = data && data.pseudonymized;
    const piiFound = (data && Array.isArray(data.pii_found)) ? data.pii_found : [];
    if (typeof pseudonymized !== 'string') {
      return { ok: false, reason: 'bad_response', error: 'kein pseudonymized-Feld' };
    }

    const hasServerMappings = Array.isArray(data && data.mappings);
    let mapping;
    try {
      mapping = mappingFromServer(text, pseudonymized, piiFound, data && data.mappings);
      if (!mapping) mapping = reconstructMapping(text, pseudonymized, piiFound);
    } catch (e) {
      // Mapping nicht sicher validierbar → fail-closed statt evtl. falscher Re-ID.
      return {
        ok: false,
        reason: 'map_failed',
        error: String((e && e.message) || e),
        diagnostics: {
          stage: 'mapping_validation',
          source: hasServerMappings ? 'server' : 'legacy_reconstruction',
          inputLength: text.length,
          outputLength: pseudonymized.length,
          entityCount: piiFound.length,
          mappingCount: hasServerMappings ? data.mappings.length : 0,
        },
      };
    }

    // entity_type für die content.js-UI (Modal/Badge) normalisieren (Server: `type`).
    const entities = piiFound.map((e) => ({
      entity_type: e.type,
      text: e.text,
      start: e.start,
      end: e.end,
      score: e.score,
    }));

    return {
      ok: true,
      masked: pseudonymized,
      forward: mapping.forward,
      reverse: mapping.reverse,
      entities,
      session_id: data.session_id || sessionId || null,
    };
  } catch (e) {
    const reason = (e && e.name === 'AbortError') ? 'timeout' : 'network';
    return { ok: false, reason, error: String((e && e.message) || e) };
  } finally {
    clearTimeout(timer);
  }
}

/**
 * NUR ERKENNEN (kein Maskieren) für die Live-Vorschau beim Tippen.
 * Leichter als maskText: keine Mapping-Rekonstruktion, nur Entities zum Einfärben.
 * Text geht an den EIGENEN Proxy (AVV), nicht an den KI-Anbieter.
 */
async function detectText(text, excluded, allowlist) {
  if (!(await isProtectionEnabled())) return { ok: false, reason: 'paused' };
  if (typeof text !== 'string' || !text.trim()) return { ok: false, reason: 'empty' };
  const token = await getApiToken();
  if (!token) return { ok: false, reason: 'no_key' };
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 6000);
  try {
    const body = { text, language: 'de' };
    if (Array.isArray(excluded) && excluded.length) body.excluded_entities = excluded;
    if (Array.isArray(allowlist) && allowlist.length) body.allowlist = allowlist;
    const resp = await fetch(DETECT_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': token },
      body: JSON.stringify(body),
      signal: ctrl.signal,
    });
    if (!resp.ok) return { ok: false, reason: 'http_' + resp.status };
    const data = await resp.json();
    const ents = (data && Array.isArray(data.entities)) ? data.entities : [];
    return {
      ok: true,
      entities: ents.map((e) => ({ entity_type: e.type, text: e.text, start: e.start, end: e.end })),
    };
  } catch (e) {
    return { ok: false, reason: (e && e.name === 'AbortError') ? 'timeout' : 'network' };
  } finally {
    clearTimeout(timer);
  }
}

async function submitFalsePositive(entityText, currentType) {
  const payload = Core.falsePositiveFeedbackPayload(entityText, currentType);
  if (!payload) return { ok: false, reason: 'invalid_feedback' };
  const token = await getApiToken();
  if (!token) return { ok: false, reason: 'no_key' };
  const ctrl = new AbortController();
  const timer = setTimeout(() => ctrl.abort(), 6000);
  try {
    const response = await fetch(FEEDBACK_URL, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'X-API-Key': token },
      body: JSON.stringify(payload),
      signal: ctrl.signal,
    });
    if (!response.ok) return { ok: false, reason: 'http_' + response.status };
    const data = await response.json();
    return { ok: true, status: data && data.status };
  } catch (error) {
    return {
      ok: false,
      reason: error && error.name === 'AbortError' ? 'timeout' : 'network',
    };
  } finally {
    clearTimeout(timer);
  }
}

// ─── Browser-Text-Extraktion (PDF/DOCX/OCR) ─────────────────────────────────
// pdf.js-Worker + Tesseract-WASM dürfen nicht auf der Host-Seite laufen
// (claude.ai & Co. blocken sie per CSP) und brauchen ein DOM. Je nach Browser
// gibt es dafür zwei Wege — die Extraktionslogik in offscreen.js ist identisch:
//
//   Chrome — Service Worker hat kein DOM → unsichtbares Offscreen-Dokument
//            (chrome.offscreen), lazy erzeugt, Bytes werden hin/zurück relayed.
//   Safari — chrome.offscreen existiert NICHT. Dort ist der Hintergrund selbst
//            eine Background-PAGE mit DOM und voller Extension-CSP; offscreen.js
//            liegt im selben Kontext und wird über self.KIShieldExtractor direkt
//            aufgerufen. Kein Relay, kein Lebenszyklus zu verwalten.
const HAS_OFFSCREEN = typeof chrome !== 'undefined' &&
  !!chrome.offscreen && typeof chrome.offscreen.createDocument === 'function';

let _offscreenCreating = null;
let _offscreenUsers = 0;
const _offscreenJobs = new Set();

/**
 * Ruft den Extraktor auf — per Message (Chrome) oder direkt (Safari).
 * Die Rückgabesemantik ist in beiden Fällen identisch: niemals throw, sondern
 * {ok:false, error} — genau wie der onMessage-Listener in offscreen.js.
 */
async function callExtractor(payload) {
  if (HAS_OFFSCREEN) {
    return await chrome.runtime.sendMessage(payload);
  }
  const extractor = self.KIShieldExtractor;
  if (!extractor) return { ok: false, error: 'Extraktor nicht geladen.' };
  const handlers = {
    'offscreen-extract': extractor.extract,
    'offscreen-job-start': extractor.startExtractionJob,
    'offscreen-job-chunk': extractor.appendExtractionChunk,
    'offscreen-job-finish': extractor.finishExtractionJob,
    'offscreen-job-result': extractor.readExtractionBatch,
    'offscreen-job-release': extractor.releaseExtractionJob,
  };
  const handler = handlers[payload && payload.target];
  if (!handler) return { ok: false, error: 'Unbekannter Extraktor-Aufruf.' };
  try {
    return await handler(payload);
  } catch (e) {
    return { ok: false, error: String((e && e.message) || e) };
  }
}

async function ensureOffscreen() {
  if (!HAS_OFFSCREEN) return; // Safari: Extraktor liegt bereits im Kontext.
  try { if (await chrome.offscreen.hasDocument()) return; } catch (_) { /* alte Chrome: hasDocument fehlt */ }
  if (_offscreenCreating) { await _offscreenCreating; return; }
  _offscreenCreating = chrome.offscreen.createDocument({
    url: 'offscreen.html',
    reasons: ['WORKERS'],
    justification: 'Text aus PDF/DOCX/Bild lokal extrahieren (pdf.js/mammoth/OCR), damit er maskiert werden kann.',
  });
  try {
    await _offscreenCreating;
  } catch (e) {
    // Race: ein paralleler Aufruf hat das Dokument schon erstellt → ok.
    if (!/single offscreen|already/i.test(String((e && e.message) || e))) throw e;
  } finally {
    _offscreenCreating = null;
  }
}

async function closeOffscreenIfIdle() {
  if (!HAS_OFFSCREEN) return; // Safari: nichts zu schließen.
  if (_offscreenUsers > 0 || _offscreenJobs.size > 0) return;
  try {
    if (!chrome.offscreen.hasDocument || await chrome.offscreen.hasDocument()) {
      await chrome.offscreen.closeDocument();
    }
  } catch (e) {
    if (!/no current offscreen|no offscreen document/i.test(String((e && e.message) || e))) {
      console.warn(`[${PLUGIN_NAME}] Offscreen-Dokument konnte nicht geschlossen werden:`, e);
    }
  }
}

async function startDocumentJob(msg) {
  if (!(await isProtectionEnabled())) return { ok: false, error: 'Schutz ist pausiert' };
  if (typeof msg.jobId !== 'string' || !msg.jobId) {
    return { ok: false, error: 'keine Dokument-Job-ID' };
  }
  await ensureOffscreen();
  const result = await callExtractor({
    target: 'offscreen-job-start',
    jobId: msg.jobId,
    name: msg.name,
    mime: msg.mime,
    size: msg.size,
  });
  if (result && result.ok) _offscreenJobs.add(msg.jobId);
  else await closeOffscreenIfIdle();
  return result || { ok: false, error: 'keine Antwort vom Extraktor' };
}

async function relayDocumentJob(msg, target) {
  if (typeof msg.jobId !== 'string' || !_offscreenJobs.has(msg.jobId)) {
    return { ok: false, error: 'Dokument-Job nicht aktiv' };
  }
  const payload = { target, jobId: msg.jobId };
  if (target === 'offscreen-job-chunk') payload.chunk = msg.chunk;
  if (target === 'offscreen-job-result') payload.index = msg.index;
  const result = await callExtractor(payload);
  return result || { ok: false, error: 'keine Antwort vom Extraktor' };
}

async function releaseDocumentJob(msg) {
  if (typeof msg.jobId !== 'string' || !msg.jobId) {
    return { ok: false, error: 'keine Dokument-Job-ID' };
  }
  let result = { ok: true };
  try {
    if (_offscreenJobs.has(msg.jobId)) {
      result = await callExtractor({
        target: 'offscreen-job-release',
        jobId: msg.jobId,
      }) || result;
    }
    return result;
  } finally {
    _offscreenJobs.delete(msg.jobId);
    await closeOffscreenIfIdle();
  }
}

async function extractDocument(msg) {
  if (!(await isProtectionEnabled())) return { ok: false, error: 'Schutz ist pausiert' };
  if (typeof msg.dataUrl !== 'string' || !msg.dataUrl) return { ok: false, error: 'keine Datei' };
  _offscreenUsers++;
  try {
    await ensureOffscreen();
    // Chrome: Antwort kommt vom Offscreen-Listener (msg.target-Filter, s.
    // offscreen.js). Safari: Direktaufruf im selben Kontext.
    const res = await callExtractor({
      target: 'offscreen-extract', dataUrl: msg.dataUrl, mime: msg.mime, name: msg.name,
    });
    return res || { ok: false, error: 'keine Antwort vom Extraktor' };
  } catch (e) {
    return { ok: false, error: String((e && e.message) || e) };
  } finally {
    _offscreenUsers = Math.max(0, _offscreenUsers - 1);
    await closeOffscreenIfIdle();
  }
}

// content.js (ISOLATED) ruft den Proxy nur über diese Kanäle auf.
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
  if (msg && msg.type === 'kishield-extract-start') {
    startDocumentJob(msg)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
    return true;
  }
  if (msg && msg.type === 'kishield-extract-chunk') {
    relayDocumentJob(msg, 'offscreen-job-chunk')
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
    return true;
  }
  if (msg && msg.type === 'kishield-extract-finish') {
    relayDocumentJob(msg, 'offscreen-job-finish')
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
    return true;
  }
  if (msg && msg.type === 'kishield-extract-result') {
    relayDocumentJob(msg, 'offscreen-job-result')
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
    return true;
  }
  if (msg && msg.type === 'kishield-extract-release') {
    releaseDocumentJob(msg)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
    return true;
  }
  if (msg && msg.type === 'kishield-extract') {
    extractDocument(msg)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, error: String((e && e.message) || e) }));
    return true; // asynchrone sendResponse
  }
  if (msg && msg.type === 'kishield-mask' && typeof msg.text === 'string') {
    maskText(msg.text, msg.session_id, msg.excluded, msg.allowlist)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, reason: 'exception', error: String((e && e.message) || e) }));
    return true; // asynchrone sendResponse
  }
  if (msg && msg.type === 'kishield-detect' && typeof msg.text === 'string') {
    detectText(msg.text, msg.excluded, msg.allowlist)
      .then(sendResponse)
      .catch((e) => sendResponse({ ok: false, reason: 'exception', error: String((e && e.message) || e) }));
    return true;
  }
  if (msg && msg.type === 'kishield-feedback-fp') {
    submitFalsePositive(msg.entity_text, msg.current_type)
      .then(sendResponse)
      .catch(() => sendResponse({ ok: false, reason: 'exception' }));
    return true;
  }
  return false;
});

chrome.runtime.onInstalled.addListener((details) => {
  console.log(`[${PLUGIN_NAME}] installiert. Reason: ${details.reason}`);
});

chrome.runtime.onStartup.addListener(() => {
  console.log(`[${PLUGIN_NAME}] Service Worker gestartet (Proxy-Client bereit).`);
});
