"""
ActivityIdleMixin — idle detection, popup handling, and idle interval management.

Provides idle-state detection and user-popup lifecycle for
:class:`~ws_package.controllers.activity_controller.ActivityController`.
All methods assume ``self`` is an ``ActivityController`` instance and have access
to all attributes defined in ``ActivityController.__init__``.

Upload-pause behaviour
----------------------
``_should_pause_api_calls`` returns ``True`` when either the idle popup is
active or every interval in the current session has zero activity.  The push
loop in ``ActivityPushMixin.__push_activity_log`` enforces a hard cap of
``_MAX_API_PAUSE_CYCLES`` (default 3) consecutive pauses before forcing a
push through regardless.  This prevents the indefinite stall where repeated
all-zero intervals caused data to be withheld for 45-60 minutes.

The DB supplement scan in ``_should_pause_api_calls`` previously called
``db_writer_service.process_immediately()`` (synchronous lock acquire) and
``db_lock_manager.read_lock()`` (blocking threading.Condition) inside an
async function, which could deadlock and always blocked the event loop.  That
code has been replaced with a non-blocking in-memory scan of the write queue.

Thread-safety:
    All public and private async methods must be awaited from the async monitoring
    loop.  ``handle_idle_popup_shown()`` is synchronous and safe to call from any
    thread because it performs no shared-state mutation.
"""
from __future__ import annotations

import asyncio
import datetime
from typing import Optional, List, Tuple

from sqlalchemy import select, desc

from ws_package.log.writer import LogWriter
from ws_package.models.base import ActivityInterval, async_session
from ws_package.utils.db_lock_manager import db_lock_manager
from ws_package.utils.activity_level import normalize_activity_level_for_idle_resolution
import json

logger = LogWriter.file(__name__)


