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"

// ─── 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)
  }
}

// ─── 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 ─────────

    // ───────── activateUrlFilter: NUR NEFilter ─────────

    AsyncFunction("activateUrlFilter") { () async -> [String: Any] in
      var error: String? = nil
      var enabled = false
      do {
        let manager = NEFilterManager.shared()
        SharedLogStore.append("📥 [activateUrlFilter] 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("💾 [activateUrlFilter] saveToPreferences (System-Dialog)...")
        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("❌ NEFilter enable failed: \(error!)")
      }
      var result: [String: Any] = ["enabled": enabled]
      if let error = error { result["error"] = error }
      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
    }

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

    AsyncFunction("activateFamilyControls") { () async -> [String: Any] in
      var error: String? = nil
      var enabled = false
      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"
              }
            } 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]
      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")
      }

      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.
      do {
        let manager = NEFilterManager.shared()
        try await manager.loadFromPreferences()
        if manager.isEnabled {
          manager.isEnabled = false
          do {
            try await manager.saveToPreferences()
            SharedLogStore.append("⏸ NEFilter isEnabled=false saved (daemon stop)")
          } catch {
            SharedLogStore.append("⚠️ saveToPreferences(disabled) failed: \(error.localizedDescription)")
          }
        }
        try await manager.removeFromPreferences()
        SharedLogStore.append("✅ NEFilter disabled + removed from preferences")
      } catch {
        SharedLogStore.append("⚠️ NEFilter disable: \(error.localizedDescription)")
      }

      // ManagedSettings (löst denyAppRemoval)
      if #available(iOS 16.0, *) {
        let store = ManagedSettingsStore(named: ManagedSettingsStore.Name(rawValue: MS_STORE_NAME))
        store.clearAllSettings()
        SharedLogStore.append("🔓 ManagedSettings cleared")
      }

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

      return ["allLayersOff": true]
    }

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

    AsyncFunction("getDeviceState") { () async -> [String: Any] in
      // NEFilter
      var urlFilter = false
      do {
        let manager = NEFilterManager.shared()
        try await manager.loadFromPreferences()
        urlFilter = manager.isEnabled
      } catch {
        // ignore
      }

      // FamilyControls
      var familyControls = false
      var appDeletionLock = 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
      }

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

      return [
        "urlFilter": urlFilter,
        "familyControls": familyControls,
        "appDeletionLock": appDeletionLock,
        "blocklistCount": count,
        "blocklistLastSyncAt": lastSync ?? NSNull(),
      ]
    }

    // ───────── 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
          try? (tmpURL as NSURL).setResourceValue(
            URLFileProtection.complete,
            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"]
      )
    }

    // ───────── 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)
        }
      }
    }
  }

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

  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
    )
  }
}

// ─── 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()))
    }
  }
}
