/*
 PacketTunnelProvider — die ReBreak iOS-Layer-1-Schutzschicht (DNS-Sinkhole).

 `NEPacketTunnelProvider`-Subclass — das iOS-Pendant zu Androids
 `RebreakVpnService`. Ersetzt den (Apple-seitig blockierten) NEURLFilter als
 primären, lieferbaren iOS-Filter.

 Architektur (1:1 zum Android-VpnService):
  - TUN-Interface mit kleinem virtuellen Subnet (10.0.0.0/24).
  - DNS-Server auf die virtuelle IP 10.0.0.1 gesetzt → DNS-Queries laufen ins
    TUN. Nur diese eine IP wird via `includedRoutes` geroutet — sonstiger
    Traffic geht direkt vorbei (batterieschonend, kein Voll-Tunnel).
  - Read-Loop liest IPv4/UDP-Pakete; `DnsFilter` parst die QNAME.
  - Treffer in der Blocklist → synthetische NXDOMAIN-Response, sofort ins TUN
    zurückgeschrieben.
  - Miss → Query an Upstream-Resolver (1.1.1.1:53) via `NWConnection`,
    Response wieder als IPv4/UDP-Paket verpackt und ins TUN geschrieben.
  - Upstream-Fehler (Timeout / recvError / leere Antwort / Connection failed)
    oder In-Flight-Cap überschritten → synthetische SERVFAIL-Response ins TUN,
    damit der DNS-Client des Geräts schnell failt statt in seinen eigenen
    mehrsekündigen Timeout zu laufen.

 Blocklist: `blocklist.bin` aus dem App-Group-Container, mmap'd via `HashList`.
 `syncBlocklist` (Haupt-App) postet die Darwin-Notification
 `rebreak.blocklist.updated` → die Extension re-mmap't ohne Neustart.

 ── HONESTY / UNGETESTETE ANNAHMEN ───────────────────────────────────────────
 NE-Packet-Tunnel laufen NICHT im iOS-Simulator. Diese Datei ist so korrekt
 wie ohne echtes Gerät möglich implementiert, aber NICHT auf einem iPhone
 verifiziert. Konkrete ungeprüfte Annahmen sind unten an Ort und Stelle mit
 `UNGETESTETE ANNAHME` markiert.
*/

import Foundation
import NetworkExtension
import Network
import os

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

private let APP_GROUP = "group.org.rebreak.app"
private let BLOCKLIST_FILENAME = "blocklist.bin"
private let DARWIN_NOTIF = "rebreak.blocklist.updated"

// Virtuelles Subnet — identisch zur Android-Wahl (RebreakVpnService).
private let TUNNEL_LOCAL_ADDR = "10.0.0.2"
private let VIRTUAL_DNS_ADDR = "10.0.0.1"
private let TUNNEL_SUBNET_MASK = "255.255.255.0"

// Upstream-Resolver — identisch zu Android (DnsFilter.UPSTREAM_DNS).
private let UPSTREAM_DNS_HOST = "1.1.1.1"
private let UPSTREAM_DNS_PORT: UInt16 = 53

// Hardcodierter Bypass: eigene Infrastruktur-Domains — niemals blocken,
// unabhängig von Blocklist-Hash-Kollisionen oder In-Flight-Cap.
// Betrifft Supabase-Auth-Callbacks (db-staging.rebreak.org),
// Backend-API (staging.rebreak.org) und zukünftige prod-Domains.
private let BYPASS_DOMAIN_SUFFIXES = ["rebreak.org", "rebreak.app"]

// ─── Extension-Log-Store ──────────────────────────────────────────────────────