class ActivityIdleMixin:
    """Idle detection, popup lifecycle, and idle interval upload for ActivityController."""

    def _aligned_slot_bounds(self, at: Optional[datetime.datetime] = None) -> Tuple[datetime.datetime, datetime.datetime]:
        """Return [start, end) for the tracking slot that contains ``at`` (default: now)."""
        at = at or datetime.datetime.now()
        slot_minutes = max(1, int(getattr(self, "interval", 600) / 60))
        rounded_minute = (at.minute // slot_minutes) * slot_minutes
        slot_start = at.replace(minute=rounded_minute, second=0, microsecond=0)
        slot_end = slot_start + datetime.timedelta(minutes=slot_minutes)
        return slot_start, slot_end

    def _interval_matches_resolving_slot(
        self,
        start_time: datetime.datetime,
        end_time: datetime.datetime,
    ) -> bool:
        resolving = getattr(self, "_idle_resolving_slot", None)
        if not resolving:
            return False
        slot_start, slot_end = resolving
        return (
            start_time.replace(microsecond=0) == slot_start.replace(microsecond=0)
            and end_time.replace(microsecond=0) == slot_end.replace(microsecond=0)
        )

    def _is_session_marker_interval(self, interval) -> bool:
        """Session marker / dummy rows (start == end) are not tracking intervals."""
        start = interval.start_time.replace(microsecond=0)
        end = interval.end_time.replace(microsecond=0)
        return start == end

    def _intervals_are_time_consecutive(
        self,
        newer,
        older,
        tolerance_seconds: int = 60,
    ) -> bool:
        """True when ``older`` ends where ``newer`` starts (adjacent slots)."""
        time_diff = abs((newer.start_time - older.end_time).total_seconds())
        return time_diff <= tolerance_seconds

    def _filter_tracking_intervals(self, intervals: list) -> list:
        """Drop session markers; keep real intervals sorted by end_time descending."""
        tracking = [iv for iv in intervals if not self._is_session_marker_interval(iv)]
        return sorted(tracking, key=lambda x: x.end_time, reverse=True)

    async def _find_two_consecutive_idle_intervals(self, intervals: list) -> list:
        """
        Return the newest pair of time-adjacent intervals with zero activity, or [].

        Example (5 min setting, start 10:03): 10:03–10:05 + 10:05–10:10 both idle → popup.
        """
        candidates = self._filter_tracking_intervals(intervals)
        for i in range(len(candidates) - 1):
            newer = candidates[i]
            older = candidates[i + 1]
            if not self._intervals_are_time_consecutive(newer, older):
                continue
            if self._interval_matches_resolving_slot(newer.start_time, newer.end_time):
                continue
            if self._interval_matches_resolving_slot(older.start_time, older.end_time):
                continue
            newer_idle = await self._has_zero_activity_for_interval(
                newer.start_time, newer.end_time
            )
            older_idle = await self._has_zero_activity_for_interval(
                older.start_time, older.end_time
            )
            if newer_idle and older_idle:
                logger.debug(
                    "[IdleDetection] Found 2 consecutive idle intervals: %s (%s–%s) and %s (%s–%s)",
                    newer.id,
                    newer.start_time,
                    newer.end_time,
                    older.id,
                    older.start_time,
                    older.end_time,
                )
                return [newer, older]
        return []

    def _apply_resolving_slot_save_policy(
        self,
        start_date: datetime.datetime,
        end_date: datetime.datetime,
        activity_level: dict,
    ) -> Optional[Tuple[dict, bool]]:
        """
        When saving the slot where the user handled the idle popup: min 1% activity, not idle.

        Prior idle slots (e.g. 12:00–12:05, 12:05–12:10) are unchanged and stay idle.
        """
        if not self._interval_matches_resolving_slot(start_date, end_date):
            return None
        level = normalize_activity_level_for_idle_resolution(activity_level, minimum_percent=1.0)
        self._idle_resolving_slot = None
        logger.info(
            "[IdleResponse] Resolving slot %s to %s — activity=%s, is_idle=0 (prior idle slots unchanged)",
            start_date,
            end_date,
            level,
        )
        return level, False

    async def _refresh_resolving_slot_if_exists(
        self,
        start_date: datetime.datetime,
        end_date: datetime.datetime,
    ) -> bool:
        """If the resolving slot row already exists, merge live activity with min 1% and clear the flag."""
        if not self._interval_matches_resolving_slot(start_date, end_date):
            return False

        counter = self.last_count + 1
        with db_lock_manager.read_lock():
            async with async_session() as session:
                query = select(ActivityInterval).where(
                    ActivityInterval.counter == counter,
                    ActivityInterval.is_break == 0,
                    ActivityInterval.start_time == start_date,
                    ActivityInterval.end_time == end_date,
                ).limit(1)
                result = await session.execute(query)
                existing = result.scalars().first()

        if not existing:
            return False

        raw_level = await self.get_activity_level_from_db(start_date, end_date)
        new_level = normalize_activity_level_for_idle_resolution(raw_level, minimum_percent=1.0)
        existing.activityLevel = new_level
        existing.is_idle = 0
        await self.activity_service.update_activity_interval(existing)
        self._idle_resolving_slot = None
        from ws_package.services.db_writer_service import db_writer_service
        await db_writer_service.process_immediately()
        logger.info(
            "[IdleResponse] Refreshed resolving slot %s to %s (id=%s): %s",
            start_date,
            end_date,
            existing.id,
            new_level,
        )
        return True

    async def check_idle_detection(self):
        """
        Check if system is idle based on 2 consecutive completed intervals with zero activity.

        Popup requires two time-adjacent tracking intervals (not session markers) that are idle —
        e.g. 5 min setting: 10:03–10:05 + 10:05–10:10 (timer shows 10 min = 2 × interval).

        Returns:
            bool: True if idle detected, False otherwise
        """
        try:
            logger.info("[IdleDetection] Starting idle detection check (counter=%s)", self.last_count + 1)

            if self._idle_popup_active and self._idle_popup_triggered_at is not None:
                logger.info(
                    "[IdleDetection] Idle popup already active (triggered_at=%s) — skipping detection until user responds",
                    self._idle_popup_triggered_at,
                )
                return False

            # CRITICAL: Never trigger idle popup when break session is active (away time)
            current_break_id = getattr(self, 'break_id', 0)
            if current_break_id != 0 or self.is_break != 0:
                logger.info("[IdleDetection] Break session is active (break_id=%s, is_break=%s) - skipping idle detection", current_break_id, self.is_break)
                return False

            # FORCE FLUSH: Ensure the latest interval is written to DB before we check
            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            # Give a small buffer for the write to complete after processing triggers
            await asyncio.sleep(0.3)

            # CRITICAL FIX: Always include BOTH uploaded and unsent intervals from the start
            # This ensures we catch idle intervals even if some were uploaded before detection
            # The issue: If interval 75 is uploaded but 76, 77 are unsent, we need to find all of them together
            intervals = await self.activity_service.get_last_n_activity_intervals(
                n=4,  # Get more intervals to find consecutive ones
                counter=self.last_count + 1,
                exclude_breaks=True,  # Only check regular intervals, not breaks
                unsent_only=False  # ALWAYS include uploaded intervals (not just as fallback)
            )

            consecutive_idle = await self._find_two_consecutive_idle_intervals(intervals)
            if len(consecutive_idle) < 2:
                tracking = self._filter_tracking_intervals(intervals)
                logger.info(
                    "[IdleDetection] No 2 consecutive idle intervals (tracking_rows=%s, counter=%s)",
                    len(tracking),
                    self.last_count + 1,
                )
                return False

            for idx, interval in enumerate(consecutive_idle):
                activity_level = interval.activityLevel if hasattr(interval, 'activityLevel') else {}
                logger.info(
                    "[IdleDetection] Consecutive idle interval %s/2 (id=%s): %s to %s, "
                    "activity=[keyboard=%s%%, mouse=%s%%, average=%s%%]",
                    idx + 1,
                    interval.id,
                    interval.start_time,
                    interval.end_time,
                    activity_level.get('keyboard', '0.0'),
                    activity_level.get('mouse', '0.0'),
                    activity_level.get('average', '0.0'),
                )

            logger.info(
                "[IdleDetection] IDLE DETECTED: 2 consecutive idle intervals "
                "(counter=%s, interval_ids=%s, idle_minutes≈%s)",
                self.last_count + 1,
                [i.id for i in consecutive_idle],
                int(getattr(self, "interval", 600) / 60) * 2,
            )
            return True

        except Exception as e:
            logger.error("Error checking idle detection: %s", e, exc_info=True)
            return False

    async def _send_file_upload_success_to_electron(self, s3_url: str):
        """Sends file upload success message to Electron for UI notification."""
        try:
            from ws_package.utils.ipc_server import is_ipc_ready

            electron_comm = self._get_electron_comm()

            if not is_ipc_ready():
                logger.debug("IPC server not ready - skipping file upload success notification")
                return False

            if not electron_comm.is_connected():
                logger.debug("IPC not connected - skipping file upload success notification")
                return False

            # Send file upload success message
            message_data = {
                'message': 'File uploaded successfully',
                's3_url': s3_url,
                'timestamp': datetime.datetime.now().isoformat()
            }

            success = electron_comm.send_to_electron('idle_file_upload_success', message_data)

            if success:
                logger.info("Sent file upload success message to Electron (URL: %s...)", s3_url)
            else:
                logger.warning("Failed to send file upload success message to Electron")
            return success
        except Exception as e:
            logger.error("Error sending file upload success message to Electron: %s", e, exc_info=True)
            return False

    async def _send_file_upload_failure_to_electron(self):
        """Sends file upload failure message to Electron for UI notification."""
        try:
            from ws_package.utils.ipc_server import is_ipc_ready

            electron_comm = self._get_electron_comm()

            if not is_ipc_ready():
                logger.debug("IPC server not ready - skipping file upload failure notification")
                return False

            if not electron_comm.is_connected():
                logger.debug("IPC not connected - skipping file upload failure notification")
                return False

            # Send file upload failure message
            message_data = {
                'message': 'File upload failed',
                'timestamp': datetime.datetime.now().isoformat()
            }

            success = electron_comm.send_to_electron('idle_file_upload_failure', message_data)

            if success:
                logger.info("Sent file upload failure message to Electron")
            else:
                logger.warning("Failed to send file upload failure message to Electron")
            return success
        except Exception as e:
            logger.error("Error sending file upload failure message to Electron: %s", e, exc_info=True)
            return False

    async def check_idle_popup_timeout(self) -> bool:
        """
        Check if 60 minutes have passed since idle popup was triggered.
        If timeout reached, auto-stop timer and mark all idle intervals as unkeep.

        Returns:
            bool: True if timeout reached and auto-stop triggered, False otherwise
        """
        try:
            if not self._idle_popup_active or self._idle_popup_triggered_at is None:
                return False

            current_time = datetime.datetime.now()
            elapsed_seconds = (current_time - self._idle_popup_triggered_at).total_seconds()

            logger.info("[IdleTimeout] Checking timeout: elapsed=%ss, limit=%ss", int(elapsed_seconds), self._idle_popup_timeout_seconds)

            if elapsed_seconds >= self._idle_popup_timeout_seconds:
                logger.warning("[IdleTimeout] TIMEOUT REACHED: %ss >= %ss - Auto-stopping timer", int(elapsed_seconds), self._idle_popup_timeout_seconds)

                # CRITICAL FIX: Mark ALL intervals created during idle popup period (from _idle_popup_triggered_at to now)
                # as unkeep (is_uploaded=True). This includes both idle intervals AND intervals with activity
                # that occurred while popup was active but not handled
                from ws_package.models.base import async_session, ActivityInterval
                from ws_package.utils.db_lock_manager import db_lock_manager
                from sqlalchemy import select, and_

                with db_lock_manager.read_lock():
                    async with async_session() as session:
                        # Query ALL intervals from popup trigger time onwards (not just idle ones)
                        query = select(ActivityInterval).where(
                            and_(
                                ActivityInterval.counter == self.last_count + 1,
                                ActivityInterval.start_time >= self._idle_popup_triggered_at,
                                ActivityInterval.is_uploaded == False
                            )
                        )
                        result = await session.execute(query)
                        all_intervals = result.scalars().all()

                        if all_intervals:
                            from ws_package.utils.db_write_queue import db_write_queue, WriteOperationType
                            interval_ids = []
                            idle_count = 0
                            activity_count = 0
                            for interval in all_intervals:
                                interval_ids.append(interval.id)
                                if interval.is_idle:
                                    idle_count += 1
                                else:
                                    activity_count += 1
                                db_write_queue.enqueue(
                                    WriteOperationType.UPDATE_ACTIVITY_INTERVAL,
                                    interval.id,
                                    {'is_uploaded': True}
                                )

                            logger.warning("[IdleTimeout] Marking %s intervals from popup period as unkeep (is_uploaded=True): %s idle + %s with activity, interval_ids=%s%s", len(interval_ids), idle_count, activity_count, interval_ids, '...' if len(interval_ids) > 10 else '')

                            # Flush immediately
                            from ws_package.services.db_writer_service import db_writer_service
                            await db_writer_service.process_immediately()

                # Clear popup state
                self._idle_popup_active = False
                self._idle_popup_triggered_at = None
                self._api_pause_active = False
                # Stop saving new intervals; Electron must drive a full stop so threads
                # and monitoring_started are cleared (do not set force_stop — blocks next Start).
                self.status = False

                logger.warning("[IdleTimeout] Timer stopped due to unhandled idle popup (60 minutes timeout)")

                # Ask Electron to drive the normal stop path. Breaking push alone is not
                # enough — MonitorThread would restart it and leave a half-stopped session.
                try:
                    electron_comm = self._get_electron_comm()
                    if electron_comm and electron_comm.is_connected():
                        electron_comm.send_to_electron('idle_timeout_auto_stop', {
                            'message': 'Your system has been idle for 60 minutes thus timer has stopped automatically...',
                            'timestamp': current_time.isoformat(),
                        })
                        logger.info("[IdleTimeout] Sent auto-stop signal to Electron")
                except Exception as e:
                    logger.error("[IdleTimeout] Error sending auto-stop signal to Electron: %s", e, exc_info=True)

                return True

            return False

        except Exception as e:
            logger.error("[IdleTimeout] Error checking idle popup timeout: %s", e, exc_info=True)
            return False

    async def arise_idle_popup(self):
        """
        Trigger idle popup in Electron app.
        This method sends a message to Electron to display the idle popup.
        Includes debounce mechanism to prevent duplicate triggers and retry logic for TCP disconnections.

        NEW REQUIREMENT:
        - If 2 consecutive idle intervals found, show popup
        - While popup active, block server uploads
        - Check 60-minute timeout for auto-stop
        """
        try:
            logger.info("[IdlePopup] arise_idle_popup() called - checking conditions before sending trigger to Electron")

            # Check 60-minute timeout first
            timeout_reached = await self.check_idle_popup_timeout()
            if timeout_reached:
                logger.warning("[IdlePopup] Timeout reached - auto-stop triggered, skipping popup")
                return

            if self._idle_popup_active and self._idle_popup_triggered_at is not None:
                logger.info(
                    "[IdlePopup] Popup already active (triggered_at=%s) — not sending another trigger",
                    self._idle_popup_triggered_at,
                )
                return

            # CRITICAL: Never trigger idle popup when break session is active (away time)
            current_break_id = getattr(self, 'break_id', 0)
            if current_break_id != 0 or self.is_break != 0:
                logger.info("[IdlePopup] Break session is active (break_id=%s, is_break=%s) - skipping idle popup trigger", current_break_id, self.is_break)
                return

            # No cooldown: Python sends trigger every time 2+ consecutive idle intervals detected.
            # Frontend suppresses duplicate popups (if popup already visible, ignores new triggers).
            # First popup's response (keep/unkeep/stop/continue) handles ALL consecutive idle intervals.

            current_time = datetime.datetime.now()
            from ws_package.utils.ipc_server import is_ipc_ready

            electron_comm = self._get_electron_comm()

            # Check if IPC server is running
            if not is_ipc_ready():
                logger.error("[IdlePopup] IPC server is not running - cannot send idle popup. IPC server may not have started properly.")
                return

            # Retry logic: Try sending with exponential backoff if IPC is not connected
            max_retries = 10  # Increased retries to give more time for connection
            retry_delays = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0, 6.0, 8.0, 10.0]  # Longer delays, more attempts
            success = False

            for attempt in range(max_retries):
                # Check if connected before attempting to send
                is_connected = electron_comm.is_connected()

                if not is_connected:
                    if attempt < max_retries - 1:
                        delay = retry_delays[attempt] if attempt < len(retry_delays) else 10.0
                        logger.info("IPC not connected for idle popup (attempt %s/%s), IPC server ready: %s, retrying in %ss...",
                                  attempt + 1, max_retries, is_ipc_ready(), delay)
                        await asyncio.sleep(delay)
                        continue
                    else:
                        logger.warning("IPC not connected after %s attempts - idle popup not sent. IPC server ready: %s",
                                     max_retries, is_ipc_ready())
                        break

                # Calculate actual consecutive idle intervals
                consecutive_idle_count = await self._get_consecutive_idle_count()
                logger.info("[IdlePopup] Calculated consecutive idle count: %s", consecutive_idle_count)

                # Strictly after 2 idle intervals: only show popup when count >= 2
                if consecutive_idle_count < 2:
                    logger.info("[IdlePopup] Not triggered: consecutive_idle_count=%s (need >= 2)", consecutive_idle_count)
                    return

                # Send idle popup trigger message to Electron
                logger.info("[IdlePopup] Triggering popup: consecutive_idle_count=%s, timestamp=%s", consecutive_idle_count, current_time.isoformat())
                success = electron_comm.send_to_electron('idle_popup_trigger', {
                    'remaining_intervals': consecutive_idle_count,
                    'timestamp': current_time.isoformat()
                })

                if success:
                    self._idle_popup_active = True  # Mark idle popup as active (for interval is_idle marking)
                    self._idle_popup_triggered_at = current_time  # Track when popup was triggered
                    self._api_pause_active = True  # Block server uploads while popup active

                    logger.info("[IdlePopup] Trigger sent to Electron — waiting for user response")
                    logger.info("[IdlePopup] Trigger sent to Electron (2 consecutive zero-activity intervals) [attempt %s]", attempt + 1)
                    logger.info("[IdlePopup] State: active=True, triggered_at=%s, timeout_limit=%ss", current_time.isoformat(), self._idle_popup_timeout_seconds)
                    logger.info("[IdlePopup] Server uploads BLOCKED until popup handled")

                    # Mark all previous consecutive zero-activity intervals as is_idle=1
                    await self._mark_previous_zero_activity_intervals_as_idle()

                    break
                else:
                    if attempt < max_retries - 1:
                        delay = retry_delays[attempt] if attempt < len(retry_delays) else 10.0
                        logger.info("Failed to send idle popup (attempt %s/%s), IPC connected: %s, retrying in %ss...",
                                  attempt + 1, max_retries, electron_comm.is_connected(), delay)
                        await asyncio.sleep(delay)
                    else:
                        logger.warning(
                            "Failed to send idle popup trigger after %s attempts. "
                            "Final IPC status - Connected: %s, IPC server ready: %s",
                            max_retries, electron_comm.is_connected(), is_ipc_ready())
                        # Electron not connected (silent mode) — activate the auto-stop timeout
                        # directly without showing a popup. check_idle_popup_timeout() will
                        # auto-stop the timer after _idle_popup_timeout_seconds elapses.
                        self._idle_popup_active = True
                        self._idle_popup_triggered_at = current_time
                        logger.warning(
                            "[IdlePopup] Silent mode — Electron not connected, cannot show idle popup. "
                            "Auto-stop timer will fire after %ss of inactivity.",
                            self._idle_popup_timeout_seconds
                        )

            if not success:
                logger.warning("Idle popup could not be sent - IPC connection unavailable. Will retry on next idle detection if cooldown allows.")

        except Exception as e:
            logger.error("Error triggering idle popup: %s", e, exc_info=True)

    async def _get_consecutive_idle_count(self) -> int:
        """
        Count consecutive zero-activity tracking intervals ending at the most recent one.

        Only counts time-adjacent intervals (skips session markers). Popup needs count >= 2
        (e.g. 5 min setting → 2 intervals ≈ 10 min idle shown on the timer).
        """
        try:
            from ws_package.models.base import async_session, ActivityInterval
            from ws_package.utils.db_lock_manager import db_lock_manager
            from sqlalchemy import select, desc

            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.3)

            with db_lock_manager.read_lock():
                async with async_session() as session:
                    query = select(ActivityInterval).where(
                        ActivityInterval.counter == self.last_count + 1,
                        ActivityInterval.is_break == 0,
                    ).order_by(desc(ActivityInterval.start_time))

                    result = await session.execute(query)
                    all_intervals = result.scalars().all()

                    if not all_intervals:
                        logger.debug("No intervals found for consecutive idle count calculation")
                        return 0

                    count = 0
                    oldest_in_chain = None

                    for interval in all_intervals:
                        if self._is_session_marker_interval(interval):
                            continue

                        if self._interval_matches_resolving_slot(
                            interval.start_time, interval.end_time
                        ):
                            break

                        if (
                            self._idle_popup_active
                            and self._idle_popup_triggered_at
                            and hasattr(interval, 'is_idle')
                            and interval.is_idle
                            and interval.start_time < self._idle_popup_triggered_at
                        ):
                            break

                        has_zero_activity = await self._has_zero_activity_for_interval(
                            interval.start_time, interval.end_time
                        )
                        if not has_zero_activity:
                            break

                        if count == 0:
                            count = 1
                            oldest_in_chain = interval
                        elif oldest_in_chain is not None and self._intervals_are_time_consecutive(
                            oldest_in_chain, interval
                        ):
                            count += 1
                            oldest_in_chain = interval
                        else:
                            break

                    logger.info(
                        "Calculated consecutive idle count: %s (checked %s rows, popup_active=%s)",
                        count,
                        len(all_intervals),
                        self._idle_popup_active,
                    )
                    return count
        except Exception as e:
            logger.error("Error calculating consecutive idle count: %s", e, exc_info=True)
            return 0

    async def _mark_previous_zero_activity_intervals_as_idle(self):
        """
        Mark all consecutive zero-activity intervals as is_idle=1.
        This includes intervals before the popup was triggered.
        Checks both activityLevel and activity_percentage for zero activity.
        """
        try:
            if self._idle_popup_triggered_at is None:
                return

            from ws_package.models.base import async_session, ActivityInterval
            from ws_package.utils.db_lock_manager import db_lock_manager
            from ws_package.utils.db_write_queue import db_write_queue, WriteOperationType
            from ws_package.repositories.activity_percentage_repo import activity_percentage_manager
            from sqlalchemy import select, desc

            # Flush queue first to ensure all pending writes are committed
            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.3)

            with db_lock_manager.read_lock():
                async with async_session() as session:
                    # Get all unsent intervals for current counter (excluding breaks), ordered by start_time descending (newest first)
                    query = select(ActivityInterval).where(
                        ActivityInterval.counter == self.last_count + 1,
                        ActivityInterval.is_break == 0
                    ).order_by(desc(ActivityInterval.start_time))

                    result = await session.execute(query)
                    all_intervals = result.scalars().all()

                    if not all_intervals:
                        return

                    # Check intervals from newest to oldest (backwards) to find consecutive zero-activity intervals
                    # Mark all consecutive zero-activity intervals going backwards until we hit one with activity
                    # CRITICAL: Only mark intervals that occurred AFTER the last idle resolution to prevent accumulation
                    intervals_to_mark = []
                    intervals_skipped_from_previous_idle = 0
                    oldest_in_chain = None

                    # Start from the newest interval and go backwards
                    for interval in all_intervals:
                        if self._is_session_marker_interval(interval):
                            continue

                        # CRITICAL FIX: Skip intervals from previous idle periods
                        # If we have a last idle resolution time, only consider intervals that ended AFTER that time
                        # This prevents accumulation of idle time across multiple idle periods in the same session
                        if self._last_idle_resolution_time and interval.end_time <= self._last_idle_resolution_time:
                            intervals_skipped_from_previous_idle += 1
                            logger.debug("Skipping interval %s from previous idle period (end_time=%s <= last_resolution=%s)", interval.id, interval.end_time, self._last_idle_resolution_time)
                            continue

                        # Check if interval has zero activity (both activityLevel and activity_percentage)
                        has_zero_activity = await self._has_zero_activity_for_interval(interval.start_time, interval.end_time)

                        if has_zero_activity:
                            if oldest_in_chain is not None and not self._intervals_are_time_consecutive(
                                oldest_in_chain, interval
                            ):
                                break
                            oldest_in_chain = interval
                            if not (hasattr(interval, 'is_idle') and interval.is_idle):
                                intervals_to_mark.append(interval)
                        else:
                            # Found activity - stop checking further intervals (older intervals)
                            # All consecutive zero-activity intervals from newest have been found
                            break

                    # Log if we skipped any intervals from previous idle periods
                    if intervals_skipped_from_previous_idle > 0:
                        logger.info("Skipped %s intervals from previous idle period(s) (last resolution time: %s)", intervals_skipped_from_previous_idle, self._last_idle_resolution_time)

                    # Mark intervals as is_idle=1
                    if intervals_to_mark:
                        logger.info("Marking %s consecutive zero-activity intervals as is_idle=1 (interval IDs: %s)", len(intervals_to_mark), [i.id for i in intervals_to_mark])
                        for interval in intervals_to_mark:
                            try:
                                # Update via queue
                                from ws_package.repositories.activity_repo import activity_manager

                                # Set is_idle=True on the interval object
                                interval.is_idle = True
                                result = await activity_manager.update_activity_interval_is_idle(interval)
                                if not result:
                                    logger.warning("Failed to enqueue is_idle update for interval %s", interval.id)
                            except Exception as e:
                                logger.error("Error marking interval %s as is_idle=1: %s", interval.id, e, exc_info=True)
                    else:
                        logger.debug("No intervals found to mark as is_idle=1")

        except Exception as e:
            logger.error("Error marking previous zero-activity intervals as idle: %s", e, exc_info=True)

    async def _has_zero_activity_for_interval(self, start_date: datetime.datetime, end_date: datetime.datetime) -> bool:
        """
        Check if an interval has zero activity in both activityLevel and activity_percentage.

        Args:
            start_date: Interval start time
            end_date: Interval end time

        Returns:
            bool: True if both activityLevel and activity_percentage have zero activity
        """
        try:
            # Get activityLevel
            activityLevel = await self.get_activity_level_from_db(start_date, end_date)
            keyboard = float(activityLevel.get('keyboard', 0) or 0)
            mouse = float(activityLevel.get('mouse', 0) or 0)
            average = float(activityLevel.get('average', 0) or 0)
            has_activity_level_activity = keyboard > 0 or mouse > 0 or average > 0

            # Get activity_percentage
            has_activity_percentage_activity = False
            try:
                from ws_package.repositories.activity_percentage_repo import activity_percentage_manager
                activity_percentage = await activity_percentage_manager.find_overlapping_activity_percentage(
                    start_date, end_date
                )
                if activity_percentage:
                    has_activity_percentage_activity = (
                        activity_percentage.keyboard > 0 or
                        activity_percentage.mouse > 0 or
                        activity_percentage.average > 0
                    )
            except Exception as e:
                logger.debug("Error checking activity_percentage for interval %s to %s: %s", start_date, end_date, e)

            # Both must be zero
            return not has_activity_level_activity and not has_activity_percentage_activity

        except Exception as e:
            logger.error("Error checking zero activity for interval: %s", e, exc_info=True)
            return False

    def _filter_intervals_excluding_in_progress_resolving_slot(self, intervals: list) -> list:
        """Drop the in-progress resolving slot from sync until the slot boundary is reached."""
        resolving = getattr(self, "_idle_resolving_slot", None)
        if not resolving or not intervals:
            return intervals

        slot_start, slot_end = resolving
        now_seconds = datetime.datetime.now().replace(microsecond=0)
        if now_seconds >= slot_end.replace(microsecond=0):
            return intervals

        before = len(intervals)
        filtered = [
            iv for iv in intervals
            if not (
                iv.start_time.replace(microsecond=0) == slot_start.replace(microsecond=0)
                and iv.end_time.replace(microsecond=0) == slot_end.replace(microsecond=0)
            )
        ]
        if len(filtered) < before:
            logger.info(
                "[IdleResponse] Excluded in-progress resolving slot %s to %s from immediate sync",
                slot_start,
                slot_end,
            )
        return filtered

    async def _update_resolving_slot_interval_if_exists(
        self,
        slot_start: datetime.datetime,
        slot_end: datetime.datetime,
        minimum_percent: float = 1.0,
    ) -> bool:
        """Update an existing resolving-slot interval with minimum activity (late popup handle)."""
        from ws_package.services.db_writer_service import db_writer_service

        await db_writer_service.process_immediately()
        await asyncio.sleep(0.3)

        counter = self.last_count + 1
        raw_level = await self.get_activity_level_from_db(slot_start, slot_end)
        new_level = normalize_activity_level_for_idle_resolution(raw_level, minimum_percent)

        existing = None
        with db_lock_manager.read_lock():
            async with async_session() as session:
                query = select(ActivityInterval).where(
                    ActivityInterval.counter == counter,
                    ActivityInterval.is_break == 0,
                    ActivityInterval.start_time == slot_start,
                    ActivityInterval.end_time == slot_end,
                ).limit(1)
                result = await session.execute(query)
                existing = result.scalars().first()

        if not existing:
            return False

        existing.activityLevel = new_level
        existing.is_idle = 0
        await self.activity_service.update_activity_interval(existing)
        await db_writer_service.process_immediately()
        logger.info(
            "[IdleResponse] Resolving slot %s to %s updated (id=%s): %s, is_idle=0",
            slot_start,
            slot_end,
            existing.id,
            new_level,
        )
        return True

    async def _persist_idle_popup_intervals_minimum_activity(self, minimum_percent: float = 1.0) -> None:
        """
        Mark the slot where the user handled the popup for minimum-activity resolution.

        In-progress slots are not written to the database until the monitoring loop reaches
        the slot boundary. Earlier idle slots stay at 0% and are unchanged here.

        If the user handles the popup after the slot already completed (slow response), update
        the existing interval row immediately.
        """
        now = datetime.datetime.now()
        now_seconds = now.replace(microsecond=0)
        slot_start, slot_end = self._aligned_slot_bounds(now)
        slot_minutes = max(1, int(getattr(self, "interval", 600) / 60))

        if now_seconds < slot_end.replace(microsecond=0):
            self._idle_resolving_slot = (slot_start, slot_end)
            logger.info(
                "[IdleResponse] Resolving slot %s to %s deferred until slot boundary (now=%s)",
                slot_start,
                slot_end,
                now_seconds,
            )
            return

        prev_slot_end = slot_start
        prev_slot_start = slot_start - datetime.timedelta(minutes=slot_minutes)
        self._idle_resolving_slot = (prev_slot_start, prev_slot_end)
        updated = await self._update_resolving_slot_interval_if_exists(
            prev_slot_start, prev_slot_end, minimum_percent
        )
        if updated:
            self._idle_resolving_slot = None
        else:
            logger.info(
                "[IdleResponse] Resolving slot %s to %s marked — interval will be created at slot boundary",
                prev_slot_start,
                prev_slot_end,
            )

    async def handle_idle_popup_response(self, keep_idle_time: bool, action: str, project_id: Optional[str] = None, task_id: Optional[str] = None, note: Optional[str] = None, file_path: Optional[str] = None):
        """
        Handle idle popup response from Electron.

        CRITICAL: The first popup response (keep/unkeep/stop/continue) applies to ALL consecutive
        idle intervals in the current session. Python sends idle triggers every interval (no cooldown),
        frontend shows only one popup and suppresses duplicates; this single response handles all.

        Args:
            keep_idle_time: True if "keep" checkbox is checked, False if unchecked
            action: Action taken ('continue', 'stop', 'reassign')
            project_id: Project ID for reassign action (required if action is 'reassign')
            task_id: Task ID for reassign action (optional, defaults to "0" if not provided)
            note: Optional note provided by user when keeping idle time (max 120 characters)
            file_path: Optional local file path to upload to S3
        """
        try:
            logger.info("[IdleResponse] Handling idle popup response: keep_idle_time=%s, action=%s, project_id=%s, task_id=%s, file_path=%s", keep_idle_time, action, project_id, task_id, file_path)

            await self._persist_idle_popup_intervals_minimum_activity(minimum_percent=1.0)

            # Upload file to S3 if file_path is provided
            s3_file_url = None
            file_upload_success = False
            if file_path:
                try:
                    s3_file_url = await self._upload_file_to_s3(file_path)
                    if s3_file_url:
                        logger.info("File uploaded to S3 successfully: %s", s3_file_url)
                        file_upload_success = True
                    else:
                        logger.warning("File upload to S3 failed or returned no URL for file: %s", file_path)
                        # Send failure message to Electron immediately
                        await self._send_file_upload_failure_to_electron()
                except Exception as e:
                    logger.error("Error uploading file to S3: %s", e, exc_info=True)
                    # Send failure message to Electron immediately
                    await self._send_file_upload_failure_to_electron()
                    # Continue without file - note will be saved without file_path

            # Save note and/or file URL to the idle intervals
            # We save if there's a note OR if a file was successfully uploaded
            if (note is not None and note.strip()) or s3_file_url:
                # Use empty string for note if only file is provided
                note_text = note if (note is not None and note.strip()) else ""

                # Truncate note to maximum 120 characters
                MAX_NOTE_LENGTH = 120
                if len(note_text) > MAX_NOTE_LENGTH:
                    original_length = len(note_text)
                    note_text = note_text[:MAX_NOTE_LENGTH]
                    logger.warning("Note truncated from %s to %s characters", original_length, MAX_NOTE_LENGTH)

                await self._save_note_to_idle_intervals(note_text, s3_file_url)
                logger.info("Note/File saved to idle intervals: note_length=%s, has_file=%s", len(note_text), s3_file_url is not None)

            # Send success message to Electron ONLY after database queuing
            if file_upload_success and s3_file_url:
                await self._send_file_upload_success_to_electron(s3_file_url)
                logger.info("File upload success message sent to Electron after database queuing")

            # Handle reassign action
            if action == 'reassign':
                if not project_id:
                    logger.error("Reassign action requires project_id - ignoring reassign request")
                    return

                # Set current project and task IDs so new intervals after reassign will use these values
                self.set_project_task_ids(project_id, task_id)
                logger.info("Set current project_id=%s, task_id=%s after reassign", self.current_project_id, self.current_task_id)

                # Reassign idle intervals to the new project/task
                await self._idle_reassign(project_id, task_id)

                # Clear idle popup active flag after handling reassign
                self._idle_popup_active = False
                self._idle_popup_triggered_at = None
                self._api_pause_active = False  # Resume API calls after handling response
                logger.info("Cleared idle popup active flag after handling reassign - API calls will resume")
                return

            # NEW: Handle held idle intervals based on keep_idle_time
            # CRITICAL: Only handle held intervals from current counter
            current_counter = self.last_count + 1
            if self._held_idle_intervals:
                # Filter held intervals by current counter
                current_counter_held = [iv for iv in self._held_idle_intervals if getattr(iv, 'counter', None) == current_counter]
                if current_counter_held:
                    if keep_idle_time:
                        # User wants to keep idle time - held intervals will be sent (is_uploaded=False)
                        logger.info("[IdleResponse] User chose to keep idle time - %s held idle interval(s) from current counter (%s) will be sent", len(current_counter_held), current_counter)
                        # Remove current counter intervals from held list (they will be sent in next sync/upload cycle)
                        self._held_idle_intervals = [iv for iv in self._held_idle_intervals if getattr(iv, 'counter', None) != current_counter]
                    else:
                        # User doesn't want to keep idle time - mark held intervals as uploaded (won't be sent)
                        logger.info("[IdleResponse] User chose not to keep idle time - marking %s held idle interval(s) from current counter (%s) as uploaded", len(current_counter_held), current_counter)
                        for interval in current_counter_held:
                            try:
                                interval.is_uploaded = True
                                # Save to database
                                from ws_package.services.db_writer_service import db_writer_service
                                db_writer_service.queue_write(interval)
                            except Exception as e:
                                logger.error("Error marking held idle interval %s as uploaded: %s", interval.id, e, exc_info=True)
                        # Remove current counter intervals from held list
                        self._held_idle_intervals = [iv for iv in self._held_idle_intervals if getattr(iv, 'counter', None) != current_counter]

                # Clear any remaining held intervals from previous counters
                if self._held_idle_intervals:
                    logger.info("[IdleResponse] Clearing %s held idle interval(s) from previous counter(s)", len(self._held_idle_intervals))
                    self._held_idle_intervals = []

            # Handle continue/stop actions
            # If keep_idle_time is True: keep is_uploaded=false (do nothing, current state is correct)
            # If keep_idle_time is False: set is_uploaded=1 for all consecutive idle intervals

            if keep_idle_time:
                # User checked "keep" box - keep is_uploaded=false (no action needed)
                logger.info("User checked 'keep' box - idle intervals will remain with is_uploaded=False")

                # CRITICAL: If user chose to keep idle time and continue, sync immediately
                if action == 'continue':
                    logger.info("Action is 'continue' with keep_idle_time=True - triggering immediate sync")
                    await self.sync_activity_immediately()
            else:
                # User unchecked "keep" box - mark idle/popup-period intervals as uploaded (is_uploaded=1)
                await self._mark_idle_intervals_as_uploaded()

            # Clear idle popup active flag after handling response
            logger.info("[IdleResponse] Clearing popup state and resuming normal operation")
            self._idle_popup_active = False
            self._last_idle_resolution_time = datetime.datetime.now()
            self._idle_popup_triggered_at = None
            self._api_pause_active = False  # Resume API calls after handling response
            logger.info("[IdleResponse] State cleared - unsent idle intervals suppressed when keep was unchecked")

        except Exception as e:
            logger.error("Error handling idle popup response: %s", e, exc_info=True)

    def handle_idle_popup_shown(self):
        """
        Called when Electron confirms the idle popup was shown to the user.
        No-op since cooldown was removed; frontend suppresses duplicate popups.
        """
        logger.debug("Idle popup shown confirmed by Electron (cooldown removed)")

    async def sync_activity_immediately(self):
        """
        Immediately sync all unuploaded activity intervals to the server.
        Called when user chooses to keep idle time in the popup.
        """
        try:
            logger.info("=== sync_activity_immediately() called ===")

            # 1. Flush DB queue to ensure all pending intervals are committed
            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.5)  # Wait for commit visibility

            # 2. Get unuploaded intervals from current session only
            # CRITICAL: Filter by current session counter to prevent picking intervals from previous sessions
            current_session_counter = self.last_count + 1
            logger.info("Filtering intervals by current session counter=%s for immediate sync", current_session_counter)
            intervals = await self.activity_service.get_activity_intervals(self.main_start_date, self.main_end_date, counter=current_session_counter)

            if not intervals:
                logger.info("No intervals found to sync immediately")
                return

            intervals = self._filter_intervals_excluding_in_progress_resolving_slot(intervals)

            if not intervals:
                logger.info("No completed intervals to sync immediately")
                return

            logger.info("Found %s intervals to sync immediately", len(intervals))

            # 3. Process and upload using process_intervals_bulk
            org_id = self.user_entity.org_id
            user_entity = self.user_entity

            # process_intervals_bulk is an async generator
            async for response in self.process_intervals_bulk(intervals, org_id, user_entity):
                if not response or not isinstance(response, (list, tuple)) or len(response) < 2:
                    continue

                payload = response[0]
                batch_intervals = response[1]

                logger.info("Sending immediate sync payload for %s intervals", len(batch_intervals))

                # Send to API
                api_response = self.activity_service.push_activity_logs(payload)
                logger.info("Immediate sync API response: %s", api_response)

                if api_response and api_response.get("planStatus") == "active":
                    # Mark intervals as uploaded in DB
                    for interval in batch_intervals:
                        try:
                            await self.activity_service.update_activity_interval(interval)
                        except Exception as e:
                            logger.error("Error marking interval %s as uploaded: %s", interval.id, e)

                    # Update screenshot statuses
                    for day_payload in payload:
                        for interval_data in day_payload.get('intervals', []):
                            screen_url = interval_data.get("screenUrl")
                            if screen_url:
                                try:
                                    await self.screenshot_controller.update_screenshots(screen_url, self.last_count + 1)
                                except Exception as e:
                                    logger.error("Error updating screenshot status for %s: %s", screen_url, e)
                else:
                    logger.warning("Immediate sync API call failed or returned inactive status: %s", api_response)

            logger.info("=== sync_activity_immediately() completed ===")

        except Exception as e:
            logger.error("Error in sync_activity_immediately: %s", e, exc_info=True)

    async def _mark_idle_intervals_as_uploaded(self):
        """
        Mark all consecutive idle intervals (is_idle=1) as uploaded (is_uploaded=1).
        This is called when user unchecks the "keep" box in idle popup.
        All intervals with is_idle=True should be marked as is_uploaded=True.
        """
        try:
            from ws_package.models.base import async_session, ActivityInterval
            from ws_package.utils.db_lock_manager import db_lock_manager
            from ws_package.utils.db_write_queue import db_write_queue, WriteOperationType
            from sqlalchemy import select, desc, update
            from ws_package.repositories.activity_percentage_repo import activity_percentage_manager

            # Flush queue first to ensure all pending writes are committed (especially is_idle=1 updates)
            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.5)  # Increased delay to ensure is_idle updates are committed

            current_counter = self.last_count + 1

            with db_lock_manager.read_lock():
                async with async_session() as session:
                    # Find ALL intervals marked as idle (is_idle=True) that haven't been uploaded yet
                    # These should include all consecutive idle intervals that triggered the popup
                    # _mark_previous_zero_activity_intervals_as_idle() should have already marked
                    # all consecutive idle intervals with is_idle=True before the popup was shown
                    query_idle = select(ActivityInterval).where(
                        ActivityInterval.counter == current_counter,
                        ActivityInterval.is_break == 0,
                        ActivityInterval.is_idle == True,
                        ActivityInterval.is_uploaded == False
                    ).order_by(desc(ActivityInterval.start_time))

                    result_idle = await session.execute(query_idle)
                    all_idle_intervals = result_idle.scalars().all()

                    # Debug logging
                    logger.debug("Found %s idle intervals (is_idle=True, is_uploaded=False) to mark as uploaded", len(all_idle_intervals))
                    if all_idle_intervals:
                        for idx, interval in enumerate(all_idle_intervals[:5]):  # Log first 5
                            logger.debug("Idle interval #%s: id=%s, is_idle=%s, is_uploaded=%s, counter=%s, start_time=%s", idx+1, interval.id, interval.is_idle, interval.is_uploaded, interval.counter, interval.start_time)

                    if not all_idle_intervals:
                        logger.warning("No idle intervals found to mark as uploaded (counter=%s). This might indicate intervals weren't marked as is_idle=True.", current_counter)
                        return

                    logger.info("Found %s idle intervals to mark as uploaded (is_uploaded=1)", len(all_idle_intervals))

                    # Collect all interval IDs
                    interval_ids = [interval.id for interval in all_idle_intervals]
                    logger.info("Marking %s idle intervals as uploaded: interval IDs: %s%s", len(interval_ids), interval_ids, '...' if len(interval_ids) > 10 else '')

                    # Mark all idle intervals as uploaded using bulk update via queue
                    # We use update_all to update multiple records at once
                    # This is more efficient than updating one by one

                    # Create update data for each interval
                    updates = []
                    for interval_id in interval_ids:
                        updates.append({
                            "id": interval_id,
                            "is_uploaded": True
                        })

                    # Queue the bulk update
                    db_write_queue.enqueue(
                        WriteOperationType.BULK_UPDATE,
                        "activity_interval",
                        {"updates": updates}
                    )

                    logger.info("Queued bulk update to mark %s idle intervals as uploaded (is_uploaded=1)", len(updates))

                    # CRITICAL: Flush immediately so is_uploaded=1 is committed before stop_event() queries.
                    # Prevents idle intervals from being sent to activity/log when user stops with keep_idle_time=False.
                    await db_writer_service.process_immediately()
                    await asyncio.sleep(0.5)  # Ensure commit is visible to other threads
                    logger.info("Flushed queue after marking %s idle intervals as uploaded", len(updates))

        except Exception as e:
            logger.error("Error marking idle intervals as uploaded: %s", e, exc_info=True)

    async def _save_note_to_idle_intervals(self, note: str, file_path: Optional[str] = None):
        """
        Save note to Notes table with array of idle interval IDs.
        This is called when user provides a note for idle time.

        Args:
            note: Note text to save
            file_path: Optional S3 URL of uploaded file
        """
        try:
            from ws_package.models.base import async_session, ActivityInterval, Notes
            from ws_package.utils.db_lock_manager import db_lock_manager
            from ws_package.utils.db_write_queue import db_write_queue, WriteOperationType
            from sqlalchemy import select, desc
            from ws_package.repositories.activity_percentage_repo import activity_percentage_manager

            # Flush queue first
            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.5)  # Increased delay to ensure is_idle updates are committed

            current_counter = self.last_count + 1

            with db_lock_manager.read_lock():
                async with async_session() as session:
                    # First, try to find intervals by is_idle flag (if they've been marked)
                    query_by_flag = select(ActivityInterval).where(
                        ActivityInterval.counter == current_counter,
                        ActivityInterval.is_break == 0,
                        ActivityInterval.is_idle == True,
                        ActivityInterval.is_uploaded == False
                    ).order_by(desc(ActivityInterval.start_time))

                    result_by_flag = await session.execute(query_by_flag)
                    idle_intervals_by_flag = result_by_flag.scalars().all()

                    # Also find intervals with zero activity that might not be marked as is_idle yet
                    # Get all intervals for current counter (excluding breaks)
                    query_all = select(ActivityInterval).where(
                        ActivityInterval.counter == current_counter,
                        ActivityInterval.is_break == 0,
                        ActivityInterval.is_uploaded == False
                    ).order_by(desc(ActivityInterval.start_time))

                    result_all = await session.execute(query_all)
                    all_intervals = result_all.scalars().all()

                    # Find consecutive zero-activity intervals from newest to oldest
                    intervals_to_mark = []
                    for interval in all_intervals:
                        # Check if interval has zero activity
                        has_zero_activity = await self._has_zero_activity_for_interval(interval.start_time, interval.end_time)

                        if has_zero_activity:
                            # Include if not already in the list
                            if interval not in intervals_to_mark:
                                intervals_to_mark.append(interval)
                        else:
                            # Found activity - stop checking further intervals
                            break

                    # Combine both sets (remove duplicates)
                    idle_intervals = list(set(idle_intervals_by_flag + intervals_to_mark))

                    # Debug logging
                    logger.debug("_save_note_to_idle_intervals: found %s intervals with is_idle=True, %s intervals with zero activity, %s total unique idle intervals", len(idle_intervals_by_flag), len(intervals_to_mark), len(idle_intervals))

                    if not idle_intervals:
                        logger.info("No idle intervals found to save note")
                        return

                    logger.info("Found %s idle intervals to save note: %s, file_path: %s", len(idle_intervals), note, file_path)

                    # Collect all interval IDs
                    interval_ids = [interval.id for interval in idle_intervals]

                    # Prepare note data with optional file_path
                    note_data = {
                        "activity_interval_id": interval_ids,
                        "note": note
                    }
                    if file_path:
                        note_data["file_path"] = file_path

                    # Queue the insert operation
                    db_write_queue.enqueue(
                        WriteOperationType.NOTES,
                        "notes",
                        note_data
                    )

                    logger.info("Queued Notes entry with %s interval IDs: %s, file_path: %s", len(interval_ids), interval_ids, file_path)

        except Exception as e:
            logger.error("Error saving note to Notes table: %s", e, exc_info=True)

    async def _save_note_to_current_interval(self, note: str, file_path: Optional[str] = None):
        """
        Save note to Notes table with current running interval ID.
        This is called when user adds a note while timer is running.

        Args:
            note: Note text to save
            file_path: Optional S3 URL of uploaded file
        """
        try:
            from ws_package.models.base import async_session, ActivityInterval, Notes
            from ws_package.utils.db_lock_manager import db_lock_manager
            from ws_package.utils.db_write_queue import db_write_queue, WriteOperationType
            from sqlalchemy import select, desc
            from ws_package.services.db_writer_service import db_writer_service

            logger.info("AddNote: Starting to save note to current interval: note_length=%s", len(note))

            # Flush queue first to ensure latest intervals are in DB
            logger.info("AddNote: Flushing write queue to ensure intervals are committed...")
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.5)  # Delay to ensure intervals are committed

            current_counter = self.last_count + 1
            logger.info("AddNote: Using counter: %s", current_counter)

            with db_lock_manager.read_lock():
                async with async_session() as session:
                    # Find the latest interval with current_counter (regardless of uploaded status)
                    # The upcoming interval will have ID = latest_interval.id + 1
                    query = select(ActivityInterval).where(
                        ActivityInterval.counter == current_counter
                    ).order_by(desc(ActivityInterval.id)).limit(1)

                    result = await session.execute(query)
                    latest_interval = result.scalar_one_or_none()

                    if not latest_interval:
                        logger.error("AddNote: No interval found with counter %s", current_counter)
                        return

                    # Use latest interval ID + 1 (upcoming interval that will be created)
                    target_interval_id = latest_interval.id + 1
                    logger.info("AddNote: Latest interval found: ID=%s, counter=%s, is_uploaded=%s", latest_interval.id, latest_interval.counter, latest_interval.is_uploaded)
                    logger.info("AddNote: Using target interval ID: %s (latest_id + 1) for upcoming interval", target_interval_id)

                    # Prepare note data with target interval ID
                    note_data = {
                        "activity_interval_id": [target_interval_id],
                        "note": note
                    }
                    if file_path:
                        note_data["file_path"] = file_path

                    # Queue the insert operation
                    enqueue_success = db_write_queue.enqueue(
                        WriteOperationType.NOTES,
                        "notes",
                        note_data
                    )

                    if not enqueue_success:
                        logger.error("AddNote: Failed to enqueue Notes entry - queue may be full")
                        return

                    logger.info("AddNote: Successfully queued Notes entry with interval ID: %s", target_interval_id)

                    # Force immediate processing of the queue to ensure note is written
                    logger.info("AddNote: Processing write queue immediately to save note...")
                    await db_writer_service.process_immediately()
                    logger.info("AddNote: Write queue processed - note should now be in database")

        except Exception as e:
            logger.error("AddNote: Error saving note to current interval: %s", e, exc_info=True)

    async def _should_pause_api_calls(self, intervals: list) -> bool:
        """Decide whether the push loop should skip uploading this cycle.

        Returns ``True`` (pause) when:
          1. The idle popup is active — block ALL uploads until the user
             responds (Continue / Stop / Reassign).
          2. Every interval in the current session has zero activity — hold
             them until a non-idle interval arrives or the popup is handled.

        The consecutive-pause cap (``_MAX_API_PAUSE_CYCLES``) is enforced by
        the push loop call-site, not here.  This method is a pure predicate.

        Note on the DB supplement scan
        --------------------------------
        The previous implementation called ``db_writer_service.process_immediately()``
        (a synchronous threading.Condition acquire) and then used the sync
        ``db_lock_manager.read_lock()`` inside an ``async def``.  Both block
        the event loop for the full duration of any in-flight DB write —
        potentially seconds — and the former is a known deadlock vector when
        the write lock is already held by another coroutine.

        The replacement uses a non-blocking scan of the in-memory write queue
        (``db_write_queue.get_pending_items``).  Items that are queued but not
        yet flushed to SQLite are still visible here, so no information is lost.
        """
        try:
            # Case 1: idle popup is active — block ALL uploads.
            if self._idle_popup_active and self._idle_popup_triggered_at is not None:
                logger.info(
                    "[APIBlock] Pausing uploads: idle popup active (triggered at %s)",
                    self._idle_popup_triggered_at.isoformat(),
                )
                return True

            # Case 2: check whether all current-session intervals have zero activity.
            if not intervals:
                return False  # Nothing to send — no reason to pause

            current_counter = self.last_count + 1
            current_counter_intervals = [
                iv for iv in intervals if getattr(iv, 'counter', None) == current_counter
            ]

            if not current_counter_intervals:
                logger.debug(
                    "[APIBlock] All intervals belong to a previous counter (not %s) — not holding",
                    current_counter,
                )
                return False

            # Supplement with intervals that are in the write queue but not yet
            # flushed to SQLite.  Uses the non-blocking in-memory queue scan so
            # the event loop is never blocked.
            try:
                from ws_package.utils.db_write_queue import db_write_queue as _dwq, WriteOperationType as _WOT

                interval_ids_in_list = {getattr(iv, 'id', None) for iv in current_counter_intervals}
                for pending in _dwq.get_pending_items(_WOT.ACTIVITY_INTERVAL):
                    d = pending.data
                    if d.get('counter') != current_counter or d.get('is_break', 0) != 0:
                        continue
                    # Pending items are dicts; synthesise a lightweight check object.
                    pending_id = d.get('id')
                    if pending_id in interval_ids_in_list:
                        continue
                    al = d.get('activityLevel') or {}
                    kb = float(al.get('keyboard', '0.0') or '0.0')
                    ms = float(al.get('mouse', '0.0') or '0.0')
                    av = float(al.get('average', '0.0') or '0.0')
                    if kb == 0 and ms == 0 and av == 0:
                        # Create a minimal proxy so the classify loop below can handle it.
                        class _Proxy:
                            def __init__(self, data):
                                self.id = data.get('id')
                                self.counter = data.get('counter')
                                self.activityLevel = data.get('activityLevel') or {}
                        current_counter_intervals.append(_Proxy(d))
                        logger.debug(
                            "[APIBlock] Pending queue idle interval (counter=%s) added to check",
                            current_counter,
                        )
            except Exception as qe:
                logger.debug("[APIBlock] Queue scan skipped: %s", qe)

            # Separate idle and non-idle intervals
            idle_intervals = []
            non_idle_intervals = []

            for interval in current_counter_intervals:
                activity_level = interval.activityLevel if hasattr(interval, 'activityLevel') else {}
                keyboard = float(activity_level.get('keyboard', '0.0') or '0.0')
                mouse = float(activity_level.get('mouse', '0.0') or '0.0')
                average = float(activity_level.get('average', '0.0') or '0.0')

                # Check if interval has zero activity
                if keyboard == 0 and mouse == 0 and average == 0:
                    idle_intervals.append(interval)
                else:
                    non_idle_intervals.append(interval)

            # Hold idle intervals (single or multiple) until non-idle found or popup handled
            if idle_intervals:
                for interval in idle_intervals:
                    # Check if already held (by ID to avoid duplicates)
                    if not any(getattr(held, 'id', None) == getattr(interval, 'id', None) for held in self._held_idle_intervals):
                        self._held_idle_intervals.append(interval)
                        logger.info("[APIBlock] Holding idle interval (id=%s, counter=%s, start=%s, end=%s) - will send when non-idle interval found or popup handled", interval.id, getattr(interval, 'counter', 'N/A'), interval.start_time, interval.end_time)

                if len(idle_intervals) == 1:
                    logger.info("[APIBlock] Holding 1 idle interval (counter=%s) - will send when non-idle interval found or popup handled", current_counter)
                else:
                    logger.info("[APIBlock] Holding %s idle intervals (counter=%s) - will send when non-idle interval found or popup handled", len(idle_intervals), current_counter)

            # If ALL intervals are idle, pause API calls (don't send anything)
            if not non_idle_intervals:
                return True

            # If there are non-idle intervals, don't pause (they will be sent, and held idle intervals will be sent before them in _push_activity_log)
            logger.debug("[APIBlock] Non-idle interval(s) found (%s) - held idle intervals will be sent before non-idle intervals", len(non_idle_intervals))
            return False

        except Exception as e:
            logger.error("[APIBlock] Error checking if API calls should be paused: %s", e, exc_info=True)
            return False  # Don't pause on error

    async def _idle_reassign(self, project_id: str, task_id: Optional[str] = None):
        """
        Reassign idle intervals to a new project and task by sending them to the API with the new project/task IDs.

        Args:
            project_id: Project ID to assign idle intervals to (mandatory)
            task_id: Task ID to assign idle intervals to (optional, defaults to "0" if not provided)
        """
        try:
            # Convert task_id to string, default to "0" if None
            task_id_str = str(task_id) if task_id is not None and task_id != "" else "0"
            project_id_str = str(project_id)

            logger.info("=== Starting idle interval reassign: project_id=%s, task_id=%s ===", project_id_str, task_id_str)

            # Flush queue first to ensure all pending writes are committed
            from ws_package.services.db_writer_service import db_writer_service
            await db_writer_service.process_immediately()
            await asyncio.sleep(0.3)

            # Get all idle intervals that are not uploaded
            from ws_package.models.base import async_session, ActivityInterval
            from ws_package.utils.db_lock_manager import db_lock_manager
            from sqlalchemy import select, desc
            from ws_package.entities.activity_entity import LocationEntity
            from ws_package.utils.system import get_ip, get_os_version

            idle_intervals = []
            with db_lock_manager.read_lock():
                async with async_session() as session:
                    # Get ALL idle intervals (is_idle=1) that are not uploaded (is_uploaded=0)
                    query = select(ActivityInterval).where(
                        ActivityInterval.is_idle == True,
                        ActivityInterval.is_uploaded == False
                    ).order_by(ActivityInterval.start_time)

                    result = await session.execute(query)
                    idle_intervals = result.scalars().all()

            if not idle_intervals:
                logger.info("No idle intervals found to reassign")
                return

            logger.info("Found %s idle intervals to reassign to project %s, task %s", len(idle_intervals), project_id_str, task_id_str)

            # Use process_intervals_bulk to build payloads, but override project/task IDs
            # Group intervals by day and process each day separately
            grouped_intervals = self.group_intervals_by_day(idle_intervals)

            success_count = 0
            failed_count = 0

            for target_date, day_intervals in grouped_intervals.items():
                try:
                    logger.info("Processing %s idle intervals for date %s", len(day_intervals), target_date)

                    # Edge case: Ensure at most 100 intervals per day
                    if len(day_intervals) > 100:
                        day_intervals = day_intervals[:100]
                        logger.warning("Limited to 100 intervals for date %s", target_date)

                    # Get day start time
                    day_start_time = self.get_day_start_time(target_date, day_intervals)

                    # Build interval payloads
                    effective_start_time = day_start_time
                    interval_payloads = []
                    # Fetch notes for all intervals in this day from Notes table
                    from ws_package.models.base import Notes
                    from sqlalchemy import select

                    interval_ids = [interval.id for interval in day_intervals if hasattr(interval, 'id')]
                    interval_note_map = {}

                    # Query Notes table for entries that contain any of these interval IDs
                    # Notes.activity_interval_id is a JSON array, so we need to check if any interval_id is in the array
                    try:
                        with db_lock_manager.read_lock():
                            async with async_session() as session:
                                notes_query = select(Notes)
                                notes_result = await session.execute(notes_query)
                                notes_entries = notes_result.scalars().all()

                                # Create a mapping of interval_id -> note
                                for notes_entry in notes_entries:
                                    if notes_entry.activity_interval_id:  # Check if array is not None
                                        for interval_id in notes_entry.activity_interval_id:
                                            if interval_id in interval_ids:
                                                interval_note_map[interval_id] = notes_entry.note

                                if interval_note_map:
                                    logger.info("Found notes for %s intervals: %s", len(interval_note_map), list(interval_note_map.keys()))
                    except Exception as e:
                        logger.error("Error fetching notes from Notes table: %s", e, exc_info=True)

                    for interval in day_intervals:
                        try:
                            payload_from, payload_to = self._payload_interval_bounds(interval, effective_start_time)
                            if payload_to is not None and payload_from is not None and payload_to <= payload_from:
                                logger.info(
                                    "Skipping idle reassignment interval (id=%s, start=%s, end=%s) — no time left after clipping to payload start %s",
                                    interval.id,
                                    interval.start_time,
                                    interval.end_time,
                                    effective_start_time,
                                )
                                continue
                            screen_url = await self.ss(payload_from, payload_to, self.user_entity)
                            activity_level = await self.get_activity_level_from_db(payload_from, payload_to)
                            app_and_urls = await self.activity_service.get_apps_urls(payload_from, payload_to, self.appandurlstatus)

                            interval_payload = {
                                "Entry": interval.id,
                                "from": payload_from.strftime("%Y-%m-%d %H:%M:%S"),
                                "to": payload_to.strftime("%Y-%m-%d %H:%M:%S"),
                                "screenUrl": screen_url,
                                "activityLevel": activity_level,
                                "appAndUrls": app_and_urls,
                                "deviceType": self.get_os_type_caps(),
                                "os_type": self.get_os_type_caps(),
                                "note": interval_note_map.get(getattr(interval, 'id', None)),
                                "idle_note_url": None,
                                "idleInterval": None,
                                "location": LocationEntity(
                                    long="null",
                                    lat="null"
                                ).model_dump(),
                                "interval_time_db": int(self.interval/60),
                                "has_on_demand_screenshot": getattr(interval, "has_on_demand_screenshot", False),
                            }
                            interval_payloads.append(interval_payload)
                        except Exception as e:
                            logger.error("Error building payload for interval %s: %s", interval.id, e, exc_info=True)
                            failed_count += 1
                            continue

                    if not interval_payloads:
                        logger.warning("No interval payloads created for date %s", target_date)
                        continue

                    # Build the final payload using the provided project_id and task_id
                    payload = [{
                        "projectId": project_id_str,
                        "start": effective_start_time.strftime("%Y-%m-%d %H:%M:%S"),
                        "stop": None,
                        "selfi_id": None,
                        "timezone": self._normalize_timezone(str(self.timezone_manager.local_timezone) if self.timezone_manager.local_timezone else str(self.get_system_timezone())),
                        "break_id": None,
                        "deviceType": self.get_os_type_caps(),
                        "os_type": self.get_os_type_caps(),
                        "selfiVerification": True,
                        "intervals": interval_payloads,
                        "time_type": "1",
                        "interval_time": int(self.interval/60),
                        "source_type": "4",
                        "org_id": str(self.user_entity.org_id),
                        "todo_id": task_id_str,
                        "deviceId": self.system_unique_id,
                        "ip_address": str(get_ip()),
                        "os_version": get_os_version()
                    }]

                    logger.info("Sending %s idle intervals to API with project_id=%s, task_id=%s", len(interval_payloads), project_id_str, task_id_str)

                    # Send to API
                    try:
                        response = self.activity_service.push_activity_logs(payload)
                        logger.info("Successfully sent idle intervals for date %s to API", target_date)

                        # Mark intervals as uploaded
                        from ws_package.repositories.activity_repo import activity_manager
                        for interval in day_intervals:
                            try:
                                await activity_manager.update_activity_interval(interval)
                                success_count += 1
                            except Exception as e:
                                logger.error("Error marking interval %s as uploaded: %s", interval.id, e, exc_info=True)
                                failed_count += 1

                    except Exception as e:
                        logger.error("Error sending idle intervals to API for date %s: %s", target_date, e, exc_info=True)
                        failed_count += len(day_intervals)

                except Exception as e:
                    logger.error("Error processing day %s for reassign: %s", target_date, e, exc_info=True)
                    failed_count += len(day_intervals)

            # Flush queue to ensure all updates are processed
            if success_count > 0:
                logger.info("Flushing database write queue to process %s is_uploaded updates...", success_count)
                await db_writer_service.process_immediately()
                await asyncio.sleep(0.5)

            logger.info("=== Idle reassign completed: %s intervals reassigned successfully, %s failed ===", success_count, failed_count)

        except Exception as e:
            logger.error("Error in _idle_reassign: %s", e, exc_info=True)
            raise
