package expo.modules.rebreakprotection

import android.accessibilityservice.AccessibilityServiceInfo
import android.app.Activity
import android.app.AppOpsManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.admin.DevicePolicyManager
import android.app.usage.UsageEvents
import android.app.usage.UsageStatsManager
import androidx.core.app.NotificationCompat
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.net.VpnService
import android.net.Uri
import android.os.Build
import android.os.PowerManager
import android.provider.Settings
import android.graphics.Color
import android.graphics.PixelFormat
import android.util.Log
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.view.accessibility.AccessibilityManager
import android.widget.LinearLayout
import android.widget.TextView
import expo.modules.rebreakprotection.admin.RebreakDeviceAdminReceiver
import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.CodedException
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.rebreakprotection.accessibility.RebreakAccessibilityService
import expo.modules.rebreakprotection.vpn.RebreakVpnService
import java.io.File
import java.net.HttpURLConnection
import java.net.UnknownHostException
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.concurrent.Executors

/**
 * Step-3-Implementation des Android-Pendants zu iOS NEFilter.
 *
 *  activate()                  → VpnService.prepare() → User-Consent → start RebreakVpnService
 *  disable()                   → ACTION_STOP → Service stoppt
 *  getDeviceState()            → vpn / accessibility / tamperLock + Blocklist-Metadaten
 *  syncBlocklist()             → HTTP-Download + ETag-Caching + atomic-replace + Service-Reload
 *  runHealthProbe()            → HEAD bet365.com → blocked/loaded/timeout/offline
 *  openSystemSettings(target)  → Generic Settings-Intent
 *  isAccessibilityEnabled()    → Live-Check via AccessibilityManager + Settings.Secure
 *  openAccessibilitySettings() → Deep-link zu Rebreak-Detail-Page (fallback liste)
 *  armTamperLock()             → setzt SharedPref-Flag, requires VPN+A11y
 *  disarmTamperLock()          → cleart SharedPref-Flag
 *  getProtectionStatus()       → vpn/a11y/blocklistCount/tamperArmed
 */
class RebreakProtectionModule : Module() {

  private var pendingActivatePromise: Promise? = null
  private val ioExecutor = Executors.newCachedThreadPool()

  // Sticky-Hint State: läuft als wiederholter Toast solange User in
  // Accessibility-Settings navigiert. Stoppt automatisch sobald
  // a11y-Service aktiviert ODER nach STICKY_HINT_MAX_SEC.
  private val mainHandler = android.os.Handler(android.os.Looper.getMainLooper())
  private var stickyHintRunnable: Runnable? = null
  private var stickyHintStepIndex: Int = 0
  private var samsungGuideOverlay: LinearLayout? = null
  private var samsungGuideTextView: TextView? = null
  private var samsungGuideWindowManager: WindowManager? = null
  private var samsungGuideLayoutParams: WindowManager.LayoutParams? = null