/// Schreibt in den geteilten App-Group-Log-Store (`url_filter_logs`), den die
/// Haupt-App via `getProtectionLogs()` ausliest. Die Extension läuft als
/// eigener Prozess — das ist das einzige Debug-Fenster ohne Console.app.
/// Pattern 1:1 aus `RebreakURLFilterControlProvider.swift#ExtLog`.
enum ExtLog {
  static func write(_ msg: String) {
    NSLog("REBREAK_PKTTUN %@", msg)
    guard let d = UserDefaults(suiteName: APP_GROUP) else { return }
    let line = "[PKTTUN \(ISO8601DateFormatter().string(from: Date()))] \(msg)"
    var logs = d.stringArray(forKey: "url_filter_logs") ?? []
    logs.append(line)
    if logs.count > 200 { logs.removeFirst(logs.count - 200) }
    d.set(logs, forKey: "url_filter_logs")
  }
}

// ─── Provider ─────────────────────────────────────────────────────────────────

class PacketTunnelProvider: NEPacketTunnelProvider {

  private let log = Logger(
    subsystem: "org.rebreak.app.packettunnel", category: "provider")

  /// Geteilte, gemmapte Blocklist.
  private var hashList: HashList?

  /// Eigene serielle Queue für Forward-Sockets — hält den Read-Loop frei.
  private let forwardQueue = DispatchQueue(
    label: "org.rebreak.app.packettunnel.forward", qos: .userInitiated)

  /// Ist der Read-Loop aktiv?
  private var running = false

  // ─── In-Flight-Cap (H1) ─────────────────────────────────────────────────────
  //
  // H1-Fix: Pro erlaubter Query wurde bisher eine frische `NWConnection`
  // geöffnet (erst nach Antwort/Timeout bis 4 s gecancelt). Bei DNS-Bursts
  // (App-Start, Webseite mit vielen Subresources) liefen so Dutzende
  // gleichzeitige Connections + Timeout-WorkItems an — NE-Packet-Tunnel-
  // Extensions haben aber ein hartes Memory-Limit (~15 MB) → Jetsam-Crash.
  //
  // Gewählte Variante: harter In-Flight-CAP statt Connection-Pooling.
  // Begründung:
  //  - UDP-`NWConnection`-Pooling auf einen einzigen Socket ist fehleranfällig:
  //    ein gemeinsamer UDP-Socket vermischt Antworten mehrerer paralleler
  //    Queries — korrektes Demultiplexing bräuchte ein eigenes Pending-Mapping
  //    über die DNS-Transaction-ID. Das ist mehr Zustand (= mehr Speicher) und
  //    mehr Crash-Fläche als das Problem rechtfertigt.
  //  - Ein simpler Zähler ist O(1)-Zustand und deckelt den Speicher hart.
  //  - DNS ist von sich aus retry-tolerant: wird der Cap überschritten,
  //    antworten wir der Query SOFORT mit SERVFAIL (K2-Pfad) — der Client
  //    retryt dann von selbst, sobald der Burst abgeebbt ist. Besser ein
  //    schneller SERVFAIL als ein Extension-Crash, der den ganzen Tunnel
  //    (= den Schutz) reißt.
  //
  // UNGETESTETE ANNAHME: Der konkrete Cap-Wert (32) ist eine konservative
  // Schätzung. Eine `NWConnection` + Timeout-WorkItem kostet grob einige KB;
  // 32 gleichzeitige Verbindungen bleiben damit klar unter dem ~15-MB-Limit,
  // decken aber typische Burst-Größen ab. Auf Gerät unter Last verifizieren
  // (Instruments / Memory-Graph der Extension).
  private static let maxInFlightUpstream = 32

  /// Zähler aktuell offener Upstream-Connections. Nur auf `forwardQueue`
  /// gelesen/geschrieben → keine zusätzliche Synchronisation nötig.
  private var inFlightUpstream = 0

  // ─── TUN-Netzwerk-Settings ──────────────────────────────────────────────────

