package org.timesafari.dailynotification

import android.Manifest
import android.app.Activity
import android.app.AlarmManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import androidx.core.app.ActivityCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.work.WorkManager
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Data
import java.util.concurrent.TimeUnit
import org.timesafari.dailynotification.DailyNotificationFetchWorker
import com.getcapacitor.JSArray
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject

/**
 * Main Android implementation of Daily Notification Plugin
 * Bridges Capacitor calls to native Android functionality
 * 
 * @author Matthew Raymer
 * @version 2.0.0
 */
@CapacitorPlugin(name = "DailyNotification")
open class DailyNotificationPlugin : Plugin() {
    
    companion object {
        private const val TAG = "DNP-PLUGIN"
        
        /**
         * Static registry for native content fetcher
         * Thread-safe: Volatile ensures visibility across threads
         */
        @Volatile
        private var nativeFetcher: NativeNotificationContentFetcher? = null
        
        /**
         * Get the registered native fetcher (called from Java code)
         * 
         * @return Registered NativeNotificationContentFetcher or null if not registered
         */
        @JvmStatic
        fun getNativeFetcherStatic(): NativeNotificationContentFetcher? {
            return nativeFetcher
        }
        
        /**
         * Register a native content fetcher
         * 
         * @param fetcher The native fetcher implementation to register
         */
        @JvmStatic
        fun registerNativeFetcher(fetcher: NativeNotificationContentFetcher?) {
            nativeFetcher = fetcher
            Log.i(TAG, "Native fetcher ${if (fetcher != null) "registered" else "unregistered"}")
        }
        
        /**
         * Set the native content fetcher (alias for registerNativeFetcher)
         * 
         * @param fetcher The native fetcher implementation to register
         */
        @JvmStatic
        fun setNativeFetcher(fetcher: NativeNotificationContentFetcher?) {
            registerNativeFetcher(fetcher)
        }
    }
    
    private var db: DailyNotificationDatabase? = null
    
    // Service instances for delegation
    private var statusChecker: NotificationStatusChecker? = null
    private var permissionManager: PermissionManager? = null
    private var exactAlarmManager: DailyNotificationExactAlarmManager? = null
    private var channelManager: ChannelManager? = null
    private var scheduler: DailyNotificationScheduler? = null
    private var integrationManager: TimeSafariIntegrationManager? = null
    
    // Pending permission request tracking (prevents wrong-call resolution)
    private var pendingPermissionRequest: PendingPermissionRequest? = null
    
    override fun load() {
        super.load()
        try {
            if (context == null) {
                Log.e(TAG, "Context is null, cannot initialize database")
                return
            }
        db = DailyNotificationDatabase.getDatabase(context)
        statusChecker = NotificationStatusChecker(context)
        channelManager = ChannelManager(context)
        permissionManager = PermissionManager(context, channelManager)
        // Note: exactAlarmManager requires AlarmManager and DailyNotificationScheduler
        // For now, we'll initialize it lazily when needed, or create a simpler version
        // This is a known limitation - exactAlarmManager initialization needs refactoring
        exactAlarmManager = null // Will be initialized on-demand if needed
        // Note: TimeSafariIntegrationManager requires many dependencies (Storage, Scheduler, ETagManager, JWTManager, Fetcher, TTLEnforcer, Logger)
        // Initialization deferred to future integration work when all dependencies are available
        integrationManager = null
        Log.i(TAG, "Daily Notification Plugin loaded successfully")
            
            // Phase 1: Perform app launch recovery (cold start only)
            // Runs asynchronously, non-blocking, with timeout
            val reactivationManager = ReactivationManager(context)
            reactivationManager.performRecovery()
        } catch (e: Exception) {
            Log.e(TAG, "Failed to initialize Daily Notification Plugin", e)
            // Don't throw - allow plugin to load even if recovery fails
        }
    }
    
