# ws_package/services/activity_service.py
import datetime, requests
from typing import Optional, List, Dict, Any
import sqlite3, sys, os
import time
import asyncio
from pathlib import Path
from collections import defaultdict
import re
from ws_package.entities.activity_entity import ActivityIntervalEntity
from ws_package.repositories.activity_repo import activity_manager
from ws_package.repositories.screenshot_repo import screenshot_manager
from ws_package.services.api_request_service import APIRequest
from ws_package.utils.api_log_policy import (
    api_name_from_url,
    log_api_response_summary,
    log_testing_request_headers,
)
from ws_package.log.writer import LogWriter
from ws_package.models.base import ActivityInterval, async_session, Appmonitor
from ws_package.utils.update_linux import update_linux_build
from ws_package.utils.update_mac import MacOSUpdater
import distro
from sqlalchemy import select, desc, text

import platform, subprocess
from ws_package.configs import config
from ws_package.utils.db_lock_manager import db_lock_manager
from ws_package.utils.activity_level import normalize_activity_level

# Platform-specific imports
if platform.system() == "Windows":
    import pygetwindow as gw
    from ws_package.utils.update_windows import update_win_build
elif platform.system() == "Linux":
    try:
        from Xlib import display, X
    except ImportError:
        display = None
        X = None
    
if sys.platform == "darwin":
    db_dir = os.path.expanduser("~/Library/Application Support/Workstatus")
    os.makedirs(db_dir, exist_ok=True)
    db_path = str(Path(db_dir) / "ws_monitor.db")
elif getattr(sys, 'frozen', False):  # Check if running from PyInstaller EXE
    base_dir = os.path.dirname(sys.executable)  # Directory where the EXE is
    db_path = str(Path(base_dir) / "ws_monitor.db")
else:
    script_dir = Path(__file__).resolve().parent
    db_path = script_dir.parents[1] / "ws_package" / "ws_monitor.db"
    db_path = str(db_path)

logger = LogWriter.file(__name__)