  /// Baut die NEPacketTunnelNetworkSettings für den DNS-Sinkhole.
  /// Eine Quelle für `startTunnel` UND den Reconnect in `reloadBlocklist`
  /// (`reapplyTunnelSettings`) — sonst driften beide Konfigurationen ab.
  private func buildTunnelSettings() -> NEPacketTunnelNetworkSettings {
    // `tunnelRemoteAddress` ist eine Pflichtangabe, wird bei einem rein lokalen
    // DNS-Sinkhole aber nie real kontaktiert — wir setzen die virtuelle
    // DNS-IP. (Pattern aus AdGuard/NextDNS/Lockdown.)
    let settings = NEPacketTunnelNetworkSettings(
      tunnelRemoteAddress: VIRTUAL_DNS_ADDR)

    // IPv4: lokale TUN-Adresse + nur die virtuelle DNS-IP routen.
    // → exakt Androids `addRoute(VIRTUAL_DNS_ADDR, 32)`: NUR DNS-Traffic geht
    //   ins TUN, sonstiger Traffic läuft direkt.
    let ipv4 = NEIPv4Settings(
      addresses: [TUNNEL_LOCAL_ADDR], subnetMasks: [TUNNEL_SUBNET_MASK])
    ipv4.includedRoutes = [
      NEIPv4Route(destinationAddress: VIRTUAL_DNS_ADDR, subnetMask: "255.255.255.255")
    ]
    settings.ipv4Settings = ipv4

    // DNS-Server auf die virtuelle IP → das System schickt seine DNS-Queries
    // an 10.0.0.1, die durch unser TUN laufen.
    let dns = NEDNSSettings(servers: [VIRTUAL_DNS_ADDR])
    // matchDomains [""] = ALLE Domains werden über diesen DNS-Server aufgelöst.
    dns.matchDomains = [""]
    settings.dnsSettings = dns

    // MTU defensiv — DNS-Pakete sind klein, der Default reicht; explizit
    // gesetzt, damit das Verhalten nicht von iOS-Defaults abhängt.
    settings.mtu = 1500

    return settings
  }

  // ─── startTunnel ────────────────────────────────────────────────────────────

  /// Vom System aufgerufen, sobald `connection.startVPNTunnel()` (aus dem
  /// RN-Modul) den Tunnel hochfährt.
  override func startTunnel(
    options: [String: NSObject]?,
    completionHandler: @escaping (Error?) -> Void
  ) {
    ExtLog.write("startTunnel — Extension-Prozess gestartet")

    // Blocklist mmap'en.
    if let containerURL = FileManager.default
      .containerURL(forSecurityApplicationGroupIdentifier: APP_GROUP) {
      let fileURL = containerURL.appendingPathComponent(BLOCKLIST_FILENAME)
      let list = HashList(fileURL: fileURL)
      list.load()
      hashList = list
      ExtLog.write("blocklist geladen — \(list.count()) Hashes")
    } else {
      ExtLog.write("⚠️ App-Group-Container nicht erreichbar — Blocklist leer")
      hashList = nil
    }

    // Darwin-Notification-Observer registrieren → re-mmap nach syncBlocklist.
    registerBlocklistObserver()

    // ── TUN-Netzwerk-Settings ──
    let settings = buildTunnelSettings()

    setTunnelNetworkSettings(settings) { [weak self] error in
      guard let self = self else {
        completionHandler(
          NSError(domain: "RebreakPacketTunnel", code: -1,
                  userInfo: [NSLocalizedDescriptionKey: "provider gone"]))
        return
      }
      if let error = error {
        ExtLog.write("❌ setTunnelNetworkSettings: \(error.localizedDescription)")
        completionHandler(error)
        return
      }
      ExtLog.write("✅ TUN-Settings gesetzt — Read-Loop startet")
      self.running = true
      self.readPackets()
      // Self-Heal: war die Blocklist beim Start leer (Data-Protection bei
      // gesperrtem Gerät), per Backoff erneut laden bis Hashes da sind.
      self.scheduleBlocklistRetryIfEmpty(attempt: 0)
      completionHandler(nil)
    }
  }

