import ExpoModulesCore
import Foundation
import NetworkExtension
import FamilyControls
import ManagedSettings
import WebKit
import UIKit
import UserNotifications

// ─── Konstanten ───────────────────────────────────────────────────────────────

private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let ETAG_KEY = "blocklist_etag"
private let LAST_SYNC_KEY = "blocklist_last_sync_at"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"
private let MS_STORE_NAME = "rebreak.shield"

// Layer 2 — webContent-Filter (ManagedSettings)
private let WEBCONTENT_BUNDLE = "RebreakProtectionResources"
private let WEBCONTENT_DOMAINS_FILE = "gambling-domains"
// Apple-Hartlimit: ManagedSettings blockt max. 50 Domains pro FilterPolicy.
// Wird hier defensiv durchgesetzt — nie mehr als 50 an blockedByFilter geben.
private let WEBCONTENT_MAX_DOMAINS = 50
private let WEBCONTENT_FALLBACK_REGION = "DE"
// Runtime-Sync-Cache: die vom Backend geholte Domain-Liste landet als
// webcontent-domains.json im App-Group-Container. loadWebContentDomains liest
// cache-first; die gebündelte gambling-domains.json bleibt Offline-Seed/Fallback.
private let WEBCONTENT_CACHE_FILENAME = "webcontent-domains.json"
private let WEBCONTENT_ETAG_KEY = "webcontent_domains_etag"
private let WEBCONTENT_LAST_SYNC_KEY = "webcontent_domains_last_sync_at"

// Layer 1 (NEU) — Packet-Tunnel-DNS-Filter (NEPacketTunnelProvider).
// Ersetzt NEURLFilter als primären, lieferbaren iOS-Filter (NEURLFilter ist
// Apple-seitig blockiert; der Code bleibt als iOS-26-Upgrade-Pfad erhalten).
// Bundle-ID MUSS exakt zu plugins/with-rebreak-protection-ios.js + app.config.ts
// (appExtensions) passen.
private let PACKET_TUNNEL_BUNDLE_ID = "org.rebreak.app.PacketTunnelExtension"
// Lesbarer Name für den iOS-VPN-Settings-Eintrag.
private let PACKET_TUNNEL_DESCRIPTION = "ReBreak Schutz"
// App-Group-Flags, die die Packet-Tunnel-Extension spiegelt (Tunnel-Lifecycle).
private let VPN_TUNNEL_RUNNING_KEY = "vpn_tunnel_running"
private let VPN_TUNNEL_REVOKED_KEY = "vpn_tunnel_revoked_by_user"

// ─── Shared Log-Store ─────────────────────────────────────────────────────────

fileprivate enum SharedLogStore {
  static let logKey = "url_filter_logs"
  static let maxEntries = 200

  static func append(_ message: String) {
    NSLog("REBREAK_PROTECTION %@", message)
    guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
    let timestamp = ISO8601DateFormatter().string(from: Date())
    let entry = "[\(timestamp)] \(message)"
    var logs = defaults.stringArray(forKey: logKey) ?? []
    logs.append(entry)
    if logs.count > maxEntries { logs.removeFirst(logs.count - maxEntries) }
    defaults.set(logs, forKey: logKey)
  }

  /// Letzte `n` Einträge — für Inline-Diagnose im activateUrlFilter-Ergebnis
  /// (enthält auch die `[EXT ...]`-Zeilen der Control-Provider-Extension).
  static func tail(_ n: Int) -> [String] {
    guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] }
    return Array((defaults.stringArray(forKey: logKey) ?? []).suffix(n))
  }
}

// ─── Module ───────────────────────────────────────────────────────────────────

/// Vereinheitlichtes ReBreak-Schutz-Modul.
///
/// Wraps:
///  - NEFilterDataProvider Konfiguration (URL-Filter Layer)
///  - AuthorizationCenter + ManagedSettings (Family Controls — denyAppRemoval)
///  - Blocklist-Sync mit Retry-Backoff
///  - End-to-End Health-Probe via hidden WKWebView
///
/// Single Source of Truth für Layer-State auf iOS. Cooldown-Logik bleibt
/// JS-seitig (`lib/protection.ts` → Backend-API).
public class RebreakProtectionModule: Module {

  // Health-Probe State (UI-Thread)
  private var probeWebView: WKWebView?
  private var probeDelegate: HealthProbeDelegate?

  public func definition() -> ModuleDefinition {
    Name("RebreakProtection")

    Events("onLayerChange")

    // ───────── activate: Family Controls + NEFilter + denyAppRemoval ─────────

    // ───────── probeContentFilter: Try NEFilter, retourniert ob das Device es
    // erlaubt. Pure Detection — kein persistenter State, kein User-Toggle-Touch.
    //
    // iOS-Verhalten 2026: NEFilter wird auf nicht-MDM-managed Geräten silent
    // gecuttet (kein Permission-Dialog, isEnabled bleibt false). Wenn die App
    // nach saveToPreferences() ein isEnabled=true sieht, ist das Device entweder
    // MDM-managed ODER der User hat dem Dialog explizit zugestimmt — beide
    // Bedeutungen "NEFilter ist gestartet". Wenn isEnabled=false bleibt →
    // Device ist nicht-MDM und kann NEFilter nicht.
    //
    // Aufgerufen vom Settings-"Auto-Detect"-Button → setzt JS-Toggle. Cleanup
    // erfolgt NICHT automatisch — wenn die Probe positiv ist, läuft NEFilter
    // weiter als Schutz-Layer (gut so). Wenn negativ, removeFromPreferences
    // räumt die halbe Config auf.

    AsyncFunction("probeContentFilter") { () async -> [String: Any] in
      var enabled = false
      var error: String? = nil
      do {
        let manager = NEFilterManager.shared()
        SharedLogStore.append("🔍 [probeContentFilter] loadFromPreferences...")
        try await manager.loadFromPreferences()

        // CACHED-DENIED-CLEAR: Apple cached den "User hat Nicht erlauben gewählt"
        // State permanent → ein simples saveToPreferences würde silent Error 5
        // bringen, OHNE Dialog. removeFromPreferences löscht den cached state,
        // sodass das nächste save als frischer Permission-Request gilt + Dialog
        // zeigt (auf non-MDM) ODER silent durchgeht (auf MDM-enrolled).
        SharedLogStore.append("🔍 [probeContentFilter] removeFromPreferences (clear cached denied)...")
        do {
          try await manager.removeFromPreferences()
        } catch {
          SharedLogStore.append("ℹ️ [probeContentFilter] removeFromPreferences skip: \(error.localizedDescription)")
        }
        // NEAgent braucht ~800ms damit der remove propagiert bevor save als
        // frisch behandelt wird. Empirisch aus resetUrlFilter (iOS 17/18).
        try? await Task.sleep(nanoseconds: 800_000_000)

        let config = NEFilterProviderConfiguration()
        config.filterBrowsers = true
        config.filterSockets = false
        manager.providerConfiguration = config
        manager.localizedDescription = "ReBreak Schutz"
        manager.isEnabled = true

        SharedLogStore.append("🔍 [probeContentFilter] saveToPreferences (Dialog auf non-MDM, silent auf MDM)...")
        try await manager.saveToPreferences()
        enabled = manager.isEnabled
        SharedLogStore.append("🔍 [probeContentFilter] post-save isEnabled=\(enabled)")

        if !enabled {
          // Cleanup — halbe Config nicht stehen lassen
          try? await manager.removeFromPreferences()
          SharedLogStore.append("🔍 [probeContentFilter] not enabled — removed config")
        }
      } catch let e as NSError {
        error = "\(e.domain):\(e.code) \(e.localizedDescription)"
        SharedLogStore.append("❌ [probeContentFilter] failed: \(error!)")
      }
      var result: [String: Any] = ["enabled": enabled]
      if let error = error { result["error"] = error }
      result["log"] = SharedLogStore.tail(30)
      return result
    }

    // ───────── activateUrlFilter: Layer 1 — Branch supervised vs unsupervised ─
    //
    // Branch entscheidet die JS-Schicht via `supervised: Bool` aus dem
    // User-Setting (AsyncStorage `protection:device-mdm-supervised`).
    //
    // Supervised=true (User-Toggle ON oder Auto-Detect bestätigt):
    //   klassisches NEFilterDataProvider — kein VPN-Toggle in Settings,
    //   kein User-Bypass. Funktioniert nur auf MDM-managed Devices (iOS-Wall).
    // Supervised=false (Default):
    //   NEPacketTunnelProvider DNS-Sinkhole — VPN-Eintrag in Settings sichtbar,
    //   MDM-frei, ab iOS 16, Android-Parität.

    AsyncFunction("activateUrlFilter") { (opts: [String: Any]) async -> [String: Any] in
      // Expo's RN-Bridge schickt JS-Booleans als NSNumber durch — direktes
      // `as? Bool`-Cast retourniert nil. Doppel-Cast (Bool → NSNumber.boolValue)
      // damit's robust ist gegen beide Wege.
      let supervisedAny = opts["supervised"]
      let supervised: Bool
      if let b = supervisedAny as? Bool {
        supervised = b
      } else if let n = supervisedAny as? NSNumber {
        supervised = n.boolValue
      } else {
        supervised = false
      }
      SharedLogStore.append("📥 [activateUrlFilter] opts.supervised raw=\(String(describing: supervisedAny)) → \(supervised) → \(supervised ? "NEFilter (content-filter)" : "PacketTunnel (VPN)")")
      if supervised {
        return await Self.activateContentFilter()
      }
      var error: String? = nil
      var enabled = false
      var statusName = "n/a"

      do {
        SharedLogStore.append("📥 [activateUrlFilter] Packet-Tunnel — loadAllFromPreferences...")
        let manager = try await Self.loadOrCreateTunnelManager()

        // Tunnel starten. saveToPreferences() löst beim allerersten Mal den
        // iOS-VPN-System-Permission-Dialog aus.
        try await manager.saveToPreferences()
        // Nach saveToPreferences muss neu geladen werden, sonst wirft
        // startVPNTunnel() NEVPNError.configurationStale.
        try await manager.loadFromPreferences()

        guard let session = manager.connection as? NETunnelProviderSession else {
          error = "tunnel_session_unavailable"
          SharedLogStore.append("❌ [activateUrlFilter] keine NETunnelProviderSession")
          var r: [String: Any] = ["enabled": false, "status": "n/a"]
          r["error"] = error
          r["log"] = SharedLogStore.tail(30)
          return r
        }

        SharedLogStore.append("🚀 [activateUrlFilter] startVPNTunnel...")
        try session.startVPNTunnel()

        // Auf .connected warten. Direkt nach startVPNTunnel() steht der Status
        // oft noch kurz auf .disconnected, BEVOR er auf .connecting → .connected
        // wechselt. Daher pollen bis .connected ODER Timeout (≈6s) — NICHT nur
        // solange .connecting (sonst false-negative tunnel_not_connected in der
        // frühen .disconnected-Phase). .invalid = Konfig-Fehler → sofort raus.
        var status = manager.connection.status
        var waited = 0
        while status != .connected && status != .invalid && waited < 15 {
          try? await Task.sleep(nanoseconds: 400_000_000)
          status = manager.connection.status
          waited += 1
        }
        statusName = Self.tunnelStatusName(status)
        enabled = (status == .connected)

        // App-Group-Flags spiegeln (die Extension setzt sie ebenfalls; hier
        // setzen wir sie optimistisch, damit getDeviceState konsistent ist).
        if let d = UserDefaults(suiteName: APP_GROUP) {
          d.set(enabled, forKey: VPN_TUNNEL_RUNNING_KEY)
          d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY)
        }

        if enabled {
          SharedLogStore.append("✅ Packet-Tunnel connected")
        } else {
          error = "tunnel_not_connected status=\(statusName)"
          SharedLogStore.append("❌ Packet-Tunnel nicht aktiv: \(error!)")
        }
      } catch let e as NSError {
        error = "\(e.domain):\(e.code) \(e.localizedDescription)"
        SharedLogStore.append("❌ [activateUrlFilter] Packet-Tunnel failed: \(error!)")
      }

      var result: [String: Any] = ["enabled": enabled, "status": statusName]
      if let error = error { result["error"] = error }
      result["log"] = SharedLogStore.tail(30)
      return result
    }

