# cython: language_level=3
"""
Hardware Event Validator - Detects and filters synthetic input events

This module provides platform-specific validation to distinguish between
hardware-originated input events and synthetic events from automation tools.

Return Value Contract:
- True: Confirmed hardware event (count it)
- False: Confirmed synthetic event (filter it)
- None: Unknown/unavailable (ACCEPT and count it - fail-safe default)
"""

import glob
import os
import platform
import re
import shutil
import subprocess
import ctypes
import ctypes.wintypes
import threading
import queue
import time
from collections import deque
from typing import Optional, Dict, Set, Tuple
from ws_package.log.writer import LogWriter
from ws_package.configs import config

logger = LogWriter.file(__name__)

# Windows LL hooks can lag pynput; only filter synthetic on a very fresh stamp.
WINDOWS_HW_CONFIRM_WINDOW = 0.10
WINDOWS_SYN_FILTER_WINDOW = 0.04
WINDOWS_RAW_CONFIRM_WINDOW = 0.15
WM_MOUSEMOVE = 0x0200
WM_INPUT = 0x00FF
RI_MOUSE_WHEEL = 0x0400
RI_MOUSE_HWHEEL = 0x0800
RI_MOUSE_BUTTONS = 0x003F


class HardwareEventValidator:
    """
    Validates whether input events originate from hardware devices.
    
    Fail-safe design: If validation cannot be initialized or fails,
    all functions return None, which signals to accept the event
    (preserves existing behavior).
    """
    
    def __init__(self):
        self._enabled = False
        self._platform = platform.system()
        self._initialized = False
        
        # Platform-specific state
        self._windows_hooks = {}
        self._windows_hook_handles = {}
        self._windows_event_queue = queue.Queue()
        self._windows_last_keyboard_event = {'injected': None, 'timestamp': 0}
        self._windows_last_mouse_event = {'injected': None, 'timestamp': 0}
        self._windows_last_hw_keyboard_ts = 0.0
        self._windows_last_hw_mouse_ts = 0.0
        self._windows_last_syn_keyboard_ts = 0.0
        self._windows_last_syn_mouse_ts = 0.0
        self._windows_last_hw_scroll_ts = 0.0
        self._windows_last_raw_mouse_ts = 0.0
        self._windows_raw_input_active = False
        self._windows_use_mouse_ll_hook = False
        self._windows_keyboard_events = deque(maxlen=512)
        self._windows_keys_down = set()
        self._macos_quartz_available = False
        self._macos_direct_validation = False
        self._macos_quartz_module = None
        self._linux_evdev_available = False
        self._linux_open_devices = []
        self._linux_lock = threading.Lock()
        self._linux_last_evdev_keyboard_ts = 0.0
        self._linux_last_evdev_mouse_ts = 0.0
        self._linux_last_xtest_keyboard_ts = 0.0
        self._linux_last_xtest_mouse_ts = 0.0
        self._linux_last_hw_keyboard_ts = 0.0
        self._linux_last_hw_mouse_ts = 0.0
        self._linux_xtest_device_ids: Set[int] = set()
        self._linux_xinput_process = None
        self._linux_xinput_thread = None
        self._linux_xinput_monitor_active = False
        self._linux_evdev_thread = None
        self._linux_correlation_window = 0.75
        self._linux_xinput_path = None
        self._automation_markers = (
            'fake_detector',
            'fake_activity',
            'pyautogui',
            'xdotool',
            'ydotool',
            'autokey',
        )
        self._automation_scan_cache_ts = 0.0
        self._automation_scan_cache_active = False
        self._automation_scan_interval = 3.0
        self._last_synthetic_warning_ts = 0.0
        self._synthetic_warning_interval = 60.0
        self._stop_hooks = False
        
        # Statistics
        self.hardware_events_counted = 0
        self.synthetic_events_filtered = 0
        
        # Check if validation is enabled via config
        try:
            validation_enabled = config('hardware_event_validation_enabled', 'ActivityMonitor', 'bool')
            if validation_enabled is False:
                logger.info("Hardware event validation disabled via config")
                return
        except Exception:
            # Default to enabled if config read fails
            pass
        
        # Initialize platform-specific detection
        self._initialize()
    
    def __del__(self):
        """Cleanup hooks on destruction"""
        try:
            self.cleanup()
        except Exception:
            pass
    
    def cleanup(self):
        """Clean up platform-specific resources"""
        self._stop_hooks = True
        
        if self._platform == "Windows" and self._windows_hook_handles:
            try:
                user32 = self._windows_hooks.get('user32')
                if user32:
                    if 'keyboard' in self._windows_hook_handles:
                        user32.UnhookWindowsHookEx(self._windows_hook_handles['keyboard'])
                    if 'mouse' in self._windows_hook_handles:
                        user32.UnhookWindowsHookEx(self._windows_hook_handles['mouse'])
            except Exception as e:
                logger.debug("Error cleaning up Windows hooks: %s", e)
        
        if self._platform == "Darwin" and hasattr(self, '_macos_event_tap'):
            try:
                if self._macos_quartz_available and hasattr(self, '_macos_quartz') and self._macos_event_tap:
                    quartz = self._macos_quartz
                    # Disable the event tap first
                    quartz.CGEventTapEnable(self._macos_event_tap, False)
                    # Wait longer for CFRunLoop to process the disable command
                    # This ensures WindowServer fully releases the event tap and flushes queued events
                    time.sleep(0.5)  # Increased from 0.2s to allow WindowServer to flush queued events
                    # Clear references to allow proper cleanup
                    self._macos_event_tap = None
                    self._macos_quartz_available = False
                    logger.debug("macOS event tap disabled and cleaned up")
            except Exception as e:
                logger.debug("Error cleaning up macOS event tap: %s", e)
                # Ensure we mark as unavailable even on error
                self._macos_quartz_available = False
                if hasattr(self, '_macos_event_tap'):
                    self._macos_event_tap = None

        if self._platform == "Linux":
            self._linux_teardown_monitors()

    def _linux_teardown_monitors(self):
        """Stop Linux evdev/xinput monitor threads and close device handles."""
        proc = self._linux_xinput_process
        if proc is not None and proc.poll() is None:
            try:
                proc.terminate()
                proc.wait(timeout=2.0)
            except Exception:
                try:
                    proc.kill()
                except Exception:
                    pass
        self._linux_xinput_process = None
        self._linux_xinput_monitor_active = False

        for device in self._linux_open_devices:
            try:
                device.close()
            except Exception:
                pass
        self._linux_open_devices = []

    def _linux_reset_timestamps(self):
        """Clear correlation timestamps (e.g. after scripted input session)."""
        with self._linux_lock:
            self._linux_last_evdev_keyboard_ts = 0.0
            self._linux_last_evdev_mouse_ts = 0.0
            self._linux_last_xtest_keyboard_ts = 0.0
            self._linux_last_xtest_mouse_ts = 0.0
            self._linux_last_hw_keyboard_ts = 0.0
            self._linux_last_hw_mouse_ts = 0.0

    def reinitialize_linux(self):
        """
        Restart Linux monitors after a listener restart.

        cleanup() alone leaves evdev/xinput dead while is_enabled() stays True,
        which breaks real activity tracking after scripted input sessions.
        """
        if self._platform != "Linux":
            return

        logger.info("Reinitializing Linux hardware validation monitors")
        self._stop_hooks = True
        time.sleep(0.4)
        self._linux_teardown_monitors()
        self._linux_reset_timestamps()
        self._stop_hooks = False
        self._initialize_linux()
        logger.info(
            "Linux hardware validation reinitialized "
            "(evdev=%s, xinput_monitor=%s, enabled=%s)",
            len(self._linux_open_devices),
            self._linux_xinput_monitor_active,
            self._enabled,
        )

    def _initialize(self):
        """Initialize platform-specific hardware detection"""
        try:
            if self._platform == "Windows":
                self._initialize_windows()
            elif self._platform == "Darwin":
                self._initialize_macos()
            elif self._platform == "Linux":
                self._initialize_linux()
            else:
                logger.warning("Hardware event validation not supported on %s", self._platform)
                self._enabled = False
        except Exception as e:
            logger.warning("Failed to initialize hardware event validation: %s", e)
            logger.warning("Falling back to accepting all events (backward compatible)")
            self._enabled = False
        finally:
            self._initialized = True
    
    def _initialize_windows(self):
        """Initialize Windows hardware event detection using low-level hooks.

        Two bugs fixed vs. the original implementation:

        Bug 1 — shared ctypes namespace poisons pynput (causes "listeners start
        but no events received" on Python 3.13):
          ctypes.windll.user32 is a SHARED module-level object. Setting
          SetWindowsHookExW.argtypes on it with OUR HOOKPROC type causes
          Python 3.13's stricter type-check to reject pynput's own HOOKPROC
          (a different WINFUNCTYPE instance), silently killing pynput's hook.
          Fix: use ctypes.WinDLL('user32') — a private, independent namespace.

        Bug 2 — hooks installed on wrong thread (callbacks never fire):
          WH_KEYBOARD_LL/WH_MOUSE_LL deliver notifications to the message queue
          of the thread that called SetWindowsHookEx.  Installing on the init
          thread but pumping on a daemon thread means callbacks never execute.
          Fix: install hooks FROM INSIDE the hook+pump thread via an Event.
        """
        # Thread-safe storage for latest event flags (read by is_hardware_*_event)
        self._windows_last_keyboard_event = {'injected': None, 'timestamp': 0}
        self._windows_last_mouse_event = {'injected': None, 'timestamp': 0}
        self._windows_last_hw_keyboard_ts = 0.0
        self._windows_last_hw_mouse_ts = 0.0
        self._windows_last_syn_keyboard_ts = 0.0
        self._windows_last_syn_mouse_ts = 0.0
        self._windows_last_hw_scroll_ts = 0.0
        self._windows_last_raw_mouse_ts = 0.0
        self._windows_raw_input_active = False
        self._windows_use_mouse_ll_hook = False
        self._windows_keyboard_events.clear()
        self._windows_keys_down.clear()
        self._windows_lock = threading.Lock()

        # Signal from hook thread back to init: did hook installation succeed?
        _hook_ready = threading.Event()
        _hook_ok = [False]  # list so the closure can write to it

        self._hook_thread = threading.Thread(
            target=self._windows_hook_thread,
            args=(_hook_ready, _hook_ok),
            daemon=True,
            name="HardwareEventValidator",
        )
        self._hook_thread.start()

        # Wait up to 3 s for hook installation to complete.
        if not _hook_ready.wait(timeout=3.0) or not _hook_ok[0]:
            logger.warning("Failed to install Windows hooks - accepting all events")
            self._enabled = False
            return

        self._enabled = True
        logger.info(
            "Windows hardware event validation initialized "
            "(keyboard LL hook + optional Raw Input mouse)"
        )

    def _windows_hook_thread(self, ready_event: threading.Event, ok_flag: list):
        """
        Dedicated thread that INSTALLS and PUMPS low-level Windows hooks.

        WH_KEYBOARD_LL / WH_MOUSE_LL are delivered to the thread that called
        SetWindowsHookEx.  Both installation and the message pump must live on
        this same thread or callbacks will never fire.

        Uses a PRIVATE ctypes.WinDLL namespace (not ctypes.windll.user32) so
        that our argtypes declarations do not pollute the shared namespace that
        pynput also uses.
        """
        try:
            # ── Private DLL handles (not shared with pynput) ─────────────────
            user32   = ctypes.WinDLL('user32',   use_last_error=True)
            kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)

            WH_KEYBOARD_LL = 13
            WH_MOUSE_LL    = 14
            WM_MOUSEWHEEL  = 0x020A
            WM_MOUSEHWHEEL = 0x020E
            WM_KEYDOWN     = 0x0100
            WM_KEYUP       = 0x0101
            WM_SYSKEYDOWN  = 0x0104
            WM_SYSKEYUP    = 0x0105
            LLKHF_INJECTED = 0x00000010
            LLMHF_INJECTED = 0x00000001
            HC_ACTION      = 0

            HOOKPROC = ctypes.WINFUNCTYPE(
                ctypes.c_int,
                ctypes.c_int,
                ctypes.wintypes.WPARAM,
                ctypes.wintypes.LPARAM,
            )

            # Declare argtypes on OUR private handle — pynput is unaffected.
            user32.SetWindowsHookExW.argtypes = [
                ctypes.c_int, HOOKPROC,
                ctypes.wintypes.HINSTANCE, ctypes.wintypes.DWORD,
            ]
            user32.SetWindowsHookExW.restype = ctypes.wintypes.HHOOK

            user32.CallNextHookEx.argtypes = [
                ctypes.wintypes.HHOOK, ctypes.c_int,
                ctypes.wintypes.WPARAM, ctypes.wintypes.LPARAM,
            ]
            user32.CallNextHookEx.restype = ctypes.wintypes.LPARAM

            user32.UnhookWindowsHookEx.argtypes = [ctypes.wintypes.HHOOK]
            user32.UnhookWindowsHookEx.restype  = ctypes.wintypes.BOOL

            kernel32.GetModuleHandleW.argtypes = [ctypes.wintypes.LPCWSTR]
            kernel32.GetModuleHandleW.restype  = ctypes.wintypes.HINSTANCE

            class KBDLLHOOKSTRUCT(ctypes.Structure):
                _fields_ = [
                    ("vkCode",      ctypes.wintypes.DWORD),
                    ("scanCode",    ctypes.wintypes.DWORD),
                    ("flags",       ctypes.wintypes.DWORD),
                    ("time",        ctypes.wintypes.DWORD),
                    ("dwExtraInfo", ctypes.POINTER(ctypes.wintypes.ULONG)),
                ]

            class MSLLHOOKSTRUCT(ctypes.Structure):
                _fields_ = [
                    ("pt",          ctypes.wintypes.POINT),
                    ("mouseData",   ctypes.wintypes.DWORD),
                    ("flags",       ctypes.wintypes.DWORD),
                    ("time",        ctypes.wintypes.DWORD),
                    ("dwExtraInfo", ctypes.POINTER(ctypes.wintypes.ULONG)),
                ]

            def low_level_keyboard_proc(nCode, wParam, lParam):
                try:
                    if nCode >= HC_ACTION:
                        kb = ctypes.cast(lParam, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents
                        injected = bool(kb.flags & LLKHF_INJECTED)
                        message = int(wParam)
                        is_down = message in (WM_KEYDOWN, WM_SYSKEYDOWN)
                        is_up = message in (WM_KEYUP, WM_SYSKEYUP)
                        now = time.time()
                        with self._windows_lock:
                            vk_code = int(kb.vkCode)
                            is_repeat = is_down and vk_code in self._windows_keys_down
                            if is_down:
                                self._windows_keys_down.add(vk_code)
                            elif is_up:
                                self._windows_keys_down.discard(vk_code)
                            self._windows_last_keyboard_event = {
                                'injected': injected,
                                'timestamp': now,
                            }
                            if injected:
                                self._windows_last_syn_keyboard_ts = now
                            else:
                                self._windows_last_hw_keyboard_ts = now
                            if is_down or is_up:
                                self._windows_keyboard_events.append({
                                    'vk_code': vk_code,
                                    'scan_code': int(kb.scanCode),
                                    'is_down': is_down,
                                    'is_up': is_up,
                                    'is_repeat': is_repeat,
                                    'injected': injected,
                                    'timestamp': now,
                                })
                except Exception:
                    pass
                return user32.CallNextHookEx(None, nCode, wParam, lParam)

            def low_level_mouse_proc(nCode, wParam, lParam):
                try:
                    if nCode >= HC_ACTION:
                        wparam = int(wParam)
                        if wparam == WM_MOUSEMOVE:
                            ms = ctypes.cast(lParam, ctypes.POINTER(MSLLHOOKSTRUCT)).contents
                            if not (ms.flags & LLMHF_INJECTED):
                                return user32.CallNextHookEx(None, nCode, wParam, lParam)
                        ms = ctypes.cast(lParam, ctypes.POINTER(MSLLHOOKSTRUCT)).contents
                        injected = bool(ms.flags & LLMHF_INJECTED)
                        now = time.time()
                        is_scroll = wparam in (WM_MOUSEWHEEL, WM_MOUSEHWHEEL)
                        is_move = wparam == WM_MOUSEMOVE
                        if is_scroll:
                            injected = False
                        with self._windows_lock:
                            self._windows_last_mouse_event = {
                                'injected': injected,
                                'timestamp': now,
                            }
                            if is_scroll:
                                self._windows_last_hw_scroll_ts = now
                                if not injected:
                                    self._windows_last_hw_mouse_ts = now
                            elif injected:
                                self._windows_last_syn_mouse_ts = now
                            elif is_move:
                                pass
                            else:
                                self._windows_last_hw_mouse_ts = now
                except Exception:
                    pass
                return user32.CallNextHookEx(None, nCode, wParam, lParam)

            # Keep callbacks alive for the lifetime of the hook.
            keyboard_proc = HOOKPROC(low_level_keyboard_proc)
            mouse_proc    = HOOKPROC(low_level_mouse_proc)

            hmod = kernel32.GetModuleHandleW(None)
            # Keyboard-only hook: required to distinguish hardware input from
            # SendInput/AutoHotkey and to expose key down/up edges. Mouse remains
            # poll/Raw-Input based and is not part of keyboard fudging detection.
            keyboard_hook = user32.SetWindowsHookExW(
                WH_KEYBOARD_LL, keyboard_proc, hmod, 0,
            )
            mouse_hook = None
            if not keyboard_hook:
                logger.warning(
                    "SetWindowsHookExW(WH_KEYBOARD_LL) failed (error %d)",
                    ctypes.get_last_error(),
                )
                ok_flag[0] = False
                ready_event.set()
                return
            self._windows_hook_handles['keyboard'] = keyboard_hook

            # Raw Input: physical mice emit WM_INPUT; SetCursorPos / pyautogui.moveTo does not.
            RIM_TYPEMOUSE = 0
            RID_INPUT = 0x10000003
            RIDEV_INPUTSINK = 0x00000100

            class RAWINPUTDEVICE(ctypes.Structure):
                _fields_ = [
                    ("usUsagePage", ctypes.c_ushort),
                    ("usUsage", ctypes.c_ushort),
                    ("dwFlags", ctypes.wintypes.DWORD),
                    ("hwndTarget", ctypes.wintypes.HWND),
                ]

            class RAWINPUTHEADER(ctypes.Structure):
                _fields_ = [
                    ("dwType", ctypes.wintypes.DWORD),
                    ("dwSize", ctypes.wintypes.DWORD),
                    ("hDevice", ctypes.wintypes.HANDLE),
                    ("wParam", ctypes.wintypes.WPARAM),
                ]

            class RAWMOUSE(ctypes.Structure):
                _fields_ = [
                    ("usFlags", ctypes.c_ushort),
                    ("usButtonFlags", ctypes.c_ushort),
                    ("usButtonData", ctypes.c_ushort),
                    ("usReserved", ctypes.c_ushort),
                    ("ulRawButtons", ctypes.c_ulong),
                    ("lLastX", ctypes.c_long),
                    ("lLastY", ctypes.c_long),
                    ("ulExtraInformation", ctypes.c_ulong),
                ]

            user32.GetRawInputData.argtypes = [
                ctypes.wintypes.LPARAM, ctypes.wintypes.UINT,
                ctypes.c_void_p, ctypes.POINTER(ctypes.wintypes.UINT),
                ctypes.wintypes.UINT,
            ]
            user32.GetRawInputData.restype = ctypes.wintypes.UINT
            _raw_input_header_size = ctypes.sizeof(RAWINPUTHEADER)

            def record_raw_mouse_input(lParam):
                try:
                    size = ctypes.wintypes.UINT(0)
                    user32.GetRawInputData(
                        lParam, RID_INPUT, None, ctypes.byref(size), _raw_input_header_size,
                    )
                    if size.value < _raw_input_header_size + ctypes.sizeof(RAWMOUSE):
                        return
                    buf = ctypes.create_string_buffer(size.value)
                    got = user32.GetRawInputData(
                        lParam, RID_INPUT, buf, ctypes.byref(size), _raw_input_header_size,
                    )
                    if got == ctypes.wintypes.UINT(-1).value:
                        return
                    header = RAWINPUTHEADER.from_buffer_copy(buf)
                    if header.dwType != RIM_TYPEMOUSE:
                        return
                    mouse = RAWMOUSE.from_buffer_copy(buf, _raw_input_header_size)
                    now = time.time()
                    with self._windows_lock:
                        self._windows_last_raw_mouse_ts = now
                        if mouse.usButtonFlags & (RI_MOUSE_WHEEL | RI_MOUSE_HWHEEL):
                            self._windows_last_hw_scroll_ts = now
                        if mouse.usButtonFlags & RI_MOUSE_BUTTONS:
                            self._windows_last_hw_mouse_ts = now
                        if mouse.lLastX or mouse.lLastY:
                            self._windows_last_hw_mouse_ts = now
                except Exception:
                    pass

            try:
                user32.RegisterRawInputDevices.argtypes = [
                    ctypes.POINTER(RAWINPUTDEVICE), ctypes.wintypes.UINT, ctypes.wintypes.UINT,
                ]
                user32.RegisterRawInputDevices.restype = ctypes.wintypes.BOOL
                rdi = RAWINPUTDEVICE(0x01, 0x02, RIDEV_INPUTSINK, None)
                if user32.RegisterRawInputDevices(
                    ctypes.byref(rdi), 1, ctypes.sizeof(RAWINPUTDEVICE),
                ):
                    self._windows_raw_input_active = True
                    logger.debug("Windows Raw Input mouse sink registered")
                else:
                    logger.debug(
                        "RegisterRawInputDevices failed (error %d)",
                        ctypes.get_last_error(),
                    )
            except Exception as e:
                logger.debug("Windows Raw Input setup failed: %s", e)

            if not self._windows_raw_input_active:
                logger.warning(
                    "Windows Raw Input mouse sink unavailable (error %d); "
                    "keyboard validation remains active",
                    ctypes.get_last_error(),
                )

            self._windows_hooks = {
                'user32': user32,
                'kernel32': kernel32,
                'keyboard_proc': keyboard_proc,
                'mouse_proc': mouse_proc,
            }
            ok_flag[0] = True
            ready_event.set()
            logger.info(
                "Windows keyboard hardware validation active "
                "(WH_KEYBOARD_LL; injected keys excluded)"
            )

            user32.GetMessageW.argtypes = [
                ctypes.POINTER(ctypes.wintypes.MSG),
                ctypes.wintypes.HWND,
                ctypes.wintypes.UINT,
                ctypes.wintypes.UINT,
            ]
            user32.GetMessageW.restype = ctypes.c_int

            # ── Message pump (runs on THIS thread — same as hook installation) ──
            # Blocking GetMessageW avoids the old PeekMessage + 5–10 ms spin loop.
            msg = ctypes.wintypes.MSG()
            while not self._stop_hooks:
                try:
                    ret = user32.GetMessageW(ctypes.byref(msg), None, 0, 0)
                    if ret in (0, -1):
                        break
                    if msg.message == WM_INPUT:
                        record_raw_mouse_input(msg.lParam)
                    user32.TranslateMessage(ctypes.byref(msg))
                    user32.DispatchMessageW(ctypes.byref(msg))
                except Exception:
                    time.sleep(0.05)

        except Exception as e:
            logger.warning("Hardware hook thread error: %s", e)
            ok_flag[0] = False
            ready_event.set()
    
    def _initialize_macos(self):
        """Initialize macOS CGEvent API for event validation"""
        try:
            # Primary path: MacCGEventListener passes Quartz CGEvent objects directly.
            # Reading kCGEventSourceStateID on the event is reliable; a secondary ctypes
            # CGEventTap without CFRunLoop never receives callbacks.
            try:
                import Quartz
                self._macos_quartz_module = Quartz
                self._macos_direct_validation = True
                self._enabled = True
                logger.info(
                    "macOS hardware event validation enabled (direct CGEvent source check)"
                )
                return
            except ImportError:
                self._macos_direct_validation = False

            # Fallback: ctypes CGEventTap (legacy pynput path)
            try:
                # Load Quartz framework
                quartz = ctypes.CDLL('/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices')
                
                # Define constants
                kCGEventSourceStateID = 0x00000045
                kCGEventSourceStateHIDSystemState = 1
                kCGEventSourceStatePrivate = -1
                kCGEventSourceStateCombinedSessionState = 0
                
                # Thread-safe storage for latest event source
                self._macos_last_keyboard_event = {'source_id': None, 'timestamp': 0}
                self._macos_last_mouse_event = {'source_id': None, 'timestamp': 0}
                self._macos_lock = threading.Lock()
                
                # Store constants for checking event sources
                self._macos_constants = {
                    'kCGEventSourceStateID': kCGEventSourceStateID,
                    'kCGEventSourceStateHIDSystemState': kCGEventSourceStateHIDSystemState,
                }
                self._macos_quartz = quartz
                
                # Try to set up CGEventTap to monitor events
                # Note: This requires accessibility permissions
                try:
                    # Constants for event tap
                    kCGEventTapOptionDefault = 0x00000000
                    kCGHeadInsertEventTap = 0
                    
                    # Event types we want to monitor
                    kCGEventKeyDown = 0x0000000A
                    kCGEventKeyUp = 0x0000000B
                    kCGEventMouseMoved = 0x00000005
                    kCGEventLeftMouseDown = 0x00000001
                    kCGEventRightMouseDown = 0x00000003
                    
                    # Define callback function type
                    CGEventTapCallBack = ctypes.CFUNCTYPE(
                        ctypes.c_void_p,  # return type
                        ctypes.c_void_p,  # proxy
                        ctypes.c_int,     # event type
                        ctypes.c_void_p,  # event
                        ctypes.c_void_p   # userInfo
                    )
                    
                    # Create event tap callback
                    def event_tap_callback(proxy, event_type, event, user_info):
                        try:
                            if event:  # Event is not None
                                # Get event source ID
                                source_id = quartz.CGEventGetIntegerValueField(
                                    ctypes.c_void_p(event),
                                    kCGEventSourceStateID
                                )
                                
                                current_time = time.time()
                                
                                # Check event type and store source
                                with self._macos_lock:
                                    if event_type in (kCGEventKeyDown, kCGEventKeyUp):
                                        self._macos_last_keyboard_event = {
                                            'source_id': source_id,
                                            'timestamp': current_time
                                        }
                                    elif event_type in (kCGEventMouseMoved, kCGEventKeyDown, kCGEventKeyUp, kCGEventLeftMouseDown, kCGEventRightMouseDown):
                                        self._macos_last_mouse_event = {
                                            'source_id': source_id,
                                            'timestamp': current_time
                                        }
                        except Exception:
                            pass  # Fail silently
                        
                        # Return event (don't block it) - return None means pass through
                        return event
                    
                    # Create callback reference
                    callback_ref = CGEventTapCallBack(event_tap_callback)
                    
                    # Build event mask
                    event_mask = (1 << kCGEventKeyDown) | (1 << kCGEventKeyUp) | \
                                (1 << kCGEventMouseMoved) | (1 << kCGEventLeftMouseDown) | (1 << kCGEventRightMouseDown)
                    
                    # Set up CGEventTapCreate function signature
                    # CGEventTapCreate(tap, place, options, eventsOfInterest, callback, userInfo)
                    quartz.CGEventTapCreate.argtypes = [
                        ctypes.c_uint32,  # tap (CGEventTapLocation)
                        ctypes.c_uint32,  # place (CGEventTapPlacement) 
                        ctypes.c_uint32,  # options (CGEventTapOptions)
                        ctypes.c_uint64,  # eventsOfInterest (CGEventMask)
                        CGEventTapCallBack,  # callback
                        ctypes.c_void_p   # userInfo
                    ]
                    quartz.CGEventTapCreate.restype = ctypes.c_void_p
                    
                    # Set up CGEventTapEnable
                    quartz.CGEventTapEnable.argtypes = [ctypes.c_void_p, ctypes.c_bool]
                    quartz.CGEventTapEnable.restype = ctypes.c_bool
                    
                    # Set up CGEventGetIntegerValueField
                    quartz.CGEventGetIntegerValueField.argtypes = [ctypes.c_void_p, ctypes.c_uint64]
                    quartz.CGEventGetIntegerValueField.restype = ctypes.c_int64
                    
                    # Create event tap (may require accessibility permissions)
                    # Parameters: tap location, placement, options, event mask, callback, userInfo
                    event_tap = quartz.CGEventTapCreate(
                        kCGHeadInsertEventTap,  # tap location
                        0,  # placement (0 = head, 1 = tail)
                        kCGEventTapOptionDefault,  # options
                        event_mask,  # events of interest
                        callback_ref,  # callback
                        None  # userInfo
                    )
                    
                    if event_tap and event_tap != 0:
                        # Enable the event tap
                        enabled = quartz.CGEventTapEnable(event_tap, True)
                        if enabled:
                            self._macos_event_tap = event_tap
                            self._macos_event_tap_callback = callback_ref  # Keep reference to prevent GC
                            self._macos_quartz_available = True
                            logger.info("macOS hardware event validation initialized with CGEventTap")
                        else:
                            logger.warning("CGEventTapEnable failed - accepting all events")
                            self._macos_quartz_available = False
                    else:
                        # Event tap creation failed (likely permissions) - disable validation
                        logger.warning("CGEventTap creation failed (may need accessibility permissions) - accepting all events")
                        self._macos_quartz_available = False
                        
                except Exception as e2:
                    logger.debug("Could not create CGEventTap: %s - accepting all events", e2)
                    self._macos_quartz_available = False
                    
            except Exception as e:
                logger.warning("Could not load Quartz framework: %s", e)
                self._macos_quartz_available = False
            
            if self._macos_quartz_available:
                self._enabled = True
            else:
                self._enabled = False
                logger.info("macOS hardware validation disabled - will accept all events")
                
        except Exception as e:
            logger.warning("Failed to initialize macOS hardware detection: %s", e)
            self._enabled = False
    
    def _initialize_linux(self):
        """
        Initialize Linux X11 input validation (evdev + XTEST path).

        Real hardware: kernel evdev -> X server -> pynput
        pyautogui/xdotool: XTEST virtual device -> X server -> pynput (no evdev)
        """
        try:
            self._linux_xtest_device_ids = self._linux_discover_xtest_device_ids()
            if self._linux_xtest_device_ids:
                self._start_linux_xinput_monitor()
            else:
                logger.warning("Could not discover XTEST device IDs on Linux X11")

            try:
                import evdev
                self._linux_evdev_available = True
                self._evdev_module = evdev
                opened = self._linux_open_evdev_devices()
                if opened:
                    self._start_linux_evdev_reader()
                    logger.info(
                        "Linux evdev hardware path ready (%s keyboard/mouse devices)",
                        opened,
                    )
                else:
                    logger.warning("Linux evdev: no keyboard/mouse devices opened")
            except ImportError:
                logger.warning("python-evdev not available on Linux - XTEST-only validation")

            self._enabled = bool(self._linux_open_devices) or self._linux_xinput_monitor_active
            if self._enabled:
                logger.info(
                    "Linux X11 hardware validation initialized "
                    "(evdev=%s, xtest_ids=%s, xinput_monitor=%s)",
                    len(self._linux_open_devices),
                    sorted(self._linux_xtest_device_ids),
                    self._linux_xinput_monitor_active,
                )
            else:
                logger.warning("Linux hardware validation unavailable - accepting all events")
        except Exception as e:
            logger.warning("Failed to initialize Linux hardware detection: %s", e)
            self._enabled = False

    def _linux_xinput_binary(self) -> Optional[str]:
        """Resolve the xinput binary path for X11 sessions."""
        if self._linux_xinput_path:
            return self._linux_xinput_path
        for candidate in (
            shutil.which('xinput'),
            '/usr/bin/xinput',
            '/bin/xinput',
        ):
            if candidate and os.path.isfile(candidate) and os.access(candidate, os.X_OK):
                self._linux_xinput_path = candidate
                return candidate
        return None

    def _linux_discover_xtest_device_ids(self) -> Set[int]:
        """Discover Virtual core XTEST pointer/keyboard device IDs."""
        ids: Set[int] = set()

        xinput_bin = self._linux_xinput_binary()
        if xinput_bin:
            try:
                output = subprocess.check_output(
                    [xinput_bin, 'list'],
                    text=True,
                    timeout=5,
                    stderr=subprocess.DEVNULL,
                    env=os.environ.copy(),
                )
                for line in output.splitlines():
                    if 'XTEST' not in line.upper():
                        continue
                    match = re.search(r'id=(\d+)', line)
                    if match:
                        ids.add(int(match.group(1)))
            except Exception as e:
                logger.debug("xinput list failed while discovering XTEST devices: %s", e)

        if ids:
            return ids

        # Fallback: query libXi directly (no external binary required)
        try:
            ids = self._linux_discover_xtest_device_ids_ctypes()
        except Exception as e:
            logger.debug("ctypes XTEST discovery failed: %s", e)
        return ids

    def _linux_discover_xtest_device_ids_ctypes(self) -> Set[int]:
        """Discover XTEST device IDs via libXi (works in frozen PyInstaller builds)."""
        import ctypes.util
        from ctypes import c_void_p, c_int, POINTER, Structure, byref, c_bool

        libx11 = ctypes.CDLL(ctypes.util.find_library('X11'))
        libxi = ctypes.CDLL(ctypes.util.find_library('Xi'))
        if not libx11 or not libxi:
            return set()

        Display = c_void_p

        class XIDeviceInfo(Structure):
            _fields_ = [
                ('deviceid', c_int),
                ('name', ctypes.c_char_p),
                ('use', c_int),
                ('attachment', c_int),
                ('enabled', c_bool),
                ('num_classes', c_int),
                ('classes', c_void_p),
            ]

        libx11.XOpenDisplay.argtypes = [ctypes.c_char_p]
        libx11.XOpenDisplay.restype = Display
        libx11.XCloseDisplay.argtypes = [Display]
        libxi.XIQueryVersion.argtypes = [Display, POINTER(c_int), POINTER(c_int)]
        libxi.XIQueryDevice.argtypes = [Display, c_int, POINTER(c_int)]
        libxi.XIQueryDevice.restype = POINTER(XIDeviceInfo)
        libxi.XIFreeDeviceInfo.argtypes = [POINTER(XIDeviceInfo)]

        display = libx11.XOpenDisplay(None)
        if not display:
            return set()

        ids: Set[int] = set()
        try:
            major, minor = c_int(2), c_int(0)
            libxi.XIQueryVersion(display, byref(major), byref(minor))
            ndevices = c_int(0)
            devices = libxi.XIQueryDevice(display, 0, byref(ndevices))
            if devices:
                for index in range(ndevices.value):
                    name = devices[index].name
                    label = name.decode('utf-8', 'replace') if name else ''
                    if 'XTEST' in label.upper():
                        ids.add(devices[index].deviceid)
                libxi.XIFreeDeviceInfo(devices)
        finally:
            libx11.XCloseDisplay(display)
        return ids

    def _automation_process_active(self) -> bool:
        """Return True when a known input-automation script/process is running."""
        now = time.time()
        if (now - self._automation_scan_cache_ts) < self._automation_scan_interval:
            return self._automation_scan_cache_active
        active = False
        try:
            if self._platform == "Linux":
                active = self._scan_linux_automation_processes()
            elif self._platform == "Windows":
                active = self._scan_windows_automation_processes()
        except Exception as e:
            logger.debug("Automation process scan failed: %s", e)
        self._automation_scan_cache_active = active
        self._automation_scan_cache_ts = now
        return active

    def _scan_linux_automation_processes(self) -> bool:
        own_pid = os.getpid()
        for cmdline_path in glob.glob('/proc/[0-9]*/cmdline'):
            try:
                pid = int(cmdline_path.split('/')[2])
            except (IndexError, ValueError):
                continue
            if pid == own_pid:
                continue
            try:
                with open(cmdline_path, 'rb') as handle:
                    cmdline = handle.read().replace(b'\x00', b' ').decode('utf-8', 'ignore').lower()
            except OSError:
                continue
            if any(marker in cmdline for marker in self._automation_markers):
                return True
        return False

    def _scan_windows_automation_processes(self) -> bool:
        own_pid = os.getpid()
        try:
            import psutil
        except ImportError:
            return False
        for proc in psutil.process_iter(['pid', 'cmdline', 'name']):
            try:
                if proc.info['pid'] == own_pid:
                    continue
                cmdline = ' '.join(proc.info['cmdline'] or []).lower()
                name = (proc.info['name'] or '').lower()
                haystack = f"{cmdline} {name}"
                if any(marker in haystack for marker in self._automation_markers):
                    return True
            except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
                continue
        return False

    def _linux_automation_process_active(self) -> bool:
        """Return True when a known input-automation script/process is running."""
        return self._automation_process_active()

    def _linux_open_evdev_devices(self) -> int:
        """Open physical keyboard/mouse evdev nodes for synchronous draining."""
        evdev = self._evdev_module
        ecodes = evdev.ecodes
        opened = 0

        for path in evdev.list_devices():
            try:
                device = evdev.InputDevice(path)
                name_lower = (device.name or '').lower()
                if 'virtual' in name_lower or 'uinput' in name_lower:
                    continue

                caps = device.capabilities(absinfo=False)
                key_codes = caps.get(ecodes.EV_KEY, [])
                is_keyboard = ecodes.EV_KEY in caps and ecodes.KEY_A in key_codes
                is_mouse = ecodes.EV_REL in caps or (
                    ecodes.EV_ABS in caps and ecodes.ABS_X in caps.get(ecodes.EV_ABS, [])
                )
                if not (is_keyboard or is_mouse):
                    continue

                os.set_blocking(device.fd, False)
                self._linux_open_devices.append(device)
                opened += 1
                logger.debug("Linux evdev opened: %s (%s)", path, device.name)
            except (OSError, PermissionError) as e:
                logger.debug("Cannot open Linux input device %s: %s", path, e)
            except Exception as e:
                logger.debug("Error opening Linux input device %s: %s", path, e)

        return opened

    def _start_linux_evdev_reader(self):
        """Background thread keeps evdev timestamps warm for pynput correlation."""
        if not self._linux_open_devices:
            return
        if self._linux_evdev_thread and self._linux_evdev_thread.is_alive():
            return

        self._linux_evdev_thread = threading.Thread(
            target=self._linux_evdev_reader_thread,
            daemon=True,
            name="LinuxEvdevReader",
        )
        self._linux_evdev_thread.start()

    def _linux_evdev_reader_thread(self):
        """Continuously timestamp physical keyboard/mouse evdev events."""
        import select

        evdev = self._evdev_module
        ecodes = evdev.ecodes
        fd_map = {device.fileno(): device for device in self._linux_open_devices}
        if not fd_map:
            return

        logger.info("Linux evdev background reader started (%s devices)", len(fd_map))

        while not self._stop_hooks:
            try:
                readable, _, _ = select.select(list(fd_map.keys()), [], [], 0.5)
                now = time.time()
                for fd in readable:
                    for event in fd_map[fd].read():
                        with self._linux_lock:
                            if event.type == ecodes.EV_KEY:
                                self._linux_last_evdev_keyboard_ts = now
                            elif event.type in (ecodes.EV_REL, ecodes.EV_ABS):
                                self._linux_last_evdev_mouse_ts = now
            except Exception as e:
                logger.debug("Linux evdev reader error: %s", e)
                time.sleep(0.5)

    def _start_linux_xinput_monitor(self):
        """Monitor XI2 events on the X11 root window for XTEST slave devices."""
        if self._linux_xinput_thread and self._linux_xinput_thread.is_alive():
            return

        self._linux_xinput_thread = threading.Thread(
            target=self._linux_xinput_monitor_thread,
            daemon=True,
            name="LinuxX11XTESTMonitor",
        )
        self._linux_xinput_thread.start()
        time.sleep(0.3)

    def _linux_xinput_monitor_thread(self):
        """
        Parse `xinput test-xi2 --root` to classify XI2 events as hardware vs XTEST.

        pyautogui injects through Virtual core XTEST devices (e.g. ids 4/5).
        Real keyboards/mice appear as physical slave devices (e.g. ids 8/11).
        """
        keyboard_events = {2, 3, 13, 14}
        mouse_events = {4, 5, 6, 15, 16, 17}
        xinput_bin = self._linux_xinput_binary()
        if not xinput_bin:
            logger.warning("xinput binary not found - XTEST path detection disabled")
            return

        while not self._stop_hooks:
            current_event_type = None
            proc = None
            try:
                proc = subprocess.Popen(
                    [xinput_bin, 'test-xi2', '--root'],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.STDOUT,
                    text=True,
                    bufsize=1,
                    env=os.environ.copy(),
                )
                self._linux_xinput_process = proc
                self._linux_xinput_monitor_active = True
                logger.info(
                    "Linux X11 input monitor started (xtest ids %s)",
                    sorted(self._linux_xtest_device_ids),
                )

                assert proc.stdout is not None
                for line in proc.stdout:
                    if self._stop_hooks:
                        break

                    stripped = line.strip()
                    event_match = re.match(r'EVENT type (\d+)', stripped)
                    if event_match:
                        current_event_type = int(event_match.group(1))
                        continue

                    device_match = re.match(r'device: \d+ \((\d+)\)', stripped)
                    if device_match and current_event_type is not None:
                        slave_id = int(device_match.group(1))
                        now = time.time()
                        with self._linux_lock:
                            if slave_id in self._linux_xtest_device_ids:
                                if current_event_type in keyboard_events:
                                    self._linux_last_xtest_keyboard_ts = now
                                elif current_event_type in mouse_events:
                                    self._linux_last_xtest_mouse_ts = now
                            else:
                                if current_event_type in keyboard_events:
                                    self._linux_last_hw_keyboard_ts = now
                                elif current_event_type in mouse_events:
                                    self._linux_last_hw_mouse_ts = now
                        current_event_type = None
            except FileNotFoundError:
                logger.warning("xinput not found - XTEST path detection disabled")
                return
            except Exception as e:
                logger.warning("Linux X11 input monitor error: %s", e)
            finally:
                self._linux_xinput_monitor_active = False
                if proc is not None and proc.poll() is None:
                    try:
                        proc.terminate()
                    except Exception:
                        pass
                self._linux_xinput_process = None

            if not self._stop_hooks:
                logger.warning("Linux X11 input monitor stopped — restarting in 2s")
                time.sleep(2.0)

    def _linux_drain_evdev(self) -> Tuple[bool, bool]:
        """Synchronously drain pending evdev events (called when pynput fires)."""
        if not self._linux_open_devices:
            return False, False

        evdev = self._evdev_module
        ecodes = evdev.ecodes
        found_keyboard = False
        found_mouse = False
        now = time.time()

        for device in self._linux_open_devices:
            try:
                for event in device.read():
                    with self._linux_lock:
                        if event.type == ecodes.EV_KEY:
                            found_keyboard = True
                            self._linux_last_evdev_keyboard_ts = now
                        elif event.type in (ecodes.EV_REL, ecodes.EV_ABS):
                            found_mouse = True
                            self._linux_last_evdev_mouse_ts = now
            except BlockingIOError:
                pass
            except OSError as e:
                logger.debug("Linux evdev read error on %s: %s", device.path, e)

        return found_keyboard, found_mouse
    
    def is_enabled(self) -> bool:
        """Returns True if validation is enabled and initialized successfully"""
        return self._enabled and self._initialized

    def classify_macos_cg_event(self, event) -> Optional[bool]:
        """
        Classify a macOS Quartz CGEvent as hardware or synthetic.

        Returns:
            True: physical HID (kCGEventSourceStateHIDSystemState)
            False: software-posted (pyautogui, CGEventPost, automation)
            None: classification unavailable
        """
        if self._platform != "Darwin":
            return None
        try:
            quartz = self._macos_quartz_module
            if quartz is None:
                import Quartz as quartz
            source_id = int(
                quartz.CGEventGetIntegerValueField(
                    event, quartz.kCGEventSourceStateID
                )
            )
            if source_id == quartz.kCGEventSourceStateHIDSystemState:
                return True
            return False
        except Exception as e:
            logger.debug("macOS CGEvent classification failed: %s", e)
            return None

    def should_count_activity(
        self,
        is_hardware: Optional[bool],
        input_kind: str = 'input',
    ) -> bool:
        """
        Decide whether an input event should count as user activity.

        Only confirmed synthetic events (is_hardware is False) are filtered.
        """
        if not self.is_enabled():
            return True
        if is_hardware is False:
            self.synthetic_events_filtered += 1
            logger.info(
                "Fake activity ignored: %s input blocked (not counted)",
                input_kind,
            )
            self._notify_synthetic_input_filtered(input_kind)
            return False
        return True

    def should_count_macos_cg_event(self, event, input_kind: str = 'input') -> bool:
        """Return True when a macOS CGEvent should count toward activity."""
        if not self.is_enabled() or not self._macos_direct_validation:
            return True
        return self.should_count_activity(
            self.classify_macos_cg_event(event),
            input_kind=input_kind,
        )

    def _notify_synthetic_input_filtered(self, input_kind: str) -> None:
        """Log when fake/scripted input is blocked (throttled)."""
        now = time.time()
        if (now - self._last_synthetic_warning_ts) < self._synthetic_warning_interval:
            return
        self._last_synthetic_warning_ts = now

        platform_hint = {
            "Darwin": "pyautogui/CGEventPost on macOS",
            "Linux": "pyautogui/xdotool on Linux",
            "Windows": "SendInput/AutoHotkey on Windows",
        }.get(self._platform, "automation tools")

        logger.warning(
            "Fake activity detected: %s input blocked (not counted). "
            "Excluded sources include %s.",
            input_kind,
            platform_hint,
        )
    
    def is_hardware_keyboard_event(self) -> Optional[bool]:
        """
        Check if keyboard event is from hardware.
        
        Returns:
            True: Confirmed hardware event (count it)
            False: Confirmed synthetic event (filter it)
            None: Unknown/unavailable (ACCEPT and count it - fail-safe)
        """
        if not self.is_enabled():
            return None  # Fail-safe: accept event
        
        try:
            if self._platform == "Windows":
                return self._check_windows_keyboard()
            elif self._platform == "Darwin":
                return self._check_macos_keyboard()
            elif self._platform == "Linux":
                return self._check_linux_keyboard()
        except Exception as e:
            logger.debug("Hardware validation error (accepting event): %s", e)
            return None  # Fail-safe: accept event
        
        return None  # Unknown platform or error

    def drain_windows_keyboard_events(self) -> list:
        """
        Return and clear queued Windows keyboard edges.

        Each event contains vk_code, is_down/is_up, injected and timestamp.
        The consumer must ignore repeated key-down messages until the matching
        key-up; Windows emits repeated WM_KEYDOWN while a key is held.
        """
        if (
            not self.is_enabled()
            or self._platform != "Windows"
            or not hasattr(self, "_windows_lock")
        ):
            return []
        try:
            with self._windows_lock:
                events = list(self._windows_keyboard_events)
                self._windows_keyboard_events.clear()
            return events
        except Exception as e:
            logger.debug("Could not drain Windows keyboard events: %s", e)
            return []

    def is_hardware_scroll_event(self) -> Optional[bool]:
        """Check if a scroll/wheel event is from hardware (Windows wheel-aware)."""
        if not self.is_enabled():
            return None
        try:
            if self._platform == "Windows":
                return self._check_windows_scroll()
            return self.is_hardware_mouse_event()
        except Exception as e:
            logger.debug("Hardware scroll validation error (accepting event): %s", e)
            return None

    def get_last_hw_scroll_timestamp(self) -> float:
        """Unix timestamp of the last physical scroll Raw Input event (Windows only)."""
        if not self.is_enabled() or self._platform != "Windows":
            return 0.0
        with self._windows_lock:
            return float(self._windows_last_hw_scroll_ts)
    
    def is_hardware_mouse_event(self) -> Optional[bool]:
        """
        Check if mouse event is from hardware.
        
        Returns:
            True: Confirmed hardware event (count it)
            False: Confirmed synthetic event (filter it)
            None: Unknown/unavailable (ACCEPT and count it - fail-safe)
        """
        if not self.is_enabled():
            return None  # Fail-safe: accept event
        
        try:
            if self._platform == "Windows":
                return self._check_windows_mouse()
            elif self._platform == "Darwin":
                return self._check_macos_mouse()
            elif self._platform == "Linux":
                return self._check_linux_mouse()
        except Exception as e:
            logger.debug("Hardware validation error (accepting event): %s", e)
            return None  # Fail-safe: accept event
        
        return None  # Unknown platform or error

    def is_hardware_mouse_move_event(self) -> Optional[bool]:
        """Check if a mouse-move event is from hardware (Windows SetCursorPos-aware)."""
        if not self.is_enabled():
            return None
        try:
            if self._platform == "Windows":
                return self._check_windows_mouse_move()
            return self.is_hardware_mouse_event()
        except Exception as e:
            logger.debug("Hardware mouse-move validation error (accepting event): %s", e)
            return None

    def _resolve_windows_input(
        self,
        hw_ts: float,
        syn_ts: float,
    ) -> Optional[bool]:
        """
        Correlate pynput callbacks with LL hook stamps.

        Hardware wins when seen recently — avoids stale synthetic stamps
        blocking real mouse/keyboard after pyautogui runs.
        """
        now = time.time()
        hw_age = (now - hw_ts) if hw_ts else None
        syn_age = (now - syn_ts) if syn_ts else None

        hw_recent = hw_age is not None and hw_age < WINDOWS_HW_CONFIRM_WINDOW
        syn_recent = syn_age is not None and syn_age < WINDOWS_SYN_FILTER_WINDOW

        if hw_recent and (not syn_recent or hw_ts >= syn_ts):
            return True
        if syn_recent and (not hw_recent or syn_ts > hw_ts):
            return False
        return None

    def _check_windows_keyboard(self) -> Optional[bool]:
        """
        Windows: Check if keyboard event is injected using low-level hook data
        
        Returns True if hardware, False if injected, None if unknown
        """
        try:
            with self._windows_lock:
                hw_ts = self._windows_last_hw_keyboard_ts
                syn_ts = self._windows_last_syn_keyboard_ts
            return self._resolve_windows_input(hw_ts, syn_ts)
        except Exception:
            return None  # Fail-safe: accept event
    
    def _check_windows_mouse(self) -> Optional[bool]:
        """
        Windows: clicks and non-move mouse events via LL hook + raw input.
        
        Returns True if hardware, False if injected, None if unknown
        """
        try:
            now = time.time()
            with self._windows_lock:
                hw_ts = self._windows_last_hw_mouse_ts
                syn_ts = self._windows_last_syn_mouse_ts
                raw_ts = self._windows_last_raw_mouse_ts

            resolved = self._resolve_windows_input(hw_ts, syn_ts)
            if resolved is False:
                return False

            syn_recent = syn_ts and (now - syn_ts) < WINDOWS_SYN_FILTER_WINDOW
            raw_recent = raw_ts and (now - raw_ts) < WINDOWS_RAW_CONFIRM_WINDOW

            if syn_recent and not (raw_recent and raw_ts >= syn_ts):
                return False
            if raw_recent:
                return True
            if self._automation_process_active():
                return False
            if resolved is True:
                return True
            return None
        except Exception:
            return None  # Fail-safe: accept event

    def _check_windows_mouse_move(self) -> Optional[bool]:
        """
        Windows mouse moves: pyautogui.moveTo uses SetCursorPos (no LLMHF_INJECTED).

        Physical mice emit WM_INPUT raw events; SetCursorPos does not.
        """
        try:
            now = time.time()
            with self._windows_lock:
                syn_ts = self._windows_last_syn_mouse_ts
                raw_ts = self._windows_last_raw_mouse_ts

            syn_recent = syn_ts and (now - syn_ts) < WINDOWS_SYN_FILTER_WINDOW
            raw_recent = raw_ts and (now - raw_ts) < WINDOWS_RAW_CONFIRM_WINDOW

            if syn_recent:
                return False
            if raw_recent:
                return True
            if self._automation_process_active():
                return False
            if self._windows_raw_input_active:
                return False
            return None
        except Exception:
            return None

    def _check_windows_scroll(self) -> Optional[bool]:
        """Windows: wheel events get a dedicated stamp (trackpad quirk)."""
        try:
            now = time.time()
            with self._windows_lock:
                scroll_ts = self._windows_last_hw_scroll_ts
                hw_ts = self._windows_last_hw_mouse_ts
                syn_ts = self._windows_last_syn_mouse_ts
            if scroll_ts and (now - scroll_ts) < WINDOWS_HW_CONFIRM_WINDOW:
                return True
            return self._resolve_windows_input(hw_ts, syn_ts)
        except Exception:
            return None
    
    def _check_macos_keyboard(self) -> Optional[bool]:
        """macOS: Check if keyboard event is from HID system"""
        try:
            if not self._macos_quartz_available:
                return None
            
            with self._macos_lock:
                event_data = self._macos_last_keyboard_event.copy()
            
            current_time = time.time()
            
            # Check if we have recent event data (within 200ms)
            if event_data.get('timestamp') and (current_time - event_data['timestamp']) < 0.2:
                source_id = event_data.get('source_id')
                if source_id is not None:
                    # Hardware events have source ID = 1 (HIDSystemState)
                    # Synthetic events have different source IDs
                    if source_id == 1:  # kCGEventSourceStateHIDSystemState
                        return True   # Hardware event - count it
                    else:
                        return False  # Synthetic event - filter it
            
            # No recent data available - fail-safe: accept event
            return None
                
        except Exception:
            return None  # Fail-safe: accept event
    
    def _check_macos_mouse(self) -> Optional[bool]:
        """macOS: Check if mouse event is from HID system"""
        try:
            if not self._macos_quartz_available:
                return None
            
            with self._macos_lock:
                event_data = self._macos_last_mouse_event.copy()
            
            current_time = time.time()
            
            # Check if we have recent event data (within 200ms)
            if event_data.get('timestamp') and (current_time - event_data['timestamp']) < 0.2:
                source_id = event_data.get('source_id')
                if source_id is not None:
                    # Hardware events have source ID = 1 (HIDSystemState)
                    if source_id == 1:  # kCGEventSourceStateHIDSystemState
                        return True   # Hardware event - count it
                    else:
                        return False  # Synthetic event - filter it
            
            # No recent data available - fail-safe: accept event
            return None
                
        except Exception:
            return None  # Fail-safe: accept event
    
    def _linux_ts_recent(self, ts: float, now: float) -> bool:
        return bool(ts) and (now - ts) < self._linux_correlation_window

    def _check_linux_input_event(self, input_kind: str) -> Optional[bool]:
        """
        Linux X11 dual-path validation (evdev + XI2 device class).

        Real input:    evdev event and/or XI2 physical slave device (Dell KB/mouse)
        Scripted input: XI2 XTEST slave device (pyautogui/xdotool) — never evdev

        Returns True (hardware), False (confirmed fake), None (uncertain → accept).
        """
        now = time.time()
        keyboard_found, mouse_found = self._linux_drain_evdev()

        with self._linux_lock:
            if input_kind == 'keyboard':
                evdev_ts = self._linux_last_evdev_keyboard_ts
                xtest_ts = self._linux_last_xtest_keyboard_ts
                hw_ts = self._linux_last_hw_keyboard_ts
                evdev_now = keyboard_found
            else:
                evdev_ts = self._linux_last_evdev_mouse_ts
                xtest_ts = self._linux_last_xtest_mouse_ts
                hw_ts = self._linux_last_hw_mouse_ts
                evdev_now = mouse_found

        evdev_recent = evdev_now or self._linux_ts_recent(evdev_ts, now)
        hw_recent = self._linux_ts_recent(hw_ts, now)
        xtest_recent = self._linux_ts_recent(xtest_ts, now)

        # Hardware confirmed on kernel (evdev) or X11 physical device path.
        if evdev_recent or hw_recent:
            if xtest_recent and xtest_ts > max(evdev_ts, hw_ts):
                return False
            return True

        # XTEST virtual device — pyautogui / fake_detector injection path.
        if xtest_recent:
            return False

        # Automation script running — block only events with no hardware backing.
        if self._linux_automation_process_active() and not (evdev_recent or hw_recent):
            return False

        # Genuinely uncertain — accept real user rather than false-reject.
        return None

    def _check_linux_keyboard(self) -> Optional[bool]:
        """Linux X11: keyboard event hardware vs XTEST validation."""
        try:
            return self._check_linux_input_event('keyboard')
        except Exception:
            return None

    def _check_linux_mouse(self) -> Optional[bool]:
        """Linux X11: mouse event hardware vs XTEST validation."""
        try:
            return self._check_linux_input_event('mouse')
        except Exception:
            return None


# Global singleton instance
_hardware_validator = None

def get_hardware_validator() -> HardwareEventValidator:
    """Get the global hardware event validator instance"""
    global _hardware_validator
    if _hardware_validator is None:
        _hardware_validator = HardwareEventValidator()
    return _hardware_validator

def cleanup_hardware_validator():
    """Explicitly cleanup the global hardware validator instance"""
    global _hardware_validator
    if _hardware_validator:
        try:
            _hardware_validator.cleanup()
            _hardware_validator = None
            logger.debug("Global hardware validator cleaned up")
        except Exception as e:
            logger.warning("Error cleaning up global hardware validator: %s", e)