  // ─── stopTunnel ─────────────────────────────────────────────────────────────

  /// Vom System aufgerufen, wenn der Tunnel stoppt — auch wenn der User den
  /// VPN-Toggle in den iOS-Settings umlegt (das iOS-Pendant zu Androids
  /// `onRevoke`). Wir spiegeln das in den App-Group-Flag, damit `getDeviceState`
  /// den „extern abgeschaltet"-Zustand erkennt → JS-Phase `recoveringFromBypass`.
  override func stopTunnel(
    with reason: NEProviderStopReason,
    completionHandler: @escaping () -> Void
  ) {
    ExtLog.write("stopTunnel — reason=\(reason.rawValue)")
    running = false
    unregisterBlocklistObserver()

    // Flag spiegeln. `userInitiated` (= User-Toggle in Settings) ist der
    // Android-`onRevoke`-Fall: explizit vom Nutzer abgeschaltet.
    if reason == .userInitiated || reason == .userLogout {
      if let d = UserDefaults(suiteName: APP_GROUP) {
        d.set(false, forKey: "vpn_tunnel_running")
        d.set(true, forKey: "vpn_tunnel_revoked_by_user")
      }
      ExtLog.write("ℹ️ Tunnel vom User abgeschaltet (revoke-Flag gesetzt)")
    } else {
      // OS-Kill / Netzwerk-Event etc. — kein User-Revoke. On-Demand fährt den
      // Tunnel ggf. von selbst wieder hoch.
      if let d = UserDefaults(suiteName: APP_GROUP) {
        d.set(false, forKey: "vpn_tunnel_running")
      }
    }
    completionHandler()
  }

  // ─── Read-Loop ──────────────────────────────────────────────────────────────

  /// Liest fortlaufend Pakete vom TUN. `readPackets(completionHandler:)` ruft
  /// den Handler einmal pro Batch — wir re-armen ihn am Ende selbst (Apple-
  /// Pattern; es gibt keinen Blocking-Read wie Androids `FileInputStream`).
  private func readPackets() {
    guard running else { return }

    packetFlow.readPackets { [weak self] packets, protocols in
      guard let self = self, self.running else { return }

      for (index, packet) in packets.enumerated() {
        // Nur IPv4 verarbeiten — protocols[i] ist die AF_*-Familie.
        // UNGETESTETE ANNAHME: `protocols` enthält `AF_INET` (Int 2) für
        // IPv4-Pakete. Apple-Doku bestätigt das; auf Gerät verifizieren.
        let proto = protocols[index].int32Value
        guard proto == AF_INET else { continue }
        self.handlePacket(packet)
      }

      // Read-Loop re-armen.
      self.readPackets()
    }
  }

  /// Verarbeitet ein einzelnes IPv4-Paket.
  private func handlePacket(_ packet: Data) {
    guard let hashList = hashList else {
      // Keine Blocklist → konservativ: einfach forwarden (kein Block).
      forwardPacket(packet)
      return
    }

    switch DnsFilter.classify(packet: packet, hashList: hashList) {
    case .block(let response, let domain):
      // Eigene Infrastruktur-Domains nie blocken — auch wenn Hash-Kollision.
      // Betrifft OAuth-Callbacks (db-staging.rebreak.org) und Backend-API.
      let isBypass = BYPASS_DOMAIN_SUFFIXES.contains { domain == $0 || domain.hasSuffix(".\($0)") }
      if isBypass {
        ExtLog.write("BYPASS (own domain): \(domain)")
        forwardPacket(packet)
      } else {
        ExtLog.write("BLOCKED: \(domain)")
        writeToTun(response)
      }

    case .forward:
      forwardPacket(packet)

    case .drop:
      // Kein DNS / IPv6 / Nicht-UDP — verwerfen.
      break
    }
  }