  override fun definition() = ModuleDefinition {
    Name("RebreakProtection")

    Events("onLayerChange")

    OnActivityResult { _, payload ->
      if (payload.requestCode != VPN_CONSENT_REQUEST_CODE) return@OnActivityResult
      val promise = pendingActivatePromise ?: return@OnActivityResult
      pendingActivatePromise = null
      if (payload.resultCode == Activity.RESULT_OK) {
        startVpnService()
        saveEnabled(true)
        promise.resolve(activateSuccessResult())
      } else {
        saveEnabled(false)
        promise.resolve(
          mapOf(
            "allLayersOn" to false,
            "missingLayers" to listOf("vpn", "accessibility", "tamperLock"),
            "errors" to listOf("vpn_permission_denied"),
          )
        )
      }
      sendLayerChange()
    }

    AsyncFunction("activate") { promise: Promise ->
      val activity = appContext.currentActivity
        ?: return@AsyncFunction promise.reject(
          CodedException("no_activity", "Activity nicht verfügbar — App im Hintergrund?", null)
        )

      // VpnService.prepare() kann auf manchen OEMs/Configs werfen (z.B. aktives
      // Always-On-VPN eines anderen Apps, Work-Profile, NPE auf älteren Samsung).
      // Ungefangen → Expo rejected → JS-Onboarding verschluckt es in Release-Builds
      // → User sieht keinen Dialog & kommt nicht weiter. Lieber sauber resolven
      // mit Fehler, damit die UI eine Meldung + Retry zeigen kann.
      val consentIntent = try {
        VpnService.prepare(activity)
      } catch (e: Exception) {
        Log.w(TAG, "VpnService.prepare() threw: ${e.message}", e)
        saveEnabled(false)
        promise.resolve(
          mapOf(
            "allLayersOn" to false,
            "missingLayers" to listOf("vpn", "accessibility", "tamperLock"),
            "errors" to listOf("vpn_prepare_failed: ${e.message ?: e::class.java.simpleName}"),
          )
        )
        return@AsyncFunction
      }
      if (consentIntent == null) {
        startVpnService()
        saveEnabled(true)
        promise.resolve(activateSuccessResult())
        sendLayerChange()
      } else {
        if (pendingActivatePromise != null) {
          promise.reject(CodedException("consent_in_flight", "Bereits laufender VPN-Consent-Dialog", null))
          return@AsyncFunction
        }
        pendingActivatePromise = promise
        try {
          activity.startActivityForResult(consentIntent, VPN_CONSENT_REQUEST_CODE)
        } catch (e: Exception) {
          // Dialog-Launch fehlgeschlagen (z.B. ActivityNotFound auf gestripptem
          // System-VPN-UI). Promise NICHT hängen lassen — sonst Silent-Stuck.
          Log.w(TAG, "startActivityForResult(VPN consent) threw: ${e.message}", e)
          pendingActivatePromise = null
          saveEnabled(false)
          promise.resolve(
            mapOf(
              "allLayersOn" to false,
              "missingLayers" to listOf("vpn", "accessibility", "tamperLock"),
              "errors" to listOf("vpn_consent_launch_failed: ${e.message ?: e::class.java.simpleName}"),
            )
          )
        }
      }
    }

    AsyncFunction("disable") {
      val ctx = requireContext()
      val intent = Intent(ctx, RebreakVpnService::class.java).apply {
        action = RebreakVpnService.ACTION_STOP
      }
      try {
        ctx.startService(intent)
      } catch (e: Exception) {
        Log.w(TAG, "stopService failed: ${e.message}")
      }
      saveEnabled(false)
      // Tamper-Lock IMMER mit-disarmen — egal welcher Caller `disable()` aufruft.
      // Sonst läuft der Accessibility-Watchdog (Settings-Block + Gambling-Block)
      // nach dem Cooldown weiter, obwohl der User legitim deaktiviert hat.
      disarmTamperLock(ctx)
      sendLayerChange()
      mapOf("allLayersOff" to true)
    }

    AsyncFunction("getDeviceState") {
      val ctx = requireContext()
      buildDeviceState(ctx)
    }

    AsyncFunction("syncBlocklist") { opts: Map<String, Any?>, promise: Promise ->
      val baseURL = opts["baseURL"] as? String
      val authToken = opts["authToken"] as? String
      if (baseURL.isNullOrBlank() || authToken.isNullOrBlank()) {
        promise.reject(CodedException("missing_params", "baseURL und authToken required", null))
        return@AsyncFunction
      }
      val ctx = requireContext()
      ioExecutor.execute {
        try {
          val result = downloadBlocklist(ctx, baseURL, authToken)
          val updated = (result["updated"] as? Boolean) == true
          // VpnService-Reload — NUR wenn der Filter tatsächlich an ist.
          // `startService` würde den Service sonst re-createn, obwohl der
          // User den Schutz (ggf. nach Cooldown) deaktiviert hat.
          if (isVpnEffectivelyOn(ctx)) {
            val reload = Intent(ctx, RebreakVpnService::class.java).apply {
              // Nach echter Listen-Änderung Tunnel hart neu starten, damit
              // DNS-Resolver + offene Sessions sofort die neue Blocklist sehen.
              // Bei 304 reicht ein normaler Reload.
              action = if (updated) RebreakVpnService.ACTION_RESTART else RebreakVpnService.ACTION_RELOAD
            }
            try { ctx.startService(reload) } catch (_: Exception) {}
          }
          promise.resolve(result)
          sendLayerChange()
        } catch (e: Exception) {
          promise.reject(CodedException("sync_failed", e.message ?: "unknown", e))
        }
      }
    }

    AsyncFunction("runHealthProbe") { opts: Map<String, Any?>?, promise: Promise ->
      val target = (opts?.get("target") as? String) ?: "https://bet365.com"
      val timeoutMs = (((opts?.get("timeoutSeconds") as? Number) ?: 5).toInt()) * 1000
      ioExecutor.execute {
        val started = System.currentTimeMillis()
        var outcome = "offline"
        var reason = "init"
        try {
          val url = URL(target)
          val conn = (url.openConnection() as HttpURLConnection).apply {
            connectTimeout = timeoutMs
            readTimeout = timeoutMs
            requestMethod = "HEAD"
            instanceFollowRedirects = false
          }
          val code = conn.responseCode
          conn.disconnect()
          outcome = "loaded"
          reason = "http_$code"
        } catch (_: UnknownHostException) {
          outcome = "blocked"
          reason = "dns_unresolved"
        } catch (_: java.net.SocketTimeoutException) {
          outcome = "timeout"
          reason = "timeout"
        } catch (e: Exception) {
          outcome = "offline"
          reason = e::class.java.simpleName
        }
        val elapsed = (System.currentTimeMillis() - started).toInt()
        promise.resolve(
          mapOf(
            "outcome" to outcome,
            "reason" to reason,
            "durationMs" to elapsed,
            "target" to target,
          )
        )
      }
    }

    AsyncFunction("openSystemSettings") { target: String? ->
      val ctx = requireContext()
      val action = when (target) {
        "vpn" -> Settings.ACTION_VPN_SETTINGS
        "accessibility" -> Settings.ACTION_ACCESSIBILITY_SETTINGS
        "notifications" -> Settings.ACTION_APP_NOTIFICATION_SETTINGS
        else -> Settings.ACTION_SETTINGS
      }
      val intent = Intent(action).apply {
        if (target == "notifications") {
          putExtra(Settings.EXTRA_APP_PACKAGE, ctx.packageName)
        }
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
      }
      ctx.startActivity(intent)
      Unit
    }

    // ─── Layer 2: Accessibility + Tamper ──────────────────────────────────

    AsyncFunction("isAccessibilityEnabled") {
      mapOf("enabled" to isAccessibilityServiceEnabled(requireContext()))
    }

    /**
     * Stoppt den Repeating-Toast-Hint manuell. Sollte vom JS aufgerufen werden
     * sobald die App wieder im Foreground ist (AppState-Listener) ODER wenn
     * der User explizit „Abbrechen" tippt. Der Hint stoppt sonst von alleine
     * sobald a11y-Service enabled oder nach STICKY_HINT_MAX_SEC.
     */
    AsyncFunction("dismissAccessibilityHint") {
      stopStickyHint()
      stopSamsungGuideOverlay()
      Unit
    }

    AsyncFunction("openPowerDialog") {
      val opened = RebreakAccessibilityService.requestPowerDialog()
      if (!opened) {
        Log.w(TAG, "openPowerDialog: accessibility service unavailable or action rejected")
      }
      mapOf("opened" to opened)
    }

    AsyncFunction("openAccessibilitySettings") { promise: Promise ->
      val ctx = requireContext()

      // KEIN Overlay-Permission-Gatekeeper mehr davor: der blockte sonst den
      // ganzen Deep-Link (öffnete erst die "Über anderen Apps anzeigen"-Seite
      // statt der Bedienungshilfen → User landet falsch). Das Overlay-Guide ist
      // seit dem in-App Explainer-Sheet redundant; auf den Listen-Fallbacks
      // (Stufe 4/5) versucht startSamsungGuideOverlay() es ohnehin nochmal und
      // degradiert sauber zum Sticky-Hint, wenn die Permission fehlt.
      val rebreakA11yComponent = ComponentName(ctx, RebreakAccessibilityService::class.java)
      val componentFlat = rebreakA11yComponent.flattenToString()

      // Fragment-Args, die viele Settings-Apps (Stock + Samsung One UI) als
      // Auto-Scroll + Highlight-Hint verstehen — sowohl auf der Detail-Page
      // als auch (als Fallback) auf der Listen-Page.
      val highlightArgs = android.os.Bundle().apply {
        putString(":settings:fragment_args_key", componentFlat)
      }

      // Reihenfolge ist UX-kritisch — User soll IMMER so dicht wie möglich am
      // Rebreak-Toggle landen (emotional belastete User, jede Extra-Tap kostet).
      // 1) Modern (Android 13+): offizielles Extra → Pixel/AOSP zielsicher
      // 2) Detail-Action + legacy Bundle-Args → meiste OEMs inkl. älterer Samsung
      // 3) Direkter ComponentName auf AOSP-Detail-Activity → Stock-Android-Backup
      // 4) Listen-Page + Fragment-Args → Samsung One UI scrollt/hebt hervor, der
      //    User sieht Rebreak markiert (nicht perfekt, aber 1 Tap statt 3-4)
      // 5) Plain Listen-Page → letzte Notlösung, dann Toast-Anleitung
      val tries = mutableListOf<Pair<String, Intent>>()

      // (1) Android 13+ offizielle API
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        tries.add(
          "details-modern-extra" to Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS").apply {
            // Settings.EXTRA_ACCESSIBILITY_SERVICE_COMPONENT_NAME (API 33+)
            putExtra("android.provider.extra.ACCESSIBILITY_SERVICE_COMPONENT_NAME", componentFlat)
            flags = Intent.FLAG_ACTIVITY_NEW_TASK
          },
        )
      }

      // (2) Detail-Action mit legacy Fragment-Args
      tries.add(
        "details-legacy-args" to Intent("android.settings.ACCESSIBILITY_DETAILS_SETTINGS").apply {
          putExtra(":settings:fragment_args_key", componentFlat)
          putExtra(":settings:show_fragment_args", highlightArgs)
          flags = Intent.FLAG_ACTIVITY_NEW_TASK
        },
      )