    // ───────── activateNeUrlFilter: NEURLFilter (iOS 26) — INAKTIV ─────────
    //
    // Behalten als optionaler iOS-26-Upgrade-Pfad (User-Entscheidung). Wird
    // NICHT mehr vom Default-Flow aufgerufen — `activateUrlFilter` startet
    // stattdessen den Packet-Tunnel. Sobald Apple den NEURLFilter-DTS-Bug
    // fixt, kann dieser Pfad als „privacy-besserer" Filter (PIR) reaktiviert
    // werden. Funktion bleibt registriert, damit der Code build-bar bleibt.

    AsyncFunction("activateNeUrlFilter") { (opts: [String: String]) async -> [String: Any] in
      var error: String? = nil
      var enabled = false
      var statusName = "n/a"
      var disconnectName: String? = nil
      if #available(iOS 26.0, *) {
        let pirAuthToken = opts["pirAuthToken"] ?? ""
        guard let pirServerURL = URL(string: opts["pirServerURL"] ?? ""), !pirAuthToken.isEmpty else {
          SharedLogStore.append("❌ [activateUrlFilter] fehlende pirServerURL/pirAuthToken")
          return ["enabled": false, "error": "missing_pir_config"]
        }
        func name(_ s: NEURLFilterManager.Status) -> String {
          switch s {
          case .invalid: return "invalid"
          case .stopped: return "stopped"
          case .starting: return "starting"
          case .running: return "running"
          case .stopping: return "stopping"
          @unknown default: return "unknown(\(s.rawValue))"
          }
        }
        var phase = "loadFromPreferences"
        do {
          let manager = NEURLFilterManager.shared
          SharedLogStore.append("📥 [activateUrlFilter] loadFromPreferences...")
          try await manager.loadFromPreferences()

          phase = "setConfiguration"
          SharedLogStore.append(
            "⚙️ [activateUrlFilter] setConfiguration (server=\(pirServerURL.absoluteString) tokenLen=\(pirAuthToken.count))..."
          )
          // pirPrivacyPassIssuerURL == pirServerURL — PIRService bedient beides.
          try manager.setConfiguration(
            pirServerURL: pirServerURL,
            pirPrivacyPassIssuerURL: pirServerURL,
            pirAuthenticationToken: pirAuthToken,
            controlProviderBundleIdentifier: "org.rebreak.app.URLFilterExtension"
          )
          // WWDC2025-NEURLFilter-Sample setzt zusätzlich `localizedDescription` +
          // `prefilterFetchInterval` — die hatten wir bisher NICHT. Eine fehlende
          // `localizedDescription` ist ein Kandidat für das `configurationInvalid`
          // bei `saveToPreferences()`.
          manager.localizedDescription = "ReBreak URL-Filter"
          manager.prefilterFetchInterval = 86400  // 1 Tag (wie WWDC-Sample)
          manager.isEnabled = true
          manager.shouldFailClosed = true

          phase = "saveToPreferences"
          SharedLogStore.append("💾 [activateUrlFilter] saveToPreferences...")
          try await manager.saveToPreferences()

          phase = "status"
          // saveToPreferences wirft NICHT, wenn iOS die Config zwar speichert,
          // aber als ungültig ablehnt ("Ungültig" in Settings). Der echte
          // Status ist erst nach kurzer Zeit stabil — direkt nach dem Save
          // steht er auf .starting. Auf stabilen Status warten (max ~3s).
          var status = await manager.status
          var waited = 0
          while status == .starting && waited < 8 {
            try? await Task.sleep(nanoseconds: 400_000_000)
            status = await manager.status
            waited += 1
          }
          statusName = name(status)
          if let d = await manager.lastDisconnectError {
            disconnectName = "\(d)"
            SharedLogStore.append("⚠️ NEURLFilter lastDisconnectError: \(d) (rawValue=\(d.rawValue))")
          }
          // Wahrheit ist der Status, NICHT isEnabled (das bleibt true auch bei
          // einer abgelehnten Config).
          enabled = (status == .running)
          SharedLogStore.append(
            "ℹ️ NEURLFilter post-save: status=\(statusName) isEnabled=\(manager.isEnabled) disconnectError=\(disconnectName ?? "nil")"
          )
          if !enabled {
            error = "config_invalid status=\(statusName) disconnectError=\(disconnectName ?? "none")"
            SharedLogStore.append("❌ NEURLFilter nicht aktiv: \(error!)")
          } else {
            SharedLogStore.append("✅ NEURLFilter running")
          }
        } catch let e as NSError {
          // lastDisconnectError auch im Throw-Fall lesen — gibt oft den echten
          // Grund hinter einem generischen configurationInvalid.
          var disc = "n/a"
          if let d = await NEURLFilterManager.shared.lastDisconnectError {
            disc = "\(d)"
          }
          error = "[\(phase)] \(e.domain):\(e.code) \(e.localizedDescription) | lastDisconnectError=\(disc)"
          SharedLogStore.append("❌ NEURLFilter enable failed: \(error!)")
        }
      } else {
        error = "iOS 26+ erforderlich für NEURLFilter"
        SharedLogStore.append("❌ \(error!)")
      }
      var result: [String: Any] = ["enabled": enabled, "status": statusName]
      if let d = disconnectName { result["disconnectError"] = d }
      if let error = error { result["error"] = error }
      // Tail des geteilten Log-Stores mitgeben — enthält die [EXT ...]-Zeilen
      // der Control-Provider-Extension (separater Prozess, sonst unsichtbar).
      result["log"] = SharedLogStore.tail(30)
      return result
    }

    // ───────── resetUrlFilter: cached "denied" State löschen + frischer Dialog ─────────
    //
    // Apple's NEFilterManager cached den "permission denied"-State wenn User einmal
    // "Nicht erlauben" getippt hat — danach zeigt iOS den System-Dialog beim erneuten
    // saveToPreferences NICHT mehr (code 5 silent). Workaround: removeFromPreferences
    // löscht die alte (denied) Config komplett, sodass der nächste saveToPreferences
    // als brandneuer Permission-Request behandelt wird → frischer System-Dialog.
    //
    // Apple-undokumentiert, aber Community-erprobt. Wenn iOS den Dialog trotzdem nicht
    // zeigt (z.B. wegen Screen-Time-Restrictions), bleibt nur Settings.app-Recovery.

    AsyncFunction("resetUrlFilter") { () async -> [String: Any] in
      var error: String? = nil
      var enabled = false
      do {
        let manager = NEFilterManager.shared()
        SharedLogStore.append("📥 [resetUrlFilter] loadFromPreferences...")
        try await manager.loadFromPreferences()

        // Best-effort remove — schlägt fehl wenn nichts existiert, das ist OK.
        SharedLogStore.append("🧹 [resetUrlFilter] removeFromPreferences (clear denied-cache)...")
        do {
          try await manager.removeFromPreferences()
        } catch {
          SharedLogStore.append("ℹ️ removeFromPreferences ignored: \(error.localizedDescription)")
        }

        // NEAgent braucht Zeit den remove zu propagieren bevor ein neuer save
        // als "frischer Permission-Request" behandelt wird. Ohne Pause feuert
        // iOS oft direkt wieder NEFilterErrorDomain:5 (cached denied).
        // 800ms aus empirischen Tests (iOS 17/18).
        try? await Task.sleep(nanoseconds: 800_000_000)

        // Frische Config setzen + neu speichern → iOS zeigt jetzt frischen Dialog.
        let config = NEFilterProviderConfiguration()
        config.filterBrowsers = true
        config.filterSockets = false
        manager.providerConfiguration = config
        manager.localizedDescription = "Rebreak URL Filter"
        manager.isEnabled = true

        // Retry-Loop: bis 3 Versuche mit exponentiellem Backoff. Apple hat
        // seit iOS 17 den denied-cache hardened — manchmal braucht's mehrere
        // saves bis der System-Dialog wirklich aufpoppt.
        var lastError: NSError? = nil
        for attempt in 1...3 {
          SharedLogStore.append("💾 [resetUrlFilter] saveToPreferences attempt \(attempt)/3...")
          do {
            try await manager.saveToPreferences()
            enabled = manager.isEnabled
            SharedLogStore.append("✅ NEFilter re-enabled after reset (isEnabled=\(enabled))")
            lastError = nil
            break
          } catch let e as NSError {
            lastError = e
            SharedLogStore.append("⚠️ saveToPreferences attempt \(attempt) failed: \(e.domain):\(e.code)")
            // Wenn code != 5 (denied): nicht retry, das ist anderer Fehler.
            if !(e.domain == "NEFilterErrorDomain" && e.code == 5) {
              throw e
            }
            // Sonst: kurzes Wait + nochmal versuchen
            try? await Task.sleep(nanoseconds: UInt64(attempt) * 600_000_000)
          }
        }
        if let lastError = lastError {
          throw lastError
        }
      } catch let e as NSError {
        error = "\(e.domain):\(e.code) \(e.localizedDescription)"
        SharedLogStore.append("❌ resetUrlFilter failed: \(error!)")
      }
      var result: [String: Any] = ["enabled": enabled]
      if let error = error { result["error"] = error }
      return result
    }

    // ───────── reconcileUrlFilter: Self-Heal nach „VPN löschen" in Settings ─────────
    //
    // User-Bypass-Pfad: Settings → VPN → ReBreak Schutz → „VPN löschen" entfernt
    // unsere NETunnelProviderManager-Config. iOS lässt diesen Button bei app-managed
    // VPNs immer zu — kein MDM-Key blockt selektiv nur diesen einen Button (Apple-
    // Limitation, verifiziert 2026-05-24).
    //
    // Counter-Strategie: bei jedem Foreground/Polling-Tick prüfen ob unser
    // Tunnel-Manager noch in loadAllFromPreferences enthalten ist. Falls weg →
    // silent recreate via loadOrCreateTunnelManager + saveToPreferences. Wenn iOS
    // wegen frischem Manager den Permission-Dialog zeigt: akzeptierte Friktion —
    // der User sieht dass sein Delete erkannt wurde.
    //
    // Wird vom JS-Wrapper `protection.reconcileVpn()` (iOS-Branch) gerufen, der
    // wiederum aus `enforceProtection()` in app/(app)/_layout.tsx (mount +
    // foreground + 15s-Poll) feuert.

    AsyncFunction("reconcileUrlFilter") { () async -> [String: Any] in
      do {
        let managers = try await NETunnelProviderManager.loadAllFromPreferences()
        if let existing = Self.findRebreakTunnel(in: managers) {
          // Config noch da — kein recreate nötig. OnDemand-Regel kümmert sich
          // um Reconnect bei Netzwerk-Events, hier kein explizites startVPNTunnel.
          let statusName = Self.tunnelStatusName(existing.connection.status)
          return ["recreated": false, "status": statusName]
        }

        // Config WEG — wahrscheinlich „VPN löschen" durch User. Silent recreate.
        SharedLogStore.append("⚠️ [reconcileUrlFilter] tunnel MISSING — recreating after user-delete")
        let manager = try await Self.loadOrCreateTunnelManager()
        try await manager.saveToPreferences()
        try await manager.loadFromPreferences()

        // Tunnel sofort starten — OnDemand fängt sonst erst beim nächsten
        // Netzwerk-Event. Bewusst nicht warten/timeout: das Polling sieht den
        // Connected-State spätestens beim nächsten Tick.
        if let session = manager.connection as? NETunnelProviderSession {
          try? session.startVPNTunnel()
        }

        // App-Group-Flag spiegeln (siehe activateUrlFilter — getDeviceState liest hier).
        if let d = UserDefaults(suiteName: APP_GROUP) {
          d.set(true, forKey: VPN_TUNNEL_RUNNING_KEY)
          d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY)
        }

        SharedLogStore.append("✅ [reconcileUrlFilter] tunnel recreated")
        return ["recreated": true]
      } catch let e as NSError {
        let errStr = "\(e.domain):\(e.code) \(e.localizedDescription)"
        SharedLogStore.append("❌ [reconcileUrlFilter] failed: \(errStr)")
        return ["recreated": false, "error": errStr]
      }
    }

    // ───────── activateFamilyControls: NUR FC + denyAppRemoval ─────────

    AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
      var error: String? = nil
      var enabled = false
      var webContentCount = 0
      if #available(iOS 16.0, *) {
        // Retry-Loop: FamilyControls XPC-Daemon kann auf den ersten Call
        // mit NSCocoaErrorDomain:4099 antworten (Communication-Failure, oft
        // direkt nach App-Start oder nach NEFilter-Activation). 3 Versuche
        // mit Backoff lösen das in der Praxis.
        var lastError: NSError? = nil
        for attempt in 1...3 {
          do {
            SharedLogStore.append("🔐 [activateFamilyControls] requestAuthorization attempt \(attempt)/3...")
            try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
            let authorized = AuthorizationCenter.shared.authorizationStatus == .approved
            SharedLogStore.append("✅ FamilyControls authorized (\(authorized))")
            lastError = nil
            if authorized {
              let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
              store.application.denyAppRemoval = true
              store.application.denyAppInstallation = false
              let lockActive = (store.application.denyAppRemoval as? Bool) == true
              enabled = lockActive
              SharedLogStore.append("🔒 denyAppRemoval = \(lockActive)")
              if !lockActive {
                error = "denyAppRemoval_not_active"
              }
              // Layer 2 — der webContent-Filter ist Teil des FC-Schutzes:
              // greift mit, sobald FC aktiv ist (stilles Sicherheitsnetz für
              // den Fall, dass der User Layer 1 / VPN abschaltet). Best-effort
              // — ein Fehlschlag hier kippt die FC-Aktivierung NICHT.
              let wc = Self.applyWebContentLayer()
              webContentCount = wc.count
            } else {
              enabled = false
            }
            break
          } catch let e as NSError {
            lastError = e
            SharedLogStore.append("⚠️ FamilyControls attempt \(attempt) failed: \(e.domain):\(e.code) \(e.localizedDescription)")
            // Nur retryen wenn 4099 (XPC-Daemon nicht erreichbar). Sonst sofort fail.
            if !(e.domain == "NSCocoaErrorDomain" && e.code == 4099) {
              break
            }
            try? await Task.sleep(nanoseconds: UInt64(attempt) * 700_000_000)
          }
        }
        if let lastError = lastError, error == nil {
          error = "\(lastError.domain):\(lastError.code) \(lastError.localizedDescription)"
          SharedLogStore.append("❌ FamilyControls auth failed (all retries): \(error!)")
        }
      } else {
        error = "iOS 16+ required for FamilyControls"
      }
      var result: [String: Any] = ["enabled": enabled, "webContentDomains": webContentCount]
      if let error = error { result["error"] = error }
      return result
    }

    // ───────── activate (legacy, alle Layer in einem Call) ─────────

    AsyncFunction("activate") { () async -> [String: Any] in
      var missingLayers: [String] = []
      var errors: [String] = []

      // 1) Family Controls Authorization (iOS 16+)
      var familyControlsApproved = false
      if #available(iOS 16.0, *) {
        do {
          try await AuthorizationCenter.shared.requestAuthorization(for: .individual)
          familyControlsApproved = AuthorizationCenter.shared.authorizationStatus == .approved
          SharedLogStore.append("✅ FamilyControls authorized")
        } catch let error as NSError {
          let msg = "FamilyControls auth failed: \(error.domain):\(error.code) \(error.localizedDescription)"
          SharedLogStore.append("❌ \(msg)")
          errors.append(msg)
        }
      } else {
        errors.append("iOS 16+ required for FamilyControls")
      }
      if !familyControlsApproved {
        missingLayers.append("familyControls")
      }

      // 2) NEFilter aktivieren
      var urlFilterOn = false
      do {
        let manager = NEFilterManager.shared()
        SharedLogStore.append("📥 [activate] loadFromPreferences...")
        try await manager.loadFromPreferences()

        let config = NEFilterProviderConfiguration()
        config.filterBrowsers = true
        config.filterSockets = false
        manager.providerConfiguration = config
        manager.localizedDescription = "Rebreak URL Filter"
        manager.isEnabled = true

        SharedLogStore.append("💾 [activate] saveToPreferences (System-Dialog)...")
        try await manager.saveToPreferences()
        urlFilterOn = manager.isEnabled
        SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(urlFilterOn))")
      } catch let error as NSError {
        let msg = "NEFilter enable failed: \(error.domain):\(error.code) \(error.localizedDescription)"
        SharedLogStore.append("❌ \(msg)")
        errors.append(msg)
      }
      if !urlFilterOn {
        missingLayers.append("urlFilter")
      }

      // 3) ManagedSettings denyAppRemoval (nur wenn FamilyControls approved)
      if #available(iOS 16.0, *), familyControlsApproved {
        let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
        store.application.denyAppRemoval = true
        store.application.denyAppInstallation = false
        SharedLogStore.append("🔒 denyAppRemoval = true")
        // Layer 2 — webContent-Filter als Teil des FC-Schutzes mit-aktivieren.
        _ = Self.applyWebContentLayer()
      }

      return [
        "allLayersOn": missingLayers.isEmpty,
        "missingLayers": missingLayers,
        // Errors ans JS hochbubblen damit das UI wirklich anzeigen kann was failt
        "errors": errors,
      ]
    }

    // ───────── disable: NUR aufrufen wenn JS-Cooldown abgelaufen! ─────────

    AsyncFunction("disable") { () async -> [String: Any] in
      // NEFilter — robuster Disable-Path:
      //   1. loadFromPreferences (current config + isEnabled state lesen)
      //   2. isEnabled = false + saveToPreferences (Filter-Daemon stoppen +
      //      Settings.app-UI flippt sofort auf "deaktiviert")
      //   3. removeFromPreferences (Config-Eintrag aus Settings entfernen)
      //
      // Warum 2-Step: removeFromPreferences ALLEIN ist auf manchen iOS-Versionen
      // (insb. iOS 18+) unzuverlässig — Settings-UI zeigt "Läuft..." obwohl der
      // Provider beendet sein sollte. Erst isEnabled=false + save bringt das
      // System dazu, den Filter-Daemon sauber zu beenden bevor wir die Config
      // löschen. Pattern aus Apple-Developer-Forums + eigene Empirie.
      // Layer 1 = Packet-Tunnel-DNS-Filter stoppen + Config entfernen.
      do {
        let managers = try await NETunnelProviderManager.loadAllFromPreferences()
        if let manager = Self.findRebreakTunnel(in: managers) {
          if let session = manager.connection as? NETunnelProviderSession {
            session.stopVPNTunnel()
          }
          try await manager.removeFromPreferences()
          SharedLogStore.append("✅ Packet-Tunnel stopped + removed from preferences")
        } else {
          SharedLogStore.append("ℹ️ Packet-Tunnel disable: keine Config vorhanden")
        }
      } catch {
        SharedLogStore.append("⚠️ Packet-Tunnel disable: \(error.localizedDescription)")
      }
      // App-Group-Tunnel-Flags zurücksetzen.
      if let d = UserDefaults(suiteName: APP_GROUP) {
        d.removeObject(forKey: VPN_TUNNEL_RUNNING_KEY)
        d.removeObject(forKey: VPN_TUNNEL_REVOKED_KEY)
      }

      // NEFilter (klassisch, content-filter) defensiv ebenfalls deaktivieren —
      // falls vorher der supervised-Pfad lief. Idempotent: macht nichts wenn
      // keine NEFilter-Config existiert.
      await Self.disableContentFilter()

      // NEURLFilter (iOS 26) defensiv ebenfalls deaktivieren — falls ein
      // früherer Build NEURLFilter aktiviert hatte. Bleibt als Code erhalten.
      if #available(iOS 26.0, *) {
        do {
          let manager = NEURLFilterManager.shared
          try await manager.loadFromPreferences()
          manager.isEnabled = false
          try await manager.removeFromPreferences()
          SharedLogStore.append("✅ NEURLFilter disabled + removed from preferences")
        } catch {
          SharedLogStore.append("⚠️ NEURLFilter disable: \(error.localizedDescription)")
        }
      }

      // ManagedSettings (löst denyAppRemoval UND Layer-2-webContent-Filter —
      // clearAllSettings() setzt den gesamten Store inkl. webContent zurück).
      if #available(iOS 16.0, *) {
        let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
        store.clearAllSettings()
        SharedLogStore.append("🔓 ManagedSettings cleared (denyAppRemoval + webContent)")
      }

      // Blocklist-Datei löschen
      Self.clearBlocklistFile()

      return ["allLayersOff": true]
    }

    // ───────── Layer 2: webContent-Filter (ManagedSettings) ─────────
    //
    // WebKit-interner Content-Filter via ManagedSettingsStore().webContent.
    // Stilles Sicherheitsnetz: blockt eine kuratierte, länderabhängige Top-
    // Gambling-Domain-Liste (gebündeltes JSON, ≤50 Domains pro Land — Apple-
    // Hartlimit) plus systemseitig Adult-Content gratis mit (.auto-Variante).
    //
    // Voraussetzung: gültige Family-Controls-Authorization (wie denyAppRemoval).
    // Kein MDM, kein neues Entitlement. Wirkt nur, solange FC authorisiert ist —
    // bei FC-Widerruf fällt der Filter lautlos weg (Apple-Verhalten, kein
    // Callback).
    //
    // TODO(layer2-gating): Aktuell NUR explizit aufrufbare Capability. Die
    // Auto-Trigger-Logik ("Layer 2 automatisch AN sobald NEURLFilter/Layer 1
    // deaktiviert ist + während ein Cooldown läuft, sonst AUS") ist bewusst
    // NICHT implementiert — sie hängt an einer zuverlässigen NEURLFilter-
    // Status-Erkennung (die aktuell selbst nicht stabil funktioniert) und ist
    // eine offene Produkt-/Design-Entscheidung des Users. Bis dahin ruft die
    // JS-Schicht applyWebContentFilter/clearWebContentFilter explizit auf.

    // ───────── applyWebContentFilter: Land → Domains → blockedByFilter ─────────

    AsyncFunction("applyWebContentFilter") { () async -> [String: Any] in
      var error: String? = nil
      var enabled = false
      var appliedCount = 0
      var resolvedRegion = WEBCONTENT_FALLBACK_REGION

      if #available(iOS 16.0, *) {
        // Family Controls muss authorisiert sein, sonst ist webContent stumm.
        guard AuthorizationCenter.shared.authorizationStatus == .approved else {
          SharedLogStore.append("⚠️ [applyWebContentFilter] FamilyControls nicht authorisiert — abort")
          return [
            "enabled": false,
            "appliedCount": 0,
            "region": resolvedRegion,
            "error": "family_controls_not_authorized",
          ]
        }

        // Gemeinsame Layer-2-Logik (applyWebContentLayer) — exakt dieselbe,
        // die auch bei der FC-Aktivierung mitläuft.
        let wc = Self.applyWebContentLayer()
        enabled = wc.enabled
        appliedCount = wc.count
        resolvedRegion = wc.region
        if !enabled { error = "no_domains_for_region" }
      } else {
        error = "iOS 16+ required for webContent filter"
        SharedLogStore.append("❌ [applyWebContentFilter] \(error!)")
      }

      var result: [String: Any] = [
        "enabled": enabled,
        "appliedCount": appliedCount,
        "region": resolvedRegion,
      ]
      if let error = error { result["error"] = error }
      return result
    }

    // ───────── clearWebContentFilter: Filter zurücksetzen ─────────

    AsyncFunction("clearWebContentFilter") { () async -> [String: Any] in
      if #available(iOS 16.0, *) {
        let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
        // Nur den webContent-Layer zurücksetzen — NICHT clearAllSettings(),
        // damit denyAppRemoval (Layer „App-Lock") unangetastet bleibt.
        store.webContent.blockedByFilter = .none
        SharedLogStore.append("🔓 [clearWebContentFilter] webContent.blockedByFilter=.none")
        return ["cleared": true]
      }
      return ["cleared": false, "error": "iOS 16+ required"]
    }

    // ───────── isNeFilterActive: System-NEFilter via Profile detecten ─────────
    //
    // Build 19 (2026-05-26): Apple's webcontent-filter Profile (Sideload non-removable)
    // aktiviert die ContentFilter-Extension autonom — App-Code aktiviert NICHT
    // mehr selbst (würde gegen MDM-managed-Filter konfligieren, plus Apple-Wall
    // bei Distribution-Cert). App liest nur den State um UI all-green zu zeigen.
    //
    // NEFilterManager.shared().loadFromPreferences() returns die aktive Config
    // (kann via App-Code ODER via webcontent-filter Profile gesetzt sein —
    // egal, wir betrachten beides als „aktiv").

    AsyncFunction("isNeFilterActive") { () async -> [String: Any] in
      var enabled = false
      var localizedDescription: String? = nil
      var error: String? = nil
      do {
        let manager = NEFilterManager.shared()
        try await manager.loadFromPreferences()
        enabled = manager.isEnabled
        localizedDescription = manager.localizedDescription
      } catch let e as NSError {
        error = "\(e.domain):\(e.code) \(e.localizedDescription)"
      }
      var result: [String: Any] = ["enabled": enabled]
      if let ld = localizedDescription { result["localizedDescription"] = ld }
      if let error = error { result["error"] = error }
      return result
    }

    // ───────── getDeviceState: aktueller Status aller Layer ─────────

    AsyncFunction("getDeviceState") { () async -> [String: Any] in
      // Layer 1 = Packet-Tunnel-DNS-Filter (alter VPN-Pfad, unsupervised devices)
      // ODER NEFilter via webcontent-filter Profile (Sideload non-removable, MDM-mode).
      // UI muss beide State-Quellen kennen — `nefilterActive` (neu) hat höhere Prio.
      var urlFilter = false
      var mdmManaged = false
      do {
        let managers = try await NETunnelProviderManager.loadAllFromPreferences()
        // Legacy MDM-Detection via VPN-Tunnel-Count (heute irrelevant — wir nutzen
        // jetzt nefilterActive als primary MDM-Indikator, da der Sideload-Pfad
        // den MDM-VPN-Push überholt hat).
        let rebreakTunnels = managers.filter { manager in
          guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol
          else { return false }
          return proto.providerBundleIdentifier == PACKET_TUNNEL_BUNDLE_ID
        }
        mdmManaged = rebreakTunnels.count > 1
        if let manager = rebreakTunnels.first {
          urlFilter = (manager.connection.status == .connected)
        }
      } catch {
        // ignore — kein Tunnel konfiguriert → urlFilter + mdmManaged bleiben false.
      }

      // NEFilter (klassisches Content-Filter via Sideload-Profile)
      var nefilterActive = false
      var nefilterDescription: String? = nil
      do {
        let nefManager = NEFilterManager.shared()
        try await nefManager.loadFromPreferences()
        nefilterActive = nefManager.isEnabled
        nefilterDescription = nefManager.localizedDescription
      } catch {
        // ignore — kein NEFilter konfiguriert → nefilterActive bleibt false
      }

      // FamilyControls
      var familyControls = false
      var appDeletionLock = false
      var webContentFilter = false
      if #available(iOS 16.0, *) {
        familyControls = AuthorizationCenter.shared.authorizationStatus == .approved
        let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
        appDeletionLock = (store.application.denyAppRemoval as? Bool) == true
        // Layer 2 gilt als „an", wenn eine FilterPolicy ≠ .none gesetzt ist.
        if let policy = store.webContent.blockedByFilter {
          if case .none = policy {
            webContentFilter = false
          } else {
            webContentFilter = true
          }
        }
      }

      let count = Self.currentHashCount()
      let lastSync = UserDefaults(suiteName: APP_GROUP)?.string(forKey: LAST_SYNC_KEY)

      var result: [String: Any] = [
        "urlFilter": urlFilter,
        "familyControls": familyControls,
        "appDeletionLock": appDeletionLock,
        "webContentFilter": webContentFilter,
        "mdmManaged": mdmManaged,
        "nefilterActive": nefilterActive,
        "blocklistCount": count,
        "blocklistLastSyncAt": lastSync ?? NSNull(),
      ]
      if let nd = nefilterDescription { result["nefilterDescription"] = nd }
      return result
    }

    // ───────── syncBlocklist: download + atomic write + DarwinNotif ─────────

    AsyncFunction("syncBlocklist") { (opts: [String: String]) async throws -> [String: Any] in
      guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
        throw NSError(
          domain: "RebreakProtection", code: 400,
          userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
        )
      }
      guard let endpoint = URL(string: "\(baseURL)/api/url-filter/blocklist.bin") else {
        throw NSError(
          domain: "RebreakProtection", code: 400,
          userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
        )
      }

      // Retry mit Backoff (1s, 2s) — fängt iOS NECP-Race nach saveToPreferences
      let maxAttempts = 3
      var attempt = 1
      var lastError: Error?

      while attempt <= maxAttempts {
        do {
          var request = URLRequest(url: endpoint)
          request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
          if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: ETAG_KEY) {
            request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
          }

          let (data, response) = try await URLSession.shared.data(for: request)
          guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(
              domain: "RebreakProtection", code: 500,
              userInfo: [NSLocalizedDescriptionKey: "invalid response"]
            )
          }

          if httpResponse.statusCode == 304 {
            SharedLogStore.append("📡 sync 304 (cached)")
            return ["updated": false, "count": Self.currentHashCount()]
          }

          guard httpResponse.statusCode == 200 else {
            throw NSError(
              domain: "RebreakProtection", code: httpResponse.statusCode,
              userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
            )
          }

          guard let appGroupURL = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
          else {
            throw NSError(
              domain: "RebreakProtection", code: 500,
              userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
            )
          }

          let finalURL = appGroupURL.appendingPathComponent(BLOCKLIST_FILENAME)
          let tmpURL = finalURL.appendingPathExtension("tmp")
          try data.write(to: tmpURL, options: .atomic)

          // DiGA-Hardening: Datei verschlüsselt at-rest. WICHTIG:
          // `.completeUntilFirstUserAuthentication`, NICHT `.complete` —
          // die PacketTunnel-Extension muss `blocklist.bin` auch bei
          // GESPERRTEM Gerät lesen können. `startTunnel` läuft OS-getrieben
          // (on-demand) auch während Lock-Phasen; `.complete` macht die Datei
          // dann unlesbar → `HashList.load()` liefert 0 Hashes → Layer 1
          // blockt nichts, bis die App das nächste Mal synct.
          // `.completeUntilFirstUserAuthentication` bleibt at-rest
          // verschlüsselt, ist aber nach dem ersten Entsperren seit Boot
          // lesbar — die korrekte Klasse für eine 24/7-NE-Datei.
          try? (tmpURL as NSURL).setResourceValue(
            URLFileProtection.completeUntilFirstUserAuthentication,
            forKey: .fileProtectionKey
          )
          var mut = tmpURL
          var rv = URLResourceValues()
          rv.isExcludedFromBackup = true
          try? mut.setResourceValues(rv)

          // Atomic replace
          _ = try? FileManager.default.removeItem(at: finalURL)
          try FileManager.default.moveItem(at: tmpURL, to: finalURL)

          // ETag + lastSync persistieren
          let defaults = UserDefaults(suiteName: APP_GROUP)
          if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
            defaults?.set(newEtag, forKey: ETAG_KEY)
          }
          defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: LAST_SYNC_KEY)

          // Extension benachrichtigen (cross-process)
          CFNotificationCenterPostNotification(
            CFNotificationCenterGetDarwinNotifyCenter(),
            CFNotificationName(DARWIN_NOTIF as CFString),
            nil, nil, true
          )

          let count = data.count / 8
          let plan = httpResponse.value(forHTTPHeaderField: "x-rebreak-plan") ?? ""
          SharedLogStore.append("📡 sync ok: \(count) hashes (plan=\(plan))")

          var result: [String: Any] = ["updated": true, "count": count]
          if !plan.isEmpty { result["plan"] = plan }
          return result

        } catch {
          lastError = error
          let nsError = error as NSError
          let isTransient = nsError.domain == NSURLErrorDomain && (
            nsError.code == NSURLErrorNotConnectedToInternet ||
            nsError.code == NSURLErrorCannotConnectToHost ||
            nsError.code == NSURLErrorTimedOut ||
            nsError.code == NSURLErrorNetworkConnectionLost ||
            nsError.code == NSURLErrorCannotFindHost
          )
          if isTransient && attempt < maxAttempts {
            let delaySec = UInt64(attempt)
            SharedLogStore.append("⏳ sync transient \(nsError.code), retry in \(delaySec)s")
            try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
            attempt += 1
            continue
          }
          SharedLogStore.append("❌ sync failed: \(error.localizedDescription)")
          throw error
        }
      }

      throw lastError ?? NSError(
        domain: "RebreakProtection", code: 500,
        userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
      )
    }

    // ───────── syncWebContentDomains: Layer-2-Domain-Liste vom Backend ─────────
    //
    // Gespiegelt von `syncBlocklist` (oben). Holt die kuratierte Gambling-
    // Domain-Liste (`GET /api/protection/webcontent-domains`) und schreibt das
    // JSON atomar in den App-Group-Container als `webcontent-domains.json`.
    // `loadWebContentDomains` liest danach cache-first aus dieser Datei; die
    // gebündelte `gambling-domains.json` bleibt nur noch Offline-Seed/Fallback.
    //
    // URL-/Auth-Handling exakt wie `syncBlocklist`: baseURL + authToken kommen
    // aus JS (`opts`), Bearer-Auth, ETag/If-None-Match, Retry mit Backoff.
    //
    // Nach erfolgreichem Sync wird — wenn Family Controls authorisiert ist —
    // `applyWebContentLayer()` erneut aufgerufen, damit die neue Liste sofort
    // greift. Solange der Backend-Endpoint nicht deployed ist, schlägt der
    // Fetch fehl → `loadWebContentDomains` fällt sauber auf die gebündelte
    // JSON zurück (App funktioniert offline weiter).

    AsyncFunction("syncWebContentDomains") { (opts: [String: String]) async throws -> [String: Any] in
      guard let baseURL = opts["baseURL"], let authToken = opts["authToken"] else {
        throw NSError(
          domain: "RebreakProtection", code: 400,
          userInfo: [NSLocalizedDescriptionKey: "missing baseURL or authToken"]
        )
      }
      guard let endpoint = URL(string: "\(baseURL)/api/protection/webcontent-domains") else {
        throw NSError(
          domain: "RebreakProtection", code: 400,
          userInfo: [NSLocalizedDescriptionKey: "invalid baseURL"]
        )
      }

      // Retry mit Backoff (1s, 2s) — fängt transiente Netzwerk-Fehler.
      let maxAttempts = 3
      var attempt = 1
      var lastError: Error?

      while attempt <= maxAttempts {
        do {
          var request = URLRequest(url: endpoint)
          request.setValue("Bearer \(authToken)", forHTTPHeaderField: "Authorization")
          if let lastEtag = UserDefaults(suiteName: APP_GROUP)?.string(forKey: WEBCONTENT_ETAG_KEY) {
            request.setValue(lastEtag, forHTTPHeaderField: "If-None-Match")
          }

          let (data, response) = try await URLSession.shared.data(for: request)
          guard let httpResponse = response as? HTTPURLResponse else {
            throw NSError(
              domain: "RebreakProtection", code: 500,
              userInfo: [NSLocalizedDescriptionKey: "invalid response"]
            )
          }

          if httpResponse.statusCode == 304 {
            SharedLogStore.append("📡 [webContent] sync 304 (cached)")
            return ["updated": false]
          }

          guard httpResponse.statusCode == 200 else {
            throw NSError(
              domain: "RebreakProtection", code: httpResponse.statusCode,
              userInfo: [NSLocalizedDescriptionKey: "HTTP \(httpResponse.statusCode)"]
            )
          }

          // Vor dem Schreiben validieren: muss parsebares JSON mit Country-Keys
          // sein (gleiche Struktur wie die gebündelte gambling-domains.json).
          // Eine kaputte Response NICHT in den Cache schreiben — sonst killt
          // sie den Fallback.
          guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
            throw NSError(
              domain: "RebreakProtection", code: 422,
              userInfo: [NSLocalizedDescriptionKey: "response is not valid JSON object"]
            )
          }
          // Mindestens ein Country-Key mit einem String-Array erwartet.
          let countryKeyCount = root.keys.filter { !$0.hasPrefix("_") }.count
          guard countryKeyCount > 0 else {
            throw NSError(
              domain: "RebreakProtection", code: 422,
              userInfo: [NSLocalizedDescriptionKey: "response has no country keys"]
            )
          }

          guard let appGroupURL = FileManager.default
            .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)
          else {
            throw NSError(
              domain: "RebreakProtection", code: 500,
              userInfo: [NSLocalizedDescriptionKey: "App-Group container unavailable"]
            )
          }

          let finalURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME)
          let tmpURL = finalURL.appendingPathExtension("tmp")
          try data.write(to: tmpURL, options: .atomic)

          // DiGA-Hardening (gespiegelt von syncBlocklist) —
          // `.completeUntilFirstUserAuthentication`, damit die Datei auch aus
          // Hintergrund-Kontexten bei gesperrtem Gerät lesbar bleibt.
          try? (tmpURL as NSURL).setResourceValue(
            URLFileProtection.completeUntilFirstUserAuthentication,
            forKey: .fileProtectionKey
          )
          var mut = tmpURL
          var rv = URLResourceValues()
          rv.isExcludedFromBackup = true
          try? mut.setResourceValues(rv)

          // Atomic replace
          _ = try? FileManager.default.removeItem(at: finalURL)
          try FileManager.default.moveItem(at: tmpURL, to: finalURL)

          // ETag + lastSync persistieren
          let defaults = UserDefaults(suiteName: APP_GROUP)
          if let newEtag = httpResponse.value(forHTTPHeaderField: "etag") {
            defaults?.set(newEtag, forKey: WEBCONTENT_ETAG_KEY)
          }
          defaults?.set(ISO8601DateFormatter().string(from: Date()), forKey: WEBCONTENT_LAST_SYNC_KEY)

          let version = ((root["_meta"] as? [String: Any])?["version"] as? Int) ?? 0
          SharedLogStore.append(
            "📡 [webContent] sync ok: \(countryKeyCount) Länder (version=\(version))"
          )

          // Reapply: wenn Family Controls authorisiert ist, die neue Liste
          // sofort wirksam machen. Best-effort — ein Fehlschlag hier kippt den
          // erfolgreichen Sync NICHT.
          var reapplied = false
          if #available(iOS 16.0, *),
             AuthorizationCenter.shared.authorizationStatus == .approved {
            let wc = Self.applyWebContentLayer()
            reapplied = wc.enabled
            SharedLogStore.append(
              "🛡️ [webContent] reapply nach sync: enabled=\(wc.enabled) count=\(wc.count) region=\(wc.region)"
            )
          }

          return [
            "updated": true,
            "countries": countryKeyCount,
            "version": version,
            "reapplied": reapplied,
          ]

        } catch {
          lastError = error
          let nsError = error as NSError
          let isTransient = nsError.domain == NSURLErrorDomain && (
            nsError.code == NSURLErrorNotConnectedToInternet ||
            nsError.code == NSURLErrorCannotConnectToHost ||
            nsError.code == NSURLErrorTimedOut ||
            nsError.code == NSURLErrorNetworkConnectionLost ||
            nsError.code == NSURLErrorCannotFindHost
          )
          if isTransient && attempt < maxAttempts {
            let delaySec = UInt64(attempt)
            SharedLogStore.append("⏳ [webContent] sync transient \(nsError.code), retry in \(delaySec)s")
            try? await Task.sleep(nanoseconds: delaySec * 1_000_000_000)
            attempt += 1
            continue
          }
          SharedLogStore.append("❌ [webContent] sync failed: \(error.localizedDescription)")
          throw error
        }
      }

      throw lastError ?? NSError(
        domain: "RebreakProtection", code: 500,
        userInfo: [NSLocalizedDescriptionKey: "max attempts exhausted"]
      )
    }

    // ───────── runHealthProbe: hidden WKWebView gegen bet365 ─────────

    AsyncFunction("runHealthProbe") { (opts: [String: Any]?) async -> [String: Any] in
      let target = (opts?["target"] as? String) ?? "https://bet365.com"
      let timeoutSec = (opts?["timeoutSeconds"] as? Double) ?? 5.0
      guard let url = URL(string: target) else {
        return [
          "outcome": "offline",
          "reason": "invalid_target",
          "durationMs": 0,
          "target": target,
        ]
      }

      return await withCheckedContinuation { continuation in
        DispatchQueue.main.async { [weak self] in
          guard let self = self else {
            continuation.resume(returning: [
              "outcome": "offline",
              "reason": "module_gone",
              "durationMs": 0,
              "target": target,
            ])
            return
          }

          // Vorherige Probe abbrechen
          self.probeWebView?.stopLoading()
          self.probeWebView?.navigationDelegate = nil
          self.probeWebView = nil
          self.probeDelegate = nil

          let config = WKWebViewConfiguration()
          config.websiteDataStore = .nonPersistent()
          let webView = WKWebView(
            frame: CGRect(x: 0, y: 0, width: 1, height: 1),
            configuration: config
          )

          let delegate = HealthProbeDelegate(timeoutSeconds: timeoutSec) { [weak self] result in
            self?.probeWebView?.stopLoading()
            self?.probeWebView?.navigationDelegate = nil
            self?.probeWebView = nil
            self?.probeDelegate = nil

            switch result {
            case .blocked(let reason, let durationMs):
              SharedLogStore.append("🛡️ probe BLOCKED: \(target) — \(reason) — \(durationMs)ms")
              continuation.resume(returning: [
                "outcome": "blocked", "reason": reason,
                "durationMs": durationMs, "target": target,
              ])
            case .loaded(let durationMs):
              SharedLogStore.append("🚨 probe LOADED \(target) (filter DEAD) — \(durationMs)ms")
              continuation.resume(returning: [
                "outcome": "loaded", "reason": "page_loaded",
                "durationMs": durationMs, "target": target,
              ])
            case .offline(let reason):
              continuation.resume(returning: [
                "outcome": "offline", "reason": reason,
                "durationMs": 0, "target": target,
              ])
            case .timeout:
              continuation.resume(returning: [
                "outcome": "timeout", "reason": "no_resolution",
                "durationMs": Int(timeoutSec * 1000), "target": target,
              ])
            }
          }

          self.probeWebView = webView
          self.probeDelegate = delegate
          webView.navigationDelegate = delegate
          var request = URLRequest(url: url)
          request.timeoutInterval = timeoutSec
          request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData
          webView.load(request)
        }
      }
    }

    // ───────── openSystemSettings ─────────

    AsyncFunction("openSystemSettings") { (target: String?) -> Void in
      // Auf iOS gibt's nur einen single-entry-point (Settings-App). Spezifische
      // Tabs (Screen Time / Notifications) öffnen sich nicht zuverlässig per
      // URL-Scheme — Apple deny'd das seit iOS 13. Wir öffnen die App-Settings
      // unserer App; der User navigiert von dort weiter.
      DispatchQueue.main.async {
        if let url = URL(string: UIApplication.openSettingsURLString),
           UIApplication.shared.canOpenURL(url) {
          UIApplication.shared.open(url)
        }
      }
    }

    // ───────── getProtectionLogs / clearProtectionLogs ─────────
    //
    // SharedLogStore (NEFilter/FamilyControls native flow) schreibt nach
    // UserDefaults(APP_GROUP) key "url_filter_logs". Die App hat bisher KEINEN
    // Weg die auszulesen → TestFlight-Debugging war auf Console.app angewiesen.
    // Dieser Export macht die nativen Logs in der Debug-Page sichtbar.

    AsyncFunction("getProtectionLogs") { () -> [String] in
      guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return [] }
      return defaults.stringArray(forKey: SharedLogStore.logKey) ?? []
    }

    AsyncFunction("clearProtectionLogs") { () -> Void in
      guard let defaults = UserDefaults(suiteName: APP_GROUP) else { return }
      defaults.removeObject(forKey: SharedLogStore.logKey)
    }
  }

  // ─── Helpers ────────────────────────────────────────────────────────────────

  /// Wendet den Layer-2-webContent-Filter an: Land bestimmen → Domain-Liste
  /// laden → `webContent.blockedByFilter = .auto`. Gemeinsame Logik für die
  /// explizite `applyWebContentFilter`-Function UND die FC-Aktivierung
  /// (`activateFamilyControls`/`activate`) — Layer 2 ist Teil des FC-Schutzes
  /// und läuft mit, sobald Family Controls aktiv ist. Voraussetzung: FC
  /// authorisiert (der Aufrufer prüft das).
  @available(iOS 16.0, *)
  private static func applyWebContentLayer() -> (enabled: Bool, count: Int, region: String) {
    var resolvedRegion = WEBCONTENT_FALLBACK_REGION
    if let region = Locale.current.region?.identifier, !region.isEmpty {
      resolvedRegion = region.uppercased()
    } else if let region = Locale.current.regionCode, !region.isEmpty {
      resolvedRegion = region.uppercased()
    }
    let domains = loadWebContentDomains(forRegion: resolvedRegion)
    guard !domains.isEmpty else {
      SharedLogStore.append("⚠️ [webContent] keine Domains für \(resolvedRegion)")
      return (false, 0, resolvedRegion)
    }
    // .auto(_, except:) blockt die gelisteten Domains PLUS systemseitig
    // Adult-Content. Hartlimit 50 Domains.
    let webDomains = Set(domains.prefix(WEBCONTENT_MAX_DOMAINS).map { WebDomain(domain: $0) })
    let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
    store.webContent.blockedByFilter = .auto(webDomains, except: [])
    SharedLogStore.append("🛡️ [webContent] blockedByFilter=.auto — \(webDomains.count) Domains (\(resolvedRegion))")
    return (true, webDomains.count, resolvedRegion)
  }

  // ── Packet-Tunnel (Layer 1) ──────────────────────────────────────────────

  /// Lesbarer Name eines NEVPNStatus — fürs Logging und das JS-Ergebnis.
  private static func tunnelStatusName(_ s: NEVPNStatus) -> String {
    switch s {
    case .invalid: return "invalid"
    case .disconnected: return "disconnected"
    case .connecting: return "connecting"
    case .connected: return "connected"
    case .reasserting: return "reasserting"
    case .disconnecting: return "disconnecting"
    @unknown default: return "unknown(\(s.rawValue))"
    }
  }

  /// Findet unter allen geladenen Tunnel-Managern den ReBreak-Tunnel
  /// (identifiziert über die `providerBundleIdentifier`).
  private static func findRebreakTunnel(
    in managers: [NETunnelProviderManager]
  ) -> NETunnelProviderManager? {
    return managers.first { manager in
      guard let proto = manager.protocolConfiguration as? NETunnelProviderProtocol
      else { return false }
      return proto.providerBundleIdentifier == PACKET_TUNNEL_BUNDLE_ID
    }
  }

  /// Lädt den bestehenden ReBreak-Tunnel-Manager oder erstellt einen neuen,
  /// vollständig konfigurierten Manager (Protocol + On-Demand-Regel).
  ///
  /// On-Demand (`isOnDemandEnabled`): der Tunnel fährt nach Netzwerk-Wechseln
  /// automatisch wieder hoch (Self-Healing-Friction — KEIN Hard-Lock; der User
  /// kann On-Demand in den iOS-Settings abschalten). User-Entscheidung: an.
  private static func loadOrCreateTunnelManager() async throws -> NETunnelProviderManager {
    let managers = try await NETunnelProviderManager.loadAllFromPreferences()
    let manager = findRebreakTunnel(in: managers) ?? NETunnelProviderManager()

    // Protocol-Konfiguration (idempotent — bei jedem Aufruf frisch gesetzt).
    let proto = NETunnelProviderProtocol()
    proto.providerBundleIdentifier = PACKET_TUNNEL_BUNDLE_ID
    // `serverAddress` ist ein Pflichtfeld, wird bei einem rein lokalen
    // DNS-Sinkhole aber nie real kontaktiert — nur als Settings-UI-Label.
    proto.serverAddress = "ReBreak DNS-Filter (lokal)"
    manager.protocolConfiguration = proto
    manager.localizedDescription = PACKET_TUNNEL_DESCRIPTION
    manager.isEnabled = true

    // On-Demand: Tunnel nach Netzwerk-Events selbst hochfahren.
    let connectRule = NEOnDemandRuleConnect()
    connectRule.interfaceTypeMatch = .any
    manager.onDemandRules = [connectRule]
    manager.isOnDemandEnabled = true

    return manager
  }

  /// Lädt die kuratierte Gambling-Domain-Liste für ein Land — **cache-first**.
  ///
  /// 1. Zuerst die per `syncWebContentDomains` vom Backend geholte
  ///    `webcontent-domains.json` aus dem App-Group-Container lesen.
  /// 2. Nur wenn die fehlt / nicht parsebar ist / das Land nicht enthält →
  ///    Fallback auf die gebündelte `gambling-domains.json` im
  ///    RebreakProtectionResources.bundle (Offline-Seed).
  ///
  /// Beide Quellen haben dieselbe Struktur (`{ "DE": ["..."], ... }` plus
  /// `_comment`/`_meta`-Felder, die ignoriert werden) und werden über
  /// `parseDomainsJSON` identisch geparst. Liefert [] wenn keine der beiden
  /// Quellen das Land listet — der Aufrufer behandelt das als
  /// no_domains_for_region.
  private static func loadWebContentDomains(forRegion region: String) -> [String] {
    // ── 1) Cache-first: App-Group-webcontent-domains.json ──────────────────
    if let appGroupURL = FileManager.default
        .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) {
      let cacheURL = appGroupURL.appendingPathComponent(WEBCONTENT_CACHE_FILENAME)
      if let domains = parseDomainsJSON(at: cacheURL, forRegion: region) {
        SharedLogStore.append(
          "🛡️ [loadWebContentDomains] cache-hit (\(domains.count) Domains, \(region.uppercased()))")
        return domains
      }
    }

    // ── 2) Fallback: gebündelte gambling-domains.json (Offline-Seed) ───────
    if let url = bundledWebContentDomainsURL(),
       let domains = parseDomainsJSON(at: url, forRegion: region) {
      SharedLogStore.append(
        "🛡️ [loadWebContentDomains] bundle-fallback (\(domains.count) Domains, \(region.uppercased()))")
      return domains
    }

    SharedLogStore.append(
      "❌ [loadWebContentDomains] keine Domains für \(region.uppercased()) (cache+bundle leer)")
    return []
  }

  /// Löst die URL der gebündelten `gambling-domains.json` auf. Das JSON wird
  /// von der Podspec als RebreakProtectionResources.bundle ins App-Bundle
  /// gepackt; je nach Build-Konfig liegt es im Pod-Bundle oder flach im
  /// Main-Bundle.
  private static func bundledWebContentDomainsURL() -> URL? {
    let moduleBundle = Bundle(for: RebreakProtectionModule.self)
    var jsonURL: URL? = moduleBundle.url(
      forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")

    if jsonURL == nil,
       let resourceBundleURL = moduleBundle.url(
        forResource: WEBCONTENT_BUNDLE, withExtension: "bundle"),
       let resourceBundle = Bundle(url: resourceBundleURL) {
      jsonURL = resourceBundle.url(
        forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
    }
    // Fallback: manche Build-Konfigs flachen Resource-Bundles ins Main-Bundle.
    if jsonURL == nil {
      jsonURL = Bundle.main.url(
        forResource: WEBCONTENT_DOMAINS_FILE, withExtension: "json")
    }
    return jsonURL
  }

  /// Parst eine Domain-Liste-JSON-Datei (Cache ODER Bundle — identische
  /// Struktur) und gibt die normalisierte Domain-Liste für `region` zurück.
  /// Liefert `nil` wenn die Datei fehlt, nicht parsebar ist oder das Land
  /// nicht enthält — der Aufrufer behandelt `nil` als „diese Quelle leer,
  /// nächste versuchen".
  private static func parseDomainsJSON(at url: URL, forRegion region: String) -> [String]? {
    guard FileManager.default.fileExists(atPath: url.path),
          let data = try? Data(contentsOf: url),
          let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
          let domains = root[region.uppercased()] as? [String]
    else {
      return nil
    }
    // Defensiv normalisieren: leere Strings raus, lowercase, dedupliziert,
    // hart auf das Apple-Limit (WEBCONTENT_MAX_DOMAINS) gekappt.
    var seen = Set<String>()
    var cleaned: [String] = []
    for raw in domains {
      let d = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
      if d.isEmpty || seen.contains(d) { continue }
      seen.insert(d)
      cleaned.append(d)
      if cleaned.count >= WEBCONTENT_MAX_DOMAINS { break }
    }
    // Ein leeres Array ist eine gültige Antwort dieser Quelle (Land gelistet,
    // aber 0 Domains) — der Aufrufer entscheidet dann no_domains_for_region.
    return cleaned
  }

  private static func currentHashCount() -> Int {
    guard let url = FileManager.default
      .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
      .appendingPathComponent(BLOCKLIST_FILENAME),
      let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),
      let size = attrs[.size] as? Int
    else { return 0 }
    return size / 8
  }

  private static func clearBlocklistFile() {
    if let url = FileManager.default
      .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP)?
      .appendingPathComponent(BLOCKLIST_FILENAME) {
      try? FileManager.default.removeItem(at: url)
    }
    UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: ETAG_KEY)
    UserDefaults(suiteName: APP_GROUP)?.removeObject(forKey: LAST_SYNC_KEY)
    CFNotificationCenterPostNotification(
      CFNotificationCenterGetDarwinNotifyCenter(),
      CFNotificationName(DARWIN_NOTIF as CFString),
      nil, nil, true
    )
  }

  // ─── Supervised-Pfad: NEFilterDataProvider (content-filter) ─────────────────
  //
  // Klassisches NEFilter-Setup. Filter-Logik (Hash-Lookup gegen App-Group
  // blocklist.bin) lebt in RebreakContentFilter/FilterDataProvider.swift.
  // Permission-Dialog: einmaliger System-Prompt beim ersten saveToPreferences.
  // Kein VPN-Eintrag in iOS-Settings, kein User-Toggle (Bypass nur via Profile-
  // Entfernung — die wir via Sideload-Protect-Profile blockieren).

  fileprivate static func activateContentFilter() async -> [String: Any] {
    var error: String? = nil
    var enabled = false
    do {
      let manager = NEFilterManager.shared()
      SharedLogStore.append("📥 [activateContentFilter] loadFromPreferences...")
      try await manager.loadFromPreferences()

      let config = NEFilterProviderConfiguration()
      config.filterBrowsers = true
      config.filterSockets = false
      manager.providerConfiguration = config
      manager.localizedDescription = "ReBreak Schutz"
      manager.isEnabled = true

      SharedLogStore.append("💾 [activateContentFilter] saveToPreferences (System-Dialog möglich)...")
      try await manager.saveToPreferences()
      enabled = manager.isEnabled
      SharedLogStore.append("✅ NEFilter enabled (isEnabled=\(enabled))")
    } catch let e as NSError {
      error = "\(e.domain):\(e.code) \(e.localizedDescription)"
      SharedLogStore.append("❌ [activateContentFilter] NEFilter enable failed: \(error!)")
    }
    var result: [String: Any] = ["enabled": enabled, "status": enabled ? "running" : "stopped"]
    if let error = error { result["error"] = error }
    result["log"] = SharedLogStore.tail(30)
    return result
  }

  fileprivate static func disableContentFilter() async {
    do {
      let manager = NEFilterManager.shared()
      try await manager.loadFromPreferences()
      if manager.isEnabled {
        manager.isEnabled = false
        try await manager.saveToPreferences()
        SharedLogStore.append("✅ NEFilter disabled (isEnabled=false saved)")
      }
      try await manager.removeFromPreferences()
      SharedLogStore.append("✅ NEFilter removed from preferences")
    } catch {
      SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
    }
  }
}