  // ─── Upstream-Forwarding ────────────────────────────────────────────────────

  /// Forwarded eine erlaubte DNS-Query an den Upstream-Resolver und schreibt
  /// die Antwort als IPv4/UDP-Paket zurück ins TUN.
  ///
  /// Verwendet `NWConnection` (Network.framework) statt eines POSIX-Sockets.
  /// Wichtig: die Verbindung läuft auf der iOS-Standard-Route, NICHT durch
  /// unser eigenes TUN — das ist auf iOS automatisch so, weil wir nur die
  /// virtuelle DNS-IP `10.0.0.1` routen. Eine reale 1.1.1.1-Verbindung matcht
  /// diese Route nicht und geht direkt raus. Damit entfällt das `protect()`,
  /// das Android braucht (dort wäre ein DNS-Loop möglich).
  private func forwardPacket(_ packet: Data) {
    guard let query = DnsFilter.extractDnsPayload(packet: packet) else {
      return
    }

    let host = NWEndpoint.Host(UPSTREAM_DNS_HOST)
    guard let port = NWEndpoint.Port(rawValue: UPSTREAM_DNS_PORT) else {
      return
    }

    // Gesamte Connection-Lebenszeit läuft auf `forwardQueue` — damit ist auch
    // der `inFlightUpstream`-Zähler ohne extra Lock konsistent.
    forwardQueue.async { [weak self] in
      guard let self = self, self.running else { return }

      // ── H1: In-Flight-Cap ──
      // Burst über dem Cap → keine neue Connection öffnen, sondern die Query
      // sofort mit SERVFAIL beantworten. Der Client retryt selbst; der
      // Speicher der Extension bleibt gedeckelt.
      if self.inFlightUpstream >= PacketTunnelProvider.maxInFlightUpstream {
        ExtLog.write("⚠️ Upstream-Cap erreicht — SERVFAIL (in-flight=\(self.inFlightUpstream))")
        self.replyServFail(for: packet)
        return
      }

      let connection = NWConnection(host: host, port: port, using: .udp)
      var didFinish = false
      self.inFlightUpstream += 1

      // Timeout-WorkItem — vorab deklariert, damit `finish()` es referenzieren
      // kann; die eigentliche Item-Closure wird gleich darunter zugewiesen.
      var timeoutWork: DispatchWorkItem?

      // Zentrale Abschluss-Logik: idempotent, dekrementiert den Cap-Zähler
      // genau einmal und schreibt — wenn keine echte Antwort kam — eine
      // SERVFAIL-Response ins TUN (K2). `gotAnswer = true` heißt: es wurde
      // bereits eine valide Upstream-Antwort geschrieben.
      func finish(gotAnswer: Bool) {
        // Läuft immer auf `forwardQueue` (alle Aufrufer tun das).
        if didFinish { return }
        didFinish = true
        timeoutWork?.cancel()
        connection.cancel()
        self.inFlightUpstream -= 1
        if !gotAnswer {
          // K2: Upstream lieferte nichts Brauchbares → Client nicht hängen
          // lassen, sondern schnell mit SERVFAIL failen.
          self.replyServFail(for: packet)
        }
      }

      // Timeout — wie Androids `soTimeout = 4000`. Bei Ablauf: SERVFAIL.
      let work = DispatchWorkItem {
        ExtLog.write("Upstream-Timeout — SERVFAIL")
        finish(gotAnswer: false)
      }
      timeoutWork = work
      self.forwardQueue.asyncAfter(deadline: .now() + 4.0, execute: work)

      connection.stateUpdateHandler = { state in
        switch state {
        case .ready:
          connection.send(content: query, completion: .contentProcessed { sendError in
            if sendError != nil {
              // Send fehlgeschlagen → SERVFAIL.
              self.forwardQueue.async { finish(gotAnswer: false) }
              return
            }
            // Antwort empfangen.
            connection.receiveMessage { [weak self] data, _, _, recvError in
              guard let self = self else { return }
              self.forwardQueue.async {
                if didFinish { return }
                guard recvError == nil, let answer = data, !answer.isEmpty,
                      let response = DnsFilter.buildForwardResponse(
                        request: packet, dnsAnswer: answer) else {
                  // recvError / leere / unverpackbare Antwort → SERVFAIL (K2).
                  finish(gotAnswer: false)
                  return
                }
                // Echte Antwort als IPv4/UDP-Paket ins TUN schreiben.
                self.writeToTun(response)
                finish(gotAnswer: true)
              }
            }
          })
        case .failed:
          // Connection endgültig fehlgeschlagen (z.B. kein Netz) → SERVFAIL
          // statt den Client hängen zu lassen (K2).
          self.forwardQueue.async { finish(gotAnswer: false) }
        case .cancelled:
          // `cancelled` ist Folge eines bereits gelaufenen finish() —
          // finish() ist idempotent, hier ist nichts mehr zu tun.
          break
        case .waiting:
          // `.waiting` (kein Pfad) ist NICHT zwingend final — die Connection
          // kann sich erholen, sobald Konnektivität zurückkommt. Wir brechen
          // hier deshalb NICHT sofort ab, sondern überlassen es dem 4-s-
          // Timeout (der dann SERVFAIL liefert). Ein vorzeitiger Abbruch bei
          // kurzem Netz-Flackern wäre unnötig aggressiv.
          break
        default:
          break
        }
      }
      connection.start(queue: self.forwardQueue)
    }
  }