      // (3) AOSP Detail-Activity direkt (ohne Action-Intent-Resolution)
      tries.add(
        "details-aosp-component" to Intent().apply {
          setClassName(
            "com.android.settings",
            "com.android.settings.Settings\$AccessibilityDetailsSettingsActivity",
          )
          putExtra(":settings:fragment_args_key", componentFlat)
          putExtra(":settings:show_fragment_args", highlightArgs)
          flags = Intent.FLAG_ACTIVITY_NEW_TASK
        },
      )

      // (4) Listen-Page MIT Highlight-Args → Samsung scrollt + markiert Rebreak
      tries.add(
        "list-highlighted" to Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
          putExtra(":settings:fragment_args_key", componentFlat)
          putExtra(":settings:show_fragment_args", highlightArgs)
          flags = Intent.FLAG_ACTIVITY_NEW_TASK
        },
      )

      // (5) Plain Listen-Page
      tries.add(
        "list-plain" to Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS).apply {
          flags = Intent.FLAG_ACTIVITY_NEW_TASK
        },
      )

      for ((tag, intent) in tries) {
        // resolveActivity vorm Start checken — sonst zeigt Android bei ungültiger
        // Action einen Toast „App nicht gefunden" und wir laufen blind durch alle
        // Tries (verschwendete UI-Transitions).
        val resolved = intent.resolveActivity(ctx.packageManager)
        if (resolved == null) {
          Log.d(TAG, "openA11y: $tag → no resolver, skip")
          continue
        }
        try {
          ctx.startActivity(intent)
          Log.i(TAG, "openA11y: $tag → started ($resolved)")
          // Landet auf der a11y-Übersicht/Liste (Samsung: Detail/Dienste-Liste sind
          // signature-gesperrt → tiefer kommen wir nicht). Hier den User durch die
          // Navigation FÜHREN: State-aware Notification-Guide (UsageStats erkennt den
          // echten Settings-Screen, schaltet die Nachricht selbst weiter). KEIN
          // Overlay (das wäre ein zweites UI zum Bedienen). Ohne Usage-Access-Recht
          // → dummer Sticky-Hint-Toast als Fallback.
          if (tag == "list-highlighted" || tag == "list-plain") {
            if (hasUsageAccess(ctx)) {
              startA11yGuideWatch(ctx)
            } else {
              val hintText = currentStickyHintText(ctx)
              startStickyHint(ctx, hintText)
            }
          }
          promise.resolve(mapOf("opened" to true, "via" to tag))
          return@AsyncFunction
        } catch (e: Exception) {
          Log.w(TAG, "openA11y: $tag → failed: ${e.message}")
        }
      }
      promise.reject(CodedException("open_failed", "no accessibility settings activity available", null))
    }

    // Hat die App "Nutzungszugriff"? (→ state-aware a11y-Setup-Guide möglich)
    AsyncFunction("hasUsageAccess") {
      mapOf("granted" to hasUsageAccess(requireContext()))
    }

    // Öffnet die "Nutzungsdaten-Zugriff"-Settings, damit der User ReBreak freigibt.
    AsyncFunction("openUsageAccessSettings") {
      val ctx = requireContext()
      val intent = Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS).apply {
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
      }
      try {
        ctx.startActivity(intent)
        mapOf("opened" to true)
      } catch (e: Exception) {
        Log.w(TAG, "openUsageAccessSettings: ${e.message}")
        mapOf("opened" to false)
      }
    }

    // Battery-Optimization: ohne Ausnahme schläfert Samsung & Co. die App ein
    // → der a11y-Tamper-Lock wird entbunden → Schutz fällt still aus. Daher
    // Status prüfbar + per System-Dialog (ein Tap) anforderbar machen.
    AsyncFunction("isBatteryOptimizationIgnored") {
      mapOf("ignored" to isIgnoringBatteryOptimizations(requireContext()))
    }

    AsyncFunction("requestIgnoreBatteryOptimizations") {
      val ctx = requireContext()
      if (isIgnoringBatteryOptimizations(ctx)) {
        return@AsyncFunction mapOf("opened" to false, "alreadyIgnored" to true)
      }
      // Direkter System-Dialog „… Akku-Optimierung ignorieren?" (ein Tap „Zulassen").
      val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
        data = Uri.parse("package:${ctx.packageName}")
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
      }
      try {
        ctx.startActivity(intent)
        mapOf("opened" to true)
      } catch (e: Exception) {
        Log.w(TAG, "requestIgnoreBatteryOptimizations: ${e.message}")
        // Fallback: generische Battery-Optimization-Liste
        try {
          ctx.startActivity(
            Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
              .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
          )
          mapOf("opened" to true, "fallback" to true)
        } catch (e2: Exception) {
          mapOf("opened" to false, "error" to (e2.message ?: ""))
        }
      }
    }

    // Öffnet die App-Detail-Settings — für Samsung der Weg zu „Akku →
    // Uneingeschränkt" + raus aus „Schlafende/Tief schlafende Apps" (das deckt
    // der reine AOSP-Whitelist-Dialog NICHT ab).
    AsyncFunction("openAppDetailsSettings") {
      val ctx = requireContext()
      val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
        data = Uri.parse("package:${ctx.packageName}")
        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
      }
      try {
        ctx.startActivity(intent)
        mapOf("opened" to true)
      } catch (e: Exception) {
        mapOf("opened" to false, "error" to (e.message ?: ""))
      }
    }

    // Hat die App das "Über anderen Apps anzeigen"-Recht? (→ passives Guide-Overlay)
    AsyncFunction("hasOverlayPermission") {
      val ctx = requireContext()
      val granted = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx)
      mapOf("granted" to granted)
    }

    // Öffnet die "Über anderen Apps anzeigen"-Settings für ReBreak.
    AsyncFunction("openOverlayPermissionSettings") {
      mapOf("opened" to openOverlayPermissionSettings(requireContext()))
    }

    AsyncFunction("armTamperLock") { promise: Promise ->
      val ctx = requireContext()
      val vpn = isVpnEffectivelyOn(ctx)
      val a11y = isAccessibilityServiceEnabled(ctx)
      if (!vpn || !a11y) {
        promise.reject(
          CodedException(
            "preconditions_not_met",
            "VPN and Accessibility must both be active before arming",
            null,
          )
        )
        return@AsyncFunction
      }
      val prefs = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
      prefs.edit().putBoolean(KEY_TAMPER_ARMED, true).apply()
      Log.i(TAG, "tamper-lock ARMED")
      promise.resolve(mapOf("armed" to true))
      sendLayerChange()
    }

    AsyncFunction("disarmTamperLock") {
      val ctx = requireContext()
      disarmTamperLock(ctx)
      sendLayerChange()
      mapOf("armed" to false)
    }

    AsyncFunction("getProtectionStatus") {
      val ctx = requireContext()
      val vpn = isVpnEffectivelyOn(ctx)
      val a11y = isAccessibilityServiceEnabled(ctx)
      val count = currentHashCount(ctx)
      val armed = isTamperLockArmed(ctx)
      Log.i(TAG, "getProtectionStatus: vpn=$vpn, a11y=$a11y, blocklist=$count, armed=$armed")
      mapOf(
        "vpnEnabled" to vpn,
        "accessibilityEnabled" to a11y,
        "blocklistCount" to count,
        "tamperArmed" to armed,
      )
    }

    // ─── Device-Admin ──────────────────────────────────────────────────────────

    /**
     * Loest den System-Dialog aus, der den User fragt ob er ReBreak als
     * Geraeteadministrator aktivieren will. Der Dialog laeuft asynchron —
     * nach Return hat der User accept/deny noch nicht getippt. JS prueft
     * das Ergebnis via AppState-Return + isDeviceAdminActive.
     *
     * Gibt {launched:true} zurueck wenn der Intent gestartet wurde, unabhaengig
     * vom User-Entscheid. {launched:false} wenn keine Activity verfuegbar war.
     */
    AsyncFunction("requestDeviceAdmin") { promise: Promise ->
      val activity = appContext.currentActivity
        ?: return@AsyncFunction promise.reject(
          CodedException("no_activity", "Activity nicht verfuegbar", null)
        )
      val adminComponent = ComponentName(activity, RebreakDeviceAdminReceiver::class.java)
      val intent = Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN).apply {
        putExtra(DevicePolicyManager.EXTRA_DEVICE_ADMIN, adminComponent)
        putExtra(
          DevicePolicyManager.EXTRA_ADD_EXPLANATION,
          activity.getString(R.string.device_admin_explanation),
        )
      }
      return@AsyncFunction if (intent.resolveActivity(activity.packageManager) != null) {
        activity.startActivity(intent)
        promise.resolve(mapOf("launched" to true))
      } else {
        promise.resolve(mapOf("launched" to false))
      }
    }

    AsyncFunction("isDeviceAdminActive") {
      val ctx = requireContext()
      mapOf("active" to isDeviceAdminEnabled(ctx))
    }

    /**
     * Entfernt den Device-Admin-Receiver. MUSS im Cooldown-Resolve-Pfad (forceDisable)
     * aufgerufen werden, damit der User nach abgelaufenem Cooldown die App
     * deinstallieren kann — ein aktiver Device-Admin blockiert Deinstallation.
     * Safety-Auflage: legitimer Ausstieg MUSS immer moeglich sein.
     */
    AsyncFunction("removeDeviceAdmin") {
      val ctx = requireContext()
      val dpm = ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
      val adminComponent = ComponentName(ctx, RebreakDeviceAdminReceiver::class.java)
      if (dpm.isAdminActive(adminComponent)) {
        dpm.removeActiveAdmin(adminComponent)
        Log.i(TAG, "Device-Admin entfernt")
      }
      sendLayerChange()
      mapOf("removed" to true)
    }

    /**
     * Reconcile: Wenn `filter_enabled == true` (User WILL Schutz) der VpnService
     * aber nicht läuft (z.B. nach App-Reinstall oder Low-Memory-Kill den
     * START_STICKY nicht zeitnah recovert hat) → neu starten. Sonst bleibt der
     * State „an aber tot": UI zeigt grün/„aktiv", real ist nichts geschützt.
     * Wird vom JS bei App-Start / Foreground aufgerufen.
     *
     * Wenn die VPN-Permission entzogen wurde, kann hier nicht ohne UI neu
     * established werden — `isVpnEffectivelyOn` clear't in dem Fall bereits den
     * Flag, also self-resolved das.
     */
    AsyncFunction("reconcileVpn") {
      val ctx = requireContext()
      val shouldBeOn = isEnabledFlag(ctx)
      val running = RebreakVpnService.isRunning
      if (shouldBeOn && !running) {
        val consentNeeded = try { VpnService.prepare(ctx) } catch (_: Exception) { null }
        if (consentNeeded == null) {
          startVpnService()
          Log.i(TAG, "reconcileVpn: VpnService neu gestartet (flag=true, war aber nicht running)")
          sendLayerChange()
          mapOf("restarted" to true)
        } else {
          Log.w(TAG, "reconcileVpn: flag=true aber keine VPN-Permission — re-activate via UI nötig")
          mapOf("restarted" to false, "needsConsent" to true)
        }
      } else {
        mapOf("restarted" to false)
      }
    }
  }

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

  private fun requireContext(): Context =
    appContext.reactContext
      ?: throw CodedException("no_context", "ReactContext nicht verfügbar", null)

  /**
   * Sticky-Hint: wiederholter Toast als pragmatischer Ersatz für ein „echtes"
   * Overlay (SYSTEM_ALERT_WINDOW würde noch ein Permission-Grant kosten, was
   * für emotional belastete User onboarding-Friction wäre).
   *
    * PRE-enable ist kein A11y-Service aktiv; daher können wir den aktuellen
    * Settings-Screen nicht zuverlässig erkennen (Vendor-abhängig). Statt
    * Timer-Switching nutzen wir deshalb retry-basierten Progress:
    *   1. Öffnen → "Installierte Dienste"
    *   2. Nächstes Öffnen → "ReBreak — Schutz"
    *   3. Nächstes Öffnen → "oberen Schalter + Zulassen"
    *
    * Damit bleibt jeder Toast kontextarm, klar und kurz — speziell für User in
    * emotional belasteten Situationen.
   *
   * Toast.LENGTH_LONG ist hart auf ~3.5s gecapt; daher posten wir alle 3s neu.
   * Auto-Stop:
   *  - sobald a11y-Service enabled (Polling via `Settings.Secure`)
   *  - nach STICKY_HINT_MAX_SEC (Safety-Net)
   *  - manuell via `stopStickyHint()` / `dismissAccessibilityHint` (App-Return)
   *
   * Wir machen explizit KEIN Foreground-Detection — wenn User aus Settings in
   * unsere App zurückkommt während ein Toast feuert, ist das visuell etwas
   * doppelt, aber harmlos. Eskalation auf Overlay wenn das in Praxis zu viel
   * Lärm macht.
   */
  private fun startStickyHint(ctx: Context, text: String) {
    stopStickyHint()
    val intervalMs = 3000L
    val maxMs = STICKY_HINT_MAX_SEC * 1000L
    val startedAt = android.os.SystemClock.uptimeMillis()
    val runnable = object : Runnable {
      override fun run() {
        if (isAccessibilityServiceEnabled(ctx)) {
          resetStickyHintProgress()
          stopSamsungGuideOverlay()
          bringRebreakToFront(ctx)
          Log.i(TAG, "stickyHint: a11y service enabled → stop")
          stickyHintRunnable = null
          return
        }
        val elapsed = android.os.SystemClock.uptimeMillis() - startedAt
        if (elapsed >= maxMs) {
          Log.i(TAG, "stickyHint: timeout ${STICKY_HINT_MAX_SEC}s → stop")
          stickyHintRunnable = null
          return
        }
        android.widget.Toast
          .makeText(ctx, text, android.widget.Toast.LENGTH_LONG)
          .show()
        mainHandler.postDelayed(this, intervalMs)
      }
    }
    stickyHintRunnable = runnable
    mainHandler.post(runnable)
  }

  // ─── State-aware a11y-Setup-Guide (UsageStats → Notification) ────────────────
  //
  // KEIN Overlay (wäre ein zweites UI, das der überforderte User bedienen müsste).
  // Stattdessen: UsageStatsManager erkennt den echten Settings-Screen, und wir
  // posten/aktualisieren EINE Notification mit der passenden Nachricht. Der User
  // bedient nur Settings; die Notification ist reines, passives Feedback.
  // Verifizierte Samsung-Activities (A50, One UI):
  //   AccessibilityHomepageActivity → Übersicht  → "Installierte Dienste"
  //   InstalledServicesActivity     → Dienste     → "ReBreak wählen"
  //   AccessibilityDetailsSettings  → ReBreak     → "Schalter ein"

  private var a11yGuideWatchRunnable: Runnable? = null
  private var lastGuideStep = Int.MIN_VALUE

  /** Hat die App "Nutzungszugriff" (PACKAGE_USAGE_STATS via AppOps)? */
  private fun hasUsageAccess(ctx: Context): Boolean {
    return try {
      val appOps = ctx.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager
      val uid = android.os.Process.myUid()
      val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        appOps.unsafeCheckOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, uid, ctx.packageName)
      } else {
        @Suppress("DEPRECATION")
        appOps.checkOpNoThrow(AppOpsManager.OPSTR_GET_USAGE_STATS, uid, ctx.packageName)
      }
      mode == AppOpsManager.MODE_ALLOWED
    } catch (e: Exception) {
      Log.w(TAG, "hasUsageAccess: ${e.message}")
      false
    }
  }

  /** Klassenname der zuletzt in den Vordergrund gekommenen Activity (letzte ~12s). */
  private fun currentForegroundActivity(ctx: Context): String? {
    return try {
      val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
      val now = System.currentTimeMillis()
      val events = usm.queryEvents(now - 12_000L, now)
      val e = UsageEvents.Event()
      var lastClass: String? = null
      while (events.hasNextEvent()) {
        events.getNextEvent(e)
        val isFg = e.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND ||
          (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
            e.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
        if (isFg) lastClass = e.className
      }
      lastClass
    } catch (e: Exception) {
      Log.w(TAG, "currentForegroundActivity: ${e.message}")
      null
    }
  }

  /**
   * Aktueller a11y-Guide-Step aus der UsageStats-Event-SEQUENZ (nicht nur der
   * letzten Activity). Samsung nutzt für Dienste-Liste UND ReBreak-Detail dieselbe
   * generische `SubSettings`-Activity — unterscheidbar nur über die TIEFE:
   *   Homepage → setzt Tiefe 0
   *   1. SubSettings nach Homepage → Liste (Step 1: ReBreak wählen)
   *   2. SubSettings (tiefer) → Detail (Step 2: Schalter + Zulassen)
   * Für den normalen Vorwärts-Flow (Homepage→Liste→Detail) zuverlässig. Geht der
   * User zurück, kann die Tiefe überschätzen (Back ≈ tiefer, da instanceId via
   * Public-API nicht lesbar) — selbstkorrigierend, da der Guide weiter zum Ziel führt.
   * Rückgabe: 0/1/2 = Step, -1 = nicht in der a11y-Navigation (verirrt).
   */
  private fun currentA11yStep(ctx: Context): Int {
    return try {
      val usm = ctx.getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
      val now = System.currentTimeMillis()
      val events = usm.queryEvents(now - 60_000L, now)
      val e = UsageEvents.Event()
      var lastFg: String? = null
      var subDepth = 0
      while (events.hasNextEvent()) {
        events.getNextEvent(e)
        val isResumed = e.eventType == UsageEvents.Event.MOVE_TO_FOREGROUND ||
          (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q &&
            e.eventType == UsageEvents.Event.ACTIVITY_RESUMED)
        if (!isResumed) continue
        val c = (e.className ?: "").lowercase()
        lastFg = c
        when {
          c.contains("accessibilityhomepage") -> subDepth = 0
          c.contains("accessibilitydetails") -> subDepth = 2
          c.contains("installedservices") -> subDepth = 1
          c.contains("accessibility") && c.contains("subsettings") ->
            if (subDepth < 2) subDepth += 1
        }
      }
      val last = lastFg ?: return -1
      when {
        last.contains("accessibilityhomepage") -> 0
        last.contains("accessibility") && (
          last.contains("subsettings") || last.contains("installedservices") ||
            last.contains("accessibilitydetails")
          ) -> if (subDepth >= 2) 2 else 1
        last.contains("accessibilitysettings") -> 0
        else -> -1
      }
    } catch (ex: Exception) {
      Log.w(TAG, "currentA11yStep: ${ex.message}")
      -1
    }
  }

  /** Settings-Activity → Guide-Step. -1 = nicht in der a11y-Navigation (verirrt). */
  private fun a11yStepForActivity(cls: String?): Int {
    if (cls == null) return -1
    val c = cls.lowercase()
    return when {
      // Übersicht (Samsung Homepage / AOSP Liste)
      c.contains("accessibilityhomepage") -> 0
      // AOSP/Pixel: getrennte Detail-Page (Schalter)
      c.contains("accessibilitydetails") -> 2
      // AOSP: Dienste-Liste
      c.contains("installedservices") -> 1
      // Samsung One UI: Dienste-Liste UND ReBreak-Detail laufen BEIDE über die
      // generische winset-SubSettings-Activity — via UsageStats nicht trennbar.
      // Daher alle a11y-SubSettings = Step 1 mit kombinierter Anweisung
      // (ReBreak wählen → Schalter → Zulassen). Auf "accessibility" einschränken,
      // damit kein fremder Settings-SubSettings fälschlich matched.
      c.contains("accessibility") && c.contains("subsettings") -> 1
      // AOSP-Übersicht (generische Action-Activity)
      c.contains("accessibilitysettings") -> 0
      else -> -1
    }
  }

  private fun ensureGuideChannel(ctx: Context) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
      if (nm.getNotificationChannel(GUIDE_CHANNEL_ID) == null) {
        val ch = NotificationChannel(
          GUIDE_CHANNEL_ID,
          ctx.getString(R.string.a11y_guide_title),
          NotificationManager.IMPORTANCE_HIGH, // Heads-up bei jedem Schritt-Wechsel
        ).apply {
          setSound(null, null)
          enableVibration(false)
          setShowBadge(false)
        }
        nm.createNotificationChannel(ch)
      }
    }
  }

  private fun postGuideNotification(ctx: Context, step: Int) {
    ensureGuideChannel(ctx)
    val msg = when (step) {
      0 -> ctx.getString(R.string.a11y_hint_step_open_installed)
      1 -> ctx.getString(R.string.a11y_hint_step_select_rebreak)
      2 -> ctx.getString(R.string.a11y_hint_step_enable_toggle)
      else -> ctx.getString(R.string.a11y_guide_lost)
    }
    val launch = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.apply {
      addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
    }
    val piFlags = PendingIntent.FLAG_UPDATE_CURRENT or
      (if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0)
    val pi = launch?.let { PendingIntent.getActivity(ctx, 0, it, piFlags) }
    val notif = NotificationCompat.Builder(ctx, GUIDE_CHANNEL_ID)
      .setSmallIcon(ctx.applicationInfo.icon)
      .setContentTitle(ctx.getString(R.string.a11y_guide_title))
      .setContentText(msg)
      .setStyle(NotificationCompat.BigTextStyle().bigText(msg))
      .setPriority(NotificationCompat.PRIORITY_HIGH)
      .setOngoing(true)
      .setOnlyAlertOnce(false)
      .apply { pi?.let { setContentIntent(it) } }
      .build()
    try {
      (ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
        .notify(GUIDE_NOTIF_ID, notif)
    } catch (e: Exception) {
      Log.w(TAG, "postGuideNotification: ${e.message}")
    }
  }

  private fun cancelGuideNotification(ctx: Context) {
    try {
      (ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
        .cancel(GUIDE_NOTIF_ID)
    } catch (_: Exception) {
    }
    lastGuideStep = Int.MIN_VALUE
  }

  /** Pollt den Settings-Screen + aktualisiert das Guide-Display (Overlay/Notif), bis a11y an ist. */
  private fun startA11yGuideWatch(ctx: Context) {
    a11yGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) }
    lastGuideStep = Int.MIN_VALUE
    val startedAt = android.os.SystemClock.uptimeMillis()
    val maxMs = STICKY_HINT_MAX_SEC * 1000L
    val runnable = object : Runnable {
      override fun run() {
        if (isAccessibilityServiceEnabled(ctx)) {
          stopGuideDisplay(ctx)
          a11yGuideWatchRunnable = null
          // 1s warten, BEVOR wir in die App zurück-routen — gibt dem System Zeit,
          // den frisch aktivierten a11y-Service zu propagieren, sonst erkennt die
          // App ihn beim Foreground-Return evtl. noch nicht. Davor Settings auf die
          // Startseite zurücksetzen (Suchbegriff + a11y-Detailstand wegwischen), dann
          // ~250ms später ReBreak drüberlegen.
          mainHandler.postDelayed({
            resetSettingsToRoot(ctx)
            mainHandler.postDelayed({ bringRebreakToFront(ctx) }, 250L)
          }, 1000L)
          Log.i(TAG, "a11yGuide: service enabled → reset settings + return in ~1.25s")
          return
        }
        if (android.os.SystemClock.uptimeMillis() - startedAt >= maxMs) {
          stopGuideDisplay(ctx)
          a11yGuideWatchRunnable = null
          return
        }
        val step = currentA11yStep(ctx)
        if (step != lastGuideStep) {
          lastGuideStep = step
          showGuideStep(ctx, step)
        }
        mainHandler.postDelayed(this, 500L)
      }
    }
    a11yGuideWatchRunnable = runnable
    mainHandler.postDelayed(runnable, 400L)
  }

  private fun stopA11yGuideWatch(ctx: Context) {
    a11yGuideWatchRunnable?.let { mainHandler.removeCallbacks(it) }
    a11yGuideWatchRunnable = null
    stopGuideDisplay(ctx)
  }

  /** Step-Text (ohne Branding — das Branding macht der „ReBreak"-Header). */
  private fun guideStepText(ctx: Context, step: Int): String = when (step) {
    0 -> ctx.getString(R.string.a11y_hint_step_open_installed)
    1 -> ctx.getString(R.string.a11y_hint_step_select_rebreak)
    2 -> ctx.getString(R.string.a11y_hint_step_enable_toggle)
    else -> ctx.getString(R.string.a11y_guide_lost)
  }

  /**
   * PASSIVES Overlay (kein Button, kein Touch-Intercept): schwebt oben VOR der
   * Settings-App und zeigt „ReBreak" + den aktuellen Schritt. Der User liest nur,
   * tippt weiter in Settings (FLAG_NOT_TOUCHABLE lässt Touches durch). Auto-Advance
   * macht der Watch via currentA11yStep. Gibt false zurück wenn Overlay-Recht fehlt
   * → Caller nutzt dann die Notification.
   */
  private fun showOverlayStep(ctx: Context, step: Int): Boolean {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(ctx)) {
      return false
    }
    stopStickyHint()
    mainHandler.post {
      try {
        if (samsungGuideOverlay == null) {
          samsungGuideWindowManager = ctx.getSystemService(Context.WINDOW_SERVICE) as WindowManager
          // Full-width Banner UNTEN mit oben abgerundeten Ecken (bottom-sheet-Look).
          val roundedBg = android.graphics.drawable.GradientDrawable().apply {
            setColor(Color.parseColor("#F2191F24"))
            // [tl, tl, tr, tr, br, br, bl, bl] → nur oben rund
            cornerRadii = floatArrayOf(48f, 48f, 48f, 48f, 0f, 0f, 0f, 0f)
          }
          val container = LinearLayout(ctx).apply {
            orientation = LinearLayout.VERTICAL
            setPadding(56, 28, 56, 40)
            background = roundedBg
          }
          val header = TextView(ctx).apply {
            text = "ReBreak"
            setTextColor(Color.parseColor("#FF8A3D"))
            textSize = 13f
            setTypeface(typeface, android.graphics.Typeface.BOLD)
          }
          val message = TextView(ctx).apply {
            setTextColor(Color.WHITE)
            textSize = 18f
            setPadding(0, 6, 0, 0)
          }
          container.addView(header)
          container.addView(message)
          val lp = WindowManager.LayoutParams(
            WindowManager.LayoutParams.MATCH_PARENT,
            WindowManager.LayoutParams.WRAP_CONTENT,
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
            else WindowManager.LayoutParams.TYPE_PHONE,
            WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
              WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
            PixelFormat.TRANSLUCENT,
          ).apply {
            gravity = Gravity.BOTTOM
            y = 0
          }
          samsungGuideOverlay = container
          samsungGuideTextView = message
          samsungGuideLayoutParams = lp
          samsungGuideWindowManager?.addView(container, lp)
        }
        samsungGuideTextView?.text = guideStepText(ctx, step)
      } catch (e: Exception) {
        Log.w(TAG, "showOverlayStep: ${e.message}")
      }
    }
    return true
  }

  /** Zeigt den Step bevorzugt als passives Overlay, sonst als Notification. */
  private fun showGuideStep(ctx: Context, step: Int) {
    if (!showOverlayStep(ctx, step)) {
      postGuideNotification(ctx, step)
    }
  }

  private fun stopGuideDisplay(ctx: Context) {
    stopSamsungGuideOverlay()
    cancelGuideNotification(ctx)
  }

  private fun openOverlayPermissionSettings(ctx: Context): Boolean {
    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return false
    val intent = Intent(
      Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
      Uri.parse("package:${ctx.packageName}"),
    ).apply {
      addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    }
    return try {
      if (intent.resolveActivity(ctx.packageManager) != null) {
        ctx.startActivity(intent)
        Log.i(TAG, "overlayPermission: opened settings page")
        true
      } else {
        Log.w(TAG, "overlayPermission: no resolver")
        false
      }
    } catch (e: Exception) {
      Log.w(TAG, "overlayPermission: failed: ${e.message}")
      false
    }
  }

  private fun bringRebreakToFront(ctx: Context) {
    try {
      val launchIntent = ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
      if (launchIntent != null) {
        launchIntent.addFlags(
          Intent.FLAG_ACTIVITY_NEW_TASK or
            Intent.FLAG_ACTIVITY_CLEAR_TOP or
            Intent.FLAG_ACTIVITY_SINGLE_TOP,
        )
        ctx.startActivity(launchIntent)
        Log.i(TAG, "bringToFront: launched app after a11y enabled")
      }
    } catch (e: Exception) {
      Log.w(TAG, "bringToFront failed: ${e.message}")
    }
  }

  /**
   * Setzt die Settings-App auf ihren Startzustand zurück, BEVOR wir in ReBreak
   * zurück-routen. Sonst behält Settings seinen letzten Stand (a11y-Detailseite +
   * aktiver Suchbegriff): Beim nächsten Settings-Öffnen landet der User wieder tief
   * in der a11y-Page statt auf der Settings-Startseite.
   *
   * FLAG_ACTIVITY_CLEAR_TASK beendet alle Activities im Settings-Task und startet
   * die Settings-Hauptseite frisch (gleiche Task-Affinity → der bestehende Stack
   * wird geleert). Kurz sichtbar (~250ms), dann legt sich ReBreak drüber.
   */
  private fun resetSettingsToRoot(ctx: Context) {
    try {
      val home = Intent(android.provider.Settings.ACTION_SETTINGS).addFlags(
        Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK,
      )
      ctx.startActivity(home)
      Log.i(TAG, "resetSettingsToRoot: Settings-Task auf Startseite zurückgesetzt")
    } catch (e: Exception) {
      Log.w(TAG, "resetSettingsToRoot failed: ${e.message}")
    }
  }

  private fun currentStickyHintText(ctx: Context): String =
    when (stickyHintStepIndex) {
      0 -> ctx.getString(R.string.a11y_hint_step_open_installed)
      1 -> ctx.getString(R.string.a11y_hint_step_select_rebreak)
      2 -> ctx.getString(R.string.a11y_hint_step_enable_toggle)
      else -> ctx.getString(R.string.a11y_hint_step_allow_confirm)
    }

  private fun advanceStickyHintStep() {
    if (stickyHintStepIndex < 3) stickyHintStepIndex += 1
  }

  private fun resetStickyHintProgress() {
    stickyHintStepIndex = 0
  }

  private fun stopSamsungGuideOverlay() {
    mainHandler.post {
      try {
        samsungGuideOverlay?.let { view ->
          samsungGuideWindowManager?.removeView(view)
        }
      } catch (_: Exception) {
      } finally {
        samsungGuideOverlay = null
        samsungGuideTextView = null
        samsungGuideWindowManager = null
        samsungGuideLayoutParams = null
      }
    }
  }

  private fun stopStickyHint() {
    stickyHintRunnable?.let { mainHandler.removeCallbacks(it) }
    stickyHintRunnable = null
  }

  private fun startVpnService() {
    val ctx = requireContext()
    val intent = Intent(ctx, RebreakVpnService::class.java)
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      ctx.startForegroundService(intent)
    } else {
      ctx.startService(intent)
    }
  }

  private fun buildDeviceState(ctx: Context): Map<String, Any?> {
    val blocklist = File(ctx.filesDir, BLOCKLIST_FILENAME)
    val count = if (blocklist.exists()) (blocklist.length() / 8).toInt() else 0
    val lastSyncAt = prefs(ctx).getString(PREF_LAST_SYNC, null)
    return mapOf(
      "vpn" to isVpnEffectivelyOn(ctx),
      "accessibility" to isAccessibilityServiceEnabled(ctx),
      // Ein armed-aber-Schutz-aus Tamper-Lock ist effektiv KEIN Lock — sonst
      // zeigt die UI „verriegelt" ohne dass der User je rauskommt (Desync-Fall:
      // `tamper_armed` noch true, aber `filter_enabled` schon false).
      // Lock gilt nur als aktiv, wenn er WIRKLICH erzwingt: armed + VPN-flag UND
      // der a11y-Service läuft live. Sonst „armed-aber-a11y-aus" → UI zeigt
      // „verriegelt", obwohl nichts mehr blockt (z.B. a11y nach Reboot deaktiviert).
      "tamperLock" to (isTamperLockArmed(ctx) && isEnabledFlag(ctx) && isAccessibilityServiceEnabled(ctx)),
      "deviceAdmin" to isDeviceAdminEnabled(ctx),
      // Akku-Ausnahme: ohne sie schläfert Samsung & Co. die App ein und entbindet
      // den a11y-Service → Schutz fällt still aus. Teil des State, damit die UI
      // darauf hinweisen/gaten kann.
      "batteryUnrestricted" to isIgnoringBatteryOptimizations(ctx),
      "blocklistCount" to count,
      "blocklistLastSyncAt" to lastSyncAt,
    )
  }

  private fun isDeviceAdminEnabled(ctx: Context): Boolean {
    val dpm = ctx.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
    val adminComponent = ComponentName(ctx, RebreakDeviceAdminReceiver::class.java)
    return try {
      dpm.isAdminActive(adminComponent)
    } catch (_: Exception) {
      false
    }
  }

  /** Ist die App von der Akku-Optimierung ausgenommen? (AOSP-Whitelist —
   *  Samsung „Schlafende Apps" ist separat, daher zusätzlich der App-Detail-Guide.) */
  private fun isIgnoringBatteryOptimizations(ctx: Context): Boolean {
    return try {
      val pm = ctx.getSystemService(Context.POWER_SERVICE) as PowerManager
      pm.isIgnoringBatteryOptimizations(ctx.packageName)
    } catch (_: Exception) {
      false
    }
  }

  private fun activateSuccessResult(): Map<String, Any?> = mapOf(
    "allLayersOn" to false,
    "missingLayers" to listOf("accessibility", "tamperLock"),
    "errors" to emptyList<String>(),
  )

  private fun sendLayerChange() {
    val ctx = appContext.reactContext ?: return
    sendEvent("onLayerChange", buildDeviceState(ctx))
  }

  private fun prefs(ctx: Context) =
    ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)

  private fun saveEnabled(enabled: Boolean) {
    prefs(requireContext()).edit().putBoolean(KEY_ENABLED, enabled).apply()
  }

  private fun isEnabledFlag(ctx: Context): Boolean =
    prefs(ctx).getBoolean(KEY_ENABLED, false)

  private fun isTamperLockArmed(ctx: Context): Boolean =
    prefs(ctx).getBoolean(KEY_TAMPER_ARMED, false)

  /** Cleart das Tamper-Armed-Flag. Geteilte Logik zwischen der
   *  `disarmTamperLock`-AsyncFunction und `disable()` — robust für alle Caller. */
  private fun disarmTamperLock(ctx: Context) {
    prefs(ctx).edit().putBoolean(KEY_TAMPER_ARMED, false).apply()
    Log.i(TAG, "tamper-lock DISARMED")
  }

  private fun currentHashCount(ctx: Context): Int {
    val file = File(ctx.filesDir, BLOCKLIST_FILENAME)
    return if (file.exists()) (file.length() / 8L).toInt() else 0
  }

  /**
   * Live-Check: ist unser VPN tatsächlich aktiv?
    *  1. VpnService.prepare(ctx) == null → wir haben Permission
    *  2. UND der VpnService läuft wirklich (`isRunning==true`)
   *
   * Wenn der User in System-Settings das VPN ausgeschaltet hat, returnt
   * prepare() ein non-null Intent (= "musst Permission neu holen") UND
   * unser onRevoke clear't bereits die Prefs-Flag. Doppelt abgesichert.
   */
  private fun isVpnEffectivelyOn(ctx: Context): Boolean {
    val live = RebreakVpnService.isRunning
    val intent = try {
      VpnService.prepare(ctx)
    } catch (_: Exception) {
      null
    }
    val flag = isEnabledFlag(ctx)
    Log.d(TAG, "isVpnEffectivelyOn: live=$live, prepareIntent=${intent != null}, flag=$flag")
    if (intent != null) {
      // Permission entzogen → definitely off. Auch den Tamper-Lock mit-disarmen,
      // sonst bleibt der State desynct (tamper armed, Schutz aus) → „verriegelt"-UI.
      if (flag) {
        prefs(ctx).edit()
          .putBoolean(KEY_ENABLED, false)
          .putBoolean(KEY_TAMPER_ARMED, false)
          .apply()
      }
      return false
    }
    // Kein Fallback mehr auf `filter_enabled` allein: nach Device-Reboot kann
    // das Flag noch true sein, obwohl der VpnService noch nicht wieder läuft.
    // Das führte zu falschem "voll geschützt"-Banner.
    return live
  }

  /**
   * Liest Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES — kommagetrennte
   * Liste von ComponentName-Strings. Wir matchen exakt unseren Service.
   *
   * Vorteil ggü. AccessibilityManager.getEnabledAccessibilityServiceList:
   * funktioniert auch wenn der Service gerade neu installiert wurde und
   * der Cache noch nicht aktualisiert ist.
   */
  private fun isAccessibilityServiceEnabled(ctx: Context): Boolean {
    val pkg = ctx.packageName
    val expectedClass = RebreakAccessibilityService::class.java.name

    // Primary + authoritative: AccessibilityManager. Eine erfolgreich
    // gelieferte Liste (auch leer) ist die Wahrheit — der Service läuft nur
    // dann tatsächlich, wenn er hier auftaucht. `Settings.Secure` darf nur
    // einspringen wenn die AM-Abfrage technisch fehlschlägt (null/Exception);
    // andernfalls würde ein Stale-Eintrag in Settings.Secure (System hat den
    // Service deregistriert, ENABLED_ACCESSIBILITY_SERVICES nicht aufgeräumt)
    // einen aktiven Schutz vortäuschen, obwohl der Service nicht gebunden ist.
    try {
      val am = ctx.getSystemService(Context.ACCESSIBILITY_SERVICE) as? AccessibilityManager
      if (am != null) {
        val list = am.getEnabledAccessibilityServiceList(AccessibilityServiceInfo.FEEDBACK_ALL_MASK)
        if (list != null) {
          Log.d(TAG, "a11y check via AccessibilityManager: ${list.size} services")
          for (info in list) {
            val id = info.id ?: continue
            if (id.contains(pkg) && id.contains("RebreakAccessibilityService")) {
              Log.d(TAG, "a11y MATCH via AM: $id")
              return true
            }
          }
          return false
        }
      }
    } catch (e: Exception) {
      Log.w(TAG, "AccessibilityManager check failed: ${e.message}")
    }

    // Fallback NUR wenn AM technisch unverfügbar war (null/Exception oben).
    val expected = ComponentName(ctx, RebreakAccessibilityService::class.java)
    val expectedFlat = expected.flattenToString()
    val enabled = try {
      Settings.Secure.getString(
        ctx.contentResolver,
        Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES,
      )
    } catch (e: Exception) {
      null
    }
    Log.d(TAG, "a11y check via Settings.Secure (fallback): expected=$expectedFlat, settings=$enabled")
    if (!enabled.isNullOrBlank()) {
      if (enabled.contains(expectedFlat)) return true
      if (enabled.contains(expectedClass)) return true
    }
    return false
  }

  private fun downloadBlocklist(
    ctx: Context, baseURL: String, authToken: String
  ): Map<String, Any?> {
    val url = URL("$baseURL/api/url-filter/blocklist.bin")
    val conn = (url.openConnection() as HttpURLConnection).apply {
      setRequestProperty("Authorization", "Bearer $authToken")
      connectTimeout = 10_000
      readTimeout = 30_000
    }
    val p = prefs(ctx)
    p.getString(PREF_ETAG, null)?.let { conn.setRequestProperty("If-None-Match", it) }

    val now = isoTimestamp()
    return try {
      when (val code = conn.responseCode) {
        304 -> {
          val existing = File(ctx.filesDir, BLOCKLIST_FILENAME)
          val count = (if (existing.exists()) existing.length() else 0L) / 8
          mapOf("updated" to false, "count" to count.toInt())
        }
        200 -> {
          val bytes = conn.inputStream.use { it.readBytes() }
          writeBlocklistAtomic(ctx, bytes)
          val edits = p.edit()
          conn.getHeaderField("etag")?.let { edits.putString(PREF_ETAG, it) }
          edits.putString(PREF_LAST_SYNC, now).apply()
          val plan = conn.getHeaderField("x-rebreak-plan")
          mapOf(
            "updated" to true,
            "count" to bytes.size / 8,
            "plan" to plan,
          )
        }
        else -> throw RuntimeException("HTTP $code")
      }
    } finally {
      conn.disconnect()
    }
  }

  private fun writeBlocklistAtomic(ctx: Context, bytes: ByteArray) {
    val finalFile = File(ctx.filesDir, BLOCKLIST_FILENAME)
    val tmpFile = File(ctx.filesDir, "$BLOCKLIST_FILENAME.tmp")
    tmpFile.writeBytes(bytes)
    if (!tmpFile.renameTo(finalFile)) {
      finalFile.delete()
      tmpFile.renameTo(finalFile)
    }
  }

  private fun isoTimestamp(): String {
    val df = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
    df.timeZone = TimeZone.getTimeZone("UTC")
    return df.format(Date())
  }

  companion object {
    private const val TAG = "RebreakProtection"
    private const val VPN_CONSENT_REQUEST_CODE = 42101
    private const val BLOCKLIST_FILENAME = "blocklist.bin"
    private const val PREFS = "rebreak_filter_prefs"
    private const val KEY_ENABLED = "filter_enabled"
    private const val KEY_TAMPER_ARMED = "tamper_armed"
    private const val PREF_ETAG = "rebreak_blocklist_etag"
    private const val PREF_LAST_SYNC = "rebreak_blocklist_last_sync"
    /** Max-Duration der Repeating-Toast-Hint nachdem User in Settings landet. */
    private const val STICKY_HINT_MAX_SEC = 30
    /** State-aware a11y-Guide-Notification. */
    private const val GUIDE_NOTIF_ID = 47110815
    private const val GUIDE_CHANNEL_ID = "rebreak_a11y_guide"
  }
}