// ─── HealthProbeDelegate ──────────────────────────────────────────────────────

private final class HealthProbeDelegate: NSObject, WKNavigationDelegate {
  enum Outcome {
    case blocked(reason: String, durationMs: Int)
    case loaded(durationMs: Int)
    case offline(reason: String)
    case timeout
  }

  private let onComplete: (Outcome) -> Void
  private let startTime: Date
  private var resolved = false
  private var timeoutItem: DispatchWorkItem?

  init(timeoutSeconds: Double, onComplete: @escaping (Outcome) -> Void) {
    self.onComplete = onComplete
    self.startTime = Date()
    super.init()
    let item = DispatchWorkItem { [weak self] in self?.resolveOnce(.timeout) }
    self.timeoutItem = item
    DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds, execute: item)
  }

  private func resolveOnce(_ outcome: Outcome) {
    if resolved { return }
    resolved = true
    timeoutItem?.cancel()
    timeoutItem = nil
    onComplete(outcome)
  }

  private func durationMs() -> Int {
    return Int(Date().timeIntervalSince(startTime) * 1000)
  }

  func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
    resolveOnce(.loaded(durationMs: durationMs()))
  }

  func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
    let nsError = error as NSError
    let reasonStr = "\(nsError.domain):\(nsError.code)"
    if nsError.domain == NSURLErrorDomain &&
       (nsError.code == NSURLErrorNotConnectedToInternet ||
        nsError.code == NSURLErrorCannotFindHost ||
        nsError.code == NSURLErrorTimedOut) {
      resolveOnce(.offline(reason: reasonStr))
    } else {
      resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
    }
  }

  func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
    let nsError = error as NSError
    let reasonStr = "\(nsError.domain):\(nsError.code)"
    if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorNotConnectedToInternet {
      resolveOnce(.offline(reason: reasonStr))
    } else {
      resolveOnce(.blocked(reason: reasonStr, durationMs: durationMs()))
    }
  }
}