  /// Synthetisiert eine SERVFAIL-Response für ein Request-Paket und schreibt
  /// sie ins TUN. K2: gemeinsamer Fehler-Pfad für Timeout, recvError, leere
  /// Antwort, Connection-`.failed` und den H1-Cap.
  private func replyServFail(for requestPacket: Data) {
    if let response = DnsFilter.buildServFailResponse(request: requestPacket) {
      writeToTun(response)
    }
  }

  // ─── TUN-Write ──────────────────────────────────────────────────────────────

  /// Schreibt ein fertiges IPv4-Paket ins TUN zurück.
  private func writeToTun(_ packet: Data) {
    // `writePackets` ist thread-safe (Apple-Doku) — kann aus dem Read-Loop
    // und aus den Forward-Completion-Handlern aufgerufen werden.
    packetFlow.writePackets([packet], withProtocols: [NSNumber(value: AF_INET)])
  }

  // ─── Blocklist-Reload via Darwin-Notification ───────────────────────────────

  /// Registriert einen CFNotificationCenter-Observer auf `rebreak.blocklist.updated`.
  /// Die Haupt-App postet diese Notification nach jedem erfolgreichen
  /// `syncBlocklist` → die Extension re-mmap't `blocklist.bin` ohne Neustart.
  private func registerBlocklistObserver() {
    let observer = Unmanaged.passUnretained(self).toOpaque()
    CFNotificationCenterAddObserver(
      CFNotificationCenterGetDarwinNotifyCenter(),
      observer,
      { (_, observer, _, _, _) in
        guard let observer = observer else { return }
        let provider = Unmanaged<PacketTunnelProvider>
          .fromOpaque(observer).takeUnretainedValue()
        provider.reloadBlocklist()
      },
      DARWIN_NOTIF as CFString,
      nil,
      .deliverImmediately
    )
  }

  private func unregisterBlocklistObserver() {
    let observer = Unmanaged.passUnretained(self).toOpaque()
    CFNotificationCenterRemoveObserver(
      CFNotificationCenterGetDarwinNotifyCenter(),
      observer,
      CFNotificationName(DARWIN_NOTIF as CFString),
      nil
    )
  }

