/**
 * DailyNotificationWorker.java
 * 
 * WorkManager worker for handling notification processing
 * Moves heavy operations (storage, JSON, scheduling) out of BroadcastReceiver
 * 
 * @author Matthew Raymer
 * @version 1.0.0
 */

package org.timesafari.dailynotification;

import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Trace;
import android.util.Log;

import androidx.annotation.NonNull;
import androidx.core.app.NotificationCompat;
import androidx.work.Data;
import androidx.work.Worker;
import androidx.work.WorkerParameters;

import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.ConcurrentHashMap;

import org.timesafari.dailynotification.storage.DailyNotificationStorageRoom;
import org.timesafari.dailynotification.entities.NotificationContentEntity;
import org.timesafari.dailynotification.DailyNotificationFetcher;

/**
 * WorkManager worker for processing daily notifications
 * 
 * This worker handles the heavy operations that were previously done in
 * the BroadcastReceiver, ensuring the receiver stays ultra-lightweight.
 */
public class DailyNotificationWorker extends Worker {
    
    private static final String TAG = "DailyNotificationWorker";
    private static final String CHANNEL_ID = "timesafari.daily";
    
    // Work deduplication tracking
    private static final ConcurrentHashMap<String, AtomicBoolean> activeWork = new ConcurrentHashMap<>();
    private static final ConcurrentHashMap<String, Long> workTimestamps = new ConcurrentHashMap<>();
    private static final long WORK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
    
    public DailyNotificationWorker(@NonNull Context context, @NonNull WorkerParameters workerParams) {
        super(context, workerParams);
    }
    
    @NonNull
    @Override
    public Result doWork() {
        Trace.beginSection("DN:Worker");
        try {
            Data inputData = getInputData();
            String notificationId = inputData.getString("notification_id");
            String action = inputData.getString("action");
            
            if (notificationId == null || action == null) {
                Log.e(TAG, "DN|WORK_ERR missing_params id=" + notificationId + " action=" + action);
                return Result.failure();
            }
            
            // Create unique work key for deduplication
            String workKey = createWorkKey(notificationId, action);
            
            // Check for work deduplication
            if (!acquireWorkLock(workKey)) {
                Log.d(TAG, "DN|WORK_SKIP duplicate_work key=" + workKey);
                return Result.success(); // Return success for duplicate work
            }
            
            try {
                Log.d(TAG, "DN|WORK_START id=" + notificationId + " action=" + action + " key=" + workKey);
                
                // Check if work is idempotent (already completed)
                if (isWorkAlreadyCompleted(workKey)) {
                    Log.d(TAG, "DN|WORK_SKIP already_completed key=" + workKey);
                    return Result.success();
                }
                
                Result result;
                if ("display".equals(action)) {
                    result = handleDisplayNotification(notificationId);
                } else if ("dismiss".equals(action)) {
                    result = handleDismissNotification(notificationId);
                } else {
                    Log.e(TAG, "DN|WORK_ERR unknown_action=" + action);
                    result = Result.failure();
                }
                
                // Mark work as completed if successful
                if (result == Result.success()) {
                    markWorkAsCompleted(workKey);
                }
                
                return result;
                
            } finally {
                // Always release the work lock
                releaseWorkLock(workKey);
            }
            
        } catch (Exception e) {
            Log.e(TAG, "DN|WORK_ERR exception=" + e.getMessage(), e);
            return Result.retry();
        } finally {
            Trace.endSection();
        }
    }
    