class ActivityService:
    def __init__(self):
        # Instance-specific state (converted from class-level)
        self.application_activity_log_url: Optional[str] = None
        self.headers: Optional[dict] = None
        # Removed individual db_write_lock - now using centralized db_lock_manager
        
        # App monitor batching system
        self._app_monitor_buffer: List[Dict[str, Any]] = []  # In-memory buffer for app monitor data
        self._buffer_lock = asyncio.Lock()  # Thread-safe buffer access
        self._flush_interval = 45.0  # Flush every 45 seconds (middle of 30-60 range)
        self._max_buffer_size = 100  # Safety limit to prevent memory issues
        self._flush_task: Optional[asyncio.Task] = None  # Background flush task
        self._stop_flush_event: Optional[asyncio.Event] = None  # Event to stop flush task
        self._is_flush_running = False  # Track if flush task is running
        
        # Flush operation safeguards
        self._flush_in_progress = False  # Track if a flush operation is currently running
        self._flush_in_progress_lock = asyncio.Lock()  # Lock for flush_in_progress flag
        self._flush_failure_count = 0  # Track consecutive flush failures
        self._max_flush_failures = 5  # Circuit breaker threshold - stop accepting new items after 5 failures
        self._flush_operation_timeout = 30.0  # Timeout for flush operations (30 seconds)
        self._batch_chunk_size = 50  # Process large batches in chunks to avoid lock timeouts

    def get_os_type_lower(self):
        if platform.system() == "Darwin":
            return "mac"
        return platform.system().lower()

    # Maximum duration (seconds) for a single normal (non-break) interval sent to activity/log.
    # Intervals longer than this (e.g. from long sleep) are not sent to the server.
    NORMAL_INTERVAL_MAX_DURATION_SECONDS = 3600  # 1 hour

    def _parse_payload_dt(self, value: Any) -> Optional[datetime.datetime]:
        """Parse payload datetime value into a datetime object."""
        if value is None:
            return None
        if isinstance(value, datetime.datetime):
            return value
        try:
            return datetime.datetime.strptime(
                str(value).replace("T", " ").split(".")[0],
                "%Y-%m-%d %H:%M:%S",
            )
        except (ValueError, TypeError):
            return None

    def _format_payload_dt(self, value: datetime.datetime) -> str:
        return value.strftime("%Y-%m-%d %H:%M:%S")

    def _normalize_interval_start_not_before_payload_start(self, payload: Any) -> Optional[List[Dict[str, Any]]]:
        """Ensure interval.from is never earlier than payload.start for all payloads."""
        if payload is None:
            return None
        if isinstance(payload, dict):
            payload = [payload]
        if not isinstance(payload, list):
            return None

        normalized_payloads: List[Dict[str, Any]] = []
        for day_payload in payload:
            if not isinstance(day_payload, dict):
                continue

            start_dt = self._parse_payload_dt(day_payload.get("start"))
            intervals = day_payload.get("intervals") or []

            if start_dt is None or not isinstance(intervals, list):
                normalized_payloads.append(day_payload)
                continue

            clipped_intervals: List[Dict[str, Any]] = []
            for iv in intervals:
                if not isinstance(iv, dict):
                    continue
                from_dt = self._parse_payload_dt(iv.get("from"))
                to_dt = self._parse_payload_dt(iv.get("to"))

                if from_dt is None or to_dt is None:
                    clipped_intervals.append(iv)
                    continue

                # Hard guard: interval start can never be earlier than payload start.
                if from_dt < start_dt:
                    logger.info(
                        "Clipping interval start to payload start (entry=%s, from=%s, start=%s)",
                        iv.get("Entry"),
                        from_dt,
                        start_dt,
                    )
                    from_dt = start_dt

                if to_dt <= from_dt:
                    # Keep interval (do not drop) — align its start to payload start and
                    # normalize end to at least the same timestamp.
                    logger.info(
                        "Adjusting interval end after start clip (entry=%s, from=%s, old_to=%s)",
                        iv.get("Entry"),
                        from_dt,
                        to_dt,
                    )
                    to_dt = from_dt

                new_iv = dict(iv)
                new_iv["from"] = self._format_payload_dt(from_dt)
                new_iv["to"] = self._format_payload_dt(to_dt)
                clipped_intervals.append(new_iv)

            new_day_payload = dict(day_payload)
            new_day_payload["intervals"] = clipped_intervals
            normalized_payloads.append(new_day_payload)

        return normalized_payloads

    def _filter_normal_intervals_over_one_hour(self, payload: Any) -> Optional[List[Dict[str, Any]]]:
        """
        For non-break payloads, remove any interval whose from-to duration is > 1 hour.
        Break payloads are left unchanged. Payload can be a list of day payloads or a single dict.
        Returns filtered list of day payloads; payloads with no intervals left are omitted.
        Returns None if payload is None or invalid.
        """
        if payload is None:
            return None
        if isinstance(payload, dict):
            payload = [payload]
        if not isinstance(payload, list) or len(payload) == 0:
            return payload if isinstance(payload, list) else None
        max_sec = self.NORMAL_INTERVAL_MAX_DURATION_SECONDS
        filtered_list = []
        for day_payload in payload:
            if not isinstance(day_payload, dict):
                filtered_list.append(day_payload)
                continue
            break_id = day_payload.get("break_id")
            is_break = break_id is not None and break_id != 0
            intervals = day_payload.get("intervals") or []
            if is_break:
                filtered_list.append(day_payload)
                continue
            kept = []
            for iv in intervals:
                if not isinstance(iv, dict):
                    kept.append(iv)
                    continue
                from_str = iv.get("from")
                to_str = iv.get("to")
                if from_str is None or to_str is None:
                    kept.append(iv)
                    continue
                try:
                    if isinstance(from_str, datetime.datetime):
                        start = from_str
                    else:
                        start = datetime.datetime.strptime(
                            str(from_str).replace("T", " ").split(".")[0],
                            "%Y-%m-%d %H:%M:%S",
                        )
                    if isinstance(to_str, datetime.datetime):
                        end = to_str
                    else:
                        end = datetime.datetime.strptime(
                            str(to_str).replace("T", " ").split(".")[0],
                            "%Y-%m-%d %H:%M:%S",
                        )
                    duration_sec = (end - start).total_seconds()
                    if duration_sec <= max_sec:
                        kept.append(iv)
                    else:
                        logger.debug(
                            "Excluding normal interval from activity/log (duration %.0fs > %ss): from=%s, to=%s",
                            duration_sec, max_sec, from_str, to_str
                        )
                except (ValueError, TypeError) as e:
                    logger.warning("Could not parse interval from/to: %s, keeping interval", e)
                    kept.append(iv)
            if not kept:
                # Keep intentional empty-interval session boundaries:
                # - open session: stop=null, intervals=[]
                # - close session (long-gap / sleep): stop set, intervals=[]
                if not intervals:
                    boundary_payload = dict(day_payload)
                    boundary_payload["intervals"] = []
                    filtered_list.append(boundary_payload)
                    continue
                logger.debug(
                    "Skipping day payload (no intervals left after filtering >1h normal intervals): start=%s, stop=%s",
                    day_payload.get('start'), day_payload.get('stop')
                )
                continue
            new_payload = dict(day_payload)
            new_payload["intervals"] = kept
            filtered_list.append(new_payload)
        return filtered_list if filtered_list else None

    def push_activity_logs(self, payload):
        """Push filtered activity log payload to the server.

        Args:
            payload: Activity log payload (list of day dicts or a single dict).

        Returns:
            Parsed response data dict, or None if nothing was sent or request failed.
        """
        if self.application_activity_log_url is None or self.headers is None:
            return None
        normalized = self._normalize_interval_start_not_before_payload_start(payload)
        filtered = self._filter_normal_intervals_over_one_hour(normalized)
        if filtered is None or len(filtered) == 0:
            logger.debug("No payload to send after filtering normal intervals > 1 hour")
            return None
        api_request = APIRequest(
            url=self.application_activity_log_url,
            headers=self.headers,
        )
        api_request.post(filtered)
        return api_request.data()
    
    def _parse_version(self, v):
        s = str(v)
        parts = []
        for part in s.split('.'):
            try:
                parts.append(int(part))
            except ValueError:
                parts.append(0)
        return tuple(parts)

    def is_remote_newer_than_current(self, current_app_version, remote_app_version):
        """
        Compare current_app_version against remote_app_version semantically.
        Returns True if remote_app_version is strictly greater than current_app_version.
        """
        current_tuple = self._parse_version(current_app_version)
        remote_tuple = self._parse_version(remote_app_version)
        max_len = max(len(current_tuple), len(remote_tuple))
        current_tuple = current_tuple + (0,) * (max_len - len(current_tuple))
        remote_tuple = remote_tuple + (0,) * (max_len - len(remote_tuple))
        return current_tuple < remote_tuple

    async def check_version_update(self, user_entity, perform_upgrade: bool = True):
        """
        Check if a newer app version is available.
        When perform_upgrade is True, also download/apply the update (platform-specific).
        When perform_upgrade is False, only return True/False without performing upgrade.
        """
        application_check_version_url = config('application_check_version_url', 'Application')
        # Read as string — _parse_version() handles any dotted-version string correctly.
        current_app_version = config('app_version', 'System')
        url = application_check_version_url

        if platform.system() == 'Linux':
            version = distro.version()
            if version.startswith("24.04"):
                os_version = "2404"
            else:
                os_version = "2204"
        elif platform.system()=='Darwin':
            version = platform.machine()
            if version == 'x86_64':
                os_version = 'intel'
            elif version == 'arm64':
                os_version = 'metal'
            else:
                os_version = version
        else:
            os_version = platform.platform()
        from ws_package.utils.build_mode import is_silent_build
        app_mode = "silent" if is_silent_build() else "visible"
        headers = {
            "Content-Type": "application/json",
            "Authorization": f"Bearer {user_entity.token}",
            "OrgID": str(user_entity.org_id),
            "UserID": str(user_entity.user_id),
            "deviceType": self.get_os_type_lower(),
            "app_mode": app_mode,
        }
        from ws_package.utils.desktop_key import apply_desktop_key
        headers = apply_desktop_key(headers)
        body = {
            "app_version": current_app_version,
            "os": self.get_os_type_lower(),
            "os_version": os_version,
            "appmode": app_mode,
        }

        try:
            log_testing_request_headers(logger, "POST", url, headers, body)
            response = requests.post(url, json=body, headers=headers)
            log_api_response_summary(
                logger,
                api_name_from_url(url),
                response.status_code,
                response.text,
            )
            response.raise_for_status()
            json_data = response.json()

            # API may return response.data as null; avoid chaining .get on None.
            _r1 = json_data.get("response") or {}
            _d1 = _r1.get("data") or {}
            _r2 = _d1.get("response") or {}
            update_data = _r2.get("data") or {}

            if update_data:
                remote_app_version = update_data.get("app_version")
                logger.info(
                    "Current app version Local: %s and Remote: %s",
                    current_app_version,
                    remote_app_version,
                )
                logger.info("Force upgrade true/false from api: %s", update_data.get("force_upgrade"))
                is_force = str(update_data.get("force_upgrade")).lower() == "true"
                is_newer = False
                if remote_app_version:
                    is_newer = self.is_remote_newer_than_current(
                        current_app_version, remote_app_version
                    )
                update_available = is_force or is_newer
                logger.info(
                    "Update check: force=%s newer=%s update_available=%s",
                    is_force,
                    is_newer,
                    update_available,
                )

                if not perform_upgrade:
                    return {
                        "force_upgrade": is_force,
                        "update_available": update_available,
                        "current_app_version": current_app_version,
                        "remote_app_version": remote_app_version,
                        "download_link": update_data.get("download_link"),
                    }

                if update_available:
                    logger.info(
                        "Applying update (force=%s, newer=%s)",
                        is_force,
                        is_newer,
                    )
                    if self.get_os_type_lower() == "windows":
                        from ws_package.utils.update_windows import (
                            resolve_windows_force_upgrade_url,
                            windows_force_upgrade_fallback_urls,
                        )

                        update = update_win_build(update_data.get("app_version"))
                        api_link = update_data.get("download_link")
                        override_link = (
                            config("windows_exe_download_url_override", "Application") or ""
                        ).strip()
                        download_url = resolve_windows_force_upgrade_url(
                            api_link, override_link
                        )
                        if not download_url:
                            logger.error(
                                "[win_upgrade] no download URL (API link and override both empty)"
                            )
                            return False
                        fallbacks = windows_force_upgrade_fallback_urls(
                            download_url, update_data.get("app_version")
                        )
                        has_update = await update.download_file(
                            download_url, fallback_urls=fallbacks
                        )
                        logger.info("Has update properly: %s", has_update)
                        return has_update
                    elif self.get_os_type_lower() == "linux":
                        update = update_linux_build(update_data.get("app_version"))
                        api_link = update_data.get("download_link")
                        override_link = (
                            config("linux_deb_download_url_override", "Application") or ""
                        ).strip()
                        if override_link:
                            download_url = override_link
                            logger.info(
                                "Linux upgrade using test .deb override (not API build): %s",
                                download_url,
                            )
                            logger.info("API download_link (ignored): %s", api_link)
                        else:
                            download_url = api_link
                            logger.info("Updated build URL %s", download_url)
                        has_update = await update.download_file(download_url)
                        logger.info("Linux upgrade handoff completed: %s", has_update)
                        return has_update
                    elif self.get_os_type_lower() == "mac":
                        update = MacOSUpdater()
                        has_update = await update.download_file(update_data.get("download_link"))
                        logger.info("macOS upgrade handoff completed: %s", has_update)
                        return bool(has_update)

                logger.info("No version update available")
                return False

            logger.info("No version update available")
            return False
        except Exception as e:
            logger.error(
                "Failed to check/apply version update (%s): %s",
                api_name_from_url(url),
                e,
                exc_info=True,
            )
            return False
    
    async def save_activity_log(self, activityLevel, start_date: datetime.datetime, end_date: datetime.datetime, counter, weekday, break_id: int = 0, is_idle: bool = False, has_on_demand_screenshot: bool = False):
        """Save a single activity interval to the database.

        Args:
            activityLevel: Activity level percentage.
            start_date: Interval start datetime.
            end_date: Interval end datetime.
            counter: Session counter value.
            weekday: Day-of-week for the interval.
            break_id: Break ID if this is a break interval; 0 otherwise.
            is_idle: Whether the interval is idle.
            has_on_demand_screenshot: Whether an on-demand screenshot was taken.

        Returns:
            Saved ActivityInterval ORM object, or the entity on queue failure.
        """
        activityLevel = normalize_activity_level(activityLevel)
        # Save break_id in is_break field (0 if break_id is null/0)
        is_break_value = break_id if break_id else 0
        activity_interval_entity = ActivityIntervalEntity(
            start_time=start_date,
            end_time=end_date,
            is_uploaded=False,
            activityLevel=activityLevel,
            counter=counter,
            weekday=weekday,
            is_break=is_break_value,
            is_idle=is_idle,
            has_on_demand_screenshot=has_on_demand_screenshot
        )
        saved_interval = await activity_manager.save_activity_interval(activity_interval_entity)

        logger.info(
            "Activity Interval enqueued (pending DB commit) %s (is_break=%s, break_id=%s, is_idle=%s, has_on_demand_screenshot=%s)",
            saved_interval,
            is_break_value,
            break_id,
            is_idle,
            has_on_demand_screenshot,
        )
        # Return the saved database model (has id) instead of the entity (no id)
        return saved_interval
    
    async def save_first_activity_log(self, activityLevel, start_date: datetime.datetime, end_date: datetime.datetime, counter, weekday, break_id: int = 0, is_idle: bool = False, has_on_demand_screenshot: bool = False):
        """Save the first activity interval of a session (pre-marked as uploaded).

        Args:
            activityLevel: Activity level percentage.
            start_date: Interval start datetime.
            end_date: Interval end datetime.
            counter: Session counter value.
            weekday: Day-of-week for the interval.
            break_id: Break ID if this is a break interval; 0 otherwise.
            is_idle: Whether the interval is idle.
            has_on_demand_screenshot: Whether an on-demand screenshot was taken.

        Returns:
            ActivityIntervalEntity created for the first interval.
        """
        activityLevel = normalize_activity_level(activityLevel)
        # Save break_id in is_break field (0 if break_id is null/0)
        is_break_value = break_id if break_id else 0
        activity_interval_entity = ActivityIntervalEntity(
            start_time=start_date,
            end_time=end_date,
            is_uploaded=True, # True because this is the first activity log dont need to upload
            activityLevel=activityLevel,
            counter=counter,
            weekday=weekday,
            is_break=is_break_value,
            is_idle=is_idle,
            has_on_demand_screenshot=has_on_demand_screenshot
        )
        await activity_manager.save_activity_interval(activity_interval_entity)

        # Note: id is not yet assigned — the write is queued asynchronously and the
        # DB-assigned primary key is not available on this Pydantic entity object.
        logger.info("Activity Interval enqueued (id not yet assigned) %s (is_break=%s, break_id=%s, is_idle=%s, has_on_demand_screenshot=%s)", activity_interval_entity, is_break_value, break_id, is_idle, has_on_demand_screenshot)
        return activity_interval_entity

    async def start_app_monitor_flush_task(self):
        """
        Start the background flush task for app monitor batching.
        Should be called when the service is initialized.
        """
        if self._is_flush_running:
            logger.warning("App monitor flush task is already running")
            return
        
        # Clean up any old task from previous thread/loop (if exists)
        if self._flush_task and not self._flush_task.done():
            try:
                # Check if old task is in current loop
                try:
                    current_loop = asyncio.get_running_loop()
                    task_loop = self._flush_task.get_loop()
                    if task_loop == current_loop:
                        # Same loop - cancel and await
                        self._flush_task.cancel()
                        try:
                            await self._flush_task
                        except asyncio.CancelledError:
                            pass
                    else:
                        # Different loop - just reset reference (can't cancel from different loop)
                        logger.debug("Old flush task is in different event loop - will be cleaned up by its loop")
                        self._flush_task = None
                except RuntimeError:
                    # No running loop - just reset
                    self._flush_task = None
            except Exception as e:
                logger.debug("Error cleaning up old flush task: %s", e)
                self._flush_task = None
        
        self._stop_flush_event = asyncio.Event()
        self._is_flush_running = True
        # Reset circuit breaker so a fresh session always starts with a clean slate.
        # Without this, 5 transient DB errors in a previous session permanently block
        # all future flushes until the process is restarted.
        self._flush_failure_count = 0
        self._flush_task = asyncio.create_task(self._flush_loop())
        logger.info("Started app monitor flush task (interval: 45s)")
    
    async def stop_app_monitor_flush_task(self):
        """
        Stop the background flush task and flush any remaining items.
        Should be called during graceful shutdown.
        """
        if not self._is_flush_running:
            return
        
        logger.info("Stopping app monitor flush task...")
        self._is_flush_running = False
        
        # Signal stop event
        if self._stop_flush_event:
            self._stop_flush_event.set()
        
        # Cancel flush task if running
        if self._flush_task and not self._flush_task.done():
            self._flush_task.cancel()
            try:
                # Check if task belongs to current event loop
                # If not, we can't await it (it's in a different loop - happens when threads restart)
                try:
                    current_loop = asyncio.get_running_loop()
                    task_loop = self._flush_task.get_loop()
                    if task_loop == current_loop:
                        # Same loop - safe to await
                        await self._flush_task
                    else:
                        # Different loop - just cancel, can't await (task will be cleaned up by its own loop)
                        logger.debug("Flush task is in different event loop - cancelled but not awaited (expected when threads restart)")
                        # Reset task reference since it's in a different loop
                        self._flush_task = None
                except RuntimeError:
                    # No running loop - try to get the task's loop directly
                    try:
                        task_loop = self._flush_task.get_loop()
                        # If we can get the loop, try to await in that loop's context
                        # But since we're not in that loop, we can't await it
                        logger.debug("Flush task is in loop %s but no current loop - cancelled but not awaited", task_loop)
                        self._flush_task = None
                    except Exception:
                        # Task might be in a closed loop - just reset reference
                        self._flush_task = None
            except asyncio.CancelledError:
                pass
            except RuntimeError as e:
                # Task is attached to a different loop - this is expected when threads restart
                logger.debug("Could not await flush task (different event loop): %s", e)
                # Reset task reference since we can't use it
                self._flush_task = None
        
        # Flush any remaining items in buffer (bypass circuit breaker for shutdown)
        # Temporarily reset failure count to allow final flush
        original_failure_count = self._flush_failure_count
        self._flush_failure_count = 0
        try:
            await self._flush_app_monitor_buffer(force=True)
        finally:
            # Restore original failure count after shutdown flush attempt
            self._flush_failure_count = original_failure_count
        logger.info("App monitor flush task stopped")
    
    async def _flush_loop(self):
        """
        Background loop that periodically flushes the app monitor buffer.
        """
        try:
            while not self._stop_flush_event.is_set():
                try:
                    # Wait for flush interval or stop event
                    try:
                        await asyncio.wait_for(
                            self._stop_flush_event.wait(),
                            timeout=self._flush_interval
                        )
                        # If we get here, stop event was set
                        break
                    except asyncio.TimeoutError:
                        # Timeout means flush interval elapsed, proceed to flush
                        pass
                    
                    # Flush buffer if not empty
                    await self._flush_app_monitor_buffer()
                    
                except Exception as e:
                    logger.error("Error in flush loop: %s", e, exc_info=True)
                    # Wait a bit before retrying
                    await asyncio.sleep(2)
        except asyncio.CancelledError:
            logger.debug("Flush loop cancelled")
        except Exception as e:
            logger.error("Flush loop error: %s", e, exc_info=True)
        finally:
            self._is_flush_running = False
    
    async def _flush_app_monitor_buffer(self, force: bool = False):
        """
        Flush the app monitor buffer to database using batch insert.
        
        Args:
            force: If True, flush even if buffer is small (for shutdown)
        """
        # Check if flush is already in progress to prevent race conditions
        async with self._flush_in_progress_lock:
            if self._flush_in_progress:
                logger.debug("Flush already in progress, skipping duplicate flush")
                return
            self._flush_in_progress = True
        
        try:
            # Check circuit breaker - if too many failures, don't attempt flush
            if self._flush_failure_count >= self._max_flush_failures:
                logger.warning("Circuit breaker active: %s consecutive failures. Skipping flush.", self._flush_failure_count)
                return
            
            # Get items from buffer
            async with self._buffer_lock:
                if not self._app_monitor_buffer:
                    return
                
                # Don't flush if buffer is empty (unless forced)
                # Previously min=5; lowered to 1 so the first item is persisted within
                # the next 45-second flush cycle rather than waiting for 5+ items.
                if not force and len(self._app_monitor_buffer) < 1:
                    return
                
                # Copy buffer and clear it immediately to minimize lock time
                items_to_flush = self._app_monitor_buffer.copy()
                self._app_monitor_buffer.clear()
            
            # Process items outside the lock
            if not items_to_flush:
                return
            
            logger.info("Flushing %s app monitor items to database", len(items_to_flush))
            
            # Wrap flush operation with timeout
            try:
                await asyncio.wait_for(
                    self._execute_flush(items_to_flush),
                    timeout=self._flush_operation_timeout
                )
                # Reset failure count on successful flush
                self._flush_failure_count = 0
                logger.debug("Flush operation completed successfully")
                
            except asyncio.TimeoutError:
                logger.error("Flush operation timed out after %ss", self._flush_operation_timeout)
                self._flush_failure_count += 1
                # Re-add items to buffer on timeout
                async with self._buffer_lock:
                    self._app_monitor_buffer.extend(items_to_flush)
                    
            except Exception as e:
                logger.error("Failed to flush app monitor buffer: %s", e, exc_info=True)
                self._flush_failure_count += 1
                # Re-add items to buffer on error
                async with self._buffer_lock:
                    self._app_monitor_buffer.extend(items_to_flush)
                    
        finally:
            # Always clear the flush_in_progress flag
            async with self._flush_in_progress_lock:
                self._flush_in_progress = False
    
    async def _execute_flush(self, items_to_flush: List[Dict[str, Any]]):
        """
        Execute the actual database flush operation.
        This is separated to allow timeout wrapping.
        
        Args:
            items_to_flush: List of items to flush to database
        """
        with db_lock_manager.write_lock():
            async with async_session() as session:
                try:
                    # Prepare batch data for bulk insert
                    batch_data = []
                    skipped_count = 0
                    
                    for item_data in items_to_flush:
                        # Validate item
                        if 'error' in item_data:
                            logger.warning("Skipping error item: %s", item_data.get('error', 'Unknown error'))
                            skipped_count += 1
                            continue
                        
                        # Item is already validated and formatted, just add to batch
                        batch_data.append(item_data)
                    
                    if not batch_data:
                        logger.warning("No valid items to flush, skipped %s invalid item(s)", skipped_count)
                        return
                    
                    # Use bulk insert with executemany pattern for efficient batch insert
                    insert_query = text("""
                        INSERT INTO app_monitor (spendTime, active_count, name, url, isApp, timestamp, isUploaded, counter, timezone)
                        VALUES (:spendTime, :active_count, :name, :url, :isApp, :timestamp, :isUploaded, :counter, :timezone)
                    """)
                    
                    # Process in chunks if batch is large to avoid lock timeouts
                    if len(batch_data) > self._batch_chunk_size:
                        logger.debug("Processing large batch in chunks of %s", self._batch_chunk_size)
                        total_inserted = 0
                        for i in range(0, len(batch_data), self._batch_chunk_size):
                            chunk = batch_data[i:i + self._batch_chunk_size]
                            await session.execute(insert_query, chunk)
                            total_inserted += len(chunk)
                            logger.debug("Inserted chunk %s: %s items", i // self._batch_chunk_size + 1, len(chunk))
                        
                        await session.commit()
                        logger.info("Successfully flushed %s app monitor record(s) in %s chunk(s), skipped %s invalid item(s)", total_inserted, len(batch_data) // self._batch_chunk_size + 1, skipped_count)
                    else:
                        # Execute batch insert for smaller batches (SQLAlchemy handles executemany automatically)
                        await session.execute(insert_query, batch_data)
                        await session.commit()
                        logger.info("Successfully flushed %s app monitor record(s) in batch, skipped %s invalid item(s)", len(batch_data), skipped_count)
                    
                except Exception as e:
                    if session:
                        await session.rollback()
                    logger.error("Error executing flush: %s", e, exc_info=True)
                    raise

    async def save_app_monitors(self, data, current_count, timezone):
        """
        Save app monitor data to buffer for batch processing.
        Data is collected in memory and flushed periodically (every 45 seconds) or when buffer is full.
        """
        if not data:
            # Empty data is expected when window detection fails (e.g., AppleScript permissions on macOS)
            # Log at debug level to reduce noise
            logger.debug('No Data from Hardware (expected when window detection fails)')
            return

        if not isinstance(data, list):
            data = [data]  # Convert single dictionary to a list
        
        # DISABLED: App monitor data logging to reduce logging overhead
        # logger.info(data)  # This was logging the data dictionary with [device] tag

        # Process and validate items, then add to buffer
        items_to_buffer = []
        skipped_count = 0
        
        for item in data:
            # Skip items that are error records or missing required fields
            if 'error' in item:
                logger.warning("Skipping error item: %s", item.get('error', 'Unknown error'))
                skipped_count += 1
                continue
            
            # Validate required fields
            if not item.get('module'):
                logger.warning("Skipping item missing 'module' field: %s", item)
                skipped_count += 1
                continue
            
            try:
                # Parse timestamp with fallback to current time
                timestamp_str = item.get('timestamp')
                if timestamp_str:
                    try:
                        timestamp = datetime.datetime.strptime(timestamp_str, '%Y-%m-%d %H:%M:%S')
                    except (ValueError, TypeError) as e:
                        logger.warning("Invalid timestamp format '%s', using current time: %s", timestamp_str, e)
                        timestamp = datetime.datetime.now()
                else:
                    logger.warning("Missing timestamp in item, using current time: %s", item)
                    timestamp = datetime.datetime.now()
                
                module_name = item.get('module', 'Unknown')
                url_value = item.get('url', '')
                
                # Determine if it's an app or URL using the same logic as get_apps_urls
                # Check if it's an actual URL (starts with http://, https://, or www.)
                is_actual_url = url_value.lower().startswith(('http://', 'https://', 'www.'))
                
                # Check if it's a browser name
                browser_names = ["Google Chrome", "Mozilla Firefox", "Firefox", "Safari", "Brave", "Microsoft Edge", "Opera", "Vivaldi", "Chromium"]
                is_browser_name = any(browser.lower() == url_value.lower() or browser.lower() in url_value.lower() for browser in browser_names)
                
                # Check if Firefox was active (for macOS page title detection)
                app_name_for_url = item.get('active_app', '')
                is_firefox_active = app_name_for_url and ("firefox" in app_name_for_url.lower() or "mozilla" in app_name_for_url.lower())
                is_macos = platform.system() == "Darwin"
                is_firefox_page_title = is_macos and is_firefox_active and not is_actual_url
                
                # Determine isApp value
                if is_actual_url:
                    # It's an actual URL
                    is_app_value = 0  # isApp = false (it's a URL)
                elif is_firefox_page_title:
                    # On macOS: Firefox page title - treat as URL
                    is_app_value = 0  # isApp = false (it's a URL)
                elif is_browser_name:
                    # It's a browser name - treat as app
                    is_app_value = 1  # isApp = true (it's an app)
                else:
                    # Not an actual URL, not a Firefox page title, not a browser name - treat as app
                    is_app_value = 1  # isApp = true (it's an app)
                
                # Store only actual URLs in the url column (ws_monitor DB); never tab name or "New Tab"
                db_url_value = url_value if is_actual_url else ""
                
                interval_data = {
                    "spendTime": 0,  # Placeholder for spendTime
                    "active_count": 1,  # Assuming 1 for active usage
                    "name": module_name,
                    "url": db_url_value,  # Only actual URL (http/https/www); empty when not a URL
                    "isApp": is_app_value,  # Properly determine if it's an app or URL
                    "timestamp": timestamp,
                    "isUploaded": 0,
                    "counter": current_count,
                    "timezone": timezone
                }
                items_to_buffer.append(interval_data)
                # Changed to debug level - verbose logging suppressed to reduce log noise
                # logger.debug(f'Buffering item: {module_name} at {timestamp}')
            except Exception as item_error:
                logger.error("Error processing item %s: %s", item, item_error, exc_info=True)
                skipped_count += 1
                continue
        
        if not items_to_buffer:
            if skipped_count > 0:
                logger.warning("No valid items to buffer, skipped %s invalid item(s)", skipped_count)
            return
        
        # Add items to buffer (thread-safe)
        async with self._buffer_lock:
            # Check circuit breaker - if too many failures, warn but still buffer (don't block)
            if self._flush_failure_count >= self._max_flush_failures:
                logger.warning("Circuit breaker active (%s failures). Items will be buffered but may not flush.", self._flush_failure_count)
            
            self._app_monitor_buffer.extend(items_to_buffer)
            buffer_size = len(self._app_monitor_buffer)
            
            # Trigger flush if buffer is full (memory limit protection)
            if buffer_size >= self._max_buffer_size:
                logger.info("Buffer full (%s items), triggering immediate flush", buffer_size)
                # Check if flush is already in progress before triggering new one
                async with self._flush_in_progress_lock:
                    if not self._flush_in_progress:
                        # Schedule flush (don't await to avoid blocking)
                        asyncio.create_task(self._flush_app_monitor_buffer(force=True))
                    else:
                        logger.debug("Flush already in progress, skipping duplicate trigger for buffer full")
        
        logger.debug("Buffered %s app monitor item(s), skipped %s invalid item(s). Buffer size: %s", len(items_to_buffer), skipped_count, len(self._app_monitor_buffer))


    async def getCurrentSlot(self):
        start_date = datetime.datetime.now()
        minute = start_date.minute

        # Round down to the nearest 10-minute interval
        rounded_minute = (minute // 10) * 10

        # Create the end date with the rounded minute
        end_date = start_date.replace(minute=rounded_minute, second=0, microsecond=0)

        return end_date

    def rename(self, window_name):
        # Remove any trailing digits or brackets like [1], [2], etc.
        cleaned_name = re.sub(r'\s*\[.*?\]$', '', window_name)  # Remove brackets with content
        cleaned_name = re.sub(r'.*[-\|]\s*', '', cleaned_name)  # Remove anything before '-' or '|'
        return cleaned_name.strip()  # Remove extra spaces

    async def get_apps_urls(self, start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None, appandurlstatus=3):
        """Return aggregated app/URL usage records for the given time window.

        Args:
            start_date: Inclusive lower bound for app monitor timestamps.
            end_date: Inclusive upper bound for app monitor timestamps.
            appandurlstatus: Filter mode — 1=apps only, 2=URLs only, 3=both.

        Returns:
            List of dicts with keys ``isApp``, ``spendTime``, ``name``, ``active_count``.
            Returns an empty list on error or when no data is found.
        """
        try:
            app_monitors = await activity_manager.get_app_monitors(start_date, end_date)
            if not app_monitors:
                return []

            # Create a mapping of url -> app_name to check if Firefox was active when URL was captured
            # This helps identify Firefox page titles on macOS (where Firefox captures page titles instead of URLs)
            # When url is empty we use name for grouping (url column stores only actual URLs)
            url_to_app_name = {}
            for app_monitor in app_monitors:
                key = app_monitor.url or app_monitor.name
                if key not in url_to_app_name:
                    url_to_app_name[key] = app_monitor.name

            app_names = [app_monitor.url or app_monitor.name for app_monitor in app_monitors]
            timestamps = [app_monitor.timestamp for app_monitor in app_monitors]

            if not app_names:
                return []

            app_timestamps = defaultdict(list)

            previous_app = app_names[0]
            previous_timestamp = timestamps[0]
            active_count = 0  # To count the number of consecutive sequences

            # Iterate through the list of app names starting from the second item
            for i in range(1, len(app_names)):
                current_app = app_names[i]
                current_timestamp = timestamps[i]

                # If the current app is the same as the previous one, continue the sequence
                if current_app == previous_app:
                    continue
                else:
                    # Store the time range for the previous app
                    app_timestamps[previous_app].append((previous_timestamp, timestamps[i - 1]))
                    active_count += 1  # Increment active count for the finished sequence

                    # Reset variables for the new app
                    previous_app = current_app
                    previous_timestamp = current_timestamp

            # Handle the last app after the loop (final sequence)
            app_timestamps[previous_app].append((previous_timestamp, timestamps[-1]))
            active_count += 1  # Increment active count for the last sequence

            # Define the list of excluded apps (URLs)
            excluded_apps = ["https://", "www", "http://", "Google Chrome", "Mozilla", "Firefox", "safari", "Brave", "Microsoft Edge", "Opera", "Vivaldi", "Chromium", "New Tab"]
            
            # Define browser names for detection
            browser_names = ["Google Chrome", "Mozilla Firefox", "Firefox", "Safari", "Brave", "Microsoft Edge", "Opera", "Vivaldi", "Chromium"]

            # Prepare the result with the updated logic
            result = []

            for app, timestamp_pairs in app_timestamps.items():
                is_app = True
                spendTime = 0  # Initialize spendTime
                # Calculate the spendTime based on the first and last timestamp
                for start_time, end_time in timestamp_pairs:
                    # If only one occurrence, use a default of 8 seconds
                    if start_time == end_time:
                        spendTime += 8
                    else:
                        # Calculate the time difference in seconds
                        time_diff = (end_time - start_time).total_seconds()
                        spendTime += time_diff

                # Check if the value is actually a URL (starts with http://, https://, or www.)
                is_actual_url = app.lower().startswith(('http://', 'https://', 'www.'))
                
                # Check if it's a browser name (exact match or contains browser name)
                is_browser_name = any(browser.lower() == app.lower() or browser.lower() in app.lower() for browser in browser_names)
                
                # Check if Firefox was active when this URL was captured (on macOS, Firefox captures page titles)
                app_name_for_url = url_to_app_name.get(app, "")
                is_firefox_active = app_name_for_url and ("firefox" in app_name_for_url.lower() or "mozilla" in app_name_for_url.lower())
                
                # Check if we're on macOS (where Firefox captures page titles instead of URLs)
                is_macos = platform.system() == "Darwin"
                
                # On macOS: If Firefox was active and it's not an actual URL, it's a page title (treat as URL)
                is_firefox_page_title = is_macos and is_firefox_active and not is_actual_url

                # Check if the app name matches any excluded URL patterns
                if any(excluded_app.lower() in app.lower() for excluded_app in excluded_apps):
                    if appandurlstatus == 3 or appandurlstatus == 2: 
                        # If it's an actual URL, treat as URL (isApp: false)
                        # If it's a browser name without URL, treat as app (isApp: true)
                        if is_actual_url:
                            is_app = False
                        else:
                            # Browser name without URL - treat as app
                            is_app = True
                            browser_name = self.get_browser_name(app)
                            if browser_name is not None:
                                app = browser_name
                    elif appandurlstatus == 1:
                        # FIXED: When apps only mode, skip "New Tab" entirely
                        if "New Tab" in app:
                            continue  # Skip "New Tab" in apps-only mode
                        is_app = True
                        browser_name=self.get_browser_name(app)
                        if browser_name is not None:
                            app = browser_name
                        else:
                            continue
                else:
                    # Doesn't match excluded patterns
                    if is_actual_url:
                        # It's an actual URL
                        if appandurlstatus == 3 or appandurlstatus == 2:
                            is_app = False
                        elif appandurlstatus == 1:
                            is_app = True
                            app = self.rename(app)
                        else:
                            continue
                    elif is_firefox_page_title:
                        # On macOS: Firefox page title (not actual URL) - treat as URL
                        if appandurlstatus == 3 or appandurlstatus == 2:
                            is_app = False  # Treat Firefox page title as URL on macOS
                        elif appandurlstatus == 1:
                            is_app = True
                            app = self.rename(app)
                        else:
                            continue
                    elif is_browser_name:
                        # It's a browser name - treat as app
                        if appandurlstatus == 3 or appandurlstatus == 1:
                            is_app = True
                            browser_name = self.get_browser_name(app)
                            if browser_name is not None:
                                app = browser_name
                        else:
                            continue
                    else:
                        # Not an actual URL, not a Firefox page title, not a browser name - treat as app
                        if appandurlstatus == 3 or appandurlstatus == 1:
                            is_app = True
                            app = self.rename(app)
                        else:
                            continue
                # Add the app information to the result list
                result.append({
                    "isApp": is_app,
                    "spendTime": spendTime,
                    "name": app,
                    "active_count": len(timestamp_pairs)  # The active count is the number of consecutive sequences
                })

            return result

        except Exception as e:
            logger.error("Failed to process app monitors (start=%s, end=%s): %s", start_date, end_date, e, exc_info=True)
            return []

    async def get_url_by_app_name(self, app_name: str) -> Optional[str]:
        """
        Fetch the URL corresponding to a given app name from the database.
        Returns None if not found.
        """
        # Use centralized read lock for database read operations
        with db_lock_manager.read_lock():
            async with async_session() as session:
                result = await session.execute(
                    select(Appmonitor.url)
                    .where(Appmonitor.name == app_name)
                    .order_by(Appmonitor.timestamp.desc())  # optional: get latest URL
                    .limit(1)
                )
                row = result.scalar_one_or_none()
                return row  

    async def get_screenshots(self, start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None):
        screenshots = await screenshot_manager.get_screenshots(
            start_date,
            end_date
        )
        return [screenshot.s3_upload_link for screenshot in screenshots]
    
    async def get_activity_intervals(self, start_date: Optional[datetime.datetime] = None, end_date: Optional[datetime.datetime] = None, counter: Optional[int] = None):
        return await activity_manager.get_activity_interval(start_date, end_date, counter)
    
    async def get_break_intervals(self, counter: Optional[int] = None):
        return await activity_manager.get_break_intervals(counter)

    async def check_existing_interval(self, start_time: datetime.datetime, end_time: datetime.datetime, exact: bool = False):
        """
        Check if an activity interval already exists for the given time range.

        When exact=True, requires both start_time and end_time to match exactly
        (used when checking for a specific interval like a partial break interval).

        When exact=False (default), uses an overlap check: any existing interval
        whose range overlaps [start_time, end_time].
        """
        try:
            with db_lock_manager.read_lock():
                async with async_session() as session:
                    from sqlalchemy import select
                    from ws_package.models.base import ActivityInterval

                    if exact:
                        query = select(ActivityInterval).where(
                            ActivityInterval.start_time == start_time,
                            ActivityInterval.end_time == end_time
                        ).limit(1)
                    else:
                        query = select(ActivityInterval).where(
                            ActivityInterval.start_time < end_time,
                            ActivityInterval.end_time > start_time
                        ).limit(1)

                    result = await session.execute(query)
                    existing = result.scalars().first()
                    return existing is not None
        except Exception as e:
            logger.error("Failed to check for existing interval start=%s end=%s: %s", start_time, end_time, e, exc_info=True)
            return False  # Fail-safe: assume doesn't exist if we can't check
    async def sevendays(self, target):
        seven_day_old =  await activity_manager.sevendays(target)
        if seven_day_old == None:
            return None
        return seven_day_old
    
    async def update_activity_interval(self, activity_interval):
        return await activity_manager.update_activity_interval(activity_interval)
    
    async def update_screenshot(self, screenshot):
        return await screenshot_manager.update_screenshot(screenshot)

    async def save_last_min(self, last_count):
        """Retrieve the boundary timestamps for the last completed minute slot.

        Args:
            last_count: The counter value of the completed slot.

        Returns:
            Tuple of ``(start_time, end_time)`` datetimes, or ``(None, None)`` on error.
        """
        logger.info("Save last min activity for count = %s", last_count)
        # Use centralized read lock for database read operations
        with db_lock_manager.read_lock():
            async with async_session() as session:
                try:
                    activity_result = await session.execute(
                        select(ActivityInterval).where(ActivityInterval.counter==last_count).order_by(desc(ActivityInterval.id)).limit(1)
                    )
                    user_data = activity_result.scalars().first()
                    start_time = user_data.end_time if user_data else None
                    logger.info("Last min activity > start time = %s", start_time)   
                    app_result = await session.execute(
                        select(Appmonitor).where(Appmonitor.counter==last_count).order_by(desc(Appmonitor.id)).limit(1)
                    )
                    app_data = app_result.scalars().first()
                    endtime = app_data.timestamp if app_data else None
                    logger.info("Last min activity > end time = %s", endtime)
                    return start_time, endtime 
                    
                except Exception as e:
                    logger.error("Failed to retrieve last-min activity for count=%s: %s", last_count, e, exc_info=True)
                    return None, None


    async def get_last_activity_interval(self, counter: Optional[int] = None):
        """Return end_time of the latest activity interval.

        Args:
            counter: When set, only consider intervals for that session counter.
                     When None, search across all counters (legacy / trim helpers).
        """
        with db_lock_manager.read_lock():
            async with async_session() as session:
                query = select(ActivityInterval)
                if counter is not None:
                    query = query.where(ActivityInterval.counter == counter)
                query = query.order_by(desc(ActivityInterval.id)).limit(1)
                activity_result = await session.execute(query)
                user_data = activity_result.scalars().first()
                last_activity_interval = user_data.end_time if user_data else None
                logger.info(
                    "Last activity interval = %s (counter=%s)",
                    last_activity_interval,
                    counter if counter is not None else "any",
                )
                return last_activity_interval

    async def get_last_n_activity_intervals(self, n: int = 2, counter: Optional[int] = None, exclude_breaks: bool = True, unsent_only: bool = False):
        """
        Get the last N activity intervals from database.
        
        Args:
            n: Number of intervals to retrieve (default: 2)
            counter: Optional counter to filter by (if None, gets from all counters)
            exclude_breaks: If True, exclude break intervals (default: True)
            unsent_only: If True, only include intervals with is_uploaded=0 (default: False)
        
        Returns:
            List of ActivityInterval objects ordered by id descending
        """
        try:
            with db_lock_manager.read_lock():
                async with async_session() as session:
                    query = select(ActivityInterval)

                    # Filter by counter if provided
                    if counter is not None:
                        query = query.where(ActivityInterval.counter == counter)
                    
                    # Exclude break intervals if requested (for idle detection, we only want regular intervals)
                    if exclude_breaks:
                        query = query.where(ActivityInterval.is_break == 0)
                    
                    # Only unsent intervals (is_uploaded=0) if requested (for idle detection)
                    if unsent_only:
                        query = query.where(ActivityInterval.is_uploaded == 0)
                    
                    # Order by end_time descending (not id) to get most recent intervals chronologically
                    # Increase limit to n * 2 to have more intervals to filter for consecutive ones
                    query = query.order_by(desc(ActivityInterval.end_time)).limit(n * 2)
                    
                    result = await session.execute(query)
                    intervals = result.scalars().all()
                    
                    logger.info("Retrieved %s last activity intervals (requested %s, counter=%s, exclude_breaks=%s, unsent_only=%s)", len(intervals), n, counter, exclude_breaks, unsent_only)
                    return list(intervals) if intervals else []
        except Exception as e:
            logger.error("Error getting last %s activity intervals: %s", n, e, exc_info=True)
            return []

    async def get_first_start_time_by_counter(self, counter: int) -> Optional[datetime.datetime]:
        """Get the first (earliest) start_time for a given counter"""
        with db_lock_manager.read_lock():
            async with async_session() as session:
                query = (
                    select(ActivityInterval)
                    .filter(ActivityInterval.counter == counter)
                    .order_by(ActivityInterval.start_time.asc())
                    .limit(1)
                )
                result = await session.scalar(query)
                return result.start_time if result else None

    def get_active_window_geometry(self):
        # macOS: delegate to native (AppleScript) helper — no OCR dependency.
        if platform.system() == "Darwin":
            try:
                from ws_package.utils.window_geometry import get_active_window_geometry as _native_geom
                geo = _native_geom()
                if geo:
                    return {
                        "X": str(geo.get('X', 0)),
                        "Y": str(geo.get('Y', 0)),
                        "WIDTH": str(geo.get('WIDTH', 0)),
                        "HEIGHT": str(geo.get('HEIGHT', 0))
                    }
            except Exception as e:
                logger.warning("macOS window geometry failed: %s", e)
                return None
        
        # Linux: Use Xlib if available
        if platform.system() == "Linux" and display is not None and X is not None:
            try:
                d = display.Display()
                root = d.screen().root

                NET_ACTIVE_WINDOW = d.intern_atom('_NET_ACTIVE_WINDOW')
                window_id = root.get_full_property(NET_ACTIVE_WINDOW, X.AnyPropertyType).value[0]
                window = d.create_resource_object('window', window_id)

                geom = window.get_geometry()
                abs_coords = window.translate_coords(root, 0, 0)

                return {
                    "X": str(abs_coords.x),
                    "Y": str(abs_coords.y),
                    "WIDTH": str(geom.width),
                    "HEIGHT": str(geom.height)
                }
            except Exception as e:
                logger.warning("Failed to get active window geometry via Xlib: %s", e)
                return None
        
        return None

    def get_browser_name(self, tab_name):
       
        browsers = [
            "google chrome", "chrome", "chromium",
            "mozilla firefox", "firefox",
            "brave", "brave browser",
            "microsoft edge", "edge",
            "opera", "vivaldi",
            "safari", "safari technology preview",
            "internet explorer", "ie"
        ]

        lower_title = tab_name.lower()
        found = None
        for browser in browsers:
            if browser in lower_title:
                found = browser
                break
        if found:
            return found
        else:
            return None
      

    def remove_browser_name(self, title: str, process_name: str | None = None) -> str:
        # Remove browser name (and its separator) from the given title string.
        COMMON_BROWSERS = [
            "Google Chrome", "Chrome", "Chromium",
            "Mozilla Firefox", "Firefox",
            "Brave", "Brave Browser",
            "Microsoft Edge", "Edge",
            "Opera", "Vivaldi",
            "Safari", "Safari Technology Preview",
            "Internet Explorer", "IE"
        ]

        # Optional mapping from process name to likely browser display names
        PROCESS_BROWSER_MAP = {
            "chrome.exe": ["Google Chrome", "Chrome"],
            "chrome": ["Google Chrome", "Chrome"],
            "msedge.exe": ["Microsoft Edge", "Edge"],
            "msedge": ["Microsoft Edge", "Edge"],
            "firefox.exe": ["Mozilla Firefox", "Firefox"],
            "firefox": ["Mozilla Firefox", "Firefox"],
            "brave.exe": ["Brave", "Brave Browser"],
            "opera.exe": ["Opera"],
            "vivaldi.exe": ["Vivaldi"],
            "safari": ["Safari"],
            "iexplore.exe": ["Internet Explorer", "IE"],
        }

        # Choose browser list: narrow by process_name if we can, otherwise use general list
        browser_list = COMMON_BROWSERS
        if process_name:
            key = process_name.lower().split("\\")[-1]  # in case a full path was passed
            browser_list = PROCESS_BROWSER_MAP.get(key, COMMON_BROWSERS)

        # build a safe alternation (escape each name)
        alt = "|".join(re.escape(b) for b in browser_list)
        # separators commonly used between page title and browser name
        sep_chars = r'\-\–\—\|\:\·\•'   # hyphen, en/em dashes, pipe, colon, middle dot, bullet
        # pattern: browser at start followed by sep OR sep followed by browser at end
        pattern = re.compile(
            rf'(^\s*(?:{alt})\s*(?:[{sep_chars}])\s*)|(\s*(?:[{sep_chars}])\s*(?:{alt})\s*$)',
            re.IGNORECASE
        )

        cleaned = pattern.sub('', title).strip()

        # If nothing changed (rare), as a last resort remove any standalone browser token anywhere
        if cleaned == title:
            any_pat = re.compile(rf'\b(?:{alt})\b', re.IGNORECASE)
            cleaned = any_pat.sub('', cleaned).strip()

        # Collapse repeated separators/spaces and trim stray separators
        cleaned = re.sub(r'[\s\-\–\—\|\:\·\•]{2,}', ' ', cleaned).strip(' -_–—|:·•')

        return cleaned

