"""
ActivityIntervalMixin — interval grouping, session management, activity-level computation,
and bulk payload generation.

Provides interval CRUD helpers and bulk API payload construction 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__``.

Thread-safety:
    All ``async`` methods must be awaited from the async monitoring loop.
    Static / synchronous helpers (``_subtract_overlapping_intervals``,
    ``group_intervals_by_day``, ``is_idle_interval``) are thread-safe.
"""
from __future__ import annotations

import asyncio
import datetime
import json
import platform
from contextlib import nullcontext
from collections import defaultdict
from typing import List, Tuple, Optional

from sqlalchemy import select

from ws_package.log.writer import LogWriter
from ws_package.entities.user_entity import UserEntity
from ws_package.entities.activity_entity import LocationEntity
from ws_package.models.base import async_session, ActivityInterval, Appmonitor
from ws_package.configs import config
from ws_package.utils.db_lock_manager import db_lock_manager
from ws_package.repositories.activity_percentage_repo import activity_percentage_manager
from ws_package.services.activity_percentage_service import activity_percentage_service
from ws_package.utils.system import get_ip, get_os_version
from ws_package.utils.activity_level import normalize_activity_level

logger = LogWriter.file(__name__)


class ActivityIntervalMixin:
    """Interval CRUD helpers, session grouping, activity-level queries, and bulk payload generation for ActivityController."""

    async def find_missing_timestamps(self, counter: int) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """Find time gaps between consecutive intervals for the given counter."""
        # Database operation timeout constant
        DB_OPERATION_TIMEOUT = 5.0

        with db_lock_manager.read_lock():
            async with async_session() as session:
                # Fetch the records with the given counter, ordered by start_time
                query = select(ActivityInterval).filter(ActivityInterval.counter == counter).order_by(ActivityInterval.start_time)
                try:
                    result = await asyncio.wait_for(
                        session.execute(query),
                        timeout=DB_OPERATION_TIMEOUT
                    )
                    records = result.scalars().all()

                    missing_timestamps = []

                    # Iterate through records to find missing time ranges (fast in-memory processing)
                    for i in range(1, len(records)):
                        prev_end_time = records[i - 1].end_time
                        current_start_time = records[i].start_time

                        # If there is a gap between the end time of the previous record and the start time of the current record
                        if prev_end_time < current_start_time and (current_start_time - prev_end_time) > datetime.timedelta(minutes=2):
                            missing_timestamps.append((prev_end_time, current_start_time))

                    return missing_timestamps
                except asyncio.TimeoutError:
                    logger.error("Database query timed out after %ss in find_missing_timestamps", DB_OPERATION_TIMEOUT)
                    return []
                except Exception as e:
                    logger.error("Error finding missing timestamps: %s", e)
                    return []

    async def get_intervals_overlapping_range(
        self, segment_start: datetime.datetime, segment_end: datetime.datetime
    ) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """Fetch all activity intervals in the DB (any counter) that overlap [segment_start, segment_end].
        Returns list of (start_time, end_time) for each overlapping interval.
        Ensures sleep empty intervals do not overlap existing intervals (e.g. break or normal).
        """
        DB_OPERATION_TIMEOUT = 5.0
        with db_lock_manager.read_lock():
            async with async_session() as session:
                try:
                    query = (
                        select(ActivityInterval)
                        .where(
                            ActivityInterval.start_time < segment_end,
                            ActivityInterval.end_time > segment_start,
                        )
                        .order_by(ActivityInterval.start_time)
                    )
                    result = await asyncio.wait_for(
                        session.execute(query), timeout=DB_OPERATION_TIMEOUT
                    )
                    records = result.scalars().all()
                    return [(r.start_time, r.end_time) for r in records]
                except asyncio.TimeoutError:
                    logger.error(
                        "Database query timed out after %ss in get_intervals_overlapping_range",
                        DB_OPERATION_TIMEOUT
                    )
                    return []
                except Exception as e:
                    logger.error("Error getting overlapping intervals: %s", e, exc_info=True)
                    return []

    @staticmethod
    def _subtract_overlapping_intervals(
        segment_start: datetime.datetime,
        segment_end: datetime.datetime,
        overlapping: List[Tuple[datetime.datetime, datetime.datetime]],
    ) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """Given segment [segment_start, segment_end] and list of (start, end) intervals that overlap it,
        return list of sub-segments that do NOT overlap any of them. Sleep empty intervals are only
        written for these sub-segments so they do not overlap any existing interval in the DB.
        """
        if not overlapping:
            return [(segment_start, segment_end)]
        sorted_intervals = sorted(overlapping, key=lambda x: x[0])
        result = []
        current_start = segment_start
        for s, e in sorted_intervals:
            s = max(s, segment_start)
            e = min(e, segment_end)
            if s > current_start:
                result.append((current_start, s))
            current_start = max(current_start, e)
            if current_start >= segment_end:
                break
        if current_start < segment_end:
            result.append((current_start, segment_end))
        return result

    def _empty_interval_break_id(self, interval_start: datetime.datetime) -> int:
        """Return break_id for an empty/sleep interval if it falls within the current break; otherwise 0."""
        current_break_id = getattr(self, 'break_id', 0) or 0
        break_start_time = getattr(self, 'break_start_time', None)
        if current_break_id != 0 and break_start_time is not None and interval_start >= break_start_time:
            return current_break_id
        return 0

    async def _activity_level_for_gap_fill(
        self,
        seg_start: datetime.datetime,
        seg_end: datetime.datetime,
    ) -> dict:
        """
        Activity level for a detected sleep/missing gap.

        Uses rolling-average data (activity_percentage) when present instead of
        hard-coded zeros, so a missed interval save does not report 0% when input
        was actually tracked.
        """
        empty = {"mouse": "0.0", "average": "0.0", "keyboard": "0.0"}
        try:
            level = await self.get_activity_level_from_db(seg_start, seg_end)
            keyboard = float(level.get("keyboard", 0) or 0)
            mouse = float(level.get("mouse", 0) or 0)
            average = float(level.get("average", 0) or 0)
            if keyboard > 0 or mouse > 0 or average > 0:
                logger.info(
                    "[SleepGap] Using stored activity for gap %s to %s: %s",
                    seg_start,
                    seg_end,
                    level,
                )
                return normalize_activity_level(level)
            counter = (getattr(self, "last_count", 0) or 0) + 1
            if await self._has_any_activity_in_interval(seg_start, seg_end, counter):
                logger.info(
                    "[SleepGap] App/URL activity in gap %s to %s but no percentage row — using zeros",
                    seg_start,
                    seg_end,
                )
            else:
                logger.info(
                    "[SleepGap] No activity data for gap %s to %s — using zeros",
                    seg_start,
                    seg_end,
                )
            return normalize_activity_level(empty)
        except Exception as e:
            logger.warning(
                "[SleepGap] Error resolving activity for gap %s to %s: %s — using zeros",
                seg_start,
                seg_end,
                e,
            )
            return normalize_activity_level(empty)

    def _get_effective_session_start(self) -> Optional[datetime.datetime]:
        """Latest timer-session boundary (user Start / post-break segment)."""
        candidates = []
        start_date = getattr(self, 'start_date', None)
        if start_date is not None:
            candidates.append(start_date)
        session_start = getattr(self, 'current_session_start_time', None)
        if session_start is not None:
            candidates.append(session_start)
        if not candidates:
            return None
        return max(candidates)

    def _filter_gaps_before_session(
        self, gaps: List[Tuple[datetime.datetime, datetime.datetime]]
    ) -> List[Tuple[datetime.datetime, datetime.datetime]]:
        """Drop or trim gaps that predate the current timer session (e.g. overnight while stopped)."""
        session_start = self._get_effective_session_start()
        if session_start is None:
            return gaps

        filtered: List[Tuple[datetime.datetime, datetime.datetime]] = []
        for gap_start, gap_end in gaps:
            if gap_end <= session_start:
                logger.info(
                    "[SleepGap] Skipping gap %s - %s: ends before current session start %s",
                    gap_start,
                    gap_end,
                    session_start,
                )
                continue
            if gap_start < session_start:
                logger.info(
                    "[SleepGap] Trimming gap start %s -> %s (session start)",
                    gap_start,
                    session_start,
                )
                gap_start = session_start
            if gap_end > gap_start:
                filtered.append((gap_start, gap_end))
        return filtered

    def group_intervals_by_day(self, intervals):
        """Group a flat list of intervals by their ``start_time`` date.

        Args:
            intervals: Iterable of ActivityInterval ORM objects or entities.

        Returns:
            A ``defaultdict(list)`` mapping ``datetime.date`` to the list of intervals
            that start on that date, in insertion order.
        """
        grouped_intervals = defaultdict(list)

        for interval in intervals:
            # Extract the date from the start_time
            interval_date = interval.start_time.date()
            grouped_intervals[interval_date].append(interval)

        return grouped_intervals

    def _calculate_gap_seconds(self, prev_interval, current_interval) -> float:
        """Return the time gap (in seconds) between two consecutive intervals."""
        if not prev_interval or not current_interval:
            return 0.0
        try:
            return (current_interval.start_time - prev_interval.end_time).total_seconds()
        except Exception as e:
            logger.debug("Failed to calculate gap between intervals: %s", e)
            return 0.0

    def _determine_sleep_reason(self, gap_seconds: float) -> str:
        """
        Provide a descriptive reason for large gaps.
        Uses idle_time_limit_in_seconds from API instead of hardcoded values.
        """
        # Use API value, fallback to default if not set
        idle_threshold = getattr(self, 'idle_time_limit_in_seconds', 3600)

        system_name = platform.system()
        if system_name == "Windows":
            return "windows_sleep_or_restart" if gap_seconds >= idle_threshold else "windows_idle_gap"
        if system_name == "Darwin":
            return "macos_sleep_or_shutdown" if gap_seconds >= idle_threshold else "macos_idle_gap"
        return "linux_sleep_or_shutdown" if gap_seconds >= idle_threshold else "linux_idle_gap"

    def _log_session_split_reason(self, reason: str, prev_interval, current_interval, gap_seconds: float) -> None:
        """Log session split metadata for diagnostics."""
        gap_minutes = gap_seconds / 60 if gap_seconds else 0.0
        logger.info(
            "Session split detected | reason=%s | platform=%s | gap_minutes=%.2f | prev_end=%s | next_start=%s | prev_counter=%s | next_counter=%s",
            reason,
            platform.system(),
            gap_minutes,
            getattr(prev_interval, "end_time", None),
            getattr(current_interval, "start_time", None),
            getattr(prev_interval, "counter", None),
            getattr(current_interval, "counter", None),
        )

    def _should_start_new_session(self, prev_interval, current_interval) -> bool:
        """Determine whether a new session should begin based on counters and gaps."""
        if not prev_interval:
            return False

        # Counter increments indicate application restart or forced stop
        if current_interval.counter != prev_interval.counter:
            self._log_session_split_reason(
                "application_restart",
                prev_interval,
                current_interval,
                self._calculate_gap_seconds(prev_interval, current_interval),
            )
            return True

        gap_seconds = self._calculate_gap_seconds(prev_interval, current_interval)
        if gap_seconds > self.session_gap_threshold_seconds:
            reason = self._determine_sleep_reason(gap_seconds)
            self._log_session_split_reason(reason, prev_interval, current_interval, gap_seconds)
            return True

        return False

    async def _verify_user_activity_during_period(self, start_time: datetime.datetime, end_time: datetime.datetime) -> bool:
        """
        Verify if there was actual user activity (keyboard/mouse) during a time period.
        This helps prevent creating intervals for background wake events.

        Returns:
            True if user activity detected, False otherwise
        """
        try:
            # On macOS, check HID idle time to verify user activity
            if platform.system() == "Darwin":
                try:
                    import subprocess
                    import time

                    # Get current HID idle time
                    ioreg_output = subprocess.check_output(
                        ['ioreg', '-c', 'IOHIDSystem'],
                        stderr=subprocess.DEVNULL,
                        timeout=2
                    ).decode('ascii', errors='ignore')

                    # Extract HIDIdleTime (time since last keyboard/mouse input)
                    for line in ioreg_output.splitlines():
                        if 'HIDIdleTime' in line:
                            try:
                                idle_time_ns = int(line.split('=')[-1].strip().rstrip(';'))
                                idle_time_seconds = idle_time_ns / 1000000000.0

                                # If idle time is very recent (< 5 minutes), user was likely active
                                # If idle time is long (> 10 minutes), likely no user activity
                                current_time = datetime.datetime.now()
                                period_duration = (end_time - start_time).total_seconds()

                                # If the period is in the past and idle time is much longer than period duration,
                                # it's likely there was no user activity during that period
                                if end_time < current_time:
                                    # Period is in the past - check if idle time suggests no activity
                                    if idle_time_seconds > period_duration + 300:  # 5 min buffer
                                        logger.debug(
                                            "No user activity during period %s - %s: "
                                            "Current idle time (%ss) >> period duration (%ss)",
                                            start_time, end_time, idle_time_seconds, period_duration
                                        )
                                        return False

                                # If idle time is short (< 5 min), user is currently active
                                # This suggests the period might have had activity
                                if idle_time_seconds < 300:
                                    return True

                            except (ValueError, IndexError):
                                pass
                    # If we can't determine, be conservative and return False
                    # Better to skip an interval than create a false one
                    return False

                except Exception as e:
                    logger.debug("Could not verify macOS user activity: %s", e)
                    # On error, be conservative - don't create interval
                    return False

            # For Windows/Linux, use a simpler check
            # Check if there are any app monitor records during this period
            try:
                with db_lock_manager.read_lock():
                    async with async_session() as session:
                        query = select(Appmonitor).where(
                            Appmonitor.timestamp >= start_time,
                            Appmonitor.timestamp <= end_time
                        ).limit(1)

                        result = await session.execute(query)
                        app_data = result.scalars().first()

                        # If there are app monitor records, there was likely user activity
                        return app_data is not None

            except Exception as e:
                logger.debug("Could not verify user activity via app monitor: %s", e)
                # On error, be conservative
                return False

        except Exception as e:
            logger.debug("Error verifying user activity during period: %s", e)
            # Fail-safe: return False to prevent false intervals
            return False

    def is_idle_interval(self, interval) -> bool:
        """
        Our idle definition: keyboard AND mouse activity are zero for the whole interval.
        """
        if not interval:
            return False

        try:
            activity_level = interval.activityLevel
            if isinstance(activity_level, str):
                activity_level = json.loads(activity_level)
            activity_level = activity_level or {}
            mouse = float(activity_level.get("mouse", 0) or 0)
            keyboard = float(activity_level.get("keyboard", 0) or 0)
            average = float(activity_level.get("average", 0) or 0)
            return mouse == 0.0 and keyboard == 0.0 and average == 0.0
        except Exception as e:
            logger.debug("Failed to evaluate idle interval: %s", e)
            return False

    def group_intervals_by_session(self, intervals):
        """Group intervals into logical tracking sessions.

        A new session begins whenever the interval counter changes (application
        restart / forced stop) or the chronological gap between consecutive
        intervals exceeds ``self.session_gap_threshold_seconds``.
        Idle intervals remain within the current session.

        Args:
            intervals: Flat iterable of ActivityInterval ORM objects or entities.

        Returns:
            List of lists, where each inner list is the intervals belonging to one
            contiguous tracking session, sorted by ``start_time``.
        """
        if not intervals:
            return []

        sorted_intervals = sorted(intervals, key=lambda x: x.start_time)
        sessions = []
        current_session = [sorted_intervals[0]]

        for i in range(1, len(sorted_intervals)):
            prev_interval = sorted_intervals[i - 1]
            current_interval = sorted_intervals[i]

            if self._should_start_new_session(prev_interval, current_interval):
                sessions.append(current_session)
                current_session = [current_interval]
            else:
                current_session.append(current_interval)

        if current_session:
            sessions.append(current_session)

        return sessions

    def _payload_interval_bounds(self, interval, effective_start_time):
        """Return (from_dt, to_dt) for activity/log interval payload.

        Interval DB rows may start at break-stop (e.g. 10:56:33) while payload session
        start was set later; never emit ``from`` earlier than ``effective_start_time``.
        """
        from_dt = interval.start_time
        to_dt = interval.end_time
        if (
            from_dt is not None
            and effective_start_time is not None
            and from_dt < effective_start_time
        ):
            logger.info(
                "Clipped interval 'from' from %s to payload start %s (interval must not start before activity/log session start)",
                from_dt,
                effective_start_time,
            )
            from_dt = effective_start_time
        return from_dt, to_dt

    async def _build_interval_payload_for_api(
        self,
        user_entity: UserEntity,
        from_dt: datetime.datetime,
        to_dt: datetime.datetime,
        activity_level: dict,
        *,
        entry_id: int = 0,
    ) -> dict:
        """Build a single activity/log interval dict for API POST."""
        from_dt = from_dt.replace(microsecond=0)
        to_dt = to_dt.replace(microsecond=0)
        screen_url = await self.ss(from_dt, to_dt, user_entity)
        return {
            "Entry": entry_id,
            "from": from_dt.strftime("%Y-%m-%d %H:%M:%S"),
            "to": to_dt.strftime("%Y-%m-%d %H:%M:%S"),
            "screenUrl": screen_url,
            "activityLevel": activity_level,
            "appAndUrls": await self.activity_service.get_apps_urls(
                from_dt, to_dt, self.appandurlstatus
            ),
            "deviceType": self.get_os_type_caps(),
            "os_type": self.get_os_type_caps(),
            "note": 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": False,
        }

    def get_day_start_time(self, target_date, intervals_for_day) -> datetime.datetime:
        """Return the appropriate payload start time for a day's worth of intervals.

        For the current day uses ``self.current_session_start_time`` when set (i.e. after
        a break ends and a new sub-session begins), otherwise falls back to
        ``self.start_date``.  For any other date returns midnight of that date.

        Args:
            target_date: The ``datetime.date`` whose start time is needed.
            intervals_for_day: The list of intervals for ``target_date`` (used only
                to guard against empty input).

        Returns:
            A ``datetime.datetime`` representing the logical start of the day's session.
        """
        if not intervals_for_day:
            return datetime.datetime.combine(target_date, datetime.time.min)

        pinned = self._get_pinned_activity_log_session_start(target_date)
        if pinned is not None:
            return pinned

        if target_date == self.start_date.date():
            # After break end, counter is incremented and current_session_start_time is set:
            # use it so payload "start" is the new session start time, not the original timer start
            session_start = getattr(self, "current_session_start_time", None)
            if session_start is not None and session_start.date() == target_date:
                return self._normalize_activity_log_start(session_start)
            return self._normalize_activity_log_start(self.start_date)
        else:
            return datetime.datetime.combine(target_date, datetime.time.min)

    def _get_activity_level_from_interval(self, interval) -> dict:
        """
        Get activity level from interval entity (stored when interval was saved).
        This is more reliable than querying activity_percentage table, which can be
        overwritten when new intervals start.

        Falls back to database lookup if activityLevel is not available in interval.

        Args:
            interval: ActivityIntervalEntity or ORM object with activityLevel attribute

        Returns:
            dict: Activity level with keys "mouse", "average", "keyboard"
        """
        try:
            # First try to get from interval entity (most reliable - stored when interval was saved)
            if hasattr(interval, 'activityLevel') and interval.activityLevel:
                activity_level = interval.activityLevel

                # Handle JSON string format
                if isinstance(activity_level, str):
                    try:
                        activity_level = json.loads(activity_level)
                    except json.JSONDecodeError:
                        logger.debug("Failed to parse activityLevel JSON string: %s", activity_level)
                        activity_level = None

                # Extract values if it's a dict
                if isinstance(activity_level, dict):
                    return normalize_activity_level(activity_level)
        except Exception as e:
            logger.debug("Error getting activityLevel from interval: %s", e)

        # Fallback: return zeros (database lookup removed to avoid race condition)
        # The activityLevel should always be stored in the interval entity when saved
        logger.warning(
            "ActivityLevel not found in interval entity for %s to %s. "
            "Using default zeros. This may indicate the interval was saved without activityLevel.",
            getattr(interval, 'start_time', 'unknown'),
            getattr(interval, 'end_time', 'unknown')
        )
        return normalize_activity_level(None)

    async def get_activity_level_from_db(self, start_time, end_time):
        """
        DEPRECATED: Use _get_activity_level_from_interval() instead.

        This method queries activity_percentage table, which can be overwritten
        when new intervals start, causing race conditions.

        Kept for backward compatibility but should not be used for new code.
        """
        try:
            # FIRST: Check if activity_interval record already exists for this time range
            # This is the most reliable source since it stores activityLevel when interval was saved
            existing_interval = await self.activity_service.check_existing_interval(start_time, end_time)
            if existing_interval:
                # Query the actual interval record to get its activityLevel
                with db_lock_manager.read_lock():
                    async with async_session() as session:
                        query = select(ActivityInterval).where(
                            ActivityInterval.start_time == start_time,
                            ActivityInterval.end_time == end_time
                        ).limit(1)
                        result = await session.execute(query)
                        interval_record = result.scalars().first()

                        if interval_record and interval_record.activityLevel:
                            activity_level = interval_record.activityLevel
                            # Handle JSON string format
                            if isinstance(activity_level, str):
                                try:
                                    activity_level = json.loads(activity_level)
                                except json.JSONDecodeError:
                                    activity_level = None

                            if isinstance(activity_level, dict):
                                activityLevel = normalize_activity_level(activity_level)
                                logger.info("Found activity level from existing activity_interval record for %s to %s: %s", start_time, end_time, activityLevel)
                                return activityLevel

            # SECOND: Query activity_percentage table (original behavior)
            result = await activity_percentage_manager.find_overlapping_activity_percentage(start_time, end_time)
            if result is not None:
                activityLevel = normalize_activity_level({
                    "mouse": result.mouse,
                    "average": result.average,
                    "keyboard": result.keyboard,
                })
                logger.info("Found activity percentage record for overlapping range %s to %s: %s", start_time, end_time, activityLevel)
                return activityLevel

            # THIRD: Check in-memory cache (handles race condition where data is calculated but not yet persisted)
            cached_result = activity_percentage_service.get_cached_activity_percentage(start_time, end_time)
            if cached_result is not None:
                activityLevel = normalize_activity_level({
                    "mouse": cached_result.mouse,
                    "average": cached_result.average,
                    "keyboard": cached_result.keyboard,
                })
                logger.info("Found activity percentage in cache (not yet persisted) for range %s to %s: %s", start_time, end_time, activityLevel)
                return activityLevel

            # LAST RESORT: Return zeros (don't use 1% here - that's only for app activity fallback)
            logger.warning(
                "No activity percentage record found for overlapping range %s to %s",
                start_time, end_time
            )
            return normalize_activity_level(None)
        except Exception as e:
            logger.error("Error getting activity level from file storage: %s", e)
            return normalize_activity_level(None)

    async def _is_app_monitor_data_stale(self, app_monitor_time: datetime.datetime, wake_time: Optional[datetime.datetime]) -> bool:
        """
        Check if app monitor data is stale (from before system wake).

        Args:
            app_monitor_time: Timestamp of app monitor entry
            wake_time: When system woke up (if known)

        Returns:
            bool: True if data is likely stale (from before wake or within 30 seconds after wake)
        """
        if wake_time is None:
            return False  # Can't determine if stale without wake time

        # If app monitor data is from before wake or within 30 seconds after wake, it's likely stale
        time_diff = (app_monitor_time - wake_time).total_seconds()
        return time_diff < 30  # Within 30 seconds of wake = likely stale

    async def _has_any_activity_in_interval(self, start_time: datetime.datetime, end_time: datetime.datetime, counter: int) -> bool:
        """
        Check if there was ANY activity (keyboard, mouse, or app monitor) during an interval.

        Args:
            start_time: Start of the interval
            end_time: End of the interval
            counter: Counter value to filter by

        Returns:
            bool: True if any activity exists during the interval
        """
        try:
            # First check activity_percentage table for keyboard/mouse activity
            result = await activity_percentage_manager.find_overlapping_activity_percentage(start_time, end_time)
            if result is not None:
                keyboard_activity = float(result.keyboard or 0)
                mouse_activity = float(result.mouse or 0)
                average_activity = float(result.average or 0)

                if keyboard_activity > 0 or mouse_activity > 0 or average_activity > 0:
                    logger.debug("Activity found in activity_percentage for %s to %s: keyboard=%s, mouse=%s, average=%s",
                               start_time, end_time, keyboard_activity, mouse_activity, average_activity)
                    return True

            # Also check app monitor activity as fallback
            with db_lock_manager.read_lock():
                async with async_session() as session:
                    query = select(Appmonitor).where(
                        Appmonitor.counter == counter,
                        Appmonitor.timestamp >= start_time,
                        Appmonitor.timestamp <= end_time
                    ).limit(1)

                    result = await session.execute(query)
                    app_data = result.scalars().first()

                    if app_data:
                        logger.debug("App monitor activity found for %s to %s", start_time, end_time)
                        return True

            return False
        except Exception as e:
            logger.debug("Error checking activity in interval: %s", e)
            return False  # Fail-safe: if we can't check, assume no activity

    def _configure_activity_log_api_headers(self, user_entity: UserEntity) -> None:
        """Set activity/log URL and auth headers on activity_service."""
        self.activity_service.application_activity_log_url = config(
            "application_activity_log_url", "Application"
        )
        self.activity_service.headers = {
            "SDToken": str(user_entity.sd_token),
            "OrgID": str(user_entity.org_id),
            "UserID": str(user_entity.user_id),
            "appVersion": str(self.appVersion),
            "Authorization": f"Bearer {user_entity.sd_token}",
            "Content-Type": "application/json",
            "Accept": "application/json",
            "deviceType": self.get_os_type_caps(),
            "DeviceID": self.system_unique_id,
        }

    def _activity_log_push_succeeded(self, response) -> bool:
        """Return True when activity/log API accepted the payload."""
        if response is None:
            return False
        if response.get("message") == "Token is Expired":
            logger.error("Token is Expired on activity/log session registration")
            self._send_subscription_expired_popup(response)
            self.status = False
            return False
        if (
            response.get("planStatus") != "active"
            or response.get("isdeleted") == 1
            or response.get("isdeactivated") == 1
        ):
            logger.error("Activity/log session registration rejected: %s", response)
            self._send_subscription_expired_popup(response)
            self.status = False
            return False
        return response.get("planStatus") == "active"

    @staticmethod
    def _normalize_task_id_for_compare(task_id: Optional[str]) -> str:
        if task_id is None or task_id == "":
            return "0"
        return str(task_id)

    def _project_task_changed(
        self, new_project_id: Optional[str], new_task_id: Optional[str]
    ) -> bool:
        old_p = str(self.current_project_id or "1001")
        new_p = str(new_project_id) if new_project_id is not None else "1001"
        old_t = self._normalize_task_id_for_compare(self.current_task_id)
        new_t = self._normalize_task_id_for_compare(
            str(new_task_id) if new_task_id is not None else None
        )
        return old_p != new_p or old_t != new_t

    async def _close_session_for_project_switch(
        self,
        user_entity: UserEntity,
        *,
        switch_time: datetime.datetime,
        old_project_id: str,
        old_task_id: str,
    ) -> None:
        """Close the active session before switching project/task (compat with older .pyd)."""
        close_impl = getattr(self, "_last_api_call_impl", None)
        if callable(close_impl):
            await close_impl(
                user_entity,
                keep_idle_time=True,
                close_time=switch_time,
                payload_project_id=old_project_id,
                payload_task_id=old_task_id,
            )
            return

        last_call = getattr(self, "last_api_call", None)
        if not callable(last_call):
            logger.warning(
                "Project switch: no last_api_call on ActivityController — skipping session close"
            )
            return

        try:
            await last_call(
                user_entity,
                keep_idle_time=True,
                close_time=switch_time,
                payload_project_id=old_project_id,
                payload_task_id=old_task_id,
            )
        except TypeError:
            await last_call(user_entity, keep_idle_time=True)

    def _set_project_task_ids_compat(
        self,
        project_id: Optional[str],
        task_id: Optional[str],
        *,
        update_segment_start: bool = True,
    ) -> None:
        """Call set_project_task_ids; tolerate stale Cython .pyd without update_segment_start."""
        try:
            self.set_project_task_ids(
                project_id, task_id, update_segment_start=update_segment_start
            )
        except TypeError:
            self.set_project_task_ids(project_id, task_id)
            if not update_segment_start:
                self.current_segment_start_time = None

    async def project_switch_event(
        self,
        user_entity: UserEntity,
        new_project_id: Optional[str],
        new_task_id: Optional[str],
    ) -> bool:
        """
        Close the current work session on activity/log (old project/task), then open a new
        session while monitoring continues.

        Defined on this mixin (not Cython-compiled activity_controller) so Windows frozen
        builds with an older activity_controller.pyd still get full project/task switching.
        """
        if getattr(self, "_project_switch_in_progress", False):
            logger.warning("Project switch already in progress — ignoring duplicate request")
            return False

        new_project_id = str(new_project_id) if new_project_id is not None else None
        new_task_id = str(new_task_id) if new_task_id is not None else None

        if not self._project_task_changed(new_project_id, new_task_id):
            logger.info("Project/task unchanged — skipping project_switch_event")
            self._set_project_task_ids_compat(
                new_project_id, new_task_id, update_segment_start=False
            )
            return False

        self._project_switch_in_progress = True
        switch_time = datetime.datetime.now().replace(microsecond=0)
        old_project_id = self.current_project_id or "1001"
        old_task_id = self._normalize_task_id_for_compare(self.current_task_id)

        logger.info(
            "=== PROJECT SWITCH === closing session project=%s task=%s at %s, "
            "then opening project=%s task=%s",
            old_project_id,
            old_task_id,
            switch_time,
            new_project_id,
            self._normalize_task_id_for_compare(new_task_id),
        )

        close_error: Optional[Exception] = None
        session_lock = getattr(self, "_session_close_lock", None)
        try:
            lock_ctx = session_lock if session_lock is not None else nullcontext()
            with lock_ctx:
                self.current_segment_start_time = None
                try:
                    await self._close_session_for_project_switch(
                        user_entity,
                        switch_time=switch_time,
                        old_project_id=str(old_project_id),
                        old_task_id=old_task_id,
                    )
                except Exception as e:
                    close_error = e
                    logger.error(
                        "Project switch: close old session failed (%s) — still opening new segment",
                        e,
                    )

                old_counter = self.last_count
                self.last_count = old_counter + 1
                logger.info(
                    "=== PROJECT SWITCH - COUNTER INCREMENTED === %s -> %s (intervals use counter=%s)",
                    old_counter,
                    self.last_count,
                    self.last_count + 1,
                )

                self._set_project_task_ids_compat(
                    new_project_id, new_task_id, update_segment_start=False
                )
                self.current_segment_start_time = None
                self.current_session_start_time = switch_time
                self.start_date = switch_time
                self._last_work_stop_time = None

                await self.push_open_session_activity_log(
                    user_entity,
                    start_time=switch_time,
                    break_id=None,
                )
                logger.info(
                    "=== PROJECT SWITCH - OPEN SESSION POSTED === start=%s project=%s task=%s "
                    "(post-switch slot interval deferred until boundary, e.g. 10:02->10:05 at 10:05)",
                    switch_time,
                    self.current_project_id,
                    self.current_task_id,
                )

            if close_error is not None:
                raise close_error
            return True
        except Exception as e:
            logger.error("=== PROJECT SWITCH FAILED === %s", e, exc_info=True)
            raise
        finally:
            self._project_switch_in_progress = False

    @staticmethod
    def _normalize_activity_log_start(start_time: datetime.datetime) -> datetime.datetime:
        return start_time.replace(microsecond=0)

    def _pin_activity_log_session_start(self, start_time: datetime.datetime) -> datetime.datetime:
        """Record canonical session start so empty open-session POST and interval uploads match."""
        normalized = self._normalize_activity_log_start(start_time)
        self._activity_log_payload_start = normalized
        logger.info(
            "Pinned activity/log session start: %s",
            normalized.strftime("%Y-%m-%d %H:%M:%S"),
        )
        return normalized

    def _get_pinned_activity_log_session_start(
        self, target_date: Optional[datetime.date] = None
    ) -> Optional[datetime.datetime]:
        pinned = getattr(self, "_activity_log_payload_start", None)
        if pinned is None:
            return None
        pinned = self._normalize_activity_log_start(pinned)
        if target_date is not None and pinned.date() != target_date:
            return None
        return pinned

    async def push_open_session_activity_log(
        self,
        user_entity: UserEntity,
        *,
        start_time: datetime.datetime,
        break_id: Optional[int] = None,
        initial_intervals: Optional[List[dict]] = None,
    ) -> bool:
        """
        Register an open tracking session on activity/log (stop=null).

        Used when work monitoring starts and when a break starts (break_id set).
        After a project switch, ``initial_intervals`` may include the partial slot
        (e.g. 10:08:30–10:10:00) so the server shows a range, not only session start.
        """
        if user_entity is None or start_time is None:
            logger.warning("push_open_session_activity_log skipped: missing user_entity or start_time")
            return False

        is_break = break_id is not None and int(break_id) != 0
        start_dt = self._pin_activity_log_session_start(start_time)

        if is_break:
            project_id = (
                getattr(self, "break_project_id", None)
                or self.current_project_id
                or "1001"
            )
            task_id = getattr(self, "break_task_id", None) or self.current_task_id or "0"
            device_type = "Break"
            payload_break_id = int(break_id)
        else:
            project_id = self.current_project_id or "1001"
            task_id = self.current_task_id or "0"
            device_type = self.get_os_type_caps()
            payload_break_id = None

        tz = self._normalize_timezone(
            str(self.timezone_manager.local_timezone)
            if self.timezone_manager.local_timezone
            else str(self.get_system_timezone())
        )

        payload = [
            {
                "projectId": str(project_id),
                "start": start_dt.strftime("%Y-%m-%d %H:%M:%S"),
                "stop": None,
                "selfi_id": None,
                "timezone": tz,
                "break_id": payload_break_id,
                "deviceType": device_type,
                "os_type": self.get_os_type_caps(),
                "selfiVerification": True,
                "intervals": list(initial_intervals or []),
                "time_type": "1",
                "interval_time": int(self.interval / 60),
                "source_type": "4",
                "org_id": str(user_entity.org_id),
                "todo_id": str(task_id) if task_id is not None else "0",
                "deviceId": self.system_unique_id,
                "ip_address": str(get_ip()),
                "os_version": get_os_version(),
            }
        ]

        session_kind = f"break_id={payload_break_id}" if is_break else "work"
        interval_count = len(payload[0]["intervals"])
        logger.info(
            "Posting open session activity/log (%s): start=%s, stop=null, intervals=%s",
            session_kind,
            payload[0]["start"],
            interval_count,
        )
        if interval_count == 1:
            iv = payload[0]["intervals"][0]
            logger.info(
                "Open session includes partial interval: %s to %s",
                iv.get("from"),
                iv.get("to"),
            )

        try:
            self._configure_activity_log_api_headers(user_entity)
            response = self.activity_service.push_activity_logs(payload)
            logger.info("Open session activity/log response (%s): %s", session_kind, response)
            return self._activity_log_push_succeeded(response)
        except Exception as e:
            logger.error("Failed to post open session activity/log (%s): %s", session_kind, e, exc_info=True)
            return False

    async def process_intervals_bulk(self, intervals, org_id, user_entity: UserEntity, last_interval=None):
        """Async generator that builds and yields the full activity/log API payload per day.

        Groups ``intervals`` by date, constructs screenshot URLs, activity levels, and
        app/URL data for each interval, then yields one ``(payload, day_intervals)``
        tuple per day.

        Args:
            intervals: List of ActivityInterval ORM objects to process.
            org_id: Organisation ID used in the API payload.
            user_entity: Authenticated user entity supplying project/task defaults.
            last_interval: Optional last interval of the tracking session; when
                supplied, the payload ``"stop"`` timestamp is set to ``now()`` for
                that day and the screenshot query range is extended to include the
                stop screenshot.

        Yields:
            ``(payload, day_intervals)`` where ``payload`` is a list containing the
            API request body dict and ``day_intervals`` is the list of interval ORM
            objects included in that request.
        """
        if intervals is None or len(intervals) == 0:
            logger.info("No intervals found for the given date range.")
            yield None

        # Group intervals by day
        grouped_intervals = self.group_intervals_by_day(intervals)

        # Process each day separately
        for target_date, day_intervals in grouped_intervals.items():
            # Edge case: Ensure the intervals array contains at most 100 objects per day
            if len(day_intervals) > 100:
                day_intervals = day_intervals[:100]
            logger.info("Target date of intervals %s", target_date)
            # Get the appropriate start time for this day
            day_start_time = self.get_day_start_time(target_date, day_intervals)
            # When project/task was changed on running timer, use current segment start (now) for today's payload
            effective_start_time = day_start_time
            if target_date == datetime.datetime.now().date() and self.current_segment_start_time is not None:
                effective_start_time = self.current_segment_start_time
                logger.info("Using segment start (project/task changed on running timer): %s", effective_start_time)

            # CRITICAL: Filter out intervals that completely end before effective_start_time.
            # General rule: Only filter intervals that completely end before the payload start time
            # This applies to all cases: normal sessions, idle intervals, and break intervals.
            # This prevents including intervals from previous sessions/breaks when a new session starts.
            # FIX: Changed from filtering by start_time < effective_start_time to end_time < effective_start_time
            # This ensures intervals that start at or overlap with effective_start_time are included
            # (e.g., break ends at 10:02, interval created 10:02-10:10 should be included)
            if day_intervals:
                filtered_intervals = []
                filtered_count = 0
                for interval in day_intervals:
                    # Only filter if interval completely ends before effective_start_time
                    if hasattr(interval, 'start_time') and hasattr(interval, 'end_time') and interval.end_time < effective_start_time:
                        filtered_count += 1
                        logger.debug("Filtering out interval (start=%s, end=%s) - completely ends before payload start time (%s)", interval.start_time, interval.end_time, effective_start_time)
                    else:
                        filtered_intervals.append(interval)

                if filtered_count > 0:
                    logger.info("Filtered out %s interval(s) that completely end before effective_start_time (%s)", filtered_count, effective_start_time)
                    day_intervals = filtered_intervals

                if not day_intervals:
                    logger.info("No intervals remain after filtering - skipping this day")
                    continue

            # Prepare the list of interval payloads for this day
            interval_payloads = []

            # Fetch notes for all intervals in this day from Notes table
            from ws_package.models.base import Notes

            interval_ids = [interval.id for interval in day_intervals if hasattr(interval, 'id')]
            interval_note_map = {}
            interval_file_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 and file_path
                        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 hasattr(notes_entry, 'file_path') and notes_entry.file_path:
                                            interval_file_map[interval_id] = notes_entry.file_path

                        if interval_note_map:
                            logger.info("Found notes for %s intervals", len(interval_note_map))
            except Exception as e:
                logger.error("Error fetching notes from Notes table: %s", e, exc_info=True)
                # Continue without notes if there's an error

            for interval in day_intervals:
                # Format idle_note_url
                interval_id = getattr(interval, 'id', None)
                file_path = interval_file_map.get(interval_id)
                idle_note_url = {"image_url": file_path} if file_path else None

                # For the last interval (stop), extend end_time to now when fetching screenshots so the
                # stop screenshot (captured at stop button press) is included in the payload
                ss_end_time = interval.end_time
                if last_interval is not None:
                    def _dt_match(a, b, tol=1.0):
                        if a is None or b is None:
                            return a == b
                        try:
                            return abs((a - b).total_seconds()) <= tol
                        except Exception:
                            return False
                    last_match = (
                        _dt_match(getattr(interval, 'start_time', None), getattr(last_interval, 'start_time', None))
                        and _dt_match(getattr(interval, 'end_time', None), getattr(last_interval, 'end_time', None))
                        and getattr(interval, 'counter', None) == getattr(last_interval, 'counter', None)
                    )
                    if last_match:
                        ss_end_time = max(interval.end_time, datetime.datetime.now())
                        logger.info("Last interval (stop): extending screenshot range to %s to include stop screenshot", ss_end_time)

                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 interval (start=%s, end=%s) — no time left after clipping to payload start %s",
                        interval.start_time,
                        interval.end_time,
                        effective_start_time,
                    )
                    continue

                interval_payload = {
                    "Entry": interval_id or 0,  # Use 0 as default if id doesn't exist (e.g., ActivityIntervalEntity)
                    "from": payload_from.strftime("%Y-%m-%d %H:%M:%S"),
                    "to": payload_to.strftime("%Y-%m-%d %H:%M:%S"),
                    "screenUrl": await self.ss(payload_from, ss_end_time, user_entity),
                    "activityLevel": await self.get_activity_level_from_db(payload_from, payload_to),
                    "appAndUrls": await self.activity_service.get_apps_urls(payload_from, payload_to, self.appandurlstatus),
                    "deviceType": self.get_os_type_caps(),
                    "os_type": self.get_os_type_caps(),
                    "note": interval_note_map.get(interval_id),  # Get note from Notes table mapping
                    "idle_note_url": idle_note_url,
                    "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)

            # Build the final payload for this day
            # Use current project/task IDs if set, otherwise fallback to defaults
            project_id = self.current_project_id if self.current_project_id else "1001"
            task_id = self.current_task_id if self.current_task_id else "0"
            if not self.current_project_id:
                logger.warning(
                    "No project selected in Python — using fallback project_id=%s. "
                    "Ensure Electron sends set_project_task before start_activity_monitoring.",
                    project_id,
                )
            logger.info("Using project_id=%s, task_id=%s for activity log payload (current_project_id=%s, current_task_id=%s)", project_id, task_id, self.current_project_id, self.current_task_id)

            # When sending last interval (stop click), set "stop" to datetime.now()
            # BUT: Don't set "stop" for sleep intervals (long gaps with zero activity)
            stop_value = None
            if last_interval is not None:
                def _dt_match(a, b, tol=1.0):
                    if a is None or b is None:
                        return a == b
                    try:
                        return abs((a - b).total_seconds()) <= tol
                    except Exception:
                        return False

                # NEW: Check if this is a sleep interval (long gap with zero activity)
                # Sleep intervals should NOT get a "stop" timestamp
                is_sleep_interval = False
                if hasattr(last_interval, 'start_time') and hasattr(last_interval, 'end_time'):
                    gap_seconds = (last_interval.end_time - last_interval.start_time).total_seconds()
                    # Check if it's a long interval (>= 1 hour) with zero activity (typical sleep pattern)
                    if gap_seconds >= 3600:  # 1 hour
                        # Check if activity level is zero (sleep intervals have zero activity)
                        try:
                            activity_level = await self.get_activity_level_from_db(last_interval.start_time, last_interval.end_time)
                            if activity_level:
                                keyboard = float(activity_level.get('keyboard', 0) or 0)
                                mouse = float(activity_level.get('mouse', 0) or 0)
                                average = float(activity_level.get('average', 0) or 0)
                                if keyboard == 0.0 and mouse == 0.0 and average == 0.0:
                                    is_sleep_interval = True
                                    logger.info("[SleepInterval] Detected sleep interval (%s hours) - will NOT set 'stop' timestamp", gap_seconds/3600)
                        except Exception as e:
                            logger.debug("Could not check activity level for sleep detection: %s", e)
                            # If we can't check, also check if the interval has activityLevel attribute directly
                            if hasattr(last_interval, 'activityLevel') and last_interval.activityLevel:
                                try:
                                    activity_level = last_interval.activityLevel
                                    keyboard = float(activity_level.get('keyboard', 0) if isinstance(activity_level, dict) else 0)
                                    mouse = float(activity_level.get('mouse', 0) if isinstance(activity_level, dict) else 0)
                                    average = float(activity_level.get('average', 0) if isinstance(activity_level, dict) else 0)
                                    if keyboard == 0.0 and mouse == 0.0 and average == 0.0:
                                        is_sleep_interval = True
                                        logger.info("[SleepInterval] Detected sleep interval from activityLevel attribute (%s hours) - will NOT set 'stop' timestamp", gap_seconds/3600)
                                except Exception as e2:
                                    logger.debug("Could not check activityLevel attribute: %s", e2)

                if not is_sleep_interval:
                    last_in_this_day = any(
                        _dt_match(getattr(iv, 'start_time', None), getattr(last_interval, 'start_time', None))
                        and _dt_match(getattr(iv, 'end_time', None), getattr(last_interval, 'end_time', None))
                        and getattr(iv, 'counter', None) == getattr(last_interval, 'counter', None)
                        for iv in day_intervals
                    )
                    if last_in_this_day:
                        stop_dt = getattr(self, '_last_work_stop_time', None) or datetime.datetime.now()
                        stop_value = stop_dt.replace(microsecond=0).strftime("%Y-%m-%d %H:%M:%S")
                        logger.info("Last interval in this batch (stop click): setting payload 'stop' to %s", stop_value)
                else:
                    logger.info("[SleepInterval] Skipping 'stop' timestamp for sleep interval (start=%s, end=%s)", last_interval.start_time, last_interval.end_time)

            payload = [{
                "projectId": project_id,
                "start": effective_start_time.strftime("%Y-%m-%d %H:%M:%S"),
                "stop": stop_value,
                "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(org_id),
                "todo_id": task_id,
                "deviceId": self.system_unique_id,
                "ip_address": str(get_ip()),
                "os_version": get_os_version()
            }]

            logger.info("Generated payload for date %s with start time %s and %s intervals", target_date, effective_start_time.strftime('%Y-%m-%d %H:%M:%S'), len(day_intervals))
            # Clear segment start after using it so next payload uses normal day start
            if effective_start_time == self.current_segment_start_time:
                self.current_segment_start_time = None
            yield payload, day_intervals
