"""
Main application class for the auto clicker application.
"""

import tkinter as tk
import logging
import threading
import os
import sys
import subprocess
import time
from .logging_config import setup_logging
from .constants import BACKGROUND_COLOR, FULL_WIDTH, FULL_HEIGHT, FULL_HEIGHT_WITH_DEBUG
from .constants import MINIMIZED_WIDTH, MINIMIZED_HEIGHT
from .constants import UI_SCALE
from .constants import (
    SESSION_LOGS_S3_BUCKET,
    SESSION_LOGS_S3_PREFIX,
    AWS_REGION,
    CHROME_DOWNLOADS_FOLDER,
)
from .constants import LOCAL_DEV
import boto3
from .utils import TooltipMixin, LoaderMixin, calculate_screen_position
from .manage_file_processing import add_screenshot_record
from .ui_components import UIManager
from .recording import RecordingManager
from .playback import PlaybackManager
from .playlist_manager import PlaylistManager
from .screenshot import ScreenshotManager
from .auto_extractor import AutoExtractorManager
from .subplaylist_processor import SubplaylistProcessor
from .direct_integration import DirectIntegrationWatcher
from .chrome_downloads_manager import ChromeDownloadsManager


class ClickRecorderApp(tk.Tk, TooltipMixin, LoaderMixin):
    """Main application class for the auto clicker."""
    
    _instance = None
    _instance_lock = threading.Lock()
    
    def __new__(cls):
        with cls._instance_lock:
            if cls._instance is None:
                cls._instance = super(ClickRecorderApp, cls).__new__(cls)
                cls._instance.initialized = False
            return cls._instance
    
    def __init__(self):
        if hasattr(self, 'initialized') and self.initialized:
            return
            
        super().__init__()
        TooltipMixin.__init__(self)
        LoaderMixin.__init__(self)
        
        # Set up logging first
        setup_logging()
        self.logger = logging.getLogger('autoclicker.app')
        self.logger.info("Starting AuditWhizz AutoExtract application")
        self.initialized = True

        # Ensure DPI awareness so screen coordinates match screenshots (Windows)
        try:
            import sys as _sys
            if _sys.platform == 'win32':
                import ctypes as _ct
                try:
                    _ct.windll.user32.SetProcessDPIAware()
                    self.logger.info("DPI awareness enabled for accurate coordinates")
                except Exception:
                    pass
        except Exception:
            pass

        # Apply Tk scaling (allows user to enlarge UI via env UI_SCALE)
        try:
            if float(UI_SCALE) != 1.0:
                # Tk scaling impacts points -> pixels mapping (font and widget sizes)
                self.tk.call('tk', 'scaling', float(UI_SCALE))
                try:
                    self.logger.info(f"Applied Tk UI scaling: {UI_SCALE}")
                except Exception:
                    pass
        except Exception:
            pass

        # Close any stray browser processes at startup to ensure a clean DI session
        try:
            self._close_browser_processes()
        except Exception:
            pass
        # Archive session artifacts when browser/app is closed
        try:
            self.archive_session_artifacts_to_s3()
        except Exception:
            pass
        
        # Window setup
        self.title('AuditWhizz AutoExtract | Workpapers')
        self.is_minimized = True  # Start minimized
        self._setup_window()
        self._harden_window_controls()
        # Bind admin view toggle (Ctrl+Shift+A)
        try:
            self.bind_all('<Control-Shift-A>', lambda e: self.toggle_admin_view())
        except Exception:
            pass
        
        # Initialize state variables
        self._init_state_variables()
        
        # Clear SQLite session database on startup
        self._init_session_database()
        
        # Initialize managers
        self._init_managers()
        
        # Create UI and start background processes
        self.ui_manager.create_widgets()
        self.auto_extractor_manager.start_watcher()
        self.subplaylist_processor.start_processor()
        # Start direct integration watcher (S3 Excel ingestion)
        try:
            # Only initialize if we don't already have a watcher
            if not hasattr(self, 'direct_integration_watcher') or not self.direct_integration_watcher:
                self.direct_integration_watcher = DirectIntegrationWatcher(self)
                self.direct_integration_watcher.start()
                self.logger.info("Started DirectIntegrationWatcher")
        except Exception as e:
            # Log the error but don't crash the UI
            self.logger.error(f"Failed to start DirectIntegrationWatcher: {e}")
            self.direct_integration_watcher = None

        # # Start Chrome downloads manager (S3 file backup) - checking every 5 seconds
        # try:
        #     if not hasattr(self, 'chrome_downloads_manager') or not self.chrome_downloads_manager:
        #         self.chrome_downloads_manager = ChromeDownloadsManager(self)
        #         self.chrome_downloads_manager.start()
        #         self.logger.info("Started ChromeDownloadsManager (5-second monitoring)")
        # except Exception as e:
        #     # Log the error but don't crash the UI
        #     self.logger.error(f"Failed to start ChromeDownloadsManager: {e}")
        #     self.chrome_downloads_manager = None
        # Chrome downloads manager temporarily disabled
        self.chrome_downloads_manager = None

    def _setup_window(self):
        """Setup initial window properties."""
        screen_width = self.winfo_screenwidth()
        screen_height = self.winfo_screenheight()
        min_width = MINIMIZED_WIDTH if MINIMIZED_WIDTH > 0 else 100
        min_height = MINIMIZED_HEIGHT if MINIMIZED_HEIGHT > 0 else 200
        # Clamp to screen size
        min_width = min(min_width, max(200, screen_width))
        min_height = min(min_height, max(150, screen_height))
        min_y = calculate_screen_position(screen_height, min_height)

        self.geometry(f'{min_width}x{min_height}+0+{min_y}')
        self.configure(bg=BACKGROUND_COLOR)
        self.attributes('-topmost', True)
        self.resizable(False, False)
        # Remove window manager decorations so the user cannot close/minimize
        # Note: keep this as the last call in setup to avoid some WMs resetting it
        try:
            self.overrideredirect(True)
        except Exception:
            pass

    def _harden_window_controls(self):
        """Disable close/minimize and keep window accessible only for clicks."""
        # Ignore window-close requests (Alt+F4 or title bar close)
        self.protocol('WM_DELETE_WINDOW', lambda: None)
        # Immediately restore if minimized by WM (e.g., Alt+F9)
        def on_unmap(event):
            # If the window is iconified/minimized, restore it
            try:
                if self.state() == 'iconic':
                    self.after(0, self.deiconify)
            except Exception:
                pass
        self.bind('<Unmap>', on_unmap, add='+')
        # Block common close/minimize key chords while this app is focused
        self.bind_all('<Alt-F4>', lambda e: 'break')
        self.bind_all('<Control-q>', lambda e: 'break')
        self.bind_all('<Alt-F9>', lambda e: 'break')

        # Periodically enforce window state (always on top, visible, positioned)
        def enforce():
            try:
                self.attributes('-topmost', True)
                
                # Enforce geometry based on current view state
                screen_height = self.winfo_screenheight()
                
                if self.is_minimized:
                    # Minimized view
                    min_width = MINIMIZED_WIDTH if MINIMIZED_WIDTH > 0 else 100
                    min_height = MINIMIZED_HEIGHT if MINIMIZED_HEIGHT > 0 else 200
                    min_width = min(min_width, max(200, self.winfo_screenwidth()))
                    # Respect actual content height if the minimized frame needs more
                    try:
                        content_h = self._min_frame.winfo_reqheight() if hasattr(self, '_min_frame') and self._min_frame else min_height
                        min_height = max(min_height, content_h)
                    except Exception:
                        pass
                    min_height = min(min_height, max(150, screen_height))
                    min_y = calculate_screen_position(screen_height, min_height)
                    self.geometry(f'{min_width}x{min_height}+0+{min_y}')
                else:
                    # Full view - use same logic as create_full_view
                    if hasattr(self, 'debug_window') and self.debug_window and self.debug_window.winfo_exists():
                        full_height = FULL_HEIGHT_WITH_DEBUG
                    else:
                        full_height = FULL_HEIGHT
                    full_width = FULL_WIDTH if FULL_WIDTH > 0 else 700
                    # Clamp to screen
                    full_width = min(full_width, self.winfo_screenwidth())
                    full_height = min(full_height, screen_height)
                    full_y = calculate_screen_position(screen_height, full_height)
                    self.geometry(f'{full_width}x{full_height}+0+{full_y}')
                    
                if self.state() == 'iconic':
                    self.deiconify()
            except Exception:
                pass
            self.after(1500, enforce)

        self.after(1500, enforce)

    def _init_state_variables(self):
        """Initialize application state variables."""
        self.playlists = []
        self.selected_playlist = tk.StringVar()
        self.selected_application = tk.StringVar(value='No application selected')
        self.recording = False
        self.variable_input_state = None  # Track variable input state
        self.live_clicks = []
        self.playback_clicks = []
        self._main_frame = None
        self._min_frame = None
        self.status_var = tk.StringVar(value='Idle')
        # Dedicated playback-state flag to control Play button enablement
        self.is_playback_active = False
        self.debug_window = None
        # Direct Integration state defaults
        self.user_interaction_mode = False
        self.user_interaction_upload_id = None
        self.current_direct_upload_id = None
        # UI role and playlist mode
        try:
            self.ui_role = (os.getenv('UI_ROLE', 'user') or 'user').strip().lower()
        except Exception:
            self.ui_role = 'user'
        self.current_playlist_mode = None  # 'new' | 'existing' | None
        # Track desktop apps we launched (by process image name) for cleanup
        self.launched_desktop_process_names = set()
        # Track full executable paths we launched (Windows) for precise cleanup
        self.launched_desktop_executable_paths = set()
        # Track PIDs of launched desktop apps (Windows) to terminate process trees reliably
        self.launched_desktop_pids = set()
        # Track name keywords to identify processes by partial matches (e.g., 'sage')
        self.launched_desktop_name_keywords = set()
        # Cache last found coordinates for 'Attach files' to avoid repeated Rekognition calls
        self._cached_attach_files_coords = None
        
        # UI variables (will be created by UI manager)
        self.recording_name_var = tk.StringVar()
        self.wait_time_var = None
        self.playlist_dropdown = None
        self.live_clicks_text = None
        # Admin-only control: hide on sub playlist (sets playlists.show_on=0 when saving)
        self.hide_on_subplaylist_var = tk.BooleanVar(value=False)
        # Control visibility of recording name field
        self.hide_recording_name = False

        # Heartbeat/locking state
        self.current_instance_id = None
        self._last_heartbeat_sent = 0.0

    def acquire_instance_lock_if_possible(self):
        """Acquire instance lock using instance_id/user/company if available."""
        try:
            from mysql.heartbeat_functions import acquire_lock
        except Exception:
            return False
        try:
            instance_id = getattr(self, 'current_instance_id', None)
            user_id = getattr(self, 'current_user_id', None)
            company_id = getattr(self, 'current_company_id', None)
            if not instance_id or not user_id:
                return False
            ok = acquire_lock(str(instance_id), str(user_id), str(company_id) if company_id else None)
            if ok:
                try:
                    self.logger.info(f"[Heartbeat] Lock acquired for instance={instance_id} user={user_id}")
                except Exception:
                    pass
            return ok
        except Exception:
            return False

    def send_heartbeat_throttled(self, *, min_interval_sec: float = 60.0) -> bool:
        """Send heartbeat if enough time has elapsed since the last one."""
        try:
            now = time.time()
        except Exception:
            return False
        try:
            last = getattr(self, '_last_heartbeat_sent', 0.0)
            if now - last < float(min_interval_sec):
                return False
        except Exception:
            pass
        try:
            from mysql.heartbeat_functions import send_heartbeat
        except Exception:
            return False
        try:
            instance_id = getattr(self, 'current_instance_id', None)
            user_id = getattr(self, 'current_user_id', None)
            if not instance_id or not user_id:
                return False
            ok = send_heartbeat(str(instance_id), str(user_id))
            if ok:
                self._last_heartbeat_sent = now
                try:
                    self.logger.info(f"[Heartbeat] Beat sent for instance={instance_id}")
                except Exception:
                    pass
            return ok
        except Exception:
            return False

    def release_instance_lock(self):
        """Release instance lock when work ends."""
        try:
            from mysql.heartbeat_functions import release_lock
        except Exception:
            return False
        try:
            instance_id = getattr(self, 'current_instance_id', None)
            user_id = getattr(self, 'current_user_id', None)
            if not instance_id or not user_id:
                return False
            release_lock(str(instance_id), str(user_id))
            try:
                self.logger.info(f"[Heartbeat] Lock released for instance={instance_id}")
            except Exception:
                pass
            return True
        except Exception:
            return False

    def _init_session_database(self):
        """Initialize SQLite session database - clear previous session data."""
        try:
            import db
            # Initialize database structure
            db.init_db()
            # Clear any previous session data
            db.clear_session_db()
        except Exception as e:
            self.logger.error(f"Failed to initialize session database: {e}")
            # Don't fail startup, but log the error

    def _init_managers(self):
        """Initialize all manager components."""
        self.ui_manager = UIManager(self)
        self.recording_manager = RecordingManager(self)
        self.playback_manager = PlaybackManager(self)
        self.playlist_manager = PlaylistManager(self)
        self.screenshot_manager = ScreenshotManager(self)
        self.auto_extractor_manager = AutoExtractorManager(self)
        self.subplaylist_processor = SubplaylistProcessor(self)
        
        # Load playlists
        self.playlists = self.playlist_manager.load_playlists()

    # UI Control Methods
    def create_widgets(self):
        """Create widgets - delegates to UI manager."""
        self.ui_manager.create_widgets()

    def toggle_view(self):
        """Toggle between minimized and full view."""
        self.is_minimized = not self.is_minimized
        self.create_widgets()

    def toggle_admin_view(self):
        """Toggle UI role between 'user' and 'admin' and rebuild UI."""
        try:
            self.ui_role = 'admin' if (getattr(self, 'ui_role', 'user') != 'admin') else 'user'
        except Exception:
            self.ui_role = 'admin'
        # Optionally reflect in status
        try:
            role_label = 'Admin' if self.ui_role == 'admin' else 'User'
            self.status_var.set(f"Role: {role_label}")
        except Exception:
            pass
        self.create_widgets()

    # Recording Methods
    def on_start_recording(self):
        """Start recording - delegates to recording manager."""
        self.recording_manager.start_recording()
        # Refresh UI so buttons become enabled in 'new' mode
        try:
            self.create_widgets()
        except Exception:
            pass

    def on_stop_recording(self):
        """Stop recording - delegates to recording manager."""
        # Clean up any variable input state
        if hasattr(self, 'variable_input_state') and self.variable_input_state:
            self.variable_input_state = None
            
        # Clean up any open dialog
        if hasattr(self, '_input_dialog') and self._input_dialog and self._input_dialog.winfo_exists():
            self._input_dialog.destroy()
            
        self.recording_manager.stop_recording()
        # Also stop any active playback (Stop should behave the same as Play while running)
        try:
            self.playback_manager.stop_playlist()
        except Exception:
            pass
        try:
            self.status_var.set('Idle')
        except Exception:
            pass
        # Ensure Play button can be re-enabled
        try:
            self.is_playback_active = False
            # Refresh Play button states if UI is present
            if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, '_update_play_button_state'):
                self.ui_manager._update_play_button_state()
        except Exception:
            pass
        # Refresh UI so buttons revert to disabled state in 'new' mode
        try:
            self.create_widgets()
        except Exception:
            pass
        # Repopulate live clicks panel from SQLite so recorded actions remain visible
        try:
            if hasattr(self, 'recording_manager') and hasattr(self.recording_manager, 'repopulate_live_clicks_from_sqlite'):
                self.recording_manager.repopulate_live_clicks_from_sqlite()
        except Exception:
            pass

    def append_live_click(self, x, y):
        """Append live click - delegates to recording manager."""
        self.recording_manager.append_live_click(x, y)

    def append_live_key(self, key, event_type):
        """Append live key event - delegates to recording manager."""
        self.recording_manager.append_live_key(key, event_type)

    def clear_live_clicks(self):
        """Clear live clicks - delegates to recording manager."""
        self.recording_manager.clear_live_clicks()

    # Playback Methods
    def on_play_playlist(self):
        """If already running, stop then restart; otherwise start playback."""
        try:
            current_status = self.status_var.get()
        except Exception:
            current_status = ''

        # If currently playing or paused, stop immediately and restart from the beginning
        if current_status in ('Playing', 'Paused'):
            try:
                self.playback_manager.stop_playlist()
            except Exception:
                pass
            try:
                self.status_var.set('Idle')
            except Exception:
                pass
            # Give the worker thread a moment to exit, then start fresh
            try:
                # Keep Play disabled across restart gap
                try:
                    self.is_playback_active = True
                    if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, '_update_play_button_state'):
                        self.ui_manager._update_play_button_state()
                except Exception:
                    pass
                self.after(50, self.playback_manager.play_playlist)
            except Exception:
                # Fallback: direct call
                try:
                    self.is_playback_active = True
                except Exception:
                    pass
                self.playback_manager.play_playlist()
            return

        # Otherwise, start playback
        try:
            # Disable Play until Stop/Cancel explicitly re-enables
            self.is_playback_active = True
            if hasattr(self, 'ui_manager') and hasattr(self.ui_manager, '_update_play_button_state'):
                self.ui_manager._update_play_button_state()
        except Exception:
            pass
        self.playback_manager.play_playlist()

    def on_pause_playlist(self):
        """Pause playlist - delegates to playback manager."""
        self.playback_manager.pause_playlist()

    def show_playback_click(self, x, y, idx=None):
        """Show playback click - delegates to playback manager."""
        self.playback_manager.show_playback_click(x, y, idx)

    # Playlist Methods
    def on_add_playlist(self):
        """Add playlist - delegates to playlist manager."""
        self.playlist_manager.add_playlist()
        # Sync recording_name_var with newly selected playlist name for consistent saving
        try:
            if hasattr(self, 'recording_name_var') and self.recording_name_var is not None:
                self.recording_name_var.set((self.selected_playlist.get() or '').strip())
        except Exception:
            pass

    def on_save_playlist(self):
        """Save playlist - delegates to playlist manager."""
        self.playlist_manager.save_playlist()

    def on_delete_playlist_mysql(self):
        """Delete playlist from both databases - delegates to playlist manager."""
        self.playlist_manager.delete_playlist_mysql()
        # Perform common reset to waiting state
        self._reset_app_after_workflow()

    def on_export_json(self):
        """Export playlist to JSON - delegates to playlist manager."""
        self.playlist_manager.export_json()

    def on_playlist_selected(self, event=None):
        """Handle playlist selection - delegates to playlist manager."""
        self.playlist_manager.on_playlist_selected(event)

    def on_user_interaction_done(self):
        """Called when the user finishes the required interaction (e.g., login).
        This should update the direct_integration_uploads record to processed=2
        (processing) and restore the regular UI buttons.
        """
        upload_id = getattr(self, 'user_interaction_upload_id', None)
        if not upload_id:
            # Nothing to do
            try:
                self.status_var.set('Idle')
            except Exception:
                pass
            return

        try:
            # Update DB record to processing (2) only if not already completed
            from mysql.mysql_client import get_direct_upload_by_id, set_direct_upload_status
            current = get_direct_upload_by_id(int(upload_id)) or {}
            if current.get('processed') != 1:
                logging.info(f"[UserInteraction] Setting upload {upload_id} to processing (status=2)")
                set_direct_upload_status(upload_id, 2)
            else:
                logging.info(f"[UserInteraction] Upload {upload_id} already completed; skipping set to processing")
        except Exception as e:
            try:
                self.logger.error(f"Failed to update direct upload {upload_id} to processing: {e}")
            except Exception:
                pass

        # Clear user-interaction flags and restore UI
        try:
            # Preserve current playlist selection and hide dropdown for DI flow
            try:
                _current_pl_name = self.selected_playlist.get() if hasattr(self, 'selected_playlist') else None
            except Exception:
                _current_pl_name = None
            self.user_interaction_upload_id = None
            self.user_interaction_mode = False
            self.status_var.set('Idle')
            # Rebuild UI and then re-assert playlist selection so it does not flip to 'Please wait'
            self.create_widgets()
            try:
                if _current_pl_name:
                    self.selected_playlist.set(_current_pl_name)
                # Ensure DI keeps the dropdown hidden and label visible
                self.playlists = []
            except Exception:
                pass
            # For existing playlists, auto-play on Done even for admin uploads; for new playlists, stay idle to allow recording
            try:
                mode = (getattr(self, 'current_playlist_mode', None) or '').strip().lower()
            except Exception:
                mode = ''
            if mode != 'new':
                # Immediately continue with the currently selected playlist
                # Schedule on the Tk event loop to ensure UI has refreshed first
                try:
                    self.after(50, self.on_play_playlist)
                except Exception:
                    # Fallback: call directly
                    self.on_play_playlist()
        except Exception:
            pass

    def update_playlist_dropdown_selection(self, playlist_name):
        """Update playlist dropdown selection - delegates to playlist manager."""
        self.playlist_manager.update_playlist_dropdown_selection(playlist_name)

    # Screenshot Methods
    def on_manual_screenshot(self):
        """When recording, enqueue a screenshot action; otherwise capture immediately."""
        try:
            if self.recording:
                # Record a 'screenshot' trigger instead of taking a screenshot now
                import recorder
                # Use local SQLite playlist id for recording
                local_pl_id = getattr(self, 'current_local_playlist_id', None)
                if local_pl_id is None:
                    # Resolve by selected playlist name
                    import db
                    conn = db.get_connection()
                    cur = conn.cursor()
                    cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                    row = cur.fetchone()
                    conn.close()
                    local_pl_id = row[0] if row else None
                if local_pl_id is not None:
                    recorder.record_action_trigger(local_pl_id, 'screenshot')
                    self.status_var.set('Queued screenshot action')
                else:
                    # Fallback to immediate capture if no local playlist id
                    self.screenshot_manager.capture_manual_screenshot()
            else:
                self.screenshot_manager.capture_manual_screenshot()
        except Exception:
            try:
                # As a safe fallback
                self.screenshot_manager.capture_manual_screenshot()
            except Exception:
                pass

    def on_analyze_supporting_documents(self):
        """Analyze screenshot for supporting document links using OpenAI and perform automated clicking."""
        try:
            # If recording, enqueue an action trigger instead of executing immediately
            if getattr(self, 'recording', False):
                import recorder
                # Use local SQLite playlist id for recording
                local_pl_id = getattr(self, 'current_local_playlist_id', None)
                if local_pl_id is None:
                    import db
                    conn = db.get_connection()
                    cur = conn.cursor()
                    cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                    row = cur.fetchone()
                    conn.close()
                    local_pl_id = row[0] if row else None
                # If still not found, proactively ensure a local playlist row exists (like start_recording)
                if local_pl_id is None:
                    try:
                        import db
                        from datetime import datetime as _dt
                        conn = db.get_connection()
                        cur = conn.cursor()
                        cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                        row = cur.fetchone()
                        if not row:
                            cur.execute('INSERT INTO Playlists (name, created_date) VALUES (?, ?)', (self.selected_playlist.get(), _dt.now().isoformat()))
                            conn.commit()
                            cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                            row = cur.fetchone()
                        conn.close()
                        local_pl_id = row[0] if row else None
                        try:
                            # Cache for subsequent triggers during this recording session
                            if local_pl_id is not None:
                                self.current_local_playlist_id = local_pl_id
                        except Exception:
                            pass
                    except Exception:
                        local_pl_id = None

                if local_pl_id is not None:
                    # Record an analyze_docs trigger to be saved with the session
                    recorder.record_action_trigger(local_pl_id, 'analyze_docs')
                    self.status_var.set('Queued Analyze Docs action')
                else:
                    # Do not execute during recording; just inform the user
                    self.status_var.set('Could not queue Analyze Docs: select a playlist and try again')
            else:
                self.status_var.set('Starting OpenAI analysis for supporting documents...')
                # No playlist required - analyze current screen directly
                # Only run the per-link Get Supporting Docs inside the analyzer loop
                self.screenshot_manager.capture_and_analyze_supporting_documents()
            
        except Exception as e:
            self.status_var.set(f'Error: {str(e)}')
            logging.error(f"Error in on_analyze_supporting_documents: {e}")

    def on_get_supporting_documents(self):
        """Trigger the dedicated Get Supporting Docs flow.
        
        - If recording, record a 'support_docs' action trigger for playback
        - Else, run immediately using app_id routing (Xero vs generic)
        """
        try:
            if getattr(self, 'recording', False):
                import recorder
                # Resolve local SQLite playlist id (same pattern as analyze_docs)
                local_pl_id = getattr(self, 'current_local_playlist_id', None)
                if local_pl_id is None:
                    import db
                    conn = db.get_connection()
                    cur = conn.cursor()
                    cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                    row = cur.fetchone()
                    conn.close()
                    local_pl_id = row[0] if row else None
                if local_pl_id is not None:
                    recorder.record_action_trigger(local_pl_id, 'support_docs')
                    self.status_var.set('Queued Get Supporting Docs action')
                else:
                    # Fallback: run immediately
                    try:
                        app_id = getattr(self, 'current_application_id', None) or getattr(self, 'application_id', None)
                        app_id = int(app_id) if app_id is not None else None
                    except Exception:
                        app_id = None
                    if app_id == 6:
                        from .xero_supporting_docs import play_supporting_docs as _xero_play
                        _xero_play(self)
                    else:
                        # Generic capture-and-analyze
                        self.screenshot_manager.capture_and_analyze_supporting_documents()
            else:
                # Not recording: run immediately
                try:
                    app_id = getattr(self, 'current_application_id', None) or getattr(self, 'application_id', None)
                    app_id = int(app_id) if app_id is not None else None
                except Exception:
                    app_id = None
                if app_id == 6:
                    from .xero_supporting_docs import play_supporting_docs as _xero_play
                    _xero_play(self)
                else:
                    # Generic capture-and-analyze
                    self.screenshot_manager.capture_and_analyze_supporting_documents()
        except Exception as e:
            self.status_var.set(f'Error: {str(e)}')
            logging.error(f"Error in on_get_supporting_documents: {e}")

    def on_analyze_sd_icons(self):
        """Analyze screenshot for SD (Supporting Document) icons using OpenAI and perform automated clicking."""
        try:
            # If recording, enqueue an action trigger instead of executing immediately
            if getattr(self, 'recording', False):
                import recorder
                # Use local SQLite playlist id for recording
                local_pl_id = getattr(self, 'current_local_playlist_id', None)
                if local_pl_id is None:
                    import db
                    conn = db.get_connection()
                    cur = conn.cursor()
                    cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                    row = cur.fetchone()
                    conn.close()
                    local_pl_id = row[0] if row else None
                if local_pl_id is not None:
                    # Record an analyze_sd_icons trigger to be saved with the session
                    recorder.record_action_trigger(local_pl_id, 'analyze_sd_icons')
                    self.status_var.set('Queued Analyze SD Icons action')
                else:
                    # Fallback to immediate execution if no local playlist id
                    self.status_var.set('Starting OpenAI analysis for SD icons...')
                    self.screenshot_manager.capture_and_analyze_sd_icons()
            else:
                self.status_var.set('Starting OpenAI analysis for SD icons...')
                # No playlist required - analyze current screen directly
                self.screenshot_manager.capture_and_analyze_sd_icons()
            
        except Exception as e:
            self.status_var.set(f'Error: {str(e)}')
            logging.error(f"Error in on_analyze_sd_icons: {e}")

    def on_search_input(self):
        """Search for a UI element by text/value via OpenAI and optionally type a value."""
        try:
            import tkinter as _tk
        except Exception:
            return

        # Ensure only one dialog stack at a time
        if hasattr(self, '_input_dialog') and self._input_dialog and self._input_dialog.winfo_exists():
            try:
                self._input_dialog.lift()
            except Exception:
                pass
            return

        # First dialog: ask for Search Name (consistent with Input Variable flow)
        name_dlg = None
        try:
            name_dlg = _tk.Toplevel()
            self._input_dialog = name_dlg
            name_dlg.title('Search Name')
            name_dlg.geometry('300x150')
            name_dlg.transient(self)
            name_dlg.attributes('-topmost', True)
            name_dlg.focus_force()
            name_dlg.protocol("WM_DELETE_WINDOW", lambda: self._on_dialog_close(name_dlg))
            sw, sh = name_dlg.winfo_screenwidth(), name_dlg.winfo_screenheight()
            x, y = (sw - 300) // 2, (sh - 150) // 2
            name_dlg.geometry(f'300x150+{x}+{y}')
            name_dlg.update(); name_dlg.deiconify(); name_dlg.lift(); name_dlg.grab_set()

            _tk.Label(name_dlg, text='Search Name:', font=('Segoe UI', 10)).pack(pady=(20,5))
            name_var = _tk.StringVar()
            name_entry = _tk.Entry(name_dlg, textvariable=name_var, font=('Segoe UI', 10))
            name_entry.pack(pady=5, padx=20, fill='x')

            def open_value_dialog(search_name: str):
                val_dlg = None
                try:
                    val_dlg = _tk.Toplevel()
                    self._input_dialog = val_dlg
                    val_dlg.title('Search Value')
                    val_dlg.geometry('300x150')
                    val_dlg.transient(self)
                    val_dlg.attributes('-topmost', True)
                    val_dlg.focus_force()
                    val_dlg.protocol("WM_DELETE_WINDOW", lambda: self._on_dialog_close(val_dlg))
                    sw2, sh2 = val_dlg.winfo_screenwidth(), val_dlg.winfo_screenheight()
                    x2, y2 = (sw2 - 300) // 2, (sh2 - 150) // 2
                    val_dlg.geometry(f'300x150+{x2}+{y2}')
                    val_dlg.update(); val_dlg.deiconify(); val_dlg.lift(); val_dlg.grab_set()

                    _tk.Label(val_dlg, text='Search Value:', font=('Segoe UI', 10)).pack(pady=(20,5))
                    value_var = _tk.StringVar()
                    value_entry = _tk.Entry(val_dlg, textvariable=value_var, font=('Segoe UI', 10))
                    value_entry.pack(pady=5, padx=20, fill='x')

                    def proceed_value():
                        input_value = value_var.get()
                        try:
                            if hasattr(self, '_input_dialog'):
                                delattr(self, '_input_dialog')
                        except Exception:
                            pass
                        val_dlg.destroy()
                        self._run_openai_search_and_optionally_type(search_name.strip(), input_value)

                    def cancel_value():
                        try:
                            if hasattr(self, '_input_dialog'):
                                delattr(self, '_input_dialog')
                        except Exception:
                            pass
                        val_dlg.destroy()

                    btn_frame2 = _tk.Frame(val_dlg)
                    btn_frame2.pack(pady=20, fill='x')
                    _tk.Button(btn_frame2, text='OK', width=10, command=proceed_value).pack(side='left', padx=20)
                    _tk.Button(btn_frame2, text='Cancel', width=10, command=cancel_value).pack(side='right', padx=20)

                    def _enter2(e):
                        proceed_value()
                    value_entry.bind('<Return>', _enter2)
                    value_entry.focus_set(); value_entry.select_range(0, 'end')
                except Exception:
                    try:
                        if val_dlg and val_dlg.winfo_exists():
                            val_dlg.destroy()
                    except Exception:
                        pass

            def proceed_name():
                query_text = (name_var.get() or '').strip()
                if not query_text:
                    try:
                        from tkinter import messagebox as _mb
                        _mb.showerror('Error', 'Please enter a search name')
                    except Exception:
                        pass
                    return
                try:
                    if hasattr(self, '_input_dialog'):
                        delattr(self, '_input_dialog')
                except Exception:
                    pass
                name_dlg.destroy()
                open_value_dialog(query_text)

            def cancel_name():
                try:
                    if hasattr(self, '_input_dialog'):
                        delattr(self, '_input_dialog')
                except Exception:
                    pass
                name_dlg.destroy()

            btn_frame = _tk.Frame(name_dlg)
            btn_frame.pack(pady=20, fill='x')
            _tk.Button(btn_frame, text='OK', width=10, command=proceed_name).pack(side='left', padx=20)
            _tk.Button(btn_frame, text='Cancel', width=10, command=cancel_name).pack(side='right', padx=20)

            def _enter(e):
                proceed_name()
            name_entry.bind('<Return>', _enter)
            name_entry.focus_set(); name_entry.select_range(0, 'end')
        except Exception:
            try:
                if name_dlg and name_dlg.winfo_exists():
                    name_dlg.destroy()
            except Exception:
                pass

    def _run_openai_search_and_optionally_type(self, query_text: str, input_value: str | None):
        """Take screenshot, find coordinates for query_text, click, type input_value if provided, and record during recording."""
        try:
            from .openai_analyzer import OpenAIAnalyzer
            import pyautogui as _pg
            import time as _time
            import recorder as _rec
            import os as _os
        except Exception:
            return

        # Capture a full-resolution screenshot for best detection
        try:
            self.show_loader('Searching on screen...')
        except Exception:
            pass
        try:
            analyzer = self.screenshot_manager.openai_analyzer if hasattr(self, 'screenshot_manager') else OpenAIAnalyzer(self)
            screenshot_path = analyzer.capture_full_resolution_screenshot()
            if not screenshot_path:
                try:
                    self.status_var.set('Failed to capture screenshot')
                except Exception:
                    pass
                return
            # Use ONLY the search value to locate the element; do not fall back to name
            if not (isinstance(input_value, str) and input_value.strip()):
                try:
                    self.status_var.set('Please enter a search value')
                except Exception:
                    pass
                return
            query_to_find = input_value.strip()
            used_value_for_search = True
            # Single entry point: pass optional row_hint to analyzer
            row_hint = (query_text or "").strip()
            generic_hints = {"amount", "value", "amount value", "amounts"}
            if row_hint and row_hint.lower() in generic_hints:
                row_hint = None
            try:
                from .constants import USE_AWS_REKOGNITION_FOR_SEARCH
            except Exception:
                USE_AWS_REKOGNITION_FOR_SEARCH = False
            if USE_AWS_REKOGNITION_FOR_SEARCH:
                try:
                    self.logger.info("Search engine: AWS Rekognition (flag enabled)")
                except Exception:
                    pass
                # Disable 'first match' early-return for generic search so we don't click headers/tab areas.
                # Supporting-docs flows can still use first-match for speed/UX.
                _prev_first = _os.getenv('REKOGNITION_FIRST_MATCH')
                try:
                    _os.environ['REKOGNITION_FIRST_MATCH'] = '0'
                    coords = analyzer.find_text_coordinates_rekognition(screenshot_path, query_to_find)
                finally:
                    try:
                        if _prev_first is None:
                            del _os.environ['REKOGNITION_FIRST_MATCH']
                        else:
                            _os.environ['REKOGNITION_FIRST_MATCH'] = _prev_first
                    except Exception:
                        pass
            else:
                try:
                    self.logger.info("Search engine: OpenAI Vision (flag disabled)")
                except Exception:
                    pass
                coords = analyzer.find_text_coordinates(screenshot_path, query_to_find, row_hint=row_hint)
        finally:
            try:
                self.hide_loader()
            except Exception:
                pass

        if not coords or not isinstance(coords, (list, tuple)) or len(coords) != 2:
            try:
                self.status_var.set('No match found')
            except Exception:
                pass
            return

        x, y = int(coords[0]), int(coords[1])
        # Move and click with app temporarily hidden
        try:
            try:
                restore = analyzer._temporarily_hide_app() if analyzer else (lambda: None)
            except Exception:
                restore = (lambda: None)
            try:
                _pg.moveTo(x, y, duration=0.4)
                _pg.click(x, y)
            finally:
                try:
                    restore()
                except Exception:
                    pass
        except Exception:
            pass

        # If recording, record a 'search' trigger so playback can repeat the same behavior,
        # but DO NOT save the click that we just performed.
        try:
            if self.recording:
                # Record a search trigger with payload {query, value}
                try:
                    import json as _json
                    import recorder as _rec2
                    local_pl_id_for_trigger = getattr(self, 'current_local_playlist_id', None)
                    if local_pl_id_for_trigger is None:
                        try:
                            import db as _db2
                            conn = _db2.get_connection(); cur = conn.cursor()
                            cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                            row = cur.fetchone(); conn.close()
                            local_pl_id_for_trigger = row[0] if row else None
                        except Exception:
                            local_pl_id_for_trigger = None
                    if local_pl_id_for_trigger is not None:
                        payload = _json.dumps({
                            'query': query_text,
                            'value': input_value
                        })
                        _rec2.record_action_trigger(local_pl_id_for_trigger, 'search', payload=payload)
                except Exception:
                    pass
                # Type the value if provided and we did NOT already use it as the search target
                if input_value and not used_value_for_search:
                    try:
                        _pg.typewrite(str(input_value), interval=0.02)
                    except Exception:
                        pass
        except Exception:
            pass

        try:
            self.status_var.set(f"Found and clicked '{query_to_find}'")
        except Exception:
            pass

    def analyze_screenshot_with_openai(self, screenshot_path, user_prompt="Describe this screenshot."):
        """Analyze screenshot with OpenAI - delegates to screenshot manager."""
        return self.screenshot_manager.analyze_screenshot_with_openai(screenshot_path, user_prompt)

    # Auto-extractor Methods
    def start_auto_extractor_watcher(self):
        """Start auto-extractor watcher - delegates to auto-extractor manager."""
        self.auto_extractor_manager.start_watcher()

    def check_auto_extractor_queue_once(self):
        """Check auto-extractor queue once - delegates to auto-extractor manager."""
        self.auto_extractor_manager.check_queue_once()

    def check_subplaylist_queue_once(self):
        """Check subplaylist queue once - delegates to subplaylist processor."""
        self.subplaylist_processor.check_queue_once()

    # Utility Methods
    def toggle_debug_view(self):
        """Toggle debug view (currently disabled)."""
        return

    def show_text_popup(self, title, text):
        """Show text popup (currently disabled)."""
        return

    def debug_current_playlist_actions(self):
        """Debug method to check action saving for current playlist."""
        pl_name = self.selected_playlist.get()
        if pl_name and pl_name != 'Select playlist':
            self.playlist_manager.debug_action_saving(pl_name)
        else:
            self.logger.info("No playlist selected for debugging")
            
    def on_add_subplaylist_action(self):
        """Delegate Get Support Docs (subplaylist action) to helper module during recording."""
        try:
            from .xero_supporting_docs import add_subplaylist_action
        except Exception:
            # Fallback: simple status message
            try:
                self.status_var.set('Support Docs module not available')
            except Exception:
                pass
            return

        # Delegate to the helper (records action only during recording)
        add_subplaylist_action(self)

    def on_variable_input_continue(self):
        """Handle continue button click during variable input."""
        if hasattr(self, 'variable_input_state') and self.variable_input_state:
            if self.variable_input_state['step'] == 'selecting_input':
                self.variable_input_state['step'] = 'selecting_continue'
                self.status_var.set('Click where to continue after search')
                self.create_widgets()  # Refresh UI to remove Continue button
                
    def on_input_variable(self):
        """Handle input variable button click."""
        logging.info("Starting input variable sequence")
        
        if not self.recording:
            self.status_var.set('Must be recording to add input variable')
            logging.info("Not recording - variable input aborted")
            return
            
        if self.variable_input_state:
            self.status_var.set('Already in variable input mode')
            logging.info("Already in variable input mode")
            return
            
        # Initialize variable input state
        self.variable_input_state = {
            'step': 'naming',  # naming -> value_entry -> selecting_input
            'variable_name': None,
            'input_location': None,
            'test_value': None
        }
        
        # Check if dialog already exists
        if hasattr(self, '_input_dialog') and self._input_dialog and self._input_dialog.winfo_exists():
            self._input_dialog.lift()
            return
            
        dialog = None
        try:
            # Create a dialog window
            logging.info("Creating dialog window")
            # Create the dialog
            dialog = tk.Toplevel()
            self._input_dialog = dialog  # Store reference to prevent multiple dialogs
            dialog.title('Add Input Variable')
            dialog.geometry('300x150')
            
            # Make it modal and keep on top
            dialog.transient(self)
            dialog.attributes('-topmost', True)
            dialog.focus_force()  # Force focus to the dialog
            
            # Prevent dialog from being destroyed when losing focus
            dialog.protocol("WM_DELETE_WINDOW", lambda: self._on_dialog_close(dialog))
            
            # Center the dialog on screen
            logging.info("Centering dialog")
            screen_width = dialog.winfo_screenwidth()
            screen_height = dialog.winfo_screenheight()
            x = (screen_width - 300) // 2
            y = (screen_height - 150) // 2
            dialog.geometry(f'300x150+{x}+{y}')
            
            # Ensure dialog is shown and ready
            dialog.update()
            dialog.deiconify()
            dialog.lift()
            
            logging.info("Setting dialog as modal")
            dialog.grab_set()
        except Exception as e:
            logging.error(f"Error creating dialog: {e}")
            if dialog and dialog.winfo_exists():
                dialog.destroy()
            return
        
        # Variable name entry
        tk.Label(dialog, text='Variable Name:', font=('Segoe UI', 10)).pack(pady=(20,5))
        name_var = tk.StringVar()
        name_entry = tk.Entry(dialog, textvariable=name_var, font=('Segoe UI', 10))
        name_entry.pack(pady=5, padx=20, fill='x')
        
        def open_value_dialog(var_name):
            """Open the second dialog to capture a test value for the variable."""
            try:
                # Create value dialog, reuse _input_dialog reference for click-ignore
                val_dialog = tk.Toplevel()
                self._input_dialog = val_dialog
                val_dialog.title('Variable Value')
                val_dialog.geometry('300x150')
                val_dialog.transient(self)
                val_dialog.attributes('-topmost', True)
                val_dialog.focus_force()
                val_dialog.protocol("WM_DELETE_WINDOW", lambda: self._on_dialog_close(val_dialog))
                screen_width = val_dialog.winfo_screenwidth()
                screen_height = val_dialog.winfo_screenheight()
                x = (screen_width - 300) // 2
                y = (screen_height - 150) // 2
                val_dialog.geometry(f'300x150+{x}+{y}')
                val_dialog.update(); val_dialog.deiconify(); val_dialog.lift(); val_dialog.grab_set()

                tk.Label(val_dialog, text='Variable Value:', font=('Segoe UI', 10)).pack(pady=(20,5))
                value_var = tk.StringVar()
                value_entry = tk.Entry(val_dialog, textvariable=value_var, font=('Segoe UI', 10))
                value_entry.pack(pady=5, padx=20, fill='x')

                def on_value_ok():
                    test_value = value_var.get()
                    # Save into state and move to selecting_input (both app and recorder shared state)
                    self.variable_input_state['variable_name'] = var_name
                    self.variable_input_state['test_value'] = test_value
                    self.variable_input_state['step'] = 'selecting_input'
                    # Set recorder shared state to ensure listener sees it
                    try:
                        import recorder
                        recorder.set_variable_target_mode(var_name, test_value)
                        # Add a 'loop' trigger action so playback knows to iterate rows for this variable
                        try:
                            # Resolve local playlist id
                            local_pl_id = getattr(self, 'current_local_playlist_id', None)
                            if local_pl_id is None:
                                import db as _db
                                conn = _db.get_connection(); cur = conn.cursor()
                                cur.execute('SELECT id FROM Playlists WHERE name = ?', (self.selected_playlist.get(),))
                                row = cur.fetchone(); conn.close()
                                local_pl_id = row[0] if row else None
                            if local_pl_id is not None:
                                recorder.record_action_trigger(local_pl_id, 'loop', payload=var_name)
                        except Exception:
                            pass
                    except Exception:
                        pass
                    self.status_var.set('Click where to insert variable value')
                    try:
                        import logging as _logging
                        _logging.info(f"[variable] Armed for click: name='{var_name}', test_value_len={len(test_value) if test_value else 0}")
                    except Exception:
                        pass
                    # Close dialog
                    if hasattr(self, '_input_dialog'):
                        delattr(self, '_input_dialog')
                    val_dialog.destroy()
                    # Briefly lower topmost to ensure the browser receives the next click
                    try:
                        self.attributes('-topmost', False)
                        self.after(700, lambda: self.attributes('-topmost', True))
                    except Exception:
                        pass

                def on_value_cancel():
                    if hasattr(self, '_input_dialog'):
                        delattr(self, '_input_dialog')
                    self.variable_input_state = None
                    self.status_var.set('Recording')
                    val_dialog.destroy()

                btn_frame2 = tk.Frame(val_dialog)
                btn_frame2.pack(pady=20, fill='x')
                ttk_ok = tk.Button(btn_frame2, text='OK', command=on_value_ok, width=10)
                ttk_ok.pack(side='left', padx=20)
                ttk_cancel = tk.Button(btn_frame2, text='Cancel', command=on_value_cancel, width=10)
                ttk_cancel.pack(side='right', padx=20)

                def on_value_enter(event):
                    on_value_ok()
                value_entry.bind('<Return>', on_value_enter)
                val_dialog.lift(); val_dialog.focus_force(); value_entry.focus_set(); value_entry.select_range(0, 'end')
            except Exception as e:
                try:
                    logging.error(f"Error creating value dialog: {e}")
                except Exception:
                    pass
                try:
                    val_dialog.destroy()
                except Exception:
                    pass

        def on_ok():
            logging.info("OK button clicked")
            var_name = name_var.get().strip()
            if not var_name:
                # Show error message if no name entered
                logging.info("No variable name entered - showing error")
                from tkinter import messagebox
                messagebox.showerror('Error', 'Please enter a variable name')
                return
            # Close name dialog and open value dialog
            try:
                if hasattr(self, '_input_dialog'):
                    delattr(self, '_input_dialog')
            except Exception:
                pass
            dialog.destroy()
            open_value_dialog(var_name)
            
        def on_cancel():
            logging.info("Cancel button clicked")
            # Clean up dialog
            if hasattr(self, '_input_dialog'):
                delattr(self, '_input_dialog')
            # Reset variable input state
            self.variable_input_state = None
            self.status_var.set('Recording')
            dialog.destroy()
            
        def on_enter(event):
            logging.info("Enter key pressed")
            # Only proceed if Enter is pressed and there is text
            if name_var.get().strip():
                logging.info("Enter key with text - calling on_ok")
                on_ok()
                
        # Buttons
        logging.info("Creating buttons")
        button_frame = tk.Frame(dialog)
        button_frame.pack(pady=20, fill='x')
        ok_button = tk.Button(button_frame, text='OK', command=on_ok, width=10)
        ok_button.pack(side='left', padx=20)
        cancel_button = tk.Button(button_frame, text='Cancel', command=on_cancel, width=10)
        cancel_button.pack(side='right', padx=20)
        
        # Bind Enter key to OK action
        logging.info("Binding Enter key")
        name_entry.bind('<Return>', on_enter)
        
        # Make dialog modal and give it focus
        logging.info("Setting focus and making dialog modal")
        dialog.lift()  # Ensure dialog is on top
        dialog.focus_force()  # Force focus to dialog window
        name_entry.focus_set()  # Then set focus to entry
        name_entry.select_range(0, 'end')  # Select any existing text
        
        # Wait for dialog
        dialog.grab_set()
        dialog.wait_window()
        
    def on_cancel_playlist(self):
        """Cancel the current playlist run, reset UI/app state, and mark DI upload as canceled (processed=5)."""
        try:
            import logging as _logging
            _logging.info("[Cancel] Cancel requested by user")
        except Exception:
            pass

        # Mark the associated direct integration upload as canceled (processed=5)
        try:
            upload_id = getattr(self, 'current_direct_upload_id', None) or getattr(self, 'user_interaction_upload_id', None)
            if upload_id:
                from mysql.mysql_client import set_direct_upload_status
                set_direct_upload_status(int(upload_id), 5, error_message='Canceled by user')
        except Exception as e:
            try:
                self.logger.error(f"[Cancel] Failed to mark direct upload canceled: {e}")
            except Exception:
                pass

        # Perform common reset to waiting state
        self._reset_app_after_workflow()
        
    def _reset_app_after_workflow(self):
        """Reset the app to the waiting state (same as cancel, without changing DI status)."""
        # Ensure any loader is hidden to avoid UI lock perception
        try:
            self.hide_loader()
        except Exception:
            pass
        # Stop any recording/playback and reset status (skip save to avoid long DB work)
        try:
            self.status_var.set('Idle')
        except Exception:
            pass
        # Clear playback activity flag so Play re-enables
        try:
            self.is_playback_active = False
        except Exception:
            pass
        try:
            # This will also clear input dialog state
            self.recording_manager.stop_recording(skip_save=True)
        except Exception:
            pass

        # Clear direct integration and playback state
        try:
            for attr in [
                'current_direct_upload_id', 'user_interaction_upload_id', 'user_interaction_mode',
                'variable_input_data', 'current_application_id', 'current_application_name',
                'current_company_id', 'playlist_actions_source', 'current_data_row',
                'current_playlist_mode',
            ]:
                if hasattr(self, attr):
                    setattr(self, attr, None if attr != 'user_interaction_mode' else False)
        except Exception:
            pass

        # Ensure admin-related flags are cleared between runs
        try:
            setattr(self, 'current_is_admin', False)
        except Exception:
            pass

        # Clear Sub playlist checkbox
        try:
            if hasattr(self, 'hide_on_subplaylist_var') and self.hide_on_subplaylist_var is not None:
                self.hide_on_subplaylist_var.set(False)
        except Exception:
            pass

        # Reset playlist selection UI
        try:
            if hasattr(self, 'selected_playlist') and self.selected_playlist:
                # Force empty to trigger 'no playlist' state in UI
                try:
                    self.selected_playlist.set('')
                except Exception:
                    self.selected_playlist.set('Select playlist')
            # Clear available playlist list to hide dropdown in DI reset state
            try:
                self.playlists = []
            except Exception:
                pass
            if hasattr(self, 'playlist_dropdown') and self.playlist_dropdown:
                try:
                    self.playlist_dropdown.set('Select playlist')
                except Exception:
                    pass
        except Exception:
            pass

        # Reset role back to user after workflow ends
        try:
            self.ui_role = 'user'
        except Exception:
            pass

        # Rebuild widgets to reflect reset state on the main UI loop
        try:
            self.after(0, self.create_widgets)
        except Exception:
            try:
                self.create_widgets()
            except Exception:
                pass

        # Restart direct integration watcher to look for next job
        try:
            if hasattr(self, 'direct_integration_watcher') and self.direct_integration_watcher:
                self.direct_integration_watcher.restart_polling()
            else:
                from .direct_integration import DirectIntegrationWatcher
                self.direct_integration_watcher = DirectIntegrationWatcher(self)
                self.direct_integration_watcher.start()
        except Exception:
            pass
        # Run heavy cleanup tasks in background to avoid freezing UI
        try:
            import threading as _th
            def _bg_cleanup():
                try:
                    self._close_browser_processes()
                except Exception:
                    pass
                try:
                    self._close_desktop_app_processes()
                except Exception:
                    pass
                try:
                    self.archive_session_artifacts_to_s3()
                except Exception:
                    pass
                # Clear any remaining files in the Chrome downloads folder on cancel/reset
                try:
                    from pathlib import Path as _Path
                    import os as _os
                    dl_dir = _Path(CHROME_DOWNLOADS_FOLDER)
                    if dl_dir.exists() and dl_dir.is_dir():
                        for p in list(dl_dir.iterdir()):
                            try:
                                if not p.is_file():
                                    continue
                                # Skip temp/incomplete Chrome files
                                if p.name.endswith('.crdownload') or p.name.startswith('~$'):
                                    continue
                                p.unlink()
                            except Exception:
                                # Best-effort cleanup; continue with other files
                                pass
                except Exception:
                    pass
            _th.Thread(target=_bg_cleanup, daemon=True).start()
        except Exception:
            pass
    def cleanup(self):
        """Clean up resources before closing."""
        self.logger.info("Starting application cleanup")
        
        # Stop the direct integration watcher if it exists
        if hasattr(self, 'direct_integration_watcher') and self.direct_integration_watcher:
            try:
                self.direct_integration_watcher.stop()
                self.logger.info("Stopped DirectIntegrationWatcher")
            except Exception as e:
                self.logger.error(f"Error stopping DirectIntegrationWatcher: {e}")
        
        # Stop the Chrome downloads manager if it exists
        if hasattr(self, 'chrome_downloads_manager') and self.chrome_downloads_manager:
            try:
                self.chrome_downloads_manager.stop()
                self.logger.info("Stopped ChromeDownloadsManager")
            except Exception as e:
                self.logger.error(f"Error stopping ChromeDownloadsManager: {e}")
        
        # Reset singleton state
        self.__class__._instance = None
        self.initialized = False
        
        # Clean up other resources as needed
        self.logger.info("Cleanup complete, destroying window")
        try:
            # Final archival on app cleanup
            self.archive_session_artifacts_to_s3()
        except Exception:
            pass
        super().destroy()

    # --- Session archival (logs + screenshots) ---------------------------------
    def _build_session_folder_name(self) -> str:
        try:
            app_name = (getattr(self, 'current_application_name', None) or 'app').strip().replace(' ', '_')
        except Exception:
            app_name = 'app'
        try:
            playlist = (self.selected_playlist.get() if hasattr(self, 'selected_playlist') else None) or 'playlist'
            playlist = str(playlist).strip().replace(' ', '_') or 'playlist'
        except Exception:
            playlist = 'playlist'
        ts = int(time.time() * 1000)
        return f"{app_name}/{playlist}/{ts}"

    def archive_session_artifacts_to_s3(self):
        """Upload logs/ and screenshots/ to S3 under a per-session prefix and then delete local files.

        S3 layout: s3://{SESSION_LOGS_S3_BUCKET}/{SESSION_LOGS_S3_PREFIX}{app}/{playlist}/{timestamp}/...
        """
        try:
            session_folder = self._build_session_folder_name()
            bucket = SESSION_LOGS_S3_BUCKET
            prefix = SESSION_LOGS_S3_PREFIX.rstrip('/') + '/' + session_folder
            s3 = boto3.client('s3', region_name=AWS_REGION)

            def _upload_tree(root_dir: str, key_base: str):
                import os as _os
                for _dirpath, _dirnames, _filenames in _os.walk(root_dir):
                    for _fname in _filenames:
                        try:
                            local_path = _os.path.join(_dirpath, _fname)
                            rel = _os.path.relpath(local_path, root_dir)
                            key = f"{key_base}/{rel.replace('\\', '/')}"
                            s3.upload_file(local_path, bucket, key)
                        except Exception:
                            # Continue on best-effort
                            pass

            # Upload logs and screenshots if directories exist
            try:
                _upload_tree('logs', f"{prefix}/logs")
            except Exception:
                pass
            try:
                _upload_tree('screenshots', f"{prefix}/screenshots")
            except Exception:
                pass

            # Cleanup local artifacts after upload (skip cleanup in LOCAL_DEV so logs remain for inspection)
            try:
                from .constants import LOCAL_DEV
            except Exception:
                LOCAL_DEV = True
            if not LOCAL_DEV:
                try:
                    import shutil as _sh
                    # Screenshots can be safely removed entirely
                    if os.path.isdir('screenshots'):
                        _sh.rmtree('screenshots', ignore_errors=True)
                        os.makedirs('screenshots', exist_ok=True)
                    # For logs, avoid deleting the folder while log handlers are active; truncate key files instead
                    try:
                        # Truncate common log files after archival to keep disk usage low on Linux servers
                        for _log_path in [
                            'logs/app_debug.log',
                            'logs/openai.log',
                            'auto_clicker/auto_clicker.log',  # legacy path kept for safety
                            'auto_clicker.log',               # root-level file often used by shell/systemd redirection
                            'mysql_debug.log',
                            'clickrecorder.log',
                            'autoclicker_rekognition.log',
                            'supabase_debug.log',
                        ]:
                            if os.path.isfile(_log_path):
                                with open(_log_path, 'w', encoding='utf-8') as _f:
                                    _f.truncate(0)
                    except Exception:
                        pass
                    # Optionally clear verbose debug subdirs
                    try:
                        if os.path.isdir('logs/openai_debug'):
                            _sh.rmtree('logs/openai_debug', ignore_errors=True)
                            os.makedirs('logs/openai_debug', exist_ok=True)
                    except Exception:
                        pass
                except Exception:
                    pass

            try:
                self.logger.info(f"Archived session artifacts to s3://{bucket}/{prefix}/ and cleared local files")
            except Exception:
                pass
        except Exception as e:
            try:
                self.logger.error(f"Session archival failed: {e}")
            except Exception:
                pass

    def upload_supporting_documents_to_openai_s3(self):
        """Upload analyzer screenshots and local downloads to per-user OpenAI S3 folder.

        Destination layout:
        - Bucket: big-pond-openai
        - Key prefix: openai/{AWS_REGION}:{<userId>}/supporting_documents/
        - Filename: <timestamp>_<original_filename>

        After successful upload, local files are deleted.
        """
        try:
            if LOCAL_DEV:
                try:
                    self.logger.info("[SupportingDocs] LOCAL_DEV=True, skipping S3 upload (will NOT clear downloads; clearing analyzer screenshots only)")
                except Exception:
                    pass
                # Best-effort local cleanup to mirror post-upload behavior (screenshots only)
                try:
                    import os as _os
                    import shutil as _sh
                    # Clear analyzer screenshots only; preserve local downloads on LOCAL_DEV
                    for d in ['screenshots/supporting_documents', 'screenshots/openai_analysis']:
                        try:
                            if _os.path.isdir(d):
                                _sh.rmtree(d, ignore_errors=True)
                                _os.makedirs(d, exist_ok=True)
                        except Exception:
                            pass
                except Exception:
                    pass
                return

            try:
                user_uuid = getattr(self, 'current_user_id', None) or ''
            except Exception:
                user_uuid = ''

            bucket = 'big-pond-openai'
            # Derive invoice folder from first value in current Excel row (e.g., invoice number)
            invoice_folder = None
            try:
                data = getattr(self, 'variable_input_data', None)
                current_row = getattr(self, 'current_data_row', 0)
                if isinstance(data, list) and 0 <= int(current_row) < len(data):
                    row = data[int(current_row)]
                    folder_candidate = None
                    if isinstance(row, dict) and row:
                        # Prefer the first non-empty value in the row (preserves Excel column order)
                        try:
                            for _v in row.values():
                                if _v is not None and str(_v).strip():
                                    folder_candidate = str(_v).strip()
                                    break
                        except Exception:
                            folder_candidate = None
                        # Fallback to common invoice/reference field names
                        if not folder_candidate:
                            for _k in ['invoice', 'invoiceNumber', 'invoice_no', 'invoice_num', 'ref', 'reference']:
                                try:
                                    if _k in row and row[_k] is not None and str(row[_k]).strip():
                                        folder_candidate = str(row[_k]).strip()
                                        break
                                except Exception:
                                    pass
                    if folder_candidate:
                        import re as _re
                        _s = _re.sub(r'[^A-Za-z0-9 _.-]', '_', folder_candidate).strip()
                        _s = _s.replace('/', '_')
                        if _s:
                            invoice_folder = _s[:64]
            except Exception:
                invoice_folder = None

            # Build step folder same as screenshot uploads do: Step{number}-{description}
            step_folder = None
            try:
                import re as _re
                step_details = getattr(self, 'current_di_step_details', None) or {}
                try:
                    step_number = int(step_details.get('stepNumber') or 1)
                except Exception:
                    step_number = 1
                raw_desc = step_details.get('description') or 'step'
                safe_desc = _re.sub(r'[^A-Za-z0-9 _.-]', '_', str(raw_desc)).strip() or 'step'
                step_folder = f"Step{step_number}-{safe_desc}"
            except Exception:
                step_folder = None

            base_prefix = f"openai/{AWS_REGION}:{user_uuid}"
            if step_folder:
                base_prefix = f"{base_prefix}/{step_folder}/supporting_documents"
            else:
                base_prefix = f"{base_prefix}/supporting_documents"
            if invoice_folder:
                base_prefix = f"{base_prefix}/{invoice_folder}"
            s3 = boto3.client('s3', region_name=AWS_REGION)

            def _upload_dir_files(local_dir: str, subfolder: str | None = None):
                import os as _os
                import time as _t
                if not _os.path.isdir(local_dir):
                    return
                for _name in list(_os.listdir(local_dir)):
                    # Heartbeat periodically during potentially long directory uploads
                    try:
                        hb_interval = float(os.getenv("HB_INTERVAL_SEC", "60"))
                        self.send_heartbeat_throttled(min_interval_sec=hb_interval)
                    except Exception:
                        pass
                    local_path = _os.path.join(local_dir, _name)
                    if not _os.path.isfile(local_path):
                        continue
                    try:
                        ts = int(_t.time() * 1000)
                        key_prefix = base_prefix if not subfolder else f"{base_prefix}/{subfolder.strip('/')}"
                        key = f"{key_prefix}/{ts}_{_name}"
                        s3.upload_file(local_path, bucket, key)
                        try:
                            self.logger.info(f"[SupportingDocs] Uploaded {local_path} -> s3://{bucket}/{key}")
                        except Exception:
                            pass
                        try:
                            _os.remove(local_path)
                        except Exception:
                            pass
                    except Exception:
                        # Continue best-effort for other files
                        pass

            # 1) Upload ONLY the last analyzer screenshot PER analyzed document into the same folder
            try:
                import os as _os
                from pathlib import Path as _Path
                import re as _re
                sd_dir = _Path('screenshots/supporting_documents')
                if sd_dir.exists() and sd_dir.is_dir():
                    # Group by link index based on filename pattern: supporting_doc_{index}_...
                    image_paths = [p for p in sd_dir.iterdir() if p.is_file() and p.suffix.lower() in {'.png', '.jpg', '.jpeg'}]
                    latest_by_index: dict[str, _Path] = {}
                    for p in image_paths:
                        try:
                            m = _re.match(r'^supporting_doc_(\d+)_', p.name)
                            idx = m.group(1) if m else None
                            # Fallback group key when no index detected: use full name to avoid collisions
                            key_idx = idx if idx is not None else f"_noindex_{p.name.split('_')[0]}"
                            prev = latest_by_index.get(key_idx)
                            if prev is None or p.stat().st_mtime > prev.stat().st_mtime:
                                latest_by_index[key_idx] = p
                        except Exception:
                            pass

                    # Upload each latest per index
                    for _k, latest in latest_by_index.items():
                        try:
                            import time as _t
                            ts = int(_t.time() * 1000)
                            key = f"{base_prefix}/{ts}_{latest.name}"
                            s3.upload_file(str(latest), bucket, key)
                            try:
                                self.logger.info(f"[SupportingDocs] Uploaded last-per-doc analyzer screenshot {latest} -> s3://{bucket}/{key}")
                            except Exception:
                                pass
                            try:
                                _os.remove(str(latest))
                            except Exception:
                                pass
                        except Exception:
                            pass

                    # Delete any remaining analyzer screenshots in the folder
                    try:
                        for p in list(sd_dir.iterdir()):
                            try:
                                if p.is_file():
                                    _os.remove(str(p))
                            except Exception:
                                pass
                    except Exception:
                        pass
            except Exception:
                pass

            # 2) Upload Chrome downloads into the SAME folder (no 'downloads' subfolder)
            try:
                import os as _os
                from pathlib import Path as _Path
                dl_dir = _Path(CHROME_DOWNLOADS_FOLDER)
                if dl_dir.exists() and dl_dir.is_dir():
                    for p in list(dl_dir.iterdir()):
                        # Heartbeat periodically while scanning downloads
                        try:
                            hb_interval = float(os.getenv("HB_INTERVAL_SEC", "60"))
                            self.send_heartbeat_throttled(min_interval_sec=hb_interval)
                        except Exception:
                            pass
                        try:
                            if not p.is_file():
                                continue
                            # Skip temp/incomplete Chrome files
                            if p.name.endswith('.crdownload') or p.name.startswith('~$'):
                                continue
                            # upload this single file
                            import time as _t
                            ts = int(_t.time() * 1000)
                            key = f"{base_prefix}/{ts}_{p.name}"
                            s3.upload_file(str(p), bucket, key)
                            try:
                                self.logger.info(f"[SupportingDocs] Uploaded download {p} -> s3://{bucket}/{key}")
                            except Exception:
                                pass
                            try:
                                _os.remove(str(p))
                            except Exception:
                                pass
                        except Exception:
                            pass
            except Exception:
                pass

            # 3) Clear cached coordinates and Rekognition caches after S3 upload
            # This ensures new playlists don't reuse stale coordinates from previous runs
            try:
                # Clear app-level cached coordinates
                if hasattr(self, "_cached_attach_files_coords"):
                    delattr(self, "_cached_attach_files_coords")
                
                # Clear analyzer-level cached coordinates and caches
                try:
                    analyzer = getattr(self, 'screenshot_manager', None)
                    analyzer = getattr(analyzer, 'openai_analyzer', None) if analyzer else None
                    if analyzer is not None:
                        # Clear dialog button coordinate caches
                        for attr in (
                            "_dialog_cached_download_screen_coords",
                            "_dialog_cached_save_screen_coords",
                            "_dialog_cached_close_x_screen_coords",
                        ):
                            if hasattr(analyzer, attr):
                                delattr(analyzer, attr)
                        
                        # Clear Rekognition tile cache and related caches
                        for attr2 in (
                            "_last_rekognition_tile_cache",
                            "_dialog_file_cache_built",
                            "_last_rekognition_coords",
                        ):
                            if hasattr(analyzer, attr2):
                                delattr(analyzer, attr2)
                except Exception:
                    pass
                
                try:
                    self.logger.info("[SupportingDocs] Cleared cached coordinates and Rekognition caches after S3 upload")
                except Exception:
                    pass
            except Exception:
                pass

            # 4) Clean up now-empty analyzer directories
            try:
                import shutil as _sh
                import os as _os
                for d in ['screenshots/supporting_documents', 'screenshots/openai_analysis']:
                    if os.path.isdir(d):
                        _sh.rmtree(d, ignore_errors=True)
                        _os.makedirs(d, exist_ok=True)
            except Exception:
                pass

        except Exception as e:
            try:
                self.logger.error(f"[SupportingDocs] Upload flow failed: {e}")
            except Exception:
                pass

    def upload_supporting_doc_item(self, screenshot_path: str | None):
        """Upload a single analyzer screenshot and any current downloads immediately to the per-user step+invoice folder.

        This is used during the per-link loop so each document's evidence lands in the correct invoice folder as we go.
        """
        try:
            if LOCAL_DEV:
                try:
                    self.logger.info("[SupportingDocs:item] LOCAL_DEV=True, skipping S3 upload (will NOT clear downloads; clearing provided analyzer screenshot only)")
                except Exception:
                    pass
                # Local cleanup of provided screenshot only; preserve downloads on LOCAL_DEV
                try:
                    import os as _os
                    if screenshot_path and _os.path.isfile(screenshot_path):
                        try:
                            _os.remove(screenshot_path)
                        except Exception:
                            pass
                except Exception:
                    pass
                return
            # Use centralized uploader to supporting_documents and write manage table
            from .screenshot import ScreenshotManager as _SM
            mgr = getattr(self, 'screenshot_manager', None)
            if not isinstance(mgr, _SM):
                try:
                    mgr = _SM(self)
                except Exception:
                    mgr = None

            base_prefix = None
            bucket = 'big-pond-openai'
            import os as _os
            import time as _t
            if mgr and screenshot_path and _os.path.isfile(screenshot_path):
                try:
                    step_details = getattr(self, 'current_di_step_details', None) or {}
                    res = mgr.upload_supporting_doc_screenshot_to_openai_s3(
                        screenshot_path,
                        step_details=step_details,
                        record_manage_table=True,
                    )
                    if isinstance(res, dict) and res.get('success'):
                        bucket = res.get('s3_bucket') or bucket
                        key_str = res.get('s3_key') or ''
                        try:
                            base_prefix = key_str.rsplit('/', 1)[0] if '/' in key_str else None
                        except Exception:
                            base_prefix = None
                    try:
                        _os.remove(screenshot_path)
                    except Exception:
                        pass
                except Exception:
                    pass

            # Also upload any current downloads into the same folder
            try:
                from pathlib import Path as _Path
                dl_dir = _Path(CHROME_DOWNLOADS_FOLDER)
                if dl_dir.exists() and dl_dir.is_dir():
                    for p in list(dl_dir.iterdir()):
                        try:
                            if not p.is_file():
                                continue
                            if p.name.endswith('.crdownload') or p.name.startswith('~$'):
                                continue
                            ts = int(_t.time() * 1000)
                            # If we do not have a base_prefix from the screenshot upload, fall back to supporting_documents under user/step
                            if not base_prefix:
                                try:
                                    user_uuid = getattr(self, 'current_user_id', None) or ''
                                except Exception:
                                    user_uuid = ''
                                import re as _re
                                try:
                                    step_details = getattr(self, 'current_di_step_details', None) or {}
                                    step_number = int(step_details.get('stepNumber') or 1)
                                    raw_desc = step_details.get('description') or 'step'
                                    safe_desc = _re.sub(r'[^A-Za-z0-9 _.-]', '_', str(raw_desc)).strip() or 'step'
                                    step_folder = f"Step{step_number}-{safe_desc}"
                                except Exception:
                                    step_folder = None
                                base_prefix = f"openai/{AWS_REGION}:{user_uuid}"
                                base_prefix = f"{base_prefix}/{step_folder}/supporting_documents" if step_folder else f"{base_prefix}/supporting_documents"
                            key = f"{base_prefix}/{ts}_{p.name}"
                            s3 = boto3.client('s3', region_name=AWS_REGION)
                            s3.upload_file(str(p), bucket, key)
                            try:
                                self.logger.info(f"[SupportingDocs:item] Uploaded download {p.name} -> s3://{bucket}/{key}")
                            except Exception:
                                pass
                            try:
                                _os.remove(str(p))
                            except Exception:
                                pass
                        except Exception:
                            pass
            except Exception:
                pass
        except Exception as e:
            try:
                self.logger.error(f"[SupportingDocs:item] Failed: {e}")
            except Exception:
                pass

    def _terminate_chrome_processes_if_linux(self):
        """Best-effort termination of Chrome/Chromium processes on Linux servers at startup.
        Skips when running in local development (ENV=local).
        """
        try:
            if not sys.platform.startswith('linux'):
                return
            env = os.getenv('ENV', '').lower()
            if env == 'local':
                try:
                    self.logger.info("[Startup] Skipping Chrome termination in local environment")
                except Exception:
                    pass
                return
            cmds = [
                ["pkill", "-9", "-f", "chrome"],
                ["pkill", "-9", "-f", "chromium"],
                ["pkill", "-9", "-f", "chromedriver"],
                ["killall", "-9", "google-chrome"],
                ["killall", "-9", "chrome"],
                ["killall", "-9", "chromium-browser"],
                ["killall", "-9", "chromedriver"],
            ]
            for cmd in cmds:
                try:
                    subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                except Exception:
                    pass
            try:
                self.logger.info("[Startup] Terminated Chrome/Chromium processes (best effort)")
            except Exception:
                pass
        except Exception as e:
            try:
                self.logger.error(f"[Startup] Chrome termination step failed: {e}")
            except Exception:
                pass

    def _close_browser_processes(self):
        """Best-effort termination of common browser processes (Chrome/Edge/Firefox) across platforms.
        Intended for cleanup at startup and after DI completion/cancel/error.
        """
        try:
            # Skip terminating browsers during local development
            if LOCAL_DEV:
                try:
                    self.logger.info("[Cleanup] LOCAL_DEV=True, skipping browser termination")
                except Exception:
                    pass
                return
            if sys.platform.startswith('win'):  # Windows
                cmds = [
                    ["taskkill", "/F", "/IM", "chrome.exe"],
                    ["taskkill", "/F", "/IM", "msedge.exe"],
                    ["taskkill", "/F", "/IM", "firefox.exe"],
                    ["taskkill", "/F", "/IM", "chromedriver.exe"],
                ]
            elif sys.platform.startswith('linux'):
                cmds = [
                    ["pkill", "-9", "-f", "chrome"],
                    ["pkill", "-9", "-f", "chromium"],
                    ["pkill", "-9", "-f", "chromedriver"],
                    ["pkill", "-9", "-f", "msedge"],
                    ["pkill", "-9", "-f", "firefox"],
                ]
            elif sys.platform == 'darwin':  # macOS
                cmds = [
                    ["killall", "-9", "Google Chrome"],
                    ["killall", "-9", "Microsoft Edge"],
                    ["killall", "-9", "firefox"],
                    ["killall", "-9", "chromedriver"],
                ]
            else:
                cmds = []

            for cmd in cmds:
                try:
                    subprocess.run(cmd, check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                except Exception:
                    pass
            try:
                self.logger.info("[Cleanup] Closed browser processes (best effort)")
            except Exception:
                pass
        except Exception as e:
            try:
                self.logger.error(f"[Cleanup] Browser termination step failed: {e}")
            except Exception:
                pass

    def _close_desktop_app_processes(self):
        """Best-effort termination of desktop applications that this app launched.
        Uses image names recorded in self.launched_desktop_process_names.
        """
        try:
            # Warn if not elevated (may fail to close elevated apps)
            try:
                import ctypes as _ct
                if hasattr(self, 'logger') and _ct.windll.shell32.IsUserAnAdmin() == 0:  # type: ignore[attr-defined]
                    try:
                        self.logger.warning("[Cleanup] Process not elevated; terminating elevated apps may fail. Consider running as Administrator.")
                    except Exception:
                        pass
            except Exception:
                pass
            names = list(getattr(self, 'launched_desktop_process_names', set()) or [])
            paths = list(getattr(self, 'launched_desktop_executable_paths', set()) or [])
            pids = list(getattr(self, 'launched_desktop_pids', set()) or [])
            keywords = [k for k in (getattr(self, 'launched_desktop_name_keywords', set()) or []) if isinstance(k, str) and k]
            if not names and not paths:
                return
            if sys.platform.startswith('win'):
                # Terminate by PID first (kills process tree)
                for pid in pids:
                    try:
                        subprocess.run(["taskkill", "/F", "/T", "/PID", str(int(pid))], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
                # Additionally, terminate any GUI processes under the recorded install folders
                try:
                    dirs = sorted(set(os.path.dirname(p) for p in paths if isinstance(p, str) and p))
                except Exception:
                    dirs = []
                for d in dirs:
                    try:
                        ps_cmd = (
                            "$dir=\"" + d.replace('"','\\"') + "\"; "
                            "$procs=Get-Process | Where-Object { $_.Path -like (\"$dir\\*\") }; "
                            "$procs | ForEach-Object { Stop-Process -Id $_.Id -Force }"
                        )
                        subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
                # Finally, terminate by process name/window title substring keywords
                for kw in keywords:
                    try:
                        ps_cmd = (
                            "$kw=\"" + kw.replace('"','\\"') + "\"; "
                            "$procs=Get-Process | Where-Object { $_.ProcessName -like (\"*${kw}*\") -or $_.MainWindowTitle -like (\"*${kw}*\") -or ($_.Path -and $_.Path -like (\"*\\${kw}\\*\")) }; "
                            "$procs | ForEach-Object { Stop-Process -Id $_.Id -Force }"
                        )
                        subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
                # Broader match using CIM across Name/CommandLine/ExecutablePath per keyword
                for kw in keywords:
                    try:
                        ps_cmd = (
                            "$kw=\"" + kw.replace('"','\\"') + "\"; "
                            "$procs=Get-CimInstance Win32_Process | Where-Object { "
                            "($_.Name -like (\"*${kw}*\")) -or "
                            "($_.CommandLine -and $_.CommandLine -like (\"*${kw}*\")) -or "
                            "($_.ExecutablePath -and $_.ExecutablePath -like (\"*\\${kw}\\*\")) }; "
                            "$procs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
                        )
                        subprocess.run(["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
                # Terminate each recorded image name
                for name in names:
                    exe = name if name.lower().endswith('.exe') else f"{name}.exe"
                    try:
                        subprocess.run(["taskkill", "/F", "/IM", exe], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
                # Additionally, terminate by exact ExecutablePath when available (covers launchers spawning different image names)
                for p in paths:
                    try:
                        ps_cmd = (
                            "Get-CimInstance Win32_Process | "
                            f"Where-Object {{$_.ExecutablePath -eq '{p}'}} | "
                            "ForEach-Object { Stop-Process -Id $_.ProcessId -Force }"
                        )
                        subprocess.run([
                            "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd
                        ], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
                # Heuristic: if this was a Sage launch, also kill known GUI exe names
                try:
                    is_sage = any('sage' in (n or '').lower() for n in names) or any('sage' in (p or '').lower() for p in paths)
                except Exception:
                    is_sage = False
                if is_sage:
                    for exe in ["Sage50Accounts.exe", "ACCOUNTS.exe", "s50run32.exe"]:
                        try:
                            subprocess.run(["taskkill", "/F", "/IM", exe], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                        except Exception:
                            pass
            elif sys.platform.startswith('linux'):
                for name in names:
                    try:
                        subprocess.run(["pkill", "-9", "-f", name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
            elif sys.platform == 'darwin':
                for name in names:
                    try:
                        subprocess.run(["killall", "-9", name], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                    except Exception:
                        pass
            try:
                self.logger.info(f"[Cleanup] Closed desktop apps (best effort): pids={pids}, names={names}, paths={paths}")
            except Exception:
                pass
        finally:
            try:
                # Clear after attempting to close
                self.launched_desktop_process_names = set()
                self.launched_desktop_executable_paths = set()
                self.launched_desktop_pids = set()
                self.launched_desktop_name_keywords = set()
            except Exception:
                pass