  /// Re-mmap't die Blocklist (nach `syncBlocklist`) UND erzwingt einen
  /// DNS-Cache-Flush via Tunnel-Reconnect.
  ///
  /// Warum der Reconnect: re-mmap allein lädt zwar die neuen Hashes, der
  /// System-/Browser-DNS-Cache bleibt aber stehen. Eine gerade hinzugefügte
  /// Custom-Domain, die der User vorher schon mal aufgerufen hat, würde dann
  /// bis zum Ablauf der DNS-TTL über den Cache durchgereicht und NICHT durch
  /// unseren Filter laufen → "blockt nicht sofort".
  /// Android macht denselben Flush implizit über `ACTION_RESTART`
  /// (`stopVpn()` + `startVpn()`); hier ist das iOS-Pendant.
  ///
  /// HYPOTHESE, am Gerät zu verifizieren: dass `setTunnelNetworkSettings(nil)`
  /// + Re-Apply den System-DNS-Cache tatsächlich flusht (Netz-Rekonfiguration).
  /// Stark erwartet, aber bis zum Live-Test auf dem iPhone nicht hart belegt.
  private func reloadBlocklist() {
    hashList?.load()
    ExtLog.write("blocklist reloaded — \(hashList?.count() ?? 0) Hashes")
    reapplyTunnelSettings()
  }

  /// Reißt die TUN-Netzwerk-Settings kurz ab (`nil`) und setzt sie neu —
  /// erzwingt eine Netz-Rekonfiguration durch iOS und damit einen
  /// DNS-Cache-Flush. `reasserting` signalisiert dem System die kurze
  /// Rekonfigurationsphase (UI bleibt "connected", kein VPN-Drop sichtbar).
  private func reapplyTunnelSettings() {
    guard running else { return }
    reasserting = true
    setTunnelNetworkSettings(nil) { [weak self] _ in
      guard let self = self else { return }
      self.setTunnelNetworkSettings(self.buildTunnelSettings()) { [weak self] error in
        guard let self = self else { return }
        self.reasserting = false
        if let error = error {
          ExtLog.write("❌ reapply TUN-Settings: \(error.localizedDescription)")
        } else {
          ExtLog.write("🔄 TUN-Settings re-applied — DNS-Cache-Flush")
        }
      }
    }
  }

  /// Self-Heal: wenn `startTunnel` die Blocklist leer geladen hat (Datei wegen
  /// iOS-Data-Protection noch nicht lesbar — Tunnel-Start vor dem ersten
  /// Entsperren seit Boot, oder eine alte mit `.complete` geschriebene Datei),
  /// wird `load()` mit Backoff erneut versucht, bis Hashes da sind.
  ///
  /// Ohne das bliebe Layer 1 still tot, bis die App das nächste Mal synct und
  /// die Darwin-Notification feuert — beobachtet in den Geräte-Logs als
  /// ~30-Minuten-Fenster mit 0 Hashes. Der Retry läuft auf `forwardQueue`.
  private func scheduleBlocklistRetryIfEmpty(attempt: Int) {
    guard running else { return }
    guard let list = hashList, list.count() == 0 else { return }  // schon ok

    let maxAttempts = 20
    guard attempt < maxAttempts else {
      ExtLog.write("⚠️ Blocklist nach \(maxAttempts) Retries leer — warte auf App-Sync")
      return
    }
    // Backoff in Sekunden; ab Index 5 konstant 300 s.
    let backoff: [Double] = [3, 10, 30, 60, 120]
    let delay = attempt < backoff.count ? backoff[attempt] : 300

    forwardQueue.asyncAfter(deadline: .now() + delay) { [weak self] in
      guard let self = self, self.running else { return }
      self.hashList?.load()
      let n = self.hashList?.count() ?? 0
      if n > 0 {
        ExtLog.write("✅ blocklist self-heal — \(n) Hashes (Versuch \(attempt + 1))")
      } else {
        self.scheduleBlocklistRetryIfEmpty(attempt: attempt + 1)
      }
    }
  }
}