    /**
     * Handle notification display
     * 
     * @param notificationId ID of notification to display
     * @return Work result
     */
    private Result handleDisplayNotification(String notificationId) {
        Trace.beginSection("DN:display");
        try {
            Log.d(TAG, "DN|DISPLAY_START id=" + notificationId);
            
            Data inputData = getInputData();
            String scheduleId = inputData.getString("schedule_id");
            
            // Dual (New Activity): resolve title/body from persisted config + content cache (relationship: contentTimeout, fallbackBehavior)
            if (scheduleId != null && scheduleId.startsWith(DailyNotificationConstants.DUAL_NOTIFY_SCHEDULE_ID_PREFIX)) {
                NotificationContent content = DualScheduleHelper.resolveDualContentBlocking(getApplicationContext(), notificationId);
                if (content != null) {
                    boolean displayed = displayNotification(content);
                    if (displayed) {
                        Log.i(TAG, "DN|DISPLAY_OK dual id=" + notificationId);
                        return Result.success();
                    }
                }
                Log.w(TAG, "DN|DISPLAY_SKIP dual_no_content id=" + notificationId);
                return Result.success();
            }
            
            // Check if this is a static reminder (title/body in input data, not storage)
            boolean isStaticReminder = inputData.getBoolean("is_static_reminder", false);
            NotificationContent content;
            
            if (isStaticReminder) {
                // Static reminder: create NotificationContent from input data (or resolve from DB by schedule_id)
                String title = inputData.getString("title");
                String body = inputData.getString("body");
                boolean sound = inputData.getBoolean("sound", true);
                boolean vibration = inputData.getBoolean("vibration", true);
                String priority = inputData.getString("priority");
                if (priority == null) {
                    priority = "normal";
                }
                // Post-reboot/rollover: Intent may lack title/body; resolve from DB by canonical schedule_id
                if ((title == null || title.isEmpty() || body == null || body.isEmpty()) && scheduleId != null) {
                    NotificationContent canonical = getContentByScheduleId(scheduleId);
                    if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
                        title = canonical.getTitle();
                        body = canonical.getBody();
                        sound = canonical.isSound();
                        priority = canonical.getPriority() != null ? canonical.getPriority() : "normal";
                        Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER_FROM_DB id=" + notificationId + " schedule_id=" + scheduleId);
                    }
                }
                if (title == null || body == null) {
                    Log.w(TAG, "DN|DISPLAY_SKIP static_reminder_missing_data id=" + notificationId);
                    return Result.success();
                }
                
                // Create NotificationContent from input data
                // Use current time as scheduled time for static reminders
                long scheduledTime = System.currentTimeMillis();
                content = new NotificationContent(title, body, scheduledTime);
                content.setId(notificationId);
                content.setSound(sound);
                content.setPriority(priority);
                // Note: fetchedAt is automatically set to current time in NotificationContent constructor
                // Note: vibration is handled in displayNotification() method, not stored in NotificationContent
                
                Log.d(TAG, "DN|DISPLAY_STATIC_REMINDER id=" + notificationId + " title=" + title);
            } else {
                // Regular notification: load from storage (by notification_id, then by schedule_id for rollover/user content)
                content = getContentFromRoomOrLegacy(notificationId);
                // Rollover/notify_* runs: prefer canonical reminder content by schedule_id so user text is shown
                if (scheduleId != null && (content == null || content.getTitle() == null || content.getTitle().isEmpty()
                        || content.getBody() == null || content.getBody().isEmpty())) {
                    NotificationContent canonical = getContentByScheduleId(scheduleId);
                    if (canonical != null && canonical.getTitle() != null && canonical.getBody() != null) {
                        content = canonical;
                        content.setId(notificationId); // keep run id for display/dismiss
                        Log.d(TAG, "DN|DISPLAY_USE_CANONICAL id=" + notificationId + " schedule_id=" + scheduleId);
                    }
                }
                if (content == null) {
                    Log.w(TAG, "DN|DISPLAY_SKIP content_not_found id=" + notificationId + " (likely removed during deduplication)");
                    return Result.success();
                }
                if (!content.isReadyToDisplay()) {
                    Log.d(TAG, "DN|DISPLAY_SKIP not_ready id=" + notificationId);
                    return Result.success();
                }
                // JIT Freshness Re-check (Soft TTL) - skip when content has title/body from Room
                boolean hasTitleBody = content.getTitle() != null && !content.getTitle().isEmpty()
                    && content.getBody() != null && !content.getBody().isEmpty();
                if (!hasTitleBody) {
                    content = performJITFreshnessCheck(content);
                } else {
                    Log.d(TAG, "DN|DISPLAY_USE_ROOM_CONTENT id=" + notificationId + " (skip JIT)");
                }
            }
            
            // Display the notification
            boolean displayed = displayNotification(content);
            
            if (displayed) {
                // Schedule next notification if this is a recurring daily notification
                scheduleNextNotification(content);
                
                Log.i(TAG, "DN|DISPLAY_OK id=" + notificationId);
                return Result.success();
            } else {
                Log.e(TAG, "DN|DISPLAY_ERR display_failed id=" + notificationId);
                return Result.retry();
            }
            
        } catch (Exception e) {
            Log.e(TAG, "DN|DISPLAY_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
            return Result.retry();
        } finally {
            Trace.endSection();
        }
    }
    