    /**
     * Handle app resume - check if we need to resolve a pending permission call
     * Uses pendingPermissionRequest token to prevent wrong-call resolution
     */
    override fun handleOnResume() {
        super.handleOnResume()
        
        // Only resolve if we have a valid pending request token
        val pending = pendingPermissionRequest
        val call = savedCall
        
        if (pending != null && call != null && context != null) {
            val now = System.currentTimeMillis()
            val ageMs = now - pending.requestedAtMs
            
            // Only auto-resolve if request is recent (< 2 minutes) and matches expected state
            if (ageMs < 120_000) { // 2 minutes
                when (pending.requestType) {
                    PermissionRequestType.POST_NOTIFICATIONS -> {
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                            val granted = ContextCompat.checkSelfPermission(
                                context!!,
                                Manifest.permission.POST_NOTIFICATIONS
                            ) == PackageManager.PERMISSION_GRANTED
                            
                            // Only resolve if permission state changed (granted)
                            if (granted) {
                                val result = JSObject().apply {
                                    put("status", "granted")
                                    put("granted", true)
                                    put("notifications", "granted")
                                }
                                
                                Log.i(TAG, "Resolving pending POST_NOTIFICATIONS request on resume: granted=true")
                                call.resolve(result)
                                pendingPermissionRequest = null // Clear pending request
                            } else {
                                Log.d(TAG, "POST_NOTIFICATIONS still not granted, not auto-resolving")
                            }
                        }
                    }
                    PermissionRequestType.EXACT_ALARM -> {
                        // For exact alarm, check if permission was granted
                        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                            val alarmManager = context!!.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
                            val granted = alarmManager?.canScheduleExactAlarms() ?: false
                            
                            if (granted) {
                                val result = JSObject().apply {
                                    put("success", true)
                                    put("message", "Exact alarm permission granted")
                                }
                                
                                Log.i(TAG, "Resolving pending EXACT_ALARM request on resume: granted=true")
                                call.resolve(result)
                                pendingPermissionRequest = null
                            }
                        }
                    }
                    PermissionRequestType.BATTERY_OPTIMIZATION -> {
                        // Battery optimization doesn't have a simple granted/denied state
                        // Don't auto-resolve - let user action complete naturally
                        Log.d(TAG, "Battery optimization request - not auto-resolving on resume")
                    }
                }
            } else {
                // Request is stale - clear it
                Log.w(TAG, "Pending permission request expired (age: ${ageMs}ms), clearing")
                pendingPermissionRequest = null
            }
        }
    }
    
    private fun getDatabase(): DailyNotificationDatabase {
        if (db == null) {
            if (context == null) {
                throw IllegalStateException("Plugin not initialized: context is null")
            }
            db = DailyNotificationDatabase.getDatabase(context)
        }
        return db!!
    }
    
    @PluginMethod
    fun configure(call: PluginCall) {
        try {
            // Capacitor passes the object directly via call.data
            val options = call.data
            Log.i(TAG, "Configure called with options: $options")
            
            // Delegate to TimeSafariIntegrationManager if available
            // For now, this is a placeholder - configuration will be handled by integration manager
            // when it's initialized. This method maintains API compatibility.
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    // Delegate to TimeSafariIntegrationManager if available
                    integrationManager?.configure(options)?.let {
                        call.resolve()
                    } ?: run {
                        // Fallback: just resolve to maintain API compatibility
                        call.resolve()
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to configure", e)
                    call.reject("Configuration failed: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Configure error", e)
            call.reject("Configuration error: ${e.message}")
        }
    }
    
    @PluginMethod
    fun checkPermissionStatus(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Delegate to PermissionManager
            permissionManager?.checkPermissionStatus(call)
            
        } catch (e: Exception) {
            Log.e(TAG, "Failed to check permission status", e)
            call.reject("Permission check failed: ${e.message}")
        }
    }
    
    /**
     * Check permissions (Capacitor standard format)
     * Returns PermissionStatus with notifications field as PermissionState
     * Delegates to PermissionManager for single source of truth
     */
    @PluginMethod
    override fun checkPermissions(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure permissionManager is initialized
            if (permissionManager == null) {
                if (channelManager == null) {
                    channelManager = ChannelManager(context)
                }
                permissionManager = PermissionManager(context, channelManager)
            }
            
            // Get unified permission status from PermissionManager
            val status = permissionManager!!.getPermissionStatus()
            
            // Format for Capacitor standard format
            val notificationsState = when {
                status.postNotificationsGranted && status.notificationsEnabledAtOsLevel -> "granted"
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> "prompt"
                else -> if (status.notificationsEnabledAtOsLevel) "granted" else "denied"
            }
            
            val result = JSObject().apply {
                put("status", notificationsState)
                put("granted", status.canScheduleNow)
                put("notifications", notificationsState)
                put("notificationsEnabled", status.postNotificationsGranted && status.notificationsEnabledAtOsLevel)
                // Include full status for debugging
                put("postNotificationsGranted", status.postNotificationsGranted)
                put("exactAlarmGranted", status.exactAlarmGranted)
                put("notificationsEnabledAtOsLevel", status.notificationsEnabledAtOsLevel)
            }
            
            Log.i(TAG, "Permissions check: notifications=$notificationsState, canSchedule=${status.canScheduleNow}")
            call.resolve(result)
            
        } catch (e: Exception) {
            Log.e(TAG, "Failed to check permissions", e)
            call.reject("Permission check failed: ${e.message}")
        }
    }
    
    /**
     * Get exact alarm status
     * Returns detailed information about exact alarm scheduling capability
     */
    @PluginMethod
    fun getExactAlarmStatus(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure permissionManager is initialized
            if (permissionManager == null) {
                if (channelManager == null) {
                    channelManager = ChannelManager(context)
                }
                permissionManager = PermissionManager(context, channelManager)
            }
            
            // Delegate to DailyNotificationExactAlarmManager if available, otherwise use PermissionManager
            if (exactAlarmManager != null) {
                val status = exactAlarmManager!!.getExactAlarmStatus()
                val result = JSObject().apply {
                    put("supported", status.supported)
                    put("enabled", status.enabled)
                    put("canSchedule", status.canSchedule)
                    put("fallbackWindow", JSObject().apply {
                        put("startMs", status.fallbackWindow.startMs)
                        put("lengthMs", status.fallbackWindow.lengthMs)
                    })
                }
                Log.i(TAG, "Exact alarm status retrieved: ${status.toString()}")
                call.resolve(result)
            } else {
                // Fallback: Use PermissionManager's checkExactAlarmPermission
                permissionManager!!.checkExactAlarmPermission(call)
            }
            
        } catch (e: Exception) {
            Log.e(TAG, "Failed to get exact alarm status", e)
            call.reject("Exact alarm status check failed: ${e.message}")
        }
    }
    
    /**
     * Update starred plan IDs
     * Stores plan IDs in SharedPreferences for native fetcher to use
     * 
     * Input contract: { planIds: string[] }
     * Rejects any other shape with clear error message
     */
    @PluginMethod
    fun updateStarredPlans(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            val options = call.data ?: return call.reject("Options are required")
            
            // Enforce single input shape: planIds must be string[]
            val planIdsValue = options.get("planIds")
            
            if (planIdsValue == null) {
                return call.reject("planIds is required and must be a string array")
            }
            
            val planIds = mutableListOf<String>()
            
            // Primary path: JSONArray (Capacitor's standard format)
            if (planIdsValue is JSONArray) {
                for (i in 0 until planIdsValue.length()) {
                    val item = planIdsValue.get(i)
                    if (item is String) {
                        planIds.add(item)
                    } else {
                        return call.reject("planIds must be an array of strings (found non-string at index $i)")
                    }
                }
            } 
            // Fallback: List<String> (from JSObject conversion)
            else if (planIdsValue is List<*>) {
                planIdsValue.forEachIndexed { index, item ->
                    if (item is String) {
                        planIds.add(item)
                    } else {
                        return call.reject("planIds must be an array of strings (found non-string at index $index)")
                    }
                }
            }
            // Legacy support: Single string (normalize to array immediately)
            else if (planIdsValue is String) {
                Log.w(TAG, "updateStarredPlans: Received single string instead of array (legacy support)")
                planIds.add(planIdsValue)
            }
            // Reject all other shapes
            else {
                return call.reject("planIds must be a string array, got: ${planIdsValue.javaClass.simpleName}")
            }
            
            // Delegate to ScheduleHelper
            val success = ScheduleHelper.updateStarredPlans(context, planIds)
            
            if (success) {
                val result = JSObject().apply {
                    put("success", true)
                    put("planIdsCount", planIds.size)
                    put("updatedAt", System.currentTimeMillis())
                }
                call.resolve(result)
            } else {
                call.reject("Failed to update starred plans")
            }
            
        } catch (e: Exception) {
            Log.e(TAG, "Failed to update starred plans", e)
            call.reject("Failed to update starred plans: ${e.message}")
        }
    }
    
    @PluginMethod
    fun requestNotificationPermissions(call: PluginCall) {
        try {
            val activity = activity ?: return call.reject("Activity not available")
            val context = context ?: return call.reject("Context not available")
            
            // Ensure permissionManager is initialized
            if (permissionManager == null) {
                if (channelManager == null) {
                    channelManager = ChannelManager(context)
                }
                permissionManager = PermissionManager(context, channelManager)
            }
            
            // Save the call using Capacitor's mechanism so it can be retrieved later
            // (needed for handleRequestPermissionsResult to resolve the call)
            saveCall(call)
            
            // Create pending request token to track this permission request
            val requestNonce = java.util.UUID.randomUUID().toString()
            pendingPermissionRequest = PendingPermissionRequest(
                requestNonce = requestNonce,
                requestType = PermissionRequestType.POST_NOTIFICATIONS,
                requestedAtMs = System.currentTimeMillis()
            )
            
            Log.d(TAG, "Created pending permission request: nonce=$requestNonce, type=POST_NOTIFICATIONS")
            
            // Delegate to PermissionManager.requestNotificationPermissions()
            permissionManager!!.requestNotificationPermissions(call, activity)
            
        } catch (e: Exception) {
            Log.e(TAG, "Failed to request notification permissions", e)
            call.reject("Permission request failed: ${e.message}")
        }
    }
    
    /**
     * Request permissions (alias for requestNotificationPermissions)
     * Delegates to requestNotificationPermissions for consistency
     */
    @PluginMethod
    override fun requestPermissions(call: PluginCall) {
        // Delegate to requestNotificationPermissions (which delegates to PermissionManager)
        requestNotificationPermissions(call)
    }
    
    /**
     * Handle permission request results
     * Called by Capacitor when user responds to permission dialog
     */
    override fun handleRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        Log.i(TAG, "handleRequestPermissionsResult called: requestCode=$requestCode, permissions=${permissions.contentToString()}")
        
        if (requestCode == DailyNotificationConstants.PERMISSION_REQUEST_CODE) {
            // Retrieve the saved call
            val call = savedCall
            if (call != null) {
                val granted = grantResults.isNotEmpty() && 
                    grantResults[0] == PackageManager.PERMISSION_GRANTED
                
                val result = JSObject().apply {
                    put("status", if (granted) "granted" else "denied")
                    put("granted", granted)
                    put("notifications", if (granted) "granted" else "denied")
                }
                
                Log.i(TAG, "Permission request result: granted=$granted, resolving call")
                call.resolve(result)
                pendingPermissionRequest = null // Clear pending request after resolution
                return
            } else {
                Log.w(TAG, "No saved call found for permission request code $requestCode")
            }
        }
        
        // Not handled by this plugin, let parent handle it
        super.handleRequestPermissionsResult(requestCode, permissions, grantResults)
    }
    
    @PluginMethod
    fun configureNativeFetcher(call: PluginCall) {
        try {
            // Capacitor passes the object directly via call.data
            val options = call.data ?: return call.reject("Options are required")
            
            // Support both jwtToken and jwtSecret for backward compatibility
            val apiBaseUrl = options.getString("apiBaseUrl") ?: return call.reject("apiBaseUrl is required")
            val activeDid = options.getString("activeDid") ?: return call.reject("activeDid is required")
            val jwtToken = options.getString("jwtToken") ?: options.getString("jwtSecret") ?: return call.reject("jwtToken or jwtSecret is required")
            
            val jwtTokenPool: List<String>? = try {
                parseJwtTokenPool(options)
            } catch (e: IllegalArgumentException) {
                return call.reject(e.message ?: "Invalid jwt token pool")
            }
            
            val nativeFetcher = getNativeFetcherStatic()
            if (nativeFetcher == null) {
                return call.reject("No native fetcher registered. Host app must register a NativeNotificationContentFetcher.")
            }
            
            Log.i(
                TAG,
                "Configuring native fetcher: apiBaseUrl=$apiBaseUrl, activeDid=$activeDid" +
                    if (jwtTokenPool != null) ", jwtTokenPoolSize=${jwtTokenPool.size}" else ""
            )
            
            // Call the native fetcher's configure method FIRST
            // This configures the fetcher instance with API credentials for background operations
            var configureSuccess = false
            var configureError: Exception? = null
            
            try {
                Log.d(TAG, "FETCHER|CONFIGURE_START apiBaseUrl=$apiBaseUrl, activeDid=${activeDid.take(30)}...")
                nativeFetcher.configure(apiBaseUrl, activeDid, jwtToken, jwtTokenPool)
                configureSuccess = true
                Log.i(TAG, "FETCHER|CONFIGURE_COMPLETE success=true")
            } catch (e: Exception) {
                configureError = e
                Log.e(TAG, "FETCHER|CONFIGURE_COMPLETE success=false error=${e.message}", e)
                // Continue to store empty config entry - don't fail the entire operation
            }
            
            // SECURITY WARNING: JWT token storage
            // By default, tokens are NOT persisted to prevent storing bearer credentials at rest
            // If persistence is required, caller must explicitly opt-in with persistToken: true
            // Use optBoolean() to avoid JSONException when key doesn't exist
            val persistToken = options.optBoolean("persistToken", false)
            
            if (persistToken) {
                Log.w(TAG, "SECURITY WARNING: JWT token will be persisted to unencrypted database. " +
                          "This is a security risk. Consider using short-lived tokens or encrypted storage.")
            } else {
                Log.d(TAG, "JWT token will NOT be persisted (default secure behavior)")
            }
            
            // Store configuration in database for persistence across app restarts
            // If configure() failed, store a valid but empty entry that won't cause errors
            val configId = "native_fetcher_config"
            val configValue = if (configureSuccess) {
                // Store actual configuration values
                JSONObject().apply {
                    put("apiBaseUrl", apiBaseUrl)
                    put("activeDid", activeDid)
                    // Only store JWT token if explicitly opted-in
                    if (persistToken) {
                        put("jwtToken", jwtToken)
                        if (jwtTokenPool != null && jwtTokenPool.isNotEmpty()) {
                            put("jwtTokenPool", JSONArray(jwtTokenPool))
                            Log.w(TAG, "JWT token pool stored (size=${jwtTokenPool.size}, persistToken=true).")
                        } else {
                            put("jwtTokenPool", JSONArray())
                        }
                        Log.w(TAG, "JWT token stored in database (persistToken=true). " +
                                  "Database is NOT encrypted - token is stored in plain text.")
                    } else {
                        put("jwtToken", "") // Empty string - token not persisted
                        put("tokenNotPersisted", true) // Flag indicating token was not stored
                    }
                }.toString()
            } else {
                // Store valid but empty entry to prevent errors in code that reads this config
                JSONObject().apply {
                    put("apiBaseUrl", "")
                    put("activeDid", "")
                    put("jwtToken", "")
                    put("configureError", configureError?.message ?: "Unknown error")
                }.toString()
            }
            
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    val config = org.timesafari.dailynotification.entities.NotificationConfigEntity(
                        configId, null, "native_fetcher", "config", configValue, "json"
                    )
                    getDatabase().notificationConfigDao().insertConfig(config)
                    
                    if (configureSuccess) {
                        call.resolve()
                    } else {
                        // Configure failed but we stored a valid entry - reject with error details
                        call.reject("Native fetcher configure() failed: ${configureError?.message}")
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to store native fetcher config", e)
                    call.reject("Failed to store configuration: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Configure native fetcher error", e)
            call.reject("Configuration error: ${e.message}")
        }
    }

    /**
     * Optional JWT pool from `jwtTokens` (bridge array) or `jwtTokenPoolJson` (JSON array string).
     * Prefer `jwtTokens` when both are present. Empty array → null (same as omitting).
     */
    private fun parseJwtTokenPool(options: JSObject): List<String>? {
        val arr: JSONArray = when {
            options.has("jwtTokens") -> options.optJSONArray("jwtTokens")
            !options.optString("jwtTokenPoolJson", "").isNullOrBlank() ->
                JSONArray(options.getString("jwtTokenPoolJson"))
            else -> null
        } ?: return null
        if (arr.length() == 0) return null
        if (arr.length() > DailyNotificationConstants.JWT_TOKEN_POOL_MAX) {
            throw IllegalArgumentException(
                "jwtTokens must have at most ${DailyNotificationConstants.JWT_TOKEN_POOL_MAX} entries"
            )
        }
        val out = ArrayList<String>(arr.length())
        for (i in 0 until arr.length()) {
            if (arr.isNull(i)) continue
            val s = arr.optString(i)
            if (s.isNotEmpty()) {
                out.add(s)
            }
        }
        return if (out.isEmpty()) null else out
    }
    
    @PluginMethod
    fun getNotificationStatus(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                if (context == null) {
                    return@launch call.reject("Context not available")
                }
                
                val database = getDatabase()
                if (statusChecker == null) {
                    statusChecker = NotificationStatusChecker(context)
                }
                
                // Delegate to NotificationStatusChecker.getNotificationStatus()
                // (which internally uses NotificationStatusHelper)
                val result = statusChecker!!.getNotificationStatus(database)
                
                call.resolve(result)
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get notification status", e)
                call.reject("Failed to get notification status: ${e.message}")
            }
        }
    }
    
    /**
     * Cancel all scheduled notifications
     * 
     * This method:
     * 1. Cancels all AlarmManager alarms (both exact and inexact)
     * 2. Cancels all WorkManager prefetch jobs
     * 3. Clears notification schedules from database
     * 4. Updates plugin state to reflect cancellation
     * 
     * The method is idempotent - safe to call multiple times even if nothing is scheduled.
     */
    @PluginMethod
    fun cancelAllNotifications(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                if (context == null) {
                    return@launch call.reject("Context not available")
                }
                
                Log.i(TAG, "Cancelling all notifications")
                
                // 1. Get all scheduled notifications from database
                // Includes "static_reminder" (isolated kind, see PLAN-simplify-static-and-refactor.md
                // §3.1) alongside "notify" so cancelAllNotifications() genuinely cancels everything.
                val schedules = getDatabase().scheduleDao().getAll()
                val notifySchedules = schedules.filter {
                    (it.kind == "notify" || it.kind == "static_reminder") && it.enabled
                }

                // 2. Cancel all AlarmManager alarms (delegate to ScheduleHelper)
                // Only cancel alarms we can prove we scheduled (from database)
                // This prevents accidental cancellation of other alarms and false confidence
                // If alarms exist outside the database, they should be tracked or ignored
                val cancelledAlarms = ScheduleHelper.cancelAlarmsForSchedules(context, notifySchedules)
                
                if (cancelledAlarms > 0) {
                    Log.i(TAG, "Cancelled $cancelledAlarms alarm(s) from database schedules")
                } else {
                    Log.d(TAG, "No alarms found in database to cancel")
                }
                
                // 3. Cancel all WorkManager jobs (delegate to ScheduleHelper)
                val workCancelled = ScheduleHelper.cancelAllWorkManagerJobs(context)
                if (workCancelled) {
                    Log.i(TAG, "Cancelled all WorkManager jobs")
                } else {
                    Log.w(TAG, "Failed to cancel some WorkManager jobs, continuing with cleanup")
                }
                
                // 4. Clear database state - disable all notification and fetch schedules
                try {
                    // Delegate to ScheduleHelper
                    val disabledNotify = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "notify")
                    val disabledStaticReminder = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "static_reminder")
                    val disabledFetch = ScheduleHelper.disableAllSchedulesByKind(getDatabase(), "fetch")

                    Log.i(TAG, "Disabled $disabledNotify notification schedule(s), $disabledStaticReminder static reminder schedule(s), and $disabledFetch fetch schedule(s)")
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to clear database state", e)
                    // Continue - alarms and jobs are already cancelled
                }
                
                Log.i(TAG, "All notifications cancelled successfully")
                call.resolve()
                
            } catch (e: Exception) {
                Log.e(TAG, "Failed to cancel all notifications", e)
                call.reject("Failed to cancel notifications: ${e.message}")
            }
        }
    }

    /**
     * Add one-shot API-managed notifications at epoch-ms timestamps (additive; does not clear others).
     * Caller should invoke [clearApiNotifications] first when replacing a full batch.
     */
    @PluginMethod
    fun scheduleApiNotifications(call: PluginCall) {
        val timestampsArray = call.getArray("timestamps")
        if (timestampsArray == null) {
            call.reject("Missing timestamps")
            return
        }

        CoroutineScope(Dispatchers.IO).launch {
            try {
                if (context == null) {
                    return@launch call.reject("Context not available")
                }
                Log.i(TAG, "DNP-BATCH: scheduleApiNotifications — additive scheduling for ${timestampsArray.length()} timestamp(s)")

                var scheduledCount = 0
                for (i in 0 until timestampsArray.length()) {
                    val raw = timestampsArray.opt(i)
                    val epochMillis = when (raw) {
                        is Number -> raw.toLong()
                        else -> continue
                    }
                    if (ScheduleHelper.scheduleApiNotification(context!!, getDatabase(), epochMillis)) {
                        scheduledCount++
                    }
                }

                Log.i(TAG, "DNP-BATCH: scheduleApiNotifications done scheduled=$scheduledCount/${timestampsArray.length()}")
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to schedule notifications", e)
                call.reject("Failed to schedule notifications: ${e.message}")
            }
        }
    }

    /**
     * Cancel only API-managed notification schedules (`api_*`).
     * Does not affect Daily Reminders, user-created schedules, dual schedules, or fetch jobs.
     */
    @PluginMethod
    fun clearApiNotifications(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                if (context == null) {
                    return@launch call.reject("Context not available")
                }
                Log.i("DailyNotificationPlugin", "Clearing API-managed notifications")
                val cleared = ScheduleHelper.clearApiSchedules(context!!, getDatabase())
                Log.i("DailyNotificationPlugin", "Cleared $cleared API-managed schedule(s)")
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to clear API-managed notifications", e)
                call.reject("Failed to clear API-managed notifications: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun scheduleDailyReminder(call: PluginCall) {
        // Alias for scheduleDailyNotification for backward compatibility
        // This ensures both method names work the same way
        scheduleDailyNotification(call)
    }
    
    @PluginMethod
    fun cancelDailyReminder(call: PluginCall) {
        try {
            val reminderId = call.getString("reminderId")
                ?: call.getString("id")
                ?: call.getString("reminder_id")
                ?: call.getString("scheduleId")
            if (reminderId.isNullOrBlank()) {
                call.reject("cancelDailyReminder: missing reminderId")
                return
            }
            NotifyReceiver.cancelNotification(context, scheduleId = reminderId)
            // Pure single-row delete: now that static reminders are isolated to their own
            // "static_reminder" kind (see PLAN-simplify-static-and-refactor.md §3.1), there is
            // guaranteed to be at most one row to deal with, so cancel can fully remove it
            // ("cancel means gone") rather than leaving a disabled-but-present row that the
            // rearm chain could still find and resurrect. See plan §3.3 for the deleteById vs
            // setEnabled tradeoff discussion.
            try {
                kotlinx.coroutines.runBlocking {
                    val db = getDatabase()
                    db.scheduleDao().deleteById(reminderId)
                    try {
                        db.notificationContentDao().deleteNotification(reminderId)
                    } catch (contentErr: Exception) {
                        Log.w(TAG, "cancelDailyReminder: failed to delete notification content for $reminderId", contentErr)
                    }
                }
            } catch (dbErr: Exception) {
                Log.w(TAG, "cancelDailyReminder: failed DB delete for $reminderId", dbErr)
            }
            call.resolve()
        } catch (e: Exception) {
            Log.e(TAG, "cancelDailyReminder failed", e)
            call.reject("cancelDailyReminder failed: ${e.message}")
        }
    }
    
    /**
     * Check if exact alarms can be scheduled
     * Helper method for internal use
     * 
     * @param context Application context
     * @return true if exact alarms can be scheduled, false otherwise
     */
    /**
     * Check if exact alarms can be scheduled
     * Helper method for internal use
     * Delegates to PermissionManager for single source of truth
     * 
     * @param context Application context
     * @return true if exact alarms can be scheduled, false otherwise
     */
    private fun canScheduleExactAlarms(context: Context): Boolean {
        // Ensure permissionManager is initialized
        if (permissionManager == null) {
            if (channelManager == null) {
                channelManager = ChannelManager(context)
            }
            permissionManager = PermissionManager(context, channelManager)
        }
        
        // Use unified permission status
        val status = permissionManager!!.getPermissionStatus()
        return status.exactAlarmGranted
    }
    
    /**
     * Check if exact alarm permission can be requested
     * Delegates to PermissionManager.canRequestExactAlarmPermission()
     * 
     * @param context Application context
     * @return true if permission can be requested, false if permanently denied
     */
    private fun canRequestExactAlarmPermission(context: Context): Boolean {
        // Ensure permissionManager is initialized
        if (permissionManager == null) {
            if (channelManager == null) {
                channelManager = ChannelManager(context)
            }
            permissionManager = PermissionManager(context, channelManager)
        }
        
        // Delegate to PermissionManager.checkExactAlarmPermission and extract canRequest
        // Note: This is a synchronous check, so we need to check directly
        val canSchedule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
            alarmManager?.canScheduleExactAlarms() ?: false
        } else {
            true // Android 11 and below don't need this permission
        }
        
        // Check if permission can be requested (Android 13+)
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            // Try reflection to call Settings.canRequestScheduleExactAlarms()
            try {
                val method = Settings::class.java.getMethod("canRequestScheduleExactAlarms", Context::class.java)
                method.invoke(null, context) as Boolean
            } catch (e: Exception) {
                // Fallback heuristic: if exact alarms are not currently allowed, assume we can request them
                !canSchedule
            }
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
            // Android 12 (API 31-32) - permission can always be requested
            true
        } else {
            // Android 11 and below - permission not needed
            true
        }
    }
    
    /**
     * Check exact alarm permission status
     * Returns detailed information about permission status and whether it can be requested
     * 
     * @param call Plugin call with no parameters
     */
    @PluginMethod
    fun checkExactAlarmPermission(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure permissionManager is initialized
            if (permissionManager == null) {
                if (channelManager == null) {
                    channelManager = ChannelManager(context)
                }
                permissionManager = PermissionManager(context, channelManager)
            }
            
            // Delegate to PermissionManager.checkExactAlarmPermission()
            permissionManager!!.checkExactAlarmPermission(call)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to check exact alarm permission", e)
            call.reject("Permission check failed: ${e.message}")
        }
    }
    
    /**
     * Request exact alarm permission
     * Opens Settings intent to let user grant the permission
     * 
     * On Android 12+ (API 31+), SCHEDULE_EXACT_ALARM is a special permission that
     * cannot be requested through the normal permission request flow. Users must
     * grant it manually in Settings.
     * 
     * @param call Plugin call with no parameters
     */
    @PluginMethod
    fun requestExactAlarmPermission(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure permissionManager is initialized
            if (permissionManager == null) {
                if (channelManager == null) {
                    channelManager = ChannelManager(context)
                }
                permissionManager = PermissionManager(context, channelManager)
            }
            
            // Save the call for resume handling
            saveCall(call)
            
            // Create pending request token to track this exact alarm permission request
            val requestNonce = java.util.UUID.randomUUID().toString()
            pendingPermissionRequest = PendingPermissionRequest(
                requestNonce = requestNonce,
                requestType = PermissionRequestType.EXACT_ALARM,
                requestedAtMs = System.currentTimeMillis()
            )
            
            Log.d(TAG, "Created pending permission request: nonce=$requestNonce, type=EXACT_ALARM")
            
            // Delegate to PermissionManager.requestExactAlarmPermission()
            permissionManager!!.requestExactAlarmPermission(call)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to request exact alarm permission", e)
            call.reject("Permission request failed: ${e.message}")
        }
    }
    
    /**
     * Open exact alarm settings (legacy method, kept for backward compatibility)
     * Use requestExactAlarmPermission() for better error handling
     * 
     * @param call Plugin call with no parameters
     */
    @PluginMethod
    fun openExactAlarmSettings(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure permissionManager is initialized
            if (permissionManager == null) {
                if (channelManager == null) {
                    channelManager = ChannelManager(context)
                }
                permissionManager = PermissionManager(context, channelManager)
            }
            
            // Delegate to PermissionManager.openExactAlarmSettings()
            permissionManager!!.openExactAlarmSettings(call)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to open exact alarm settings", e)
            call.reject("Failed to open exact alarm settings: ${e.message}")
        }
    }
    
    @PluginMethod
    fun isChannelEnabled(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure channelManager is initialized
            if (channelManager == null) {
                channelManager = ChannelManager(context)
            }
            
            // Use the default channel ID (ChannelManager only supports default channel)
            val requestedChannelId = call.getString("channelId")
            val channelId = channelManager!!.getDefaultChannelId()
            
            if (requestedChannelId != null && requestedChannelId != channelId) {
                Log.w(TAG, "Requested channelId '$requestedChannelId' differs from default '$channelId', using default")
            }
            
            // Ensure channel exists (creates if needed)
            channelManager!!.ensureChannelExists()
            
            // Delegate to ChannelManager for channel status
            val channelEnabled = channelManager!!.isChannelEnabled()
            val importance = channelManager!!.getChannelImportance()
            
            // Check app-level notifications (this is app-level, not channel-level)
            val appNotificationsEnabled = NotificationManagerCompat.from(context).areNotificationsEnabled()
            
            // Final enabled state: both app and channel must be enabled
            val finalEnabled = appNotificationsEnabled && channelEnabled
            
            Log.i(TAG, "Channel status check complete: channelId=$channelId, appNotificationsEnabled=$appNotificationsEnabled, channelEnabled=$channelEnabled, importance=$importance, finalEnabled=$finalEnabled")
            
            val result = JSObject().apply {
                // Channel is enabled if both app notifications are enabled AND channel importance is not NONE
                put("enabled", finalEnabled as Boolean)
                put("channelId", channelId as String)
                put("importance", (if (importance >= 0) importance else android.app.NotificationManager.IMPORTANCE_DEFAULT) as Int)
                put("appNotificationsEnabled", appNotificationsEnabled as Boolean)
                put("channelBlocked", (importance == android.app.NotificationManager.IMPORTANCE_NONE) as Boolean)
            }
            call.resolve(result)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to check channel status", e)
            call.reject("Failed to check channel status: ${e.message}")
        }
    }
    
    @PluginMethod
    fun openChannelSettings(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Ensure channelManager is initialized
            if (channelManager == null) {
                channelManager = ChannelManager(context)
            }
            
            // Use the actual channel ID that matches what's used in notifications
            val channelId = call.getString("channelId") ?: channelManager!!.getDefaultChannelId()
            
            // Delegate to ChannelManager.openChannelSettings()
            val opened = channelManager!!.openChannelSettings(channelId)
            
            val result = JSObject().apply {
                put("opened", opened)
                put("channelId", channelId)
                if (!opened) {
                    put("error", "Failed to open channel settings")
                }
            }
            call.resolve(result)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to open channel settings", e)
            call.reject("Failed to open channel settings: ${e.message}")
        }
    }
    
    @PluginMethod
    fun checkStatus(call: PluginCall) {
        // Comprehensive status check - delegate to NotificationStatusChecker
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            if (statusChecker == null) {
                statusChecker = NotificationStatusChecker(context)
            }
            
            val result = statusChecker!!.getComprehensiveStatus()
            call.resolve(result)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to check status", e)
            call.reject("Failed to check status: ${e.message}")
        }
    }
    
    @PluginMethod
    fun scheduleContentFetch(call: PluginCall) {
        try {
            val configJson = call.getObject("config")
            val config = parseContentFetchConfig(configJson)
            
            Log.i(TAG, "Scheduling content fetch")
            
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    // Schedule WorkManager fetch
                    FetchWorker.scheduleFetch(context, config)
                    
                    // Store schedule in database
                    val schedule = Schedule(
                        id = "fetch_${System.currentTimeMillis()}",
                        kind = "fetch",
                        cron = config.schedule,
                        enabled = config.enabled,
                        nextRunAt = calculateNextRunTime(config.schedule)
                    )
                    getDatabase().scheduleDao().upsert(schedule)
                    
                    call.resolve()
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to schedule content fetch", e)
                    call.reject("Content fetch scheduling failed: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Schedule content fetch error", e)
            call.reject("Content fetch error: ${e.message}")
        }
    }
    
    @PluginMethod
    fun scheduleDailyNotification(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Do not open Settings or reject when exact alarms are not granted.
            // Proceed with scheduling; underlying layer uses inexact/windowed alarms when exact is unavailable.
            // Apps that want to prompt for exact alarm can use openExactAlarmSettings() or requestExactAlarmPermission().
            if (!canScheduleExactAlarms(context)) {
                Log.i(TAG, "Exact alarm permission not granted; scheduling will use inexact/windowed fallback.")
            }
            
            // Proceed with scheduling (exact when granted, otherwise inexact/windowed)
            // Capacitor passes the object directly via call.data
            val options = call.data ?: return call.reject("Options are required")
            
            val time = options.getString("time") ?: return call.reject("Time is required")
            val title = options.getString("title") ?: "Daily Notification"
            val body = options.getString("body") ?: ""
            val sound = options.getBoolean("sound") ?: true
            val priority = options.getString("priority") ?: "default"
            val url = options.getString("url") // Optional URL for prefetch (not used in helper yet)
            val rolloverIntervalMinutes = try {
                (options.getInt("rolloverIntervalMinutes") ?: 0).takeIf { it > 0 }
            } catch (_: Exception) {
                null
            }
            
            Log.i(TAG, "Scheduling daily notification: time=$time, title=$title, rolloverIntervalMinutes=$rolloverIntervalMinutes")
            
            // Convert HH:mm time to cron expression (daily at specified time)
            val cronExpression = convertTimeToCron(time)
            
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    // Use stable scheduleId for daily notifications to ensure "one per day" semantics
                    // If user provides an ID, use it; otherwise use stable "daily_notification"
                    val scheduleId = options.getString("id") ?: "daily_notification"
                    Log.i(TAG, "scheduleDailyNotification: START - time=$time, scheduleId=$scheduleId")

                    // No sweep/cleanup needed here: static reminders live in their own isolated
                    // "static_reminder" kind (see PLAN-simplify-static-and-refactor.md §3.1) and
                    // ScheduleHelper.scheduleDailyNotification() below does a direct upsert by the
                    // stable scheduleId, so there is nothing else in that kind to collide with.
                    // Static reminders also never enqueue prefetch/fetch WorkManager jobs, so there
                    // is nothing for cancelFetchRelatedWorkManagerJobs() to clean up either.

                    val config = UserNotificationConfig(
                        enabled = true,
                        schedule = cronExpression,
                        title = title,
                        body = body,
                        sound = sound,
                        vibration = true,
                        priority = priority
                    )
                    
                    // Delegate to ScheduleHelper
                    val success = ScheduleHelper.scheduleDailyNotification(
                        context,
                        getDatabase(),
                        scheduleId,
                        config,
                        time,
                        ::calculateNextRunTime,
                        rolloverIntervalMinutes
                    )
                    
                    if (success) {
                        call.resolve()
                    } else {
                        call.reject("Daily notification scheduling failed")
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to schedule daily notification", e)
                    call.reject("Daily notification scheduling failed: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Schedule daily notification error", e)
            call.reject("Daily notification error: ${e.message}")
        }
    }
    
    @PluginMethod
    fun scheduleUserNotification(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }

            if (!canScheduleExactAlarms(context)) {
                Log.i(TAG, "Exact alarm permission not granted; scheduling will use inexact/windowed fallback.")
            }

            val options = call.data ?: return call.reject("Options are required")

            val schedule = options.getString("schedule") ?: return call.reject("schedule (cron expression) is required")
            val title = options.getString("title") ?: "Daily Notification"
            val body = options.getString("body") ?: ""
            val sound = options.getBoolean("sound") ?: true
            val priority = options.getString("priority") ?: "default"

            Log.i(TAG, "Scheduling user notification: schedule=$schedule, title=$title")

            CoroutineScope(Dispatchers.IO).launch {
                try {
                    val config = UserNotificationConfig(
                        enabled = true,
                        schedule = schedule,
                        title = title,
                        body = body,
                        sound = sound,
                        vibration = true,
                        priority = priority
                    )

                    val scheduleId = ScheduleHelper.scheduleUserNotification(
                        context,
                        getDatabase(),
                        config,
                        ::calculateNextRunTime
                    )

                    if (scheduleId != null) {
                        call.resolve()
                    } else {
                        call.reject("User notification scheduling failed")
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to schedule user notification", e)
                    call.reject("User notification scheduling failed: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Schedule user notification error", e)
            call.reject("User notification error: ${e.message}")
        }
    }

    /**
     * Check if an alarm is scheduled for a given trigger time
     */
    @PluginMethod
    fun isAlarmScheduled(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            val options = call.data ?: return call.reject("Options are required")
            val triggerAtMillis = options.getLong("triggerAtMillis") ?: return call.reject("triggerAtMillis is required")
            
            // Initialize scheduler if needed (requires AlarmManager)
            if (scheduler == null) {
                val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
                    ?: return call.reject("AlarmManager not available")
                scheduler = DailyNotificationScheduler(context, alarmManager)
            }
            
            // Delegate to DailyNotificationScheduler.isScheduled()
            val isScheduled = scheduler!!.isScheduled(triggerAtMillis)
            
            val result = JSObject().apply {
                put("scheduled", isScheduled)
                put("triggerAtMillis", triggerAtMillis)
            }
            
            Log.i(TAG, "Checking alarm status: scheduled=$isScheduled, triggerAt=$triggerAtMillis")
            call.resolve(result)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to check alarm status", e)
            call.reject("Failed to check alarm status: ${e.message}")
        }
    }
    
    /**
     * Get the next scheduled alarm time from AlarmManager
     */
    @PluginMethod
    fun getNextAlarmTime(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Initialize scheduler if needed (requires AlarmManager)
            if (scheduler == null) {
                val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
                    ?: return call.reject("AlarmManager not available")
                scheduler = DailyNotificationScheduler(context, alarmManager)
            }
            
            // Delegate to DailyNotificationScheduler.getNextAlarmTime()
            val nextAlarmTime = scheduler!!.getNextAlarmTime()
            
            val result = JSObject().apply {
                if (nextAlarmTime != null) {
                    put("scheduled", true)
                    put("triggerAtMillis", nextAlarmTime)
                } else {
                    put("scheduled", false)
                }
            }
            
            Log.i(TAG, "Getting next alarm time: ${if (nextAlarmTime != null) nextAlarmTime else "none"}")
            call.resolve(result)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to get next alarm time", e)
            call.reject("Failed to get next alarm time: ${e.message}")
        }
    }
    
    /**
     * Test method: Schedule an alarm to fire in a few seconds
     * Useful for verifying alarm delivery works correctly
     */
    @PluginMethod
    fun testAlarm(call: PluginCall) {
        try {
            val options = call.data
            val secondsFromNow = options?.getInt("secondsFromNow") ?: 5
            
            val context = context ?: return call.reject("Context not available")
            
            // Initialize scheduler if needed (lazy initialization)
            if (scheduler == null) {
                val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as? AlarmManager
                    ?: return call.reject("AlarmManager not available")
                scheduler = DailyNotificationScheduler(context, alarmManager)
            }
            
            // Delegate to DailyNotificationScheduler.testAlarm()
            scheduler!!.testAlarm(secondsFromNow)
            
            val result = JSObject().apply {
                put("scheduled", true)
                put("secondsFromNow", secondsFromNow)
                put("triggerAtMillis", System.currentTimeMillis() + (secondsFromNow * 1000L))
            }
            
            call.resolve(result)
        } catch (e: Exception) {
            Log.e(TAG, "Failed to schedule test alarm", e)
            call.reject("Failed to schedule test alarm: ${e.message}")
        }
    }
    
    /**
     * Test method: Inject invalid data into database for recovery testing
     * 
     * This method is used by TEST 4 to verify that recovery handles invalid
     * data gracefully (empty IDs, null nextRunAt, etc.) without crashing.
     * 
     * @param call Plugin call with optional parameters:
     *   - injectEmptyScheduleId: boolean (default: true) - inject schedule with empty ID
     *   - injectNullNextRunAt: boolean (default: true) - inject schedule with null nextRunAt
     *   - injectEmptyNotificationId: boolean (default: true) - inject notification with empty ID
     */
    @PluginMethod
    fun injectInvalidTestData(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.data
                val injectEmptyScheduleId = options?.getBoolean("injectEmptyScheduleId") ?: true
                val injectNullNextRunAt = options?.getBoolean("injectNullNextRunAt") ?: true
                val injectEmptyNotificationId = options?.getBoolean("injectEmptyNotificationId") ?: true
                
                val db = getDatabase()
                val injected = mutableListOf<String>()
                
                // Delegate schedule injection to TestDataHelper
                val scheduleInjected = TestDataHelper.injectInvalidScheduleData(
                    database = db,
                    injectEmptyScheduleId = injectEmptyScheduleId,
                    injectNullNextRunAt = injectNullNextRunAt
                )
                injected.addAll(scheduleInjected)
                
                // Inject notification with empty ID (if requested)
                // Note: Room's @NonNull constraint may prevent this, but we try anyway
                if (injectEmptyNotificationId) {
                    val notificationInjected = TestDataHelper.injectInvalidNotificationData(db)
                    if (notificationInjected) {
                        injected.add("empty_notification_id")
                        Log.i(TAG, "TEST: Injected notification with empty ID")
                    } else {
                        Log.w(TAG, "TEST: Failed to inject empty notification ID (Room @NonNull constraint may prevent this)")
                        Log.i(TAG, "TEST: Other invalid data types (null nextRunAt, empty schedule ID) will still test recovery")
                    }
                }
                
                val result = JSObject().apply {
                    put("success", true)
                    put("injected", JSONArray(injected))
                    put("message", "Invalid test data injected: ${injected.joinToString(", ")}")
                }
                
                call.resolve(result)
            } catch (e: Exception) {
                Log.e(TAG, "Failed to inject invalid test data", e)
                call.reject("Failed to inject invalid test data: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun scheduleDualNotification(call: PluginCall) {
        try {
            if (context == null) {
                return call.reject("Context not available")
            }
            
            // Check if exact alarms can be scheduled
            if (!canScheduleExactAlarms(context)) {
                // Delegate to PermissionManager to handle exact alarm permission request
                val activity = activity
                if (activity == null) {
                    return call.reject("Activity not available")
                }
                permissionManager!!.requestExactAlarmPermission(call)
                return
            }
            
            // Permission granted - proceed with scheduling
            val configJson = call.getObject("config") ?: return call.reject("Config is required")
            val contentFetchObj = configJson.getJSObject("contentFetch") ?: return call.reject("contentFetch config is required")
            val userNotificationObj = configJson.getJSObject("userNotification") ?: return call.reject("userNotification config is required")
            val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
            val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
            
            Log.i(TAG, "Scheduling dual notification")
            
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    val success = ScheduleHelper.scheduleDualNotification(
                        context,
                        getDatabase(),
                        contentFetchConfig,
                        userNotificationConfig,
                        ::calculateNextRunTime
                    )
                    
                    if (success) {
                        saveDualScheduleConfig(context!!, configJson)
                        call.resolve()
                    } else {
                        call.reject("Dual notification scheduling failed")
                    }
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to schedule dual notification", e)
                    call.reject("Dual notification scheduling failed: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Schedule dual notification error", e)
            call.reject("Dual notification error: ${e.message}")
        }
    }
    
    private fun saveDualScheduleConfig(context: Context, configJson: JSObject) {
        try {
            val str = configJson.toString()
            if (str.isNotEmpty()) {
                context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
                    .edit()
                    .putString(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY, str)
                    .apply()
            }
        } catch (e: Exception) {
            Log.w(TAG, "saveDualScheduleConfig failed", e)
        }
    }
    
    private fun clearDualScheduleConfig(context: Context) {
        context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
            .edit()
            .remove(DailyNotificationConstants.DUAL_SCHEDULE_CONFIG_KEY)
            .remove(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY)
            .apply()
    }
    
    @PluginMethod
    fun getDualScheduleStatus(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val enabledSchedules = getDatabase().scheduleDao().getEnabled()
                val latestCache = getDatabase().contentCacheDao().getLatestByScope(ContentCacheScope.DUAL)
                val recentHistory = getDatabase().historyDao().getSince(System.currentTimeMillis() - (24 * 60 * 60 * 1000L))
                
                val status = JSObject().apply {
                    put("nextRuns", enabledSchedules.map { it.nextRunAt })
                    put("lastOutcomes", recentHistory.map { it.outcome })
                    put("cacheAgeMs", latestCache?.let { System.currentTimeMillis() - it.fetchedAt })
                    put("staleArmed", latestCache?.let { 
                        System.currentTimeMillis() > (it.fetchedAt + it.ttlSeconds * 1000L) 
                    } ?: true)
                    put("queueDepth", recentHistory.size)
                }
                
                call.resolve(status)
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get dual schedule status", e)
                call.reject("Status retrieval failed: ${e.message}")
            }
        }
    }

    @PluginMethod
    fun cancelDualSchedule(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                if (context == null) {
                    call.reject("Context not available")
                    return@launch
                }
                val ctx = context!!
                val db = getDatabase()
                ScheduleHelper.cancelDualSchedule(ctx, db)
                WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
                clearDualScheduleConfig(ctx)
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "cancelDualSchedule failed", e)
                call.reject("Cancel dual schedule failed: ${e.message}")
            }
        }
    }

    @PluginMethod
    fun updateDualScheduleConfig(call: PluginCall) {
        val configJson = call.getObject("config") ?: run {
            call.reject("Config is required")
            return
        }
        val contentFetchObj = configJson.getJSObject("contentFetch") ?: run {
            call.reject("contentFetch config is required")
            return
        }
        val userNotificationObj = configJson.getJSObject("userNotification") ?: run {
            call.reject("userNotification config is required")
            return
        }
        CoroutineScope(Dispatchers.IO).launch {
            try {
                if (context == null) {
                    call.reject("Context not available")
                    return@launch
                }
                val ctx = context!!
                val db = getDatabase()
                ScheduleHelper.cancelDualSchedule(ctx, db)
                WorkManager.getInstance(ctx).cancelUniqueWork(FetchWorker.WORK_NAME_DUAL)
                val contentFetchConfig = parseContentFetchConfig(contentFetchObj)
                val userNotificationConfig = parseUserNotificationConfig(userNotificationObj)
                val success = ScheduleHelper.scheduleDualNotification(
                    ctx,
                    db,
                    contentFetchConfig,
                    userNotificationConfig,
                    ::calculateNextRunTime
                )
                if (success) {
                    saveDualScheduleConfig(ctx, configJson)
                    call.resolve()
                } else {
                    call.reject("Update dual schedule failed")
                }
            } catch (e: Exception) {
                Log.e(TAG, "updateDualScheduleConfig failed", e)
                call.reject("Update dual schedule failed: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun registerCallback(call: PluginCall) {
        try {
            val name = call.getString("name") ?: return call.reject("Callback name is required")
            val callback = call.getObject("callback") ?: return call.reject("Callback data is required")
            
            Log.i(TAG, "Registering callback: $name")
            
            CoroutineScope(Dispatchers.IO).launch {
                try {
                    val callbackRecord = Callback(
                        id = name,
                        kind = callback.getString("kind") ?: "local",
                        target = callback.getString("target") ?: "",
                        headersJson = callback.getString("headers"),
                        enabled = true,
                        createdAt = System.currentTimeMillis()
                    )
                    
                    // Delegate to CallbackHelper
                    CallbackHelper.registerCallback(getDatabase(), callbackRecord)
                    call.resolve()
                } catch (e: Exception) {
                    Log.e(TAG, "Failed to register callback", e)
                    call.reject("Callback registration failed: ${e.message}")
                }
            }
        } catch (e: Exception) {
            Log.e(TAG, "Register callback error", e)
            call.reject("Callback registration error: ${e.message}")
        }
    }
    
    @PluginMethod
    fun getContentCache(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val database = getDatabase()
                
                // Delegate to ContentCacheHelper
                val latestCache = ContentCacheHelper.getLatest(database)
                
                val result = JSObject()
                
                if (latestCache != null) {
                    result.put("id", latestCache.id)
                    result.put("fetchedAt", latestCache.fetchedAt)
                    result.put("ttlSeconds", latestCache.ttlSeconds)
                    result.put("payload", String(latestCache.payload))
                    result.put("meta", latestCache.meta)
                }
                
                call.resolve(result)
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get content cache", e)
                call.reject("Content cache retrieval failed: ${e.message}")
            }
        }
    }
    
    // ============================================================================
    // DATABASE ACCESS METHODS
    // ============================================================================
    // These methods provide TypeScript/JavaScript access to the plugin's internal
    // SQLite database. All operations run on background threads for thread safety.
    // ============================================================================
    
    // SCHEDULES MANAGEMENT
    
    @PluginMethod
    fun getSchedules(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val kind = options?.getString("kind")
                val enabled = options?.getBoolean("enabled")
                
                val schedules = when {
                    kind != null && enabled != null -> 
                        getDatabase().scheduleDao().getByKindAndEnabled(kind, enabled)
                        kind != null -> 
                            getDatabase().scheduleDao().getByKind(kind)
                        enabled != null -> 
                            if (enabled) getDatabase().scheduleDao().getEnabled() else getDatabase().scheduleDao().getAll().filter { !it.enabled }
                        else -> 
                            getDatabase().scheduleDao().getAll()
                }
                
                // Return array wrapped in JSObject - Capacitor will serialize correctly
                val schedulesArray = org.json.JSONArray()
                schedules.forEach { schedulesArray.put(scheduleToJson(it)) }
                
                call.resolve(JSObject().apply {
                    put("schedules", schedulesArray)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get schedules", e)
                call.reject("Failed to get schedules: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun getSchedule(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Schedule ID is required")
                
                val schedule = getDatabase().scheduleDao().getById(id)
                
                if (schedule != null) {
                    call.resolve(scheduleToJson(schedule))
                } else {
                    call.resolve(JSObject().apply { put("schedule", null) })
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get schedule", e)
                call.reject("Failed to get schedule: ${e.message}")
            }
        }
    }
    
    /**
     * Get all schedules with their AlarmManager status
     * Returns schedules from database with isActuallyScheduled flag for each
     */
    @PluginMethod
    fun getSchedulesWithStatus(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val kind = options?.getString("kind")
                val enabled = options?.getBoolean("enabled")
                
                val context = context ?: return@launch call.reject("Context not available")
                
                // Get schedules from database (same logic as getSchedules())
                val schedules = when {
                    kind != null && enabled != null -> 
                        getDatabase().scheduleDao().getByKindAndEnabled(kind, enabled)
                    kind != null -> 
                        getDatabase().scheduleDao().getByKind(kind)
                    enabled != null -> 
                        if (enabled) getDatabase().scheduleDao().getEnabled() else getDatabase().scheduleDao().getAll().filter { !it.enabled }
                    else -> 
                        getDatabase().scheduleDao().getAll()
                }
                
                // Delegate to ScheduleHelper to combine with AlarmManager status
                val schedulesArray = ScheduleHelper.getSchedulesWithStatus(context, schedules) { schedule ->
                    scheduleToJson(schedule)
                }
                
                call.resolve(JSObject().apply {
                    put("schedules", schedulesArray)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get schedules with status", e)
                call.reject("Failed to get schedules with status: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun createSchedule(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val scheduleJson = call.getObject("schedule")
                    ?: return@launch call.reject("Schedule data is required")
                
                val kindStr = scheduleJson.getString("kind") ?: return@launch call.reject("Schedule kind is required")
                val id = scheduleJson.getString("id") ?: "${kindStr}_${System.currentTimeMillis()}"
                val schedule = Schedule(
                    id = id,
                    kind = kindStr,
                    cron = scheduleJson.getString("cron"),
                    clockTime = scheduleJson.getString("clockTime"),
                    enabled = scheduleJson.getBoolean("enabled") ?: true,
                    jitterMs = scheduleJson.getInt("jitterMs") ?: 0,
                    backoffPolicy = scheduleJson.getString("backoffPolicy") ?: "exp",
                    stateJson = scheduleJson.getString("stateJson")
                )
                
                // Delegate to ScheduleHelper
                val created = ScheduleHelper.createSchedule(getDatabase(), schedule)
                call.resolve(scheduleToJson(created))
            } catch (e: Exception) {
                Log.e(TAG, "Failed to create schedule", e)
                call.reject("Failed to create schedule: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun updateSchedule(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Schedule ID is required")
                
                val updates = call.getObject("updates")
                    ?: return@launch call.reject("Updates are required")
                
                // Delegate to ScheduleHelper
                val updated = ScheduleHelper.updateSchedule(
                    database = getDatabase(),
                    id = id,
                    enabled = updates.getBoolean("enabled")?.let { it },
                    cron = updates.getString("cron"),
                    clockTime = updates.getString("clockTime"),
                    jitterMs = updates.getInt("jitterMs")?.let { it },
                    backoffPolicy = updates.getString("backoffPolicy"),
                    stateJson = updates.getString("stateJson"),
                    lastRunAt = updates.getLong("lastRunAt"),
                    nextRunAt = updates.getLong("nextRunAt")
                )
                
                call.resolve(scheduleToJson(updated))
            } catch (e: Exception) {
                Log.e(TAG, "Failed to update schedule", e)
                call.reject("Failed to update schedule: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun deleteSchedule(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Schedule ID is required")
                
                // Delegate to ScheduleHelper
                ScheduleHelper.deleteSchedule(getDatabase(), id)
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to delete schedule", e)
                call.reject("Failed to delete schedule: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun enableSchedule(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Schedule ID is required")
                
                val enabled = call.getBoolean("enabled") ?: true
                
                // Delegate to ScheduleHelper
                ScheduleHelper.enableSchedule(getDatabase(), id, enabled)
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to enable/disable schedule", e)
                call.reject("Failed to update schedule: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun calculateNextRunTime(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val schedule = call.getString("schedule")
                    ?: return@launch call.reject("Schedule expression is required")
                
                val nextRun = calculateNextRunTime(schedule)
                
                call.resolve(JSObject().apply {
                    put("nextRunAt", nextRun)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to calculate next run time", e)
                call.reject("Failed to calculate next run time: ${e.message}")
            }
        }
    }
    
    // CONTENT CACHE MANAGEMENT
    
    @PluginMethod
    fun getContentCacheById(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val id = options?.getString("id")
                
                val cache = if (id != null) {
                    getDatabase().contentCacheDao().getById(id)
                } else {
                    getDatabase().contentCacheDao().getLatest()
                }
                
                if (cache != null) {
                    call.resolve(contentCacheToJson(cache))
                } else {
                    call.resolve(JSObject().apply { put("contentCache", null) })
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get content cache", e)
                call.reject("Failed to get content cache: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun getLatestContentCache(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val cache = getDatabase().contentCacheDao().getLatest()
                
                if (cache != null) {
                    call.resolve(contentCacheToJson(cache))
                } else {
                    call.resolve(JSObject().apply { put("contentCache", null) })
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get latest content cache", e)
                call.reject("Failed to get latest content cache: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun getContentCacheHistory(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val limit = call.getInt("limit") ?: 10
                
                val history = getDatabase().contentCacheDao().getHistory(limit)
                
                val historyArray = org.json.JSONArray()
                history.forEach { historyArray.put(contentCacheToJson(it)) }
                
                call.resolve(JSObject().apply {
                    put("history", historyArray)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get content cache history", e)
                call.reject("Failed to get content cache history: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun saveContentCache(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val contentJson = call.getObject("content")
                    ?: return@launch call.reject("Content data is required")
                
                val id = contentJson.getString("id") ?: "cache_${System.currentTimeMillis()}"
                val payload = contentJson.getString("payload")
                    ?: return@launch call.reject("Payload is required")
                val ttlSeconds = contentJson.getInt("ttlSeconds")
                    ?: return@launch call.reject("TTL seconds is required")
                
                val scope = contentJson.getString("cacheScope")?.takeIf { it.isNotEmpty() }
                    ?: ContentCacheScope.LEGACY
                val cache = ContentCache(
                    id = id,
                    fetchedAt = System.currentTimeMillis(),
                    ttlSeconds = ttlSeconds,
                    payload = payload.toByteArray(),
                    meta = contentJson.getString("meta"),
                    cacheScope = scope
                )
                
                getDatabase().contentCacheDao().upsert(cache)
                call.resolve(contentCacheToJson(cache))
            } catch (e: Exception) {
                Log.e(TAG, "Failed to save content cache", e)
                call.reject("Failed to save content cache: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun clearContentCacheEntries(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val olderThan = options?.getLong("olderThan")
                
                if (olderThan != null) {
                    getDatabase().contentCacheDao().deleteOlderThan(olderThan)
                } else {
                    getDatabase().contentCacheDao().deleteAll()
                }
                
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to clear content cache", e)
                call.reject("Failed to clear content cache: ${e.message}")
            }
        }
    }
    
    // CALLBACKS MANAGEMENT
    
    @PluginMethod
    fun getCallbacks(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val enabled = options?.getBoolean("enabled")
                
                val callbacks = if (enabled != null) {
                    getDatabase().callbackDao().getByEnabled(enabled)
                } else {
                    getDatabase().callbackDao().getAll()
                }
                
                val callbacksArray = org.json.JSONArray()
                callbacks.forEach { callbacksArray.put(callbackToJson(it)) }
                
                call.resolve(JSObject().apply {
                    put("callbacks", callbacksArray)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get callbacks", e)
                call.reject("Failed to get callbacks: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun getCallback(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Callback ID is required")
                
                val callback = getDatabase().callbackDao().getById(id)
                
                if (callback != null) {
                    call.resolve(callbackToJson(callback))
                } else {
                    call.resolve(JSObject().apply { put("callback", null) })
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get callback", e)
                call.reject("Failed to get callback: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun registerCallbackConfig(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val callbackJson = call.getObject("callback")
                    ?: return@launch call.reject("Callback data is required")
                
                val id = callbackJson.getString("id")
                    ?: return@launch call.reject("Callback ID is required")
                val kindStr = callbackJson.getString("kind")
                    ?: return@launch call.reject("Callback kind is required")
                val targetStr = callbackJson.getString("target")
                    ?: return@launch call.reject("Callback target is required")
                
                val callback = Callback(
                    id = id,
                    kind = kindStr,
                    target = targetStr,
                    headersJson = callbackJson.getString("headersJson"),
                    enabled = callbackJson.getBoolean("enabled") ?: true,
                    createdAt = System.currentTimeMillis()
                )
                
                getDatabase().callbackDao().upsert(callback)
                call.resolve(callbackToJson(callback))
            } catch (e: Exception) {
                Log.e(TAG, "Failed to register callback", e)
                call.reject("Failed to register callback: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun updateCallback(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Callback ID is required")
                
                val updates = call.getObject("updates")
                    ?: return@launch call.reject("Updates are required")
                
                getDatabase().callbackDao().update(
                    id = id,
                    kind = updates.getString("kind"),
                    target = updates.getString("target"),
                    headersJson = updates.getString("headersJson"),
                    enabled = updates.getBoolean("enabled")?.let { it }
                )
                
                val updated = getDatabase().callbackDao().getById(id)
                if (updated != null) {
                    call.resolve(callbackToJson(updated))
                } else {
                    call.reject("Callback not found after update")
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to update callback", e)
                call.reject("Failed to update callback: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun deleteCallback(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Callback ID is required")
                
                getDatabase().callbackDao().deleteById(id)
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to delete callback", e)
                call.reject("Failed to delete callback: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun enableCallback(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val id = call.getString("id")
                    ?: return@launch call.reject("Callback ID is required")
                
                val enabled = call.getBoolean("enabled") ?: true
                
                getDatabase().callbackDao().setEnabled(id, enabled)
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to enable/disable callback", e)
                call.reject("Failed to update callback: ${e.message}")
            }
        }
    }
    
    // HISTORY MANAGEMENT
    
    @PluginMethod
    fun getHistory(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val since = options?.getLong("since")
                val kind = options?.getString("kind")
                val limit = options?.getInt("limit") ?: 50
                
                val history = when {
                    since != null && kind != null -> 
                        getDatabase().historyDao().getSinceByKind(since, kind, limit)
                    since != null -> 
                        getDatabase().historyDao().getSince(since).take(limit)
                    else -> 
                        getDatabase().historyDao().getRecent(limit)
                }
                
                val historyArray = org.json.JSONArray()
                history.forEach { historyArray.put(historyToJson(it)) }
                
                call.resolve(JSObject().apply {
                    put("history", historyArray)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get history", e)
                call.reject("Failed to get history: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun getHistoryStats(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val allHistory = getDatabase().historyDao().getRecent(Int.MAX_VALUE)
                
                val outcomes = mutableMapOf<String, Int>()
                val kinds = mutableMapOf<String, Int>()
                var mostRecent: Long? = null
                var oldest: Long? = null
                
                allHistory.forEach { entry ->
                    outcomes[entry.outcome] = (outcomes[entry.outcome] ?: 0) + 1
                    kinds[entry.kind] = (kinds[entry.kind] ?: 0) + 1
                    
                    if (mostRecent == null || entry.occurredAt > mostRecent!!) {
                        mostRecent = entry.occurredAt
                    }
                    if (oldest == null || entry.occurredAt < oldest!!) {
                        oldest = entry.occurredAt
                    }
                }
                
                call.resolve(JSObject().apply {
                    put("totalCount", allHistory.size)
                    put("outcomes", JSObject().apply {
                        outcomes.forEach { (k, v) -> put(k, v) }
                    })
                    put("kinds", JSObject().apply {
                        kinds.forEach { (k, v) -> put(k, v) }
                    })
                    put("mostRecent", mostRecent)
                    put("oldest", oldest)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get history stats", e)
                call.reject("Failed to get history stats: ${e.message}")
            }
        }
    }
    
    // CONFIGURATION MANAGEMENT
    
    @PluginMethod
    fun getConfig(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val key = call.getString("key")
                    ?: return@launch call.reject("Config key is required")
                
                val options = call.getObject("options")
                val timesafariDid = options?.getString("timesafariDid")
                
                Log.d(TAG, "DNP-CONFIG: Loading config from database: key=$key, timesafariDid=${timesafariDid?.take(20)}...")
                
                val entity = if (timesafariDid != null) {
                    getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
                } else {
                    getDatabase().notificationConfigDao().getConfigByKey(key)
                }
                
                if (entity != null) {
                    Log.i(TAG, "DNP-CONFIG: Configuration restored from database: key=$key, configType=${entity.configType}, hasValue=${entity.configValue.isNotEmpty()}")
                    call.resolve(configToJson(entity))
                } else {
                    Log.d(TAG, "DNP-CONFIG: Configuration not found in database: key=$key")
                    call.resolve(JSObject().apply { put("config", null) })
                }
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get config", e)
                call.reject("Failed to get config: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun getAllConfigs(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val options = call.getObject("options")
                val timesafariDid = options?.getString("timesafariDid")
                val configType = options?.getString("configType")
                
                val configs = when {
                    timesafariDid != null && configType != null -> {
                        getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid)
                            .filter { it.configType == configType }
                    }
                    timesafariDid != null -> {
                        getDatabase().notificationConfigDao().getConfigsByTimeSafariDid(timesafariDid)
                    }
                    configType != null -> {
                        getDatabase().notificationConfigDao().getConfigsByType(configType)
                    }
                    else -> {
                        getDatabase().notificationConfigDao().getAllConfigs()
                    }
                }
                
                val configsArray = org.json.JSONArray()
                configs.forEach { configsArray.put(configToJson(it)) }
                
                call.resolve(JSObject().apply {
                    put("configs", configsArray)
                })
            } catch (e: Exception) {
                Log.e(TAG, "Failed to get all configs", e)
                call.reject("Failed to get configs: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun setConfig(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val configJson = call.getObject("config")
                    ?: return@launch call.reject("Config data is required")
                
                val id = configJson.getString("id") ?: "config_${System.currentTimeMillis()}"
                val timesafariDid = configJson.getString("timesafariDid")
                val configType = configJson.getString("configType")
                    ?: return@launch call.reject("Config type is required")
                val configKey = configJson.getString("configKey")
                    ?: return@launch call.reject("Config key is required")
                val configValue = configJson.getString("configValue")
                    ?: return@launch call.reject("Config value is required")
                val configDataType = configJson.getString("configDataType", "string")
                
                val entity = org.timesafari.dailynotification.entities.NotificationConfigEntity(
                    id, timesafariDid, configType, configKey, configValue, configDataType
                )
                
                // Set optional fields
                configJson.getString("metadata")?.let { entity.metadata = it }
                configJson.getBoolean("isEncrypted", false)?.let { 
                    entity.isEncrypted = it
                    configJson.getString("encryptionKeyId")?.let { entity.encryptionKeyId = it }
                }
                configJson.getLong("ttlSeconds")?.let { entity.ttlSeconds = it }
                configJson.getBoolean("isActive", true)?.let { entity.isActive = it }
                
                getDatabase().notificationConfigDao().insertConfig(entity)
                call.resolve(configToJson(entity))
            } catch (e: Exception) {
                Log.e(TAG, "Failed to set config", e)
                call.reject("Failed to set config: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun updateConfig(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val key = call.getString("key")
                    ?: return@launch call.reject("Config key is required")
                val value = call.getString("value")
                    ?: return@launch call.reject("Config value is required")
                
                val options = call.getObject("options")
                val timesafariDid = options?.getString("timesafariDid")
                
                val entity = if (timesafariDid != null) {
                    getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
                } else {
                    getDatabase().notificationConfigDao().getConfigByKey(key)
                }
                
                if (entity == null) {
                    return@launch call.reject("Config not found")
                }
                
                entity.updateValue(value)
                getDatabase().notificationConfigDao().updateConfig(entity)
                call.resolve(configToJson(entity))
            } catch (e: Exception) {
                Log.e(TAG, "Failed to update config", e)
                call.reject("Failed to update config: ${e.message}")
            }
        }
    }
    
    @PluginMethod
    fun deleteConfig(call: PluginCall) {
        CoroutineScope(Dispatchers.IO).launch {
            try {
                val key = call.getString("key")
                    ?: return@launch call.reject("Config key is required")
                
                val options = call.getObject("options")
                val timesafariDid = options?.getString("timesafariDid")
                
                val entity = if (timesafariDid != null) {
                    getDatabase().notificationConfigDao().getConfigByKeyAndDid(key, timesafariDid)
                } else {
                    getDatabase().notificationConfigDao().getConfigByKey(key)
                }
                
                if (entity == null) {
                    return@launch call.reject("Config not found")
                }
                
                getDatabase().notificationConfigDao().deleteConfig(entity.id)
                call.resolve()
            } catch (e: Exception) {
                Log.e(TAG, "Failed to delete config", e)
                call.reject("Failed to delete config: ${e.message}")
            }
        }
    }
    
    // Helper methods to convert entities to JSON
    private fun scheduleToJson(schedule: Schedule): JSObject {
        return JSObject().apply {
            put("id", schedule.id)
            put("kind", schedule.kind)
            put("cron", schedule.cron)
            put("clockTime", schedule.clockTime)
            put("enabled", schedule.enabled)
            put("lastRunAt", schedule.lastRunAt)
            put("nextRunAt", schedule.nextRunAt)
            put("jitterMs", schedule.jitterMs)
            put("backoffPolicy", schedule.backoffPolicy)
            put("stateJson", schedule.stateJson)
        }
    }
    
    private fun contentCacheToJson(cache: ContentCache): JSObject {
        return JSObject().apply {
            put("id", cache.id)
            put("fetchedAt", cache.fetchedAt)
            put("ttlSeconds", cache.ttlSeconds)
            put("payload", String(cache.payload))
            put("meta", cache.meta)
            put("cacheScope", cache.cacheScope)
        }
    }
    
    private fun callbackToJson(callback: Callback): JSObject {
        return JSObject().apply {
            put("id", callback.id)
            put("kind", callback.kind)
            put("target", callback.target)
            put("headersJson", callback.headersJson)
            put("enabled", callback.enabled)
            put("createdAt", callback.createdAt)
        }
    }
    
    private fun historyToJson(history: History): JSObject {
        return JSObject().apply {
            put("id", history.id)
            put("refId", history.refId)
            put("kind", history.kind)
            put("occurredAt", history.occurredAt)
            put("durationMs", history.durationMs)
            put("outcome", history.outcome)
            put("diagJson", history.diagJson)
        }
    }
    
    private fun configToJson(config: org.timesafari.dailynotification.entities.NotificationConfigEntity): JSObject {
        return JSObject().apply {
            put("id", config.id)
            put("timesafariDid", config.timesafariDid)
            put("configType", config.configType)
            put("configKey", config.configKey)
            put("configValue", config.configValue)
            put("configDataType", config.configDataType)
            put("isEncrypted", config.isEncrypted)
            put("encryptionKeyId", config.encryptionKeyId)
            put("createdAt", config.createdAt)
            put("updatedAt", config.updatedAt)
            put("ttlSeconds", config.ttlSeconds)
            put("isActive", config.isActive)
            put("metadata", config.metadata)
        }
    }
    
    // Helper methods
    /**
     * Optional int from JSON: absent or JSON-null → null (aligns with TS `ContentFetchConfig` optional fields).
     * [FetchWorker] applies defaults (e.g. 30000 / 3 / 1000) when null.
     */
    private fun JSObject.optIntOrNull(key: String): Int? =
        if (has(key) && !isNull(key)) optInt(key) else null

    /** Optional boolean: absent or JSON-null → null (aligns with TS optional fields). */
    private fun JSObject.optBooleanOrNull(key: String): Boolean? =
        if (has(key) && !isNull(key)) optBoolean(key) else null

    /** Optional string: absent or JSON-null → null. Present empty string is preserved. */
    private fun JSObject.optStringOrNull(key: String): String? =
        if (has(key) && !isNull(key)) optString(key) else null

    private fun parseContentFetchConfig(configJson: JSObject): ContentFetchConfig {
        val callbacksObj = configJson.getJSObject("callbacks")
        return ContentFetchConfig(
            enabled = configJson.getBoolean("enabled") ?: true,
            schedule = configJson.getString("schedule") ?: "0 9 * * *",
            url = configJson.getString("url"),
            timeout = configJson.optIntOrNull("timeout"),
            retryAttempts = configJson.optIntOrNull("retryAttempts"),
            retryDelay = configJson.optIntOrNull("retryDelay"),
            callbacks = CallbackConfig(
                apiService = callbacksObj?.getString("apiService"),
                database = callbacksObj?.getString("database"),
                reporting = callbacksObj?.getString("reporting")
            )
        )
    }
    
    private fun parseUserNotificationConfig(configJson: JSObject): UserNotificationConfig {
        return UserNotificationConfig(
            enabled = configJson.getBoolean("enabled") ?: true,
            schedule = configJson.getString("schedule") ?: "0 9 * * *",
            title = configJson.optStringOrNull("title"),
            body = configJson.optStringOrNull("body"),
            sound = configJson.optBooleanOrNull("sound"),
            vibration = configJson.optBooleanOrNull("vibration"),
            priority = configJson.optStringOrNull("priority")
        )
    }
    
    private fun calculateNextRunTime(schedule: String): Long {
        return ScheduleCronUtils.calculateNextRunTimeMillis(schedule)
    }
    
    /**
     * Convert HH:mm time string to cron expression (daily at specified time)
     * Example: "09:30" -> "30 9 * * *"
     */
    private fun convertTimeToCron(time: String): String {
        try {
            val parts = time.split(":")
            if (parts.size != 2) {
                throw IllegalArgumentException("Invalid time format: $time. Expected HH:mm")
            }
            val hour = parts[0].toInt()
            val minute = parts[1].toInt()
            
            if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
                throw IllegalArgumentException("Invalid time values: hour=$hour, minute=$minute")
            }
            
            // Cron format: minute hour day month day-of-week
            // Daily at specified time: "minute hour * * *"
            return "$minute $hour * * *"
        } catch (e: Exception) {
            Log.e(TAG, "Failed to convert time to cron: $time", e)
            // Default to 9:00 AM if conversion fails
            return "0 9 * * *"
        }
    }
}

/**
 * Helper object for test data operations
 * Provides functions for injecting test data into the database
 */
object TestDataHelper {
    /**
     * Inject invalid schedule data for recovery testing
     * 
     * @param database Database instance
     * @param injectEmptyScheduleId Whether to inject schedule with empty ID
     * @param injectNullNextRunAt Whether to inject schedule with null nextRunAt
     * @return List of injected test data types
     */
    suspend fun injectInvalidScheduleData(
        database: DailyNotificationDatabase,
        injectEmptyScheduleId: Boolean = true,
        injectNullNextRunAt: Boolean = true
    ): List<String> {
        val injected = mutableListOf<String>()
        
        // Inject schedule with empty ID
        if (injectEmptyScheduleId) {
            try {
                val invalidSchedule = Schedule(
                    id = "",  // Empty ID - should be skipped by recovery
                    kind = "notify",
                    cron = "0 9 * * *",
                    clockTime = "09:00",
                    enabled = true,
                    nextRunAt = System.currentTimeMillis() + 86400000L
                )
                database.scheduleDao().upsert(invalidSchedule)
                injected.add("empty_schedule_id")
            } catch (e: Exception) {
                // Log but continue - may fail due to constraints
            }
        }
        
        // Inject schedule with null nextRunAt
        if (injectNullNextRunAt) {
            try {
                val invalidSchedule = Schedule(
                    id = "test_null_nextrunat",
                    kind = "notify",
                    cron = "0 9 * * *",
                    clockTime = "09:00",
                    enabled = true,
                    nextRunAt = null  // Null nextRunAt - should be skipped by recovery
                )
                database.scheduleDao().upsert(invalidSchedule)
                injected.add("null_nextrunat")
            } catch (e: Exception) {
                // Log but continue
            }
        }
        
        return injected
    }
    
    /**
     * Inject invalid notification data for recovery testing
     * 
     * @param database Database instance
     * @return true if injection succeeded, false otherwise
     */
    suspend fun injectInvalidNotificationData(database: DailyNotificationDatabase): Boolean {
        return try {
            val invalidNotification = 
                org.timesafari.dailynotification.entities.NotificationContentEntity()
            invalidNotification.id = ""  // Empty ID - should be skipped by recovery
            invalidNotification.title = "Test Invalid Notification"
            invalidNotification.body = "This has an empty ID"
            invalidNotification.scheduledTime = System.currentTimeMillis() - 3600000L // 1 hour ago
            invalidNotification.deliveryStatus = "pending"
            invalidNotification.deliveryAttempts = 0
            invalidNotification.lastDeliveryAttempt = 0
            invalidNotification.userInteractionCount = 0
            invalidNotification.lastUserInteraction = 0
            invalidNotification.ttlSeconds = 86400L
            invalidNotification.createdAt = System.currentTimeMillis()
            invalidNotification.updatedAt = System.currentTimeMillis()
            
            database.notificationContentDao().insertNotification(invalidNotification)
            true
        } catch (e: Exception) {
            // Room's @NonNull constraint may prevent this - this is expected
            false
        }
    }
}

/**
 * Helper object for schedule operations
 * Provides functions for managing schedules in the database
 */
object ScheduleHelper {
    /**
     * Update starred plan IDs in SharedPreferences
     * 
     * @param context Application context
     * @param planIds List of plan IDs to star
     * @return true if update was successful
     */
    fun updateStarredPlans(context: Context, planIds: List<String>): Boolean {
        return try {
            // Validate all plan IDs are non-empty strings
            planIds.forEachIndexed { index, planId ->
                if (planId.isBlank()) {
                    throw IllegalArgumentException("planIds[$index] must be a non-empty string")
                }
            }
            
            // Store in SharedPreferences (matching TestNativeFetcher expectations)
            val prefsName = DailyNotificationConstants.PREFS_NAME
            val keyStarredPlanIds = DailyNotificationConstants.PREFS_KEY_STARRED_PLAN_IDS
            
            val prefs = context.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
            val editor = prefs.edit()
            
            // Convert planIds list to JSON array string
            val jsonArray = JSONArray()
            planIds.forEach { planId ->
                jsonArray.put(planId)
            }
            editor.putString(keyStarredPlanIds, jsonArray.toString())
            editor.apply()
            
            Log.i("ScheduleHelper", "Starred plans updated: count=${planIds.size}")
            true
        } catch (e: Exception) {
            Log.e("ScheduleHelper", "Failed to update starred plans", e)
            false
        }
    }
    
    /**
     * Disable all schedules of a specific kind
     * 
     * @param database Database instance
     * @param kind Schedule kind ("notify" or "fetch")
     * @return Number of schedules disabled
     */
    suspend fun disableAllSchedulesByKind(
        database: DailyNotificationDatabase,
        kind: String
    ): Int {
        val schedules = database.scheduleDao().getByKind(kind)
        val enabledSchedules = schedules.filter { it.enabled }
        
        enabledSchedules.forEach { schedule ->
            database.scheduleDao().setEnabled(schedule.id, false)
        }
        
        return enabledSchedules.size
    }
    
    /**
     * Cancel alarms for a list of schedules
     * 
     * @param context Application context
     * @param schedules List of schedules to cancel alarms for
     * @return Number of alarms cancelled
     */
    suspend fun cancelAlarmsForSchedules(
        context: Context,
        schedules: List<Schedule>
    ): Int {
        var cancelledCount = 0
        schedules.forEach { schedule ->
            try {
                val nextRunAt = schedule.nextRunAt
                if (nextRunAt != null && nextRunAt > 0) {
                    NotifyReceiver.cancelNotification(context, scheduleId = schedule.id, triggerAtMillis = nextRunAt)
                    cancelledCount++
                }
            } catch (e: Exception) {
                // Log but don't fail - alarm might not exist
                Log.w("ScheduleHelper", "Failed to cancel alarm for schedule ${schedule.id}", e)
            }
        }
        return cancelledCount
    }
    
    /**
     * Get schedules with AlarmManager status
     * 
     * Combines database schedules with AlarmManager status checks.
     * 
     * @param context Application context
     * @param schedules List of schedules from database
     * @return JSONArray of schedules with isActuallyScheduled field added
     */
    fun getSchedulesWithStatus(context: Context, schedules: List<Schedule>, scheduleToJson: (Schedule) -> org.json.JSONObject): org.json.JSONArray {
        val schedulesArray = org.json.JSONArray()
        schedules.forEach { schedule ->
            val scheduleJson = scheduleToJson(schedule)
            
            // Only check AlarmManager status for "notify"/"static_reminder" schedules with nextRunAt
            if ((schedule.kind == "notify" || schedule.kind == "static_reminder") && schedule.nextRunAt != null) {
                val isScheduled = NotifyReceiver.isAlarmScheduled(context, scheduleId = schedule.id, triggerAtMillis = schedule.nextRunAt!!)
                scheduleJson.put("isActuallyScheduled", isScheduled)
            } else {
                scheduleJson.put("isActuallyScheduled", false)
            }
            
            schedulesArray.put(scheduleJson)
        }
        return schedulesArray
    }
    
    /**
     * Schedule daily notification (alarm + prefetch + database)
     * 
     * Orchestrates scheduling a daily notification with prefetch WorkManager job.
     * 
     * @param context Application context
     * @param database Database instance
     * @param scheduleId Schedule ID (stable for "one per day" semantics)
     * @param config User notification configuration
     * @param clockTime Original HH:mm time string
     * @param calculateNextRunTime Function to calculate next run time from cron expression
     * @param rolloverIntervalMinutes When > 0, next occurrence is this many minutes after trigger (dev/testing). Null/0 = 24h.
     * @return true if successful, false otherwise
     */
    suspend fun scheduleDailyNotification(
        context: Context,
        database: DailyNotificationDatabase,
        scheduleId: String,
        config: UserNotificationConfig,
        clockTime: String,
        calculateNextRunTime: (String) -> Long,
        rolloverIntervalMinutes: Int? = null
    ): Boolean {
        return try {
            val nextRunTime = calculateNextRunTime(config.schedule)
            
            // CRITICAL: Cancel any existing alarm for this scheduleId BEFORE scheduling new one
            // This ensures "one per day" semantics - when updating schedule time, old alarm is canceled
            // The cleanupExistingNotificationSchedules() above only cancels OTHER schedules, not the current one
            NotifyReceiver.cancelNotification(context, scheduleId)
            Log.i("ScheduleHelper", "Cancelled existing alarm for scheduleId=$scheduleId before scheduling new one at $nextRunTime")
            
            // Schedule AlarmManager notification as static reminder
            // (doesn't require cached content). Skip PendingIntent idempotence: we just cancelled
            // this scheduleId and Android may still return the cancelled PendingIntent from cache.
            NotifyReceiver.scheduleExactNotification(
                context,
                nextRunTime,
                config,
                isStaticReminder = true,
                reminderId = scheduleId,
                scheduleId = scheduleId,
                source = ScheduleSource.INITIAL_SETUP,
                skipPendingIntentIdempotence = true
            )
            
            // Do not enqueue prefetch for static reminders: display is already in the NotifyReceiver
            // alarm. Prefetch is for "fetch content then show"; for static reminders there is nothing
            // to fetch. Enqueueing prefetch would cause the worker to use fallback content and
            // schedule a second alarm via legacy DailyNotificationScheduler, resulting in duplicate
            // notifications at fire time.
            
            // Store schedule in database (include rollover interval for dev/testing; survives reboot)
            // kind = "static_reminder" isolates this from the shared "notify" kind used by the
            // dual/API-retrieval flow (generator #2) and legacy test-data injection, so the
            // cleanup sweep in cleanupExistingNotificationSchedules() (which only scans
            // kind="notify") can never touch or be confused by this row. See
            // doc/progress/PLAN-simplify-static-and-refactor.md §3.1.
            val schedule = Schedule(
                id = scheduleId,
                kind = "static_reminder",
                cron = config.schedule,
                clockTime = clockTime,
                enabled = true,
                nextRunAt = nextRunTime,
                rolloverIntervalMinutes = rolloverIntervalMinutes
            )
            database.scheduleDao().upsert(schedule)
            
            // Persist title/body for this scheduleId so rollover and post-reboot resolve user content
            // (see plugin-feedback-android-rollover-double-fire-and-user-content)
            try {
                val entity = org.timesafari.dailynotification.entities.NotificationContentEntity(
                    scheduleId,
                    "1.3.1",
                    null,
                    "daily",
                    config.title ?: "Daily Notification",
                    config.body ?: "",
                    nextRunTime,
                    java.time.ZoneId.systemDefault().id
                )
                entity.soundEnabled = config.sound ?: true
                entity.vibrationEnabled = config.vibration ?: true
                entity.priority = when (config.priority) {
                    "high", "max" -> 2
                    "low", "min" -> -1
                    else -> 0
                }
                entity.createdAt = System.currentTimeMillis()
                entity.updatedAt = System.currentTimeMillis()
                database.notificationContentDao().insertNotification(entity)
                Log.d("ScheduleHelper", "Persisted title/body for scheduleId=$scheduleId (rollover/post-reboot)")
            } catch (e: Exception) {
                Log.w("ScheduleHelper", "Failed to persist notification content for scheduleId=$scheduleId", e)
            }
            
            true
        } catch (e: Exception) {
            Log.e("ScheduleHelper", "Failed to schedule daily notification", e)
            false
        }
    }

    /**
     * Blocking load of a schedule by id (for use from Java Worker / rollover path).
     */
    @JvmStatic
    fun getScheduleBlocking(context: Context, scheduleId: String): Schedule? {
        return kotlinx.coroutines.runBlocking {
            try {
                DailyNotificationDatabase.getDatabase(context).scheduleDao().getById(scheduleId)
            } catch (e: Exception) {
                Log.w("ScheduleHelper", "getScheduleBlocking failed: $scheduleId", e)
                null
            }
        }
    }

    /**
     * Blocking: first enabled static_reminder schedule with rolloverIntervalMinutes > 0
     * (canonical for rollover chain).
     * Used when the firing run has schedule_id = daily_rollover_* so we can still apply the interval.
     * This whole function only exists to support that orphan-fallback path; it becomes dead
     * code once §3.6 removes the daily_rollover_* fallback (see PLAN-simplify-static-and-refactor.md).
     */
    @JvmStatic
    fun getCanonicalRolloverScheduleBlocking(context: Context): Schedule? {
        return kotlinx.coroutines.runBlocking {
            try {
                DailyNotificationDatabase.getDatabase(context).scheduleDao()
                    .getByKindAndEnabled("static_reminder", true)
                    .firstOrNull { it.rolloverIntervalMinutes != null && it.rolloverIntervalMinutes > 0 }
            } catch (e: Exception) {
                Log.w("ScheduleHelper", "getCanonicalRolloverScheduleBlocking failed", e)
                null
            }
        }
    }

    /**
     * Blocking update of schedule next run time (for use from Java Worker after rollover).
     */
    @JvmStatic
    fun updateScheduleNextRunTimeBlocking(context: Context, scheduleId: String, lastRunAt: Long?, nextRunAt: Long) {
        kotlinx.coroutines.runBlocking {
            try {
                DailyNotificationDatabase.getDatabase(context).scheduleDao().updateRunTimes(scheduleId, lastRunAt, nextRunAt)
            } catch (e: Exception) {
                Log.w("ScheduleHelper", "updateScheduleNextRunTimeBlocking failed: $scheduleId", e)
            }
        }
    }
    
    /**
     * Schedule dual notification (fetch + notify)
     * 
     * Orchestrates scheduling both content fetch and user notification.
     * 
     * @param context Application context
     * @param database Database instance
     * @param contentFetchConfig Content fetch configuration
     * @param userNotificationConfig User notification configuration
     * @param calculateNextRunTime Function to calculate next run time from cron expression
     * @return true if successful, false otherwise
     */
    suspend fun scheduleDualNotification(
        context: Context,
        database: DailyNotificationDatabase,
        contentFetchConfig: ContentFetchConfig,
        userNotificationConfig: UserNotificationConfig,
        calculateNextRunTime: (String) -> Long
    ): Boolean {
        return try {
            val nextFetchAt = calculateNextRunTime(contentFetchConfig.schedule)
            val nextNotifyAt = calculateNextRunTime(userNotificationConfig.schedule)
            FetchWorker.enqueueDualFetch(
                context,
                contentFetchConfig,
                nextFetchAt,
                nextNotifyAt
            )

            // Chained dual: user notification is armed from FetchWorker after prefetch (see DualScheduleNotifyScheduler).
            val nextRunTime = nextNotifyAt
            val scheduleId = "${DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX}${System.currentTimeMillis()}"
            context.getSharedPreferences(DailyNotificationConstants.DUAL_SCHEDULE_PREFS, Context.MODE_PRIVATE)
                .edit()
                .putString(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_KEY, scheduleId)
                .apply()
            
            // Store both schedules
            val fetchSchedule = Schedule(
                id = "dual_fetch_${System.currentTimeMillis()}",
                kind = "fetch",
                cron = contentFetchConfig.schedule,
                enabled = contentFetchConfig.enabled,
                nextRunAt = nextFetchAt
            )
            val notifySchedule = Schedule(
                id = scheduleId,
                kind = "notify",
                cron = userNotificationConfig.schedule,
                enabled = userNotificationConfig.enabled,
                nextRunAt = nextRunTime
            )
            
            database.scheduleDao().upsert(fetchSchedule)
            database.scheduleDao().upsert(notifySchedule)
            
            true
        } catch (e: Exception) {
            Log.e("ScheduleHelper", "Failed to schedule dual notification", e)
            false
        }
    }
    
    /**
     * Schedule one API-managed notification at an epoch-ms trigger time.
     * Uses the api_ ID namespace so [clearApiSchedules] can replace batches safely.
     *
     * @return true when scheduled, false when timestamp is stale (in the past)
     */
    suspend fun scheduleApiNotification(
        context: Context,
        database: DailyNotificationDatabase,
        epochMillis: Long,
        title: String = "Reminder",
        body: String = "You have a scheduled notification"
    ): Boolean {
        if (epochMillis <= System.currentTimeMillis()) {
            Log.d("ScheduleHelper", "DNP-BATCH: skip stale timestamp ts=$epochMillis (not in the future)")
            return false
        }

        // API-managed notifications use the api_ namespace so they can be safely
        // replaced without affecting Daily Reminder schedules.
        val scheduleId = "${DailyNotificationConstants.API_SCHEDULE_ID_PREFIX}${epochMillis}"

        val config = UserNotificationConfig(
            enabled = true,
            schedule = scheduleId,
            title = title,
            body = body,
            sound = true,
            vibration = true,
            priority = "default"
        )

        // Persist before arming so NotifyReceiver updates this row, not an unrelated notify schedule.
        createSchedule(
            database,
            Schedule(
                id = scheduleId,
                kind = "notify",
                enabled = true,
                nextRunAt = epochMillis
            )
        )

        NotifyReceiver.scheduleExactNotification(
            context,
            epochMillis,
            config,
            reminderId = scheduleId,
            scheduleId = scheduleId,
            source = ScheduleSource.INITIAL_SETUP
        )

        Log.i("ScheduleHelper", "DNP-BATCH: scheduled ts=$epochMillis id=$scheduleId")
        return true
    }

    /**
     * Schedule user notification (alarm + database)
     * 
     * Orchestrates scheduling a user notification via NotifyReceiver and storing in database.
     * 
     * @param context Application context
     * @param database Database instance
     * @param config User notification configuration
     * @param calculateNextRunTime Function to calculate next run time from cron expression
     * @return Schedule ID if successful, null otherwise
     */
    suspend fun scheduleUserNotification(
        context: Context,
        database: DailyNotificationDatabase,
        config: UserNotificationConfig,
        calculateNextRunTime: (String) -> Long
    ): String? {
        return try {
            val nextRunTime = calculateNextRunTime(config.schedule)
            
            // Generate scheduleId before scheduling (needed for stable requestCode)
            val scheduleId = "notify_${System.currentTimeMillis()}"
            
            // Schedule AlarmManager notification
            NotifyReceiver.scheduleExactNotification(
                context, 
                nextRunTime, 
                config,
                scheduleId = scheduleId,
                source = ScheduleSource.INITIAL_SETUP
            )
            
            // Store schedule in database
            val schedule = Schedule(
                id = scheduleId,
                kind = "notify",
                cron = config.schedule,
                enabled = config.enabled,
                nextRunAt = nextRunTime
            )
            createSchedule(database, schedule)
            
            scheduleId
        } catch (e: Exception) {
            Log.e("ScheduleHelper", "Failed to schedule user notification", e)
            null
        }
    }
    
    /**
     * Cancel only the dual (New Activity) schedule: alarms for dual_fetch_* / dual_notify_* and DB rows.
     * Does not cancel Daily Reminder or other schedules. Caller must also cancel WorkManager unique work
     * FetchWorker.WORK_NAME_DUAL.
     *
     * @param context Application context
     * @param database Database instance
     * @return Number of dual schedules removed
     */
    suspend fun cancelDualSchedule(context: Context, database: DailyNotificationDatabase): Int {
        return try {
            val all = database.scheduleDao().getAll()
            val dualSchedules = all.filter { it.id.startsWith("dual_fetch_") || it.id.startsWith("dual_notify_") }
            if (dualSchedules.isEmpty()) {
                Log.d("ScheduleHelper", "cancelDualSchedule: no dual schedules found")
                return 0
            }
            cancelAlarmsForSchedules(context, dualSchedules)
            dualSchedules.forEach { database.scheduleDao().deleteById(it.id) }
            Log.i("ScheduleHelper", "cancelDualSchedule: cancelled and removed ${dualSchedules.size} dual schedule(s)")
            dualSchedules.size
        } catch (e: Exception) {
            Log.e("ScheduleHelper", "cancelDualSchedule failed", e)
            0
        }
    }

    /**
     * Cancel only API-managed notification schedules (`api_*` prefix).
     * Removes matching alarms, persisted schedules, and notification content.
     * Does not disable Daily Reminders, user schedules, dual schedules, or fetch jobs.
     */
    suspend fun clearApiSchedules(context: Context, database: DailyNotificationDatabase): Int {
        return try {
            val prefix = DailyNotificationConstants.API_SCHEDULE_ID_PREFIX
            val all = database.scheduleDao().getAll()
            val apiSchedules = all.filter { it.id.startsWith(prefix) }
            if (apiSchedules.isEmpty()) {
                Log.d("ScheduleHelper", "clearApiSchedules: no API-managed schedules found")
                return 0
            }

            val notificationManager =
                context.getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager

            apiSchedules.forEach { schedule ->
                NotifyReceiver.cancelNotification(
                    context,
                    scheduleId = schedule.id,
                    triggerAtMillis = schedule.nextRunAt
                )
                notificationManager.cancel(schedule.id.hashCode())
            }

            apiSchedules.forEach { database.scheduleDao().deleteById(it.id) }

            database.notificationContentDao().getAllNotifications()
                .filter { it.id.startsWith(prefix) }
                .forEach { content ->
                    database.notificationContentDao().deleteNotification(content.id)
                    notificationManager.cancel(content.id.hashCode())
                }

            Log.i("ScheduleHelper", "clearApiSchedules: cancelled and removed ${apiSchedules.size} API-managed schedule(s)")
            apiSchedules.size
        } catch (e: Exception) {
            Log.e("ScheduleHelper", "clearApiSchedules failed", e)
            0
        }
    }

    /**
     * Cancel only WorkManager jobs that can create a second (UUID) alarm for the static-reminder path:
     * prefetch and daily_notification_fetch. Does not cancel display, dismiss, or maintenance.
     * Use this when handling scheduleDailyNotification so pending prefetch does not run and create
     * a duplicate alarm; future fetched-content flows should use distinct tags so they are not affected.
     *
     * @param context Application context
     * @return true if cancellation was successful
     */
    suspend fun cancelFetchRelatedWorkManagerJobs(context: Context): Boolean {
        return try {
            val workManager = WorkManager.getInstance(context)
            workManager.cancelAllWorkByTag("prefetch")
            workManager.cancelAllWorkByTag("daily_notification_fetch")
            true
        } catch (e: Exception) {
            Log.w("ScheduleHelper", "Failed to cancel fetch-related WorkManager jobs", e)
            false
        }
    }

    /**
     * Cancel all WorkManager jobs by tags
     *
     * @param context Application context
     * @return true if cancellation was successful
     */
    suspend fun cancelAllWorkManagerJobs(context: Context): Boolean {
        return try {
            val workManager = WorkManager.getInstance(context)
            
            // Cancel all prefetch jobs
            workManager.cancelAllWorkByTag("prefetch")
            
            // Cancel fetch jobs (if using DailyNotificationFetcher tags)
            workManager.cancelAllWorkByTag("daily_notification_fetch")
            workManager.cancelAllWorkByTag("daily_notification_maintenance")
            workManager.cancelAllWorkByTag("soft_refetch")
            workManager.cancelAllWorkByTag("daily_notification_display")
            workManager.cancelAllWorkByTag("daily_notification_dismiss")
            
            // Note: WorkManager doesn't support wildcard cancellation, so we cancel by tag
            // The unique work names will be replaced when new work is scheduled
            
            true
        } catch (e: Exception) {
            Log.w("ScheduleHelper", "Failed to cancel WorkManager jobs", e)
            false
        }
    }
    
    /**
     * Clean up existing notification schedules (cancel alarms and delete from database)
     * Used to ensure "one per day" semantics for daily notifications
     * 
     * @param context Application context
     * @param database Database instance
     * @param excludeScheduleId Schedule ID to exclude from cleanup (will be updated/created)
     * @return Number of schedules cleaned up
     */
    suspend fun cleanupExistingNotificationSchedules(
        context: Context,
        database: DailyNotificationDatabase,
        excludeScheduleId: String? = null
    ): Int {
        val existingSchedules = database.scheduleDao().getByKind("notify")
        var cleanedCount = 0
        
        existingSchedules.forEach { existingSchedule ->
            try {
                // Skip if this is the same schedule we're about to create (will be upserted anyway)
                if (existingSchedule.id == excludeScheduleId) {
                    return@forEach
                }
                
                // Cancel the alarm in AlarmManager
                NotifyReceiver.cancelNotification(context, existingSchedule.id)
                
                // Delete from database
                database.scheduleDao().deleteById(existingSchedule.id)
                cleanedCount++
            } catch (e: Exception) {
                Log.e("ScheduleHelper", "Failed to cancel/delete existing schedule: ${existingSchedule.id}", e)
                // Continue with other schedules - don't fail entire operation
            }
        }
        
        return cleanedCount
    }
    /**
     * Create a new schedule
     * 
     * @param database Database instance
     * @param schedule Schedule entity to create
     * @return Created schedule
     */
    suspend fun createSchedule(database: DailyNotificationDatabase, schedule: Schedule): Schedule {
        database.scheduleDao().upsert(schedule)
        return schedule
    }
    
    /**
     * Update an existing schedule
     * 
     * @param database Database instance
     * @param id Schedule ID
     * @param enabled Optional enabled flag
     * @param cron Optional cron expression
     * @param clockTime Optional clock time
     * @param jitterMs Optional jitter milliseconds
     * @param backoffPolicy Optional backoff policy
     * @param stateJson Optional state JSON
     * @param lastRunAt Optional last run time
     * @param nextRunAt Optional next run time
     * @return Updated schedule
     */
    suspend fun updateSchedule(
        database: DailyNotificationDatabase,
        id: String,
        enabled: Boolean? = null,
        cron: String? = null,
        clockTime: String? = null,
        jitterMs: Int? = null,
        backoffPolicy: String? = null,
        stateJson: String? = null,
        lastRunAt: Long? = null,
        nextRunAt: Long? = null
    ): Schedule {
        // Check if schedule exists
        val existing = database.scheduleDao().getById(id)
            ?: throw IllegalArgumentException("Schedule not found: $id")
        
        // Update fields
        database.scheduleDao().update(
            id = id,
            enabled = enabled,
            cron = cron,
            clockTime = clockTime,
            jitterMs = jitterMs,
            backoffPolicy = backoffPolicy,
            stateJson = stateJson
        )
        
        // Update run times if provided
        if (lastRunAt != null || nextRunAt != null) {
            database.scheduleDao().updateRunTimes(id, lastRunAt, nextRunAt)
        }
        
        return database.scheduleDao().getById(id)!!
    }
    
    /**
     * Delete a schedule by ID
     * 
     * @param database Database instance
     * @param id Schedule ID to delete
     */
    suspend fun deleteSchedule(database: DailyNotificationDatabase, id: String) {
        database.scheduleDao().deleteById(id)
    }
    
    /**
     * Enable or disable a schedule
     * 
     * @param database Database instance
     * @param id Schedule ID
     * @param enabled Enabled flag
     */
    suspend fun enableSchedule(database: DailyNotificationDatabase, id: String, enabled: Boolean) {
        database.scheduleDao().setEnabled(id, enabled)
    }
}

/**
 * Helper object for callback operations
 * Provides functions for managing callbacks in the database
 */
object CallbackHelper {
    /**
     * Register a new callback
     * 
     * @param database Database instance
     * @param callback Callback entity to register
     * @return Created callback
     */
    suspend fun registerCallback(database: DailyNotificationDatabase, callback: Callback): Callback {
        database.callbackDao().upsert(callback)
        return callback
    }
    
    /**
     * Get callback by ID
     * 
     * @param database Database instance
     * @param id Callback ID
     * @return Callback or null if not found
     */
    suspend fun getCallback(database: DailyNotificationDatabase, id: String): Callback? {
        return database.callbackDao().getById(id)
    }
    
    /**
     * Get all callbacks
     * 
     * @param database Database instance
     * @return List of all callbacks
     */
    suspend fun getAllCallbacks(database: DailyNotificationDatabase): List<Callback> {
        return database.callbackDao().getAll()
    }
    
    /**
     * Get enabled callbacks
     * 
     * @param database Database instance
     * @param enabled Enabled flag
     * @return List of callbacks matching enabled status
     */
    suspend fun getCallbacksByEnabled(database: DailyNotificationDatabase, enabled: Boolean): List<Callback> {
        return database.callbackDao().getByEnabled(enabled)
    }
}

/**
 * Helper object for content cache operations
 * Provides functions for accessing ContentCache from the database
 */
object ContentCacheHelper {
    /**
     * Get the latest content cache entry
     * 
     * This is a suspend function that queries the database for the latest
     * content cache entry.
     * 
     * @param database Database instance for querying content cache
     * @return ContentCache entry or null if none exists
     */
    suspend fun getLatest(database: DailyNotificationDatabase): ContentCache? {
        return database.contentCacheDao().getLatest()
    }
    
    /**
     * Get content cache by ID
     * 
     * @param database Database instance for querying content cache
     * @param id Content cache ID
     * @return ContentCache entry or null if not found
     */
    suspend fun getById(database: DailyNotificationDatabase, id: String): ContentCache? {
        return database.contentCacheDao().getById(id)
    }
}

/**
 * Helper object for notification status operations
 * Provides functions that can be called from both Java and Kotlin code
 */
object NotificationStatusHelper {
    /**
     * Get notification status information (schedules and history)
     * 
     * This is a suspend function that queries the database for notification
     * schedules and history, then builds a status object.
     * 
     * @param database Database instance for querying schedules and history
     * @return JSObject containing notification status
     */
    suspend fun getNotificationStatus(database: DailyNotificationDatabase): JSObject {
        val schedules = database.scheduleDao().getAll()
        // Includes "static_reminder" alongside "notify" so status reporting (isEnabled,
        // isScheduled, nextNotificationTime, etc.) still reflects the static reminder now
        // that it's isolated to its own kind. See PLAN-simplify-static-and-refactor.md §3.1.
        val notifySchedules = schedules.filter {
            (it.kind == "notify" || it.kind == "static_reminder") && it.enabled
        }

        // Get last notification time from history. History.kind is a fixed event-type tag
        // written uniformly by NotifyReceiver.recordHistory() for any alarm fire (static
        // reminder or dual-notify) — independent of Schedule.kind, so no change needed here.
        val history = database.historyDao().getRecent(100) // Get last 100 entries
        val lastNotification = history
            .filter { it.kind == "notify" && it.outcome == "success" }
            .maxByOrNull { it.occurredAt }
        val lastNotificationTime = lastNotification?.occurredAt ?: 0
        
        return JSObject().apply {
            put("isEnabled", notifySchedules.isNotEmpty())
            put("isScheduled", notifySchedules.isNotEmpty())
            put("lastNotificationTime", lastNotificationTime)
            put("nextNotificationTime", notifySchedules.minOfOrNull { it.nextRunAt ?: Long.MAX_VALUE } ?: 0)
            put("scheduledCount", notifySchedules.size)
            put("pending", notifySchedules.size) // Alias for scheduledCount
            put("settings", JSObject().apply {
                put("enabled", notifySchedules.isNotEmpty())
                put("count", notifySchedules.size)
            })
        }
    }
    
    /**
     * Java-compatible wrapper that uses runBlocking to call the suspend function
     * 
     * @param database Database instance for querying schedules and history
     * @return JSObject containing notification status
     */
    @JvmStatic
    fun getNotificationStatusBlocking(database: DailyNotificationDatabase): JSObject {
        return kotlinx.coroutines.runBlocking {
            getNotificationStatus(database)
        }
    }
}