    /**
     * Handle notification dismissal
     * 
     * @param notificationId ID of notification to dismiss
     * @return Work result
     */
    private Result handleDismissNotification(String notificationId) {
        Trace.beginSection("DN:dismiss");
        try {
            Log.d(TAG, "DN|DISMISS_START id=" + notificationId);
            
            // Cancel the notification from NotificationManager FIRST
            // This ensures the notification disappears immediately when dismissed
            NotificationManager notificationManager = 
                (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
            if (notificationManager != null) {
                int systemNotificationId = notificationId.hashCode();
                notificationManager.cancel(systemNotificationId);
                Log.d(TAG, "DN|DISMISS_CANCEL_NOTIF systemId=" + systemNotificationId);
            }
            
            // Remove from Room if present; also remove from legacy storage for compatibility
            try {
                DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
                // No direct delete DAO exposed via service; legacy removal still applied
            } catch (Exception ignored) { }
            DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
            storage.removeNotification(notificationId);
            
            // Cancel any pending alarms
            DailyNotificationScheduler scheduler = new DailyNotificationScheduler(
                getApplicationContext(),
                (android.app.AlarmManager) getApplicationContext().getSystemService(Context.ALARM_SERVICE)
            );
            scheduler.cancelNotification(notificationId);
            
            Log.i(TAG, "DN|DISMISS_OK id=" + notificationId);
            return Result.success();
            
        } catch (Exception e) {
            Log.e(TAG, "DN|DISMISS_ERR exception id=" + notificationId + " err=" + e.getMessage(), e);
            return Result.retry();
        } finally {
            Trace.endSection();
        }
    }
    
    /**
     * Perform JIT (Just-In-Time) freshness re-check for notification content
     * with soft re-fetch for borderline age content
     * 
     * @param content Original notification content
     * @return Updated content if refresh succeeded, original content otherwise
     */
    private NotificationContent performJITFreshnessCheck(NotificationContent content) {
        Trace.beginSection("DN:jitCheck");
        try {
            // Check if content is stale (older than 6 hours for JIT check)
            long currentTime = System.currentTimeMillis();
            long age = currentTime - content.getFetchedAt();
            long staleThreshold = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
            long borderlineThreshold = 4 * 60 * 60 * 1000; // 4 hours in milliseconds (80% of TTL)
            int ageMinutes = (int) (age / 1000 / 60);
            
            if (age < staleThreshold) {
                // Check if content is borderline stale (80% of TTL) for soft re-fetch
                if (age >= borderlineThreshold) {
                    Log.i(TAG, "DN|JIT_BORDERLINE ageMin=" + ageMinutes + " id=" + content.getId() + " triggering_soft_refetch");
                    
                    // Trigger soft re-fetch for tomorrow's content asynchronously
                    scheduleSoftRefetchForTomorrow(content);
                } else {
                    Log.d(TAG, "DN|JIT_FRESH skip=true ageMin=" + ageMinutes + " id=" + content.getId());
                }
                return content;
            }
            
            Log.i(TAG, "DN|JIT_STALE skip=false ageMin=" + ageMinutes + " id=" + content.getId());
            
            // Attempt to fetch fresh content
            DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
                getApplicationContext(), 
                new DailyNotificationStorage(getApplicationContext())
            );
            
            // Attempt immediate fetch for fresh content
            NotificationContent freshContent = fetcher.fetchContentImmediately();
            
            if (freshContent != null && freshContent.getTitle() != null && !freshContent.getTitle().isEmpty()) {
                Log.i(TAG, "DN|JIT_REFRESH_OK id=" + content.getId());
                
                // Update the original content with fresh data while preserving the original ID and scheduled time
                String originalId = content.getId();
                long originalScheduledTime = content.getScheduledTime();
                
                content.setTitle(freshContent.getTitle());
                content.setBody(freshContent.getBody());
                content.setSound(freshContent.isSound());
                content.setPriority(freshContent.getPriority());
                content.setUrl(freshContent.getUrl());
                content.setMediaUrl(freshContent.getMediaUrl());
                content.setScheduledTime(originalScheduledTime); // Preserve original scheduled time
                // Note: fetchedAt remains unchanged to preserve original fetch time
                
                // Save updated content to storage
                DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
                storage.saveNotificationContent(content);
                
                return content;
            } else {
                Log.w(TAG, "DN|JIT_REFRESH_FAIL id=" + content.getId());
                return content;
            }
            
        } catch (Exception e) {
            Log.e(TAG, "DN|JIT_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
            return content; // Return original content on error
        } finally {
            Trace.endSection();
        }
    }
    
    /**
     * Schedule soft re-fetch for tomorrow's content asynchronously
     * 
     * This prefetches fresh content for tomorrow while still showing today's notification.
     * The soft re-fetch runs in the background and updates tomorrow's notification content.
     * 
     * @param content Current notification content
     */
    private void scheduleSoftRefetchForTomorrow(NotificationContent content) {
        try {
            // Calculate tomorrow's scheduled time (24 hours from current scheduled time)
            long tomorrowScheduledTime = content.getScheduledTime() + TimeUnit.HOURS.toMillis(24);
            
            // Schedule soft re-fetch 2 hours before tomorrow's notification
            long softRefetchTime = tomorrowScheduledTime - TimeUnit.HOURS.toMillis(2);
            
            if (softRefetchTime > System.currentTimeMillis()) {
                androidx.work.WorkManager workManager = androidx.work.WorkManager.getInstance(getApplicationContext());
                
                // Create constraints for the soft re-fetch work
                androidx.work.Constraints constraints = new androidx.work.Constraints.Builder()
                    .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED)
                    .setRequiresBatteryNotLow(false)
                    .setRequiresCharging(false)
                    .setRequiresDeviceIdle(false)
                    .build();
                
                // Create input data
                androidx.work.Data inputData = new androidx.work.Data.Builder()
                    .putLong("tomorrow_scheduled_time", tomorrowScheduledTime)
                    .putString("action", "soft_refetch")
                    .putString("original_id", content.getId())
                    .build();
                
                // Create one-time work request
                androidx.work.OneTimeWorkRequest softRefetchWork = new androidx.work.OneTimeWorkRequest.Builder(
                    org.timesafari.dailynotification.SoftRefetchWorker.class)
                    .setConstraints(constraints)
                    .setInputData(inputData)
                    .setInitialDelay(softRefetchTime - System.currentTimeMillis(), java.util.concurrent.TimeUnit.MILLISECONDS)
                    .addTag("soft_refetch")
                    .build();
                
                // Enqueue the work
                workManager.enqueue(softRefetchWork);
                
                Log.d(TAG, "DN|SOFT_REFETCH_SCHEDULED original_id=" + content.getId() + 
                          " tomorrow_time=" + tomorrowScheduledTime + 
                          " refetch_time=" + softRefetchTime);
            }
        } catch (Exception e) {
            Log.e(TAG, "DN|SOFT_REFETCH_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
        }
    }
    
    /**
     * Display the notification to the user
     * 
     * @param content Notification content to display
     * @return true if displayed successfully, false otherwise
     */
    private boolean displayNotification(NotificationContent content) {
        Trace.beginSection("DN:displayNotif");
        try {
            Log.d(TAG, "DN|DISPLAY_NOTIF_START id=" + content.getId());
            
            // Ensure notification channel exists before displaying
            ChannelManager channelManager = new ChannelManager(getApplicationContext());
            if (!channelManager.ensureChannelExists()) {
                Log.w(TAG, "DN|DISPLAY_NOTIF_ERR channel_blocked id=" + content.getId());
                return false;
            }
            
            NotificationManager notificationManager = 
                (NotificationManager) getApplicationContext().getSystemService(Context.NOTIFICATION_SERVICE);
            
            if (notificationManager == null) {
                Log.e(TAG, "DN|DISPLAY_NOTIF_ERR no_manager id=" + content.getId());
                return false;
            }
            
            // Create notification builder
            NotificationCompat.Builder builder = new NotificationCompat.Builder(getApplicationContext(), CHANNEL_ID)
                .setSmallIcon(android.R.drawable.ic_dialog_info)
                .setContentTitle(content.getTitle())
                .setContentText(content.getBody())
                .setPriority(getNotificationPriority(content.getPriority()))
                .setAutoCancel(true)
                .setCategory(NotificationCompat.CATEGORY_REMINDER);
            
            // Add sound if enabled
            if (content.isSound()) {
                builder.setDefaults(NotificationCompat.DEFAULT_SOUND);
            }
            
            // Add click action - always open the app, optionally with URL
            Intent clickIntent;
            if (content.getUrl() != null && !content.getUrl().isEmpty()) {
                // If URL is provided, open the app and pass the URL as data
                clickIntent = new Intent(Intent.ACTION_VIEW);
                clickIntent.setData(android.net.Uri.parse(content.getUrl()));
                clickIntent.setPackage(getApplicationContext().getPackageName());
                clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
                Log.d(TAG, "DN|CLICK_INTENT with_url=" + content.getUrl());
            } else {
                // If no URL, just open the main app
                clickIntent = getApplicationContext().getPackageManager().getLaunchIntentForPackage(getApplicationContext().getPackageName());
                clickIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
                Log.d(TAG, "DN|CLICK_INTENT app_only");
            }
            
            PendingIntent clickPendingIntent = PendingIntent.getActivity(
                getApplicationContext(),
                content.getId().hashCode(),
                clickIntent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
            );
            
            builder.setContentIntent(clickPendingIntent);
            
            // Add action buttons
            // 1. Dismiss action
            Intent dismissIntent = new Intent(getApplicationContext(), DailyNotificationReceiver.class);
            dismissIntent.setAction("org.timesafari.daily.DISMISS");
            dismissIntent.putExtra("notification_id", content.getId());
            
            PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(
                getApplicationContext(),
                content.getId().hashCode() + 1000, // Different request code
                dismissIntent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
            );
            
            builder.addAction(
                android.R.drawable.ic_menu_close_clear_cancel,
                "Dismiss",
                dismissPendingIntent
            );
            
            // 2. View Details action (if URL is available)
            if (content.getUrl() != null && !content.getUrl().isEmpty()) {
                Intent viewDetailsIntent = new Intent(Intent.ACTION_VIEW);
                viewDetailsIntent.setData(android.net.Uri.parse(content.getUrl()));
                viewDetailsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                
                PendingIntent viewDetailsPendingIntent = PendingIntent.getActivity(
                    getApplicationContext(),
                    content.getId().hashCode() + 2000, // Different request code
                    viewDetailsIntent,
                    PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
                );
                
                builder.addAction(
                    android.R.drawable.ic_menu_info_details,
                    "View Details",
                    viewDetailsPendingIntent
                );
                
                Log.d(TAG, "DN|ACTION_BUTTONS added_view_details url=" + content.getUrl());
            } else {
                Log.d(TAG, "DN|ACTION_BUTTONS dismiss_only");
            }
            
            // Build and display notification
            int notificationId = content.getId().hashCode();
            notificationManager.notify(notificationId, builder.build());
            
            Log.i(TAG, "DN|DISPLAY_NOTIF_OK id=" + content.getId());
            return true;
            
        } catch (Exception e) {
            Log.e(TAG, "DN|DISPLAY_NOTIF_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
            return false;
        } finally {
            Trace.endSection();
        }
    }
    
    /**
     * Schedule the next occurrence of this daily notification with DST-safe calculation
     * and deduplication to prevent double-firing
     * 
     * @param content Current notification content
     */
    private void scheduleNextNotification(NotificationContent content) {
        Trace.beginSection("DN:scheduleNext");
        try {
            Log.d(TAG, "DN|RESCHEDULE_START id=" + content.getId());
            
            // Resolve schedule_id first so we can load rollover interval from DB
            Data inputDataForSchedule = getInputData();
            boolean preserveStaticReminder = inputDataForSchedule.getBoolean("is_static_reminder", false);
            String scheduleIdForRollover = inputDataForSchedule.getString("schedule_id");
            if (scheduleIdForRollover == null || scheduleIdForRollover.isEmpty()) {
                String notificationId = content.getId();
                if (preserveStaticReminder && notificationId != null && !notificationId.isEmpty()) {
                    scheduleIdForRollover = notificationId;
                } else if (notificationId != null && notificationId.startsWith("daily_")) {
                    scheduleIdForRollover = notificationId;
                }
            }
            // When firing run used daily_rollover_* id, resolve canonical schedule so we still apply rolloverIntervalMinutes
            String logicalScheduleIdForRollover = scheduleIdForRollover;
            if (scheduleIdForRollover != null && scheduleIdForRollover.startsWith("daily_rollover_")) {
                org.timesafari.dailynotification.Schedule canonical = org.timesafari.dailynotification.ScheduleHelper.getCanonicalRolloverScheduleBlocking(getApplicationContext());
                if (canonical != null) {
                    logicalScheduleIdForRollover = canonical.getId();
                }
            }
            Integer rolloverMinutes = null;
            if (logicalScheduleIdForRollover != null && !logicalScheduleIdForRollover.isEmpty()) {
                org.timesafari.dailynotification.Schedule s = org.timesafari.dailynotification.ScheduleHelper.getScheduleBlocking(getApplicationContext(), logicalScheduleIdForRollover);
                // Cancellation guard: if the user cancelled (row deleted, see cancelDailyReminder's
                // deleteById) or disabled this schedule since the alarm fired, stop here rather than
                // unconditionally re-arming the next occurrence. This closes the race described in
                // PLAN-simplify-static-and-refactor.md §3.4 — without this check, scheduleNextNotification
                // always re-armed regardless of cancellation state, which is why "cancel" didn't
                // actually stop notifications that were already in-flight when cancelled.
                if (s == null || !s.getEnabled()) {
                    Log.i(TAG, "DN|RESCHEDULE_SKIP_CANCELLED id=" + content.getId()
                        + " scheduleId=" + logicalScheduleIdForRollover
                        + " reason=" + (s == null ? "deleted" : "disabled"));
                    return;
                }
                if (s.getRolloverIntervalMinutes() != null && s.getRolloverIntervalMinutes() > 0) {
                    rolloverMinutes = s.getRolloverIntervalMinutes();
                    Log.d(TAG, "DN|ROLLOVER_INTERVAL scheduleId=" + logicalScheduleIdForRollover + " minutes=" + rolloverMinutes);
                }
            }
            long nextScheduledTime;
            if (rolloverMinutes != null && rolloverMinutes > 0) {
                nextScheduledTime = addMinutesToTime(content.getScheduledTime(), rolloverMinutes);
                Log.d(TAG, "DN|ROLLOVER_NEXT using_interval_minutes=" + rolloverMinutes + " next=" + nextScheduledTime);
            } else {
                nextScheduledTime = calculateNextScheduledTime(content.getScheduledTime());
            }
            
            // Check for existing notification at the same time to prevent duplicates
            DailyNotificationStorage legacyStorage = new DailyNotificationStorage(getApplicationContext());
            java.util.List<NotificationContent> existingNotifications = legacyStorage.getAllNotifications();
            
            // Look for existing notification scheduled at the same time (within 1 minute tolerance)
            boolean duplicateFound = false;
            long toleranceMs = 60 * 1000; // 1 minute tolerance for DST shifts
            
            for (NotificationContent existing : existingNotifications) {
                if (Math.abs(existing.getScheduledTime() - nextScheduledTime) <= toleranceMs) {
                    Log.w(TAG, "DN|RESCHEDULE_DUPLICATE id=" + content.getId() + 
                              " existing_id=" + existing.getId() + 
                              " time_diff_ms=" + Math.abs(existing.getScheduledTime() - nextScheduledTime));
                    duplicateFound = true;
                    break;
                }
            }
            
            if (duplicateFound) {
                Log.i(TAG, "DN|RESCHEDULE_SKIP id=" + content.getId() + " duplicate_prevented");
                return;
            }
            
            String cronExpression = null;
            String notificationId = content.getId();
            String scheduleId = scheduleIdForRollover;
            if (scheduleId == null || scheduleId.isEmpty()) {
                // Previously fell back to a freshly-minted "daily_rollover_<timestamp>" id here,
                // which created an orphan schedule row every time it triggered — itself a source
                // of the "stray schedule that survives cancellation" bug (generator #4 in
                // PLAN-simplify-static-and-refactor.md §2). Per §3.6, this branch should be
                // unreachable for static reminders (NotifyReceiver.scheduleExactNotification
                // always sets the schedule_id intent extra), so fail loudly instead of silently
                // spawning an untrackable, uncancellable orphan alarm.
                Log.e(TAG, "DN|RESCHEDULE_ABORT id=" + content.getId()
                    + " reason=no_resolvable_schedule_id - not re-arming to avoid creating an orphan schedule");
                return;
            }

            // When using rollover interval, next time already set; otherwise compute from cron (tomorrow same time)
            if (rolloverMinutes == null || rolloverMinutes <= 0) {
                try {
                    java.util.Calendar cal = java.util.Calendar.getInstance();
                    cal.setTimeInMillis(content.getScheduledTime());
                    int hour = cal.get(java.util.Calendar.HOUR_OF_DAY);
                    int minute = cal.get(java.util.Calendar.MINUTE);
                    cronExpression = String.format("%d %d * * *", minute, hour);
                    nextScheduledTime = calculateNextRunTimeFromCron(cronExpression);
                } catch (Exception e) {
                    Log.w(TAG, "Failed to calculate cron from scheduled time, using default", e);
                    cronExpression = "0 9 * * *";
                }
            } else {
                cronExpression = String.format("%d %d * * *",
                    java.util.Calendar.getInstance().get(java.util.Calendar.MINUTE),
                    java.util.Calendar.getInstance().get(java.util.Calendar.HOUR_OF_DAY));
            }
            
            // Create config for next notification
            org.timesafari.dailynotification.UserNotificationConfig config = 
                new org.timesafari.dailynotification.UserNotificationConfig(
                    true, // enabled
                    cronExpression,
                    content.getTitle() != null ? content.getTitle() : "Daily Notification",
                    content.getBody(),
                    content.isSound(),
                    true, // vibration
                    content.getPriority() != null ? content.getPriority() : "normal"
                );
            
            // Use centralized scheduling function with ROLLOVER_ON_FIRE source
            Log.d(TAG, "DN|ROLLOVER next=" + nextScheduledTime + " scheduleId=" + scheduleId + " static=" + preserveStaticReminder);
            org.timesafari.dailynotification.NotifyReceiver.scheduleExactNotification(
                getApplicationContext(),
                nextScheduledTime,
                config,
                preserveStaticReminder, // isStaticReminder – preserve so next run keeps title/body
                preserveStaticReminder ? scheduleId : null, // reminderId
                scheduleId,
                org.timesafari.dailynotification.ScheduleSource.ROLLOVER_ON_FIRE,
                false  // skipPendingIntentIdempotence – rollover path does not skip
            );
            if (scheduleId != null && !scheduleId.startsWith("daily_rollover_")) {
                org.timesafari.dailynotification.ScheduleHelper.updateScheduleNextRunTimeBlocking(
                    getApplicationContext(), scheduleId, content.getScheduledTime(), nextScheduledTime);
            }
            // Log next scheduled time in readable format
            String nextTimeStr = formatScheduledTime(nextScheduledTime);
            Log.i(TAG, "DN|RESCHEDULE_OK id=" + content.getId() + " next=" + nextTimeStr + " scheduleId=" + scheduleId);
            
            // Do not schedule prefetch for static reminders (single NotifyReceiver alarm is enough; avoids second alarm)
            if (preserveStaticReminder) {
                Log.d(TAG, "DN|RESCHEDULE_SKIP_PREFETCH static_reminder scheduleId=" + scheduleId);
            } else {
                // Schedule background fetch for next notification (5 minutes before scheduled time)
                try {
                    DailyNotificationStorage storageForFetcher = new DailyNotificationStorage(getApplicationContext());
                    DailyNotificationStorageRoom roomStorageForFetcher = new DailyNotificationStorageRoom(getApplicationContext());
                    DailyNotificationFetcher fetcher = new DailyNotificationFetcher(
                        getApplicationContext(),
                        storageForFetcher,
                        roomStorageForFetcher
                    );
                    long fetchTime = nextScheduledTime - TimeUnit.MINUTES.toMillis(5);
                    long currentTime = System.currentTimeMillis();
                    if (fetchTime > currentTime) {
                        fetcher.scheduleFetch(fetchTime);
                        Log.i(TAG, "DN|RESCHEDULE_PREFETCH_SCHEDULED id=" + content.getId() + " next_fetch=" + fetchTime + " next_notification=" + nextScheduledTime);
                    } else {
                        Log.w(TAG, "DN|RESCHEDULE_PREFETCH_PAST id=" + content.getId() + " fetch_time=" + fetchTime + " current=" + currentTime);
                        fetcher.scheduleImmediateFetch();
                    }
                } catch (Exception e) {
                    Log.e(TAG, "DN|RESCHEDULE_PREFETCH_ERR id=" + content.getId() + " error scheduling prefetch", e);
                }
            }
            
        } catch (Exception e) {
            Log.e(TAG, "DN|RESCHEDULE_ERR id=" + content.getId() + " err=" + e.getMessage(), e);
        } finally {
            Trace.endSection();
        }
    }

    /**
     * Load notification content by canonical schedule id (for static reminder / rollover user text).
     * Tries id then "daily_" + id to match getTitleBodyForSchedule / BootReceiver.
     */
    private NotificationContent getContentByScheduleId(String scheduleId) {
        if (scheduleId == null || scheduleId.isEmpty()) return null;
        try {
            org.timesafari.dailynotification.DailyNotificationDatabase db =
                org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
            NotificationContentEntity entity = db.notificationContentDao().getNotificationById(scheduleId);
            if (entity == null) {
                entity = db.notificationContentDao().getNotificationById("daily_" + scheduleId);
            }
            if (entity != null) {
                return mapEntityToContent(entity);
            }
        } catch (Throwable t) {
            Log.w(TAG, "DN|CANONICAL_READ_FAIL schedule_id=" + scheduleId + " err=" + t.getMessage());
        }
        return null;
    }

    /**
     * Try to load content from Room; fallback to legacy storage
     */
    private NotificationContent getContentFromRoomOrLegacy(String notificationId) {
        // Attempt Room
        try {
            DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
            // Use unified database (Kotlin schema with Java entities)
            org.timesafari.dailynotification.DailyNotificationDatabase db =
                org.timesafari.dailynotification.DailyNotificationDatabase.getInstance(getApplicationContext());
            NotificationContentEntity entity = db.notificationContentDao().getNotificationById(notificationId);
            if (entity != null) {
                return mapEntityToContent(entity);
            }
        } catch (Throwable t) {
            Log.w(TAG, "DN|ROOM_READ_FAIL id=" + notificationId + " err=" + t.getMessage());
        }
        // Fallback legacy
        DailyNotificationStorage legacy = new DailyNotificationStorage(getApplicationContext());
        return legacy.getNotificationContent(notificationId);
    }

    private NotificationContent mapEntityToContent(NotificationContentEntity entity) {
        NotificationContent c = new NotificationContent();
        // Preserve ID by embedding in URL hashcode; actual NotificationContent lacks explicit setter for ID in snippet
        // Assuming NotificationContent has setId; if not, ID used only for hashing here remains consistent via title/body/time
        try {
            java.lang.reflect.Method setId = NotificationContent.class.getDeclaredMethod("setId", String.class);
            setId.setAccessible(true);
            setId.invoke(c, entity.id);
        } catch (Exception ignored) { }
        c.setTitle(entity.title);
        c.setBody(entity.body);
        c.setScheduledTime(entity.scheduledTime);
        c.setPriority(mapPriorityFromInt(entity.priority));
        c.setSound(entity.soundEnabled);
        try {
            java.lang.reflect.Method setVibration = NotificationContent.class.getDeclaredMethod("setVibration", boolean.class);
            setVibration.setAccessible(true);
            setVibration.invoke(c, entity.vibrationEnabled);
        } catch (Exception ignored) { }
        c.setMediaUrl(entity.mediaUrl);
        return c;
    }

    private String mapPriorityFromInt(int p) {
        if (p >= 2) return "high";
        if (p <= -1) return "low";
        return "default";
    }

    private void saveNextToRoom(NotificationContent content) {
        try {
            DailyNotificationStorageRoom room = new DailyNotificationStorageRoom(getApplicationContext());
            NotificationContentEntity entity = new NotificationContentEntity(
                content.getId() != null ? content.getId() : java.util.UUID.randomUUID().toString(),
                "1.2.1",
                null,
                "daily",
                content.getTitle(),
                content.getBody(),
                content.getScheduledTime(),
                java.time.ZoneId.systemDefault().getId()
            );
            entity.priority = mapPriorityToInt(content.getPriority());
            try {
                java.lang.reflect.Method isVibration = NotificationContent.class.getDeclaredMethod("isVibration");
                Object vib = isVibration.invoke(content);
                if (vib instanceof Boolean) {
                    entity.vibrationEnabled = (Boolean) vib;
                }
            } catch (Exception ignored) { }
            entity.soundEnabled = content.isSound();
            room.saveNotificationContent(entity);
        } catch (Throwable t) {
            Log.w(TAG, "DN|ROOM_SAVE_FAIL err=" + t.getMessage());
        }
    }

    private int mapPriorityToInt(String priority) {
        if (priority == null) return 0;
        switch (priority) {
            case "max":
            case "high":
                return 2;
            case "low":
            case "min":
                return -1;
            default:
                return 0;
        }
    }
    
    /**
     * Add minutes to a timestamp (DST-safe via Calendar).
     * Used for rollover interval (e.g. 10 minutes for testing).
     */
    private long addMinutesToTime(long timeMillis, int minutes) {
        try {
            java.util.Calendar cal = java.util.Calendar.getInstance();
            cal.setTimeInMillis(timeMillis);
            cal.add(java.util.Calendar.MINUTE, minutes);
            return cal.getTimeInMillis();
        } catch (Exception e) {
            return timeMillis + (minutes * 60 * 1000L);
        }
    }

    /**
     * Calculate next scheduled time with DST-safe handling
     * 
     * @param currentScheduledTime Current scheduled time
     * @return Next scheduled time (24 hours later, DST-safe)
     */
    private long calculateNextScheduledTime(long currentScheduledTime) {
        try {
            // Get user's timezone
            ZoneId userZone = ZoneId.systemDefault();
            
            // Convert to ZonedDateTime
            ZonedDateTime currentZoned = ZonedDateTime.ofInstant(
                java.time.Instant.ofEpochMilli(currentScheduledTime), 
                userZone
            );
            
            // Add 24 hours (handles DST transitions automatically)
            ZonedDateTime nextZoned = currentZoned.plusHours(24);
            
            // Convert back to epoch millis
            return nextZoned.toInstant().toEpochMilli();
            
        } catch (Exception e) {
            Log.e(TAG, "DN|DST_CALC_ERR fallback_to_simple err=" + e.getMessage(), e);
            // Fallback to simple 24-hour addition if DST calculation fails
            return currentScheduledTime + (24 * 60 * 60 * 1000);
        }
    }
    
    /**
     * Format scheduled time for logging
     * 
     * @param scheduledTime Epoch millis
     * @return Formatted time string
     */
    /**
     * Helper to convert HH:mm time to cron expression
     */
    private String convertTimeToCron(String clockTime) {
        try {
            String[] parts = clockTime.split(":");
            if (parts.length == 2) {
                int hour = Integer.parseInt(parts[0]);
                int minute = Integer.parseInt(parts[1]);
                return String.format("%d %d * * *", minute, hour);
            }
        } catch (Exception e) {
            Log.w(TAG, "Failed to parse clockTime: " + clockTime, e);
        }
        return "0 9 * * *"; // Default to 9 AM
    }
    
    /**
     * Helper to calculate next run time from cron expression
     */
    private long calculateNextRunTimeFromCron(String cron) {
        try {
            String[] parts = cron.trim().split("\\s+");
            if (parts.length >= 2) {
                int minute = Integer.parseInt(parts[0]);
                int hour = Integer.parseInt(parts[1]);
                
                java.util.Calendar calendar = java.util.Calendar.getInstance();
                long now = calendar.getTimeInMillis();
                
                calendar.set(java.util.Calendar.HOUR_OF_DAY, hour);
                calendar.set(java.util.Calendar.MINUTE, minute);
                calendar.set(java.util.Calendar.SECOND, 0);
                calendar.set(java.util.Calendar.MILLISECOND, 0);
                
                long nextRun = calendar.getTimeInMillis();
                if (nextRun <= now) {
                    calendar.add(java.util.Calendar.DAY_OF_YEAR, 1);
                    nextRun = calendar.getTimeInMillis();
                }
                return nextRun;
            }
        } catch (Exception e) {
            Log.w(TAG, "Failed to calculate next run time from cron: " + cron, e);
        }
        // Fallback: use DST-safe calculation
        return calculateNextScheduledTime(System.currentTimeMillis());
    }
    
    private String formatScheduledTime(long scheduledTime) {
        try {
            ZonedDateTime zoned = ZonedDateTime.ofInstant(
                java.time.Instant.ofEpochMilli(scheduledTime), 
                ZoneId.systemDefault()
            );
            return zoned.format(DateTimeFormatter.ofPattern("HH:mm:ss on MM/dd/yyyy"));
        } catch (Exception e) {
            return "epoch:" + scheduledTime;
        }
    }
    
    /**
     * Get notification priority constant
     * 
     * @param priority Priority string from content
     * @return NotificationCompat priority constant
     */
    private int getNotificationPriority(String priority) {
        if (priority == null) {
            return NotificationCompat.PRIORITY_DEFAULT;
        }
        
        switch (priority.toLowerCase()) {
            case "high":
                return NotificationCompat.PRIORITY_HIGH;
            case "low":
                return NotificationCompat.PRIORITY_LOW;
            case "min":
                return NotificationCompat.PRIORITY_MIN;
            case "max":
                return NotificationCompat.PRIORITY_MAX;
            default:
                return NotificationCompat.PRIORITY_DEFAULT;
        }
    }
    
    // MARK: - Work Deduplication and Idempotence Methods
    
    /**
     * Create unique work key for deduplication
     * 
     * @param notificationId Notification ID
     * @param action Action type
     * @return Unique work key
     */
    private String createWorkKey(String notificationId, String action) {
        return String.format("%s_%s_%d", notificationId, action, System.currentTimeMillis() / (60 * 1000)); // Group by minute
    }
    
    /**
     * Acquire work lock to prevent duplicate execution
     * 
     * @param workKey Unique work key
     * @return true if lock acquired, false if work is already running
     */
    private boolean acquireWorkLock(String workKey) {
        try {
            // Clean up expired locks
            cleanupExpiredLocks();
            
            // Try to acquire lock
            AtomicBoolean lock = activeWork.computeIfAbsent(workKey, k -> new AtomicBoolean(false));
            
            if (lock.compareAndSet(false, true)) {
                workTimestamps.put(workKey, System.currentTimeMillis());
                Log.d(TAG, "DN|LOCK_ACQUIRED key=" + workKey);
                return true;
            } else {
                Log.d(TAG, "DN|LOCK_BUSY key=" + workKey);
                return false;
            }
            
        } catch (Exception e) {
            Log.e(TAG, "DN|LOCK_ERR key=" + workKey + " err=" + e.getMessage(), e);
            return false;
        }
    }
    
    /**
     * Release work lock
     * 
     * @param workKey Unique work key
     */
    private void releaseWorkLock(String workKey) {
        try {
            AtomicBoolean lock = activeWork.get(workKey);
            if (lock != null) {
                lock.set(false);
                workTimestamps.remove(workKey);
                Log.d(TAG, "DN|LOCK_RELEASED key=" + workKey);
            }
        } catch (Exception e) {
            Log.e(TAG, "DN|LOCK_RELEASE_ERR key=" + workKey + " err=" + e.getMessage(), e);
        }
    }
    
    /**
     * Check if work is already completed (idempotence)
     * 
     * @param workKey Unique work key
     * @return true if work is already completed
     */
    private boolean isWorkAlreadyCompleted(String workKey) {
        try {
            // Check if we have a completion record for this work
            DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
            String completionKey = "work_completed_" + workKey;
            
            // For now, we'll use a simple approach - check if the work was completed recently
            // In a production system, this would be stored in a database
            return false; // Always allow work to proceed for now
            
        } catch (Exception e) {
            Log.e(TAG, "DN|IDEMPOTENCE_CHECK_ERR key=" + workKey + " err=" + e.getMessage(), e);
            return false;
        }
    }
    
    /**
     * Mark work as completed for idempotence
     * 
     * @param workKey Unique work key
     */
    private void markWorkAsCompleted(String workKey) {
        try {
            DailyNotificationStorage storage = new DailyNotificationStorage(getApplicationContext());
            String completionKey = "work_completed_" + workKey;
            long completionTime = System.currentTimeMillis();
            
            // Store completion timestamp
            // Legacy storeLong may not exist; skip persistence for idempotence marker
            
            Log.d(TAG, "DN|WORK_COMPLETED key=" + workKey + " time=" + completionTime);
            
        } catch (Exception e) {
            Log.e(TAG, "DN|WORK_COMPLETION_ERR key=" + workKey + " err=" + e.getMessage(), e);
        }
    }
    
    /**
     * Clean up expired work locks
     */
    private void cleanupExpiredLocks() {
        try {
            long currentTime = System.currentTimeMillis();
            
            activeWork.entrySet().removeIf(entry -> {
                String workKey = entry.getKey();
                Long timestamp = workTimestamps.get(workKey);
                
                if (timestamp != null && (currentTime - timestamp) > WORK_TIMEOUT_MS) {
                    Log.d(TAG, "DN|LOCK_CLEANUP expired key=" + workKey);
                    workTimestamps.remove(workKey);
                    return true;
                }
                
                return false;
            });
            
        } catch (Exception e) {
            Log.e(TAG, "DN|LOCK_CLEANUP_ERR err=" + e.getMessage(), e);
        }
    }
    
    /**
     * Get work deduplication statistics
     * 
     * @return Statistics string
     */
    public static String getWorkDeduplicationStats() {
        return String.format("Active work: %d, Timestamps: %d", 
            activeWork.size(), workTimestamps.size());
    }
}
