"""
Playback functionality for the auto clicker application.
"""

import threading
import time
import pyautogui
import logging
import os
import sys
import ctypes
from ctypes import wintypes
from .constants import SPECIAL_KEY_MAP
from .manage_file_processing import add_or_update_file_record, add_or_append_screenshot_record, add_screenshot_record
from .constants import SCREENSHOT_S3_BUCKET, SCREENSHOT_S3_PREFIX, AWS_REGION, LOCAL_DEV
import boto3
from .support_docs_router import route_supporting_docs

# Disable PyAutoGUI failsafe to prevent accidental stops
pyautogui.FAILSAFE = False
# Set a small pause between PyAutoGUI actions to prevent issues
pyautogui.PAUSE = 0.1


class PlaybackManager:
    """Manages playback functionality."""
    
    def __init__(self, app):
        self.app = app
        # Playback state flags
        self._is_paused = False
        self._stop_requested = False
        # Condition variable to coordinate pause/resume between UI and playback thread
        self._pause_condition = threading.Condition()
        # Cap per-action wait and enforce a minimum to allow page loads
        self._max_wait_per_action = 3.0  # seconds (upper cap)
        # Make playback feel responsive; a small floor prevents zero-interval thrashing
        self._min_wait_per_action = 0.2  # seconds (minimum wait between actions)
        # Extra pause after the focus clicks before inserting a value
        self._post_click_type_delay = 0.0  # seconds (not used in new flow)
        # Pause after inserting a value before continuing to following actions
        self._post_type_pause = 0.5  # seconds
        # Extra stabilization delay after any click before continuing
        self._focus_delay_after_click = 0.30  # seconds
        # Short settle after OpenAI-driven search clicks before proceeding to next action
        self._post_search_click_stabilize = 0.01  # seconds
        # Track last variable used during playback for contextual screenshots
        self._last_variable_context = {'name': None, 'value': None}
        # Wait before taking a screenshot action to allow pages to finish loading
        self._pre_screenshot_wait = 5.0  # seconds
        # Ensure DPI awareness on Windows so window coordinates match screenshot pixels
        self._ensure_dpi_awareness()
        
        # Get screen dimensions for coordinate validation
        # Prefer runtime-detected size; allow env override when specified or detection fails
        try:
            detected_w, detected_h = pyautogui.size()
        except Exception:
            detected_w, detected_h = 0, 0
        try:
            from .constants import SCREEN_WIDTH as CONF_SCREEN_WIDTH, SCREEN_HEIGHT as CONF_SCREEN_HEIGHT
        except Exception:
            CONF_SCREEN_WIDTH, CONF_SCREEN_HEIGHT = None, None
        use_env = os.getenv("USE_ENV_SCREEN_SIZE", os.getenv("use_env_screen_size", "false")).lower() == "true"
        if use_env and CONF_SCREEN_WIDTH and CONF_SCREEN_HEIGHT:
            self._screen_width, self._screen_height = int(CONF_SCREEN_WIDTH), int(CONF_SCREEN_HEIGHT)
            logging.info(f"[Playback] Using env screen size override: {self._screen_width}x{self._screen_height}")
        elif detected_w and detected_h:
            self._screen_width, self._screen_height = int(detected_w), int(detected_h)
            logging.info(f"[Playback] Using detected screen size: {self._screen_width}x{self._screen_height}")
        elif CONF_SCREEN_WIDTH and CONF_SCREEN_HEIGHT:
            self._screen_width, self._screen_height = int(CONF_SCREEN_WIDTH), int(CONF_SCREEN_HEIGHT)
            logging.info(f"[Playback] Fallback to configured screen size: {self._screen_width}x{self._screen_height}")
        else:
            self._screen_width, self._screen_height = 1920, 1080
            logging.warning("[Playback] Could not determine screen size; defaulting to 1920x1080")
    
    def _safe_click(self, x, y):
        """
        Safely execute a click with coordinate validation.
        
        Args:
            x, y: Coordinates to click
            
        Returns:
            bool: True if click succeeded, False otherwise
        """
        # Validate coordinates
        if not (0 <= x <= self._screen_width and 0 <= y <= self._screen_height):
            logging.error(f"[Playback] Invalid coordinates ({x},{y}). Screen size: {self._screen_width}x{self._screen_height}")
            return False
            
        # Debounce: skip duplicate click at same coordinates within a short window
        try:
            now = time.time()
            last_pos = getattr(self, "_last_click_pos", None)
            last_ts = getattr(self, "_last_click_ts", 0.0)
            debounce_window = getattr(self, "_debounce_window_sec", 0.60)
            if last_pos == (x, y) and (now - last_ts) < debounce_window:
                logging.info(f"[Playback] Skipping duplicate click within {debounce_window:.2f}s at ({x},{y})")
                return True
        except Exception:
            pass

        # Log click attempt
        logging.info(f"[Playback] Executing click at ({x},{y}) (non-blocking)")
        
        try:
            # Fire-and-forget click in a daemon thread so we don't block the flow
            def _click_with_hide():
                restore = self._temporarily_hide_app()
                try:
                    pyautogui.click(x, y)
                finally:
                    try:
                        restore()
                    except Exception:
                        pass
            threading.Thread(target=_click_with_hide, daemon=True).start()
            # Record last click for debounce logic
            try:
                self._last_click_pos = (x, y)
                self._last_click_ts = time.time()
            except Exception:
                pass
            # Give a short moment for focus to move to the target element
            time.sleep(self._focus_delay_after_click)
            logging.info(f"[Playback] Click at ({x},{y}) dispatched")
            return True
                    
        except Exception as e:
            logging.error(f"[Playback] Click at ({x},{y}) failed with exception: {e}")
            return False
    
    def _is_row_valid(self, row: dict) -> bool:
        """
        Check if a row has all fields populated (no empty string values).
        
        Args:
            row: Dictionary representing a row from Excel data
            
        Returns:
            bool: True if all fields have non-empty values, False if any field is empty
        """
        if not row or not isinstance(row, dict):
            return False
        
        # Check all fields in the row - if any field has an empty string value, row is invalid
        for field_name, value in row.items():
            # Check if value is None, empty string, or just whitespace
            # Note: 0, False, etc. are valid values and should not be considered empty
            if value is None:
                logging.info(f"[Playback] Row skipped - field '{field_name}' is None: {row}")
                return False
            elif isinstance(value, str) and value.strip() == "":
                logging.info(f"[Playback] Row skipped - field '{field_name}' is empty string: {row}")
                return False
        
        # All fields have non-empty values
        return True
        
    def play_playlist(self):
        """Play the selected playlist."""
        # Fresh start always (caller may request stop+restart semantics)
        self._stop_requested = False
        self._is_paused = False
        # Reflect playback active in app state and UI
        try:
            self.app.is_playback_active = True
        except Exception:
            pass
        self.app.status_var.set('Playing')
        self.app.clear_live_clicks()
        
        pl_name = self.app.selected_playlist.get()
        if not pl_name or pl_name == 'Select playlist':
            self.app.status_var.set('Idle')
            try:
                self.app.is_playback_active = False
            except Exception:
                pass
            return
            
        # Reset data row counter
        self.app.current_data_row = 0
            
        # Try to fetch actions from MySQL first, then fallback to SQLite
        try:
            # Attempt to acquire instance lock early if context is available
            try:
                self.app.acquire_instance_lock_if_possible()
            except Exception:
                pass
            actions = self._fetch_actions_from_mysql(pl_name)
            try:
                # Track source for downstream completion logic
                if actions:
                    self.app.playlist_actions_source = 'mysql'
                    self.app.new_playlist_pending_save = False
                else:
                    self.app.playlist_actions_source = None
            except Exception:
                pass
            
            # If not found in MySQL, try SQLite (for unsaved recordings)
            if not actions:
                logging.info(f"No actions found in MySQL for '{pl_name}', trying SQLite...")
                actions = self._fetch_actions_from_sqlite(pl_name)
                try:
                    # If actions come from SQLite, this indicates a new/unsaved playlist
                    if actions:
                        self.app.playlist_actions_source = 'sqlite'
                        self.app.new_playlist_pending_save = True
                except Exception:
                    pass
            
            # If still no actions, treat as completion for existing MySQL playlists
            if not actions:
                try:
                    from mysql.mysql_client import get_playlist_id_by_name
                    playlist_id = get_playlist_id_by_name(pl_name)
                except Exception:
                    playlist_id = None
                if playlist_id:
                    logging.info(f"[Playback] Existing playlist '{pl_name}' has no actions; finalizing as completed")
                    try:
                        self.app.playlist_actions_source = 'mysql'
                    except Exception:
                        pass
                    # Run the same completion steps as a normal finish
                    self._handle_playback_completion()
                    self.app.status_var.set('Idle')
                    try:
                        self.app.is_playback_active = False
                    except Exception:
                        pass
                    return
                else:
                    logging.warning(f"No actions found in MySQL or SQLite for playlist '{pl_name}'")
                    self.app.status_var.set('No actions found')
                    try:
                        self.app.is_playback_active = False
                    except Exception:
                        pass
                    return
                
            # Start playback in separate thread
            threading.Thread(target=self._playback_thread, args=(actions,), daemon=True).start()
            
        except Exception as e:
            logging.error(f"Error fetching or playing actions: {e}")
            self.app.status_var.set('Idle')
            try:
                self.app.is_playback_active = False
            except Exception:
                pass

    def _fetch_actions_from_mysql(self, playlist_name):
        """Fetch actions for a playlist from MySQL."""
        from mysql.mysql_client import get_playlist_id_by_name, get_playlist_actions
        
        playlist_id = get_playlist_id_by_name(playlist_name)
        if not playlist_id:
            return None
            
        actions = get_playlist_actions(playlist_id)
        return actions if actions else None

    def _fetch_actions_from_sqlite(self, playlist_name):
        """Fetch actions for a playlist from SQLite (fallback for unsaved recordings)."""
        import db
        
        try:
            # Get SQLite playlist ID
            conn = db.get_connection()
            cur = conn.cursor()
            cur.execute('SELECT id FROM Playlists WHERE name = ?', (playlist_name,))
            row = cur.fetchone()
            
            if not row:
                conn.close()
                return None
                
            playlist_id = row[0]
            
            # Get clicks and keyboard events
            cur.execute(
                'SELECT x, y, timestamp, variable_name FROM Clicks WHERE playlist_id = ? ORDER BY timestamp ASC',
                (playlist_id,)
            )
            clicks = cur.fetchall()
            
            cur.execute(
                'SELECT key, event_type, timestamp FROM KeyboardEvents WHERE playlist_id = ? ORDER BY timestamp ASC',
                (playlist_id,)
            )
            keys = cur.fetchall()

            # Get action triggers
            try:
                cur.execute(
                    'SELECT action_type, timestamp, payload FROM ActionTriggers WHERE playlist_id = ? ORDER BY timestamp ASC',
                    (playlist_id,)
                )
                triggers = cur.fetchall()
            except Exception:
                triggers = []
            
            conn.close()
            
            # Convert to MySQL-compatible format
            actions = []
            
            # Add clicks
            for x, y, timestamp, variable_name in clicks:
                actions.append({
                    'action_type': 'click',
                    'x': x,
                    'y': y,
                    'timestamp': timestamp,
                    'key_name': None,
                    'variable_name': variable_name
                })
            
            # Add keyboard events
            for key, event_type, timestamp in keys:
                action_type = 'key_press' if event_type == 'press' else 'key_release'
                actions.append({
                    'action_type': action_type,
                    'x': None,
                    'y': None,
                    'timestamp': timestamp,
                    'key_name': key
                })

            # Add triggers (e.g., screenshot, loop, subplaylist)
            for action_type, timestamp, payload in [(t[0], t[1], t[2] if len(t) > 2 else None) for t in triggers]:
                if action_type == 'subplaylist':
                    # Convert SQLite trigger payload into MySQL-like subplaylist action
                    try:
                        sub_id = None
                        if isinstance(payload, (int, float)):
                            sub_id = int(payload)
                        elif isinstance(payload, (bytes, bytearray)):
                            try:
                                sub_id = int(payload.decode('utf-8', errors='ignore').strip())
                            except Exception:
                                sub_id = None
                        elif isinstance(payload, str):
                            try:
                                sub_id = int(payload.strip())
                            except Exception:
                                sub_id = None
                    except Exception:
                        sub_id = None
                    actions.append({
                        'action_type': 'subplaylist',
                        'timestamp': timestamp,
                        'subplaylist_id': sub_id
                    })
                else:
                    actions.append({
                        'action_type': action_type,
                        'timestamp': timestamp,
                        'x': None,
                        'y': None,
                        'payload': payload
                    })
            
            # Sort by timestamp
            actions.sort(key=lambda x: x['timestamp'])
            
            logging.info(f"Loaded {len(actions)} actions from SQLite for playlist '{playlist_name}'")
            return actions if actions else None
            
        except Exception as e:
            logging.error(f"Error fetching actions from SQLite: {e}")
            return None

    def _playback_thread(self, actions):
        """Execute playback actions in a separate thread."""
        prev_time = 0
        playback_completed = True
        try:
            # Get input data based on environment
            input_data = None
            try:
                import os
                env = os.getenv("ENV", "local").lower()  # Default to local if not set
                logging.info(f"Current environment: {env}")
                
                if env == "local":
                    # In local development, read from Excel file
                    import pandas as pd
                    local_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'local_data', 'test_invoices.xlsx')
                    logging.info(f"Looking for local file: {local_file}")
                    if os.path.exists(local_file):
                        input_data = pd.read_excel(local_file).to_dict('records')
                        logging.info(f"Loaded {len(input_data)} records from local Excel file: {input_data}")
                    else:
                        logging.warning(f"Local data file not found: {local_file}")
                        # Create sample data for testing
                        input_data = [{'invoice': '1008'}, {'invoice': '1009'}]
                        logging.info("Using default test data")
                else:
                    from mysql.mysql_client import get_direct_upload_by_id
                    upload_id = getattr(self.app, 'current_direct_upload_id', None)
                    if upload_id:
                        upload = get_direct_upload_by_id(int(upload_id))
                        if upload and upload.get('s3_bucket') and upload.get('s3_key'):
                            try:
                                import boto3
                                import openpyxl
                                s3 = boto3.client('s3')
                                tmp_path = f"/tmp/{upload['s3_key'].rsplit('/', 1)[-1]}"
                                s3.download_file(upload['s3_bucket'], upload['s3_key'], tmp_path)
                                logging.info(f"[Playback] Downloaded Excel from S3: s3://{upload['s3_bucket']}/{upload['s3_key']}")
                                wb = openpyxl.load_workbook(tmp_path, data_only=True)
                                sheet = wb.active
                                rows = list(sheet.iter_rows(values_only=True))
                                if rows:
                                    headers = [str(h).strip() if h is not None else f"col_{i}" for i, h in enumerate(rows[0])]
                                    input_data = [{headers[i]: row[i] for i in range(len(headers))} for row in rows[1:]]
                                else:
                                    input_data = []
                                logging.info(f"[Playback] Extracted {len(input_data)} rows directly from Excel")
                            except Exception as e:
                                logging.error(f"[Playback] Failed to download/parse Excel from S3: {e}")
                                try:
                                    import json
                                    if upload and upload.get('extracted_data'):
                                        input_data = json.loads(upload.get('extracted_data'))
                                        logging.info(f"[Playback] Fell back to {len(input_data)} rows from table")
                                except Exception:
                                    pass
                            finally:
                                try:
                                    if 'tmp_path' in locals() and os.path.exists(tmp_path):
                                        os.remove(tmp_path)
                                except Exception:
                                    pass
                        elif upload and upload.get('extracted_data'):
                            import json
                            input_data = json.loads(upload.get('extracted_data'))
                            logging.info(f"[Playback] Loaded {len(input_data)} rows from table (no S3 info)")
            except Exception as e:
                logging.error(f"Error loading input data: {e}")
            
            # Expose input data for downstream click execution
            try:
                self.app.variable_input_data = input_data
                logging.info(f"[Playback] variable_input_data set with {len(input_data) if input_data else 0} rows")
            except Exception:
                pass
            
            # Determine if any actions require variable data
            has_variables = any(act.get('variable_name') for act in actions)
            
            # If we have variable inputs but no data, warn user
            if (has_variables or any(a.get('action_type') == 'loop' for a in actions)) and not input_data:
                self.app.status_var.set('No data found in direct integration upload')
                return
            
            # Determine loop start: first 'loop' or 'search' action
            try:
                start_idx = next((i for i, a in enumerate(actions) if a.get('action_type') in ('loop', 'search')), None)
            except Exception:
                start_idx = None
            # Keep playing until all data rows are processed or stopped
            first_iteration = True
            while True:
                # Validate current row before processing (skip if required fields are empty)
                if input_data and (any(a.get('variable_name') for a in actions) or any(a.get('action_type') == 'loop' for a in actions)):
                    current_row = getattr(self.app, 'current_data_row', 0)
                    if current_row < len(input_data):
                        row = input_data[current_row]
                        if not self._is_row_valid(row):
                            # Current row is invalid, skip to next valid row
                            logging.info(f"[Playback] Current row {current_row + 1} is invalid, skipping to next valid row")
                            current_row += 1
                            # Find next valid row
                            while current_row < len(input_data):
                                row = input_data[current_row]
                                if self._is_row_valid(row):
                                    self.app.current_data_row = current_row
                                    logging.info(f"[Playback] Found valid row at index {current_row + 1}")
                                    break
                                else:
                                    logging.info(f"[Playback] Skipping row {current_row + 1} - missing required fields")
                                    current_row += 1
                            
                            # If no valid row found, end playback
                            if current_row >= len(input_data):
                                self.app.status_var.set('All records processed')
                                break
                
                # If a loop marker is present, we will apply its variable to the first subsequent click
                pending_loop_variable = None
                # For iterations after the first, seed timing so the loop segment starts at 0
                if (not first_iteration) and (start_idx is not None):
                    try:
                        prev_time = actions[start_idx].get('timestamp', 0) or 0
                    except Exception:
                        prev_time = 0
                for idx, act in enumerate(actions):
                    # On subsequent iterations, skip actions before the first loop/search marker
                    if (not first_iteration) and (start_idx is not None) and (idx < start_idx):
                        continue
                    # Periodic heartbeat so the reaper doesn't stop the instance during active work
                    try:
                        # 60s default; can be adjusted via env HB_INTERVAL_SEC
                        hb_interval = float(os.getenv("HB_INTERVAL_SEC", "60"))
                        self.app.send_heartbeat_throttled(min_interval_sec=hb_interval)
                    except Exception:
                        pass
                    # Honor stop requests or non-playing states
                    if self._stop_requested or self.app.status_var.get() not in ('Playing', 'Paused'):
                        playback_completed = False
                        return
                    
                    # If paused, wait until resumed
                    with self._pause_condition:
                        while self._is_paused and not self._stop_requested:
                            try:
                                self._pause_condition.wait(timeout=0.2)
                            except Exception:
                                break
                        if self._stop_requested:
                            playback_completed = False
                            return
                    
                    # Wait for timing interval with global min and cap
                    interval = max(act.get('timestamp', 0) - prev_time, 0)
                    wait_capped = min(interval, self._max_wait_per_action)
                    wait_for = max(self._min_wait_per_action, wait_capped)
                    logging.info(f"[Playback] Action {idx+1}/{len(actions)} waiting {wait_for:.2f}s (raw={interval:.2f}, ts={act.get('timestamp',0):.2f})")
                    time_start = time.time()
                    # Sleep in small chunks so pause can take effect promptly
                    slept = 0.0
                    chunk = 0.1
                    while slept < wait_for:
                        # Allow responsive pause/stop
                        with self._pause_condition:
                            if self._is_paused or self._stop_requested:
                                break
                        remaining = wait_for - slept
                        dt = chunk if remaining > chunk else remaining
                        time.sleep(dt)
                        slept += dt
                    # If we paused during wait, re-loop so the pause waiter handles the state
                    with self._pause_condition:
                        if self._is_paused or self._stop_requested:
                            continue
                    time_end = time.time()
                    logging.info(f"[Playback] Action {idx+1} wait complete (slept {(time_end-time_start):.2f}s)")
                    
                    # Execute action
                    if act.get('action_type') == 'click':
                        # If this click already has a variable_name, consume any pending loop variable
                        # to avoid applying it again to a following click (which causes double entry).
                        try:
                            if act.get('variable_name') and pending_loop_variable:
                                logging.info(f"[Playback] Click carries variable '{act.get('variable_name')}', clearing pending loop variable '{pending_loop_variable}'")
                                pending_loop_variable = None
                        except Exception:
                            pass
                        
                        # If this click does not carry a variable_name but we have a pending
                        # loop variable, apply it to this click and then clear the pending flag.
                        effective_act = act
                        try:
                            if pending_loop_variable and not act.get('variable_name'):
                                effective_act = dict(act)
                                effective_act['variable_name'] = pending_loop_variable
                                logging.info(f"[Playback] Applying loop variable '{pending_loop_variable}' to next click at ({act.get('x')},{act.get('y')})")
                                pending_loop_variable = None
                        except Exception:
                            pass
                        
                        result = self._execute_click(effective_act)
                        if result is False:  # No more data rows
                            break
                    elif act.get('action_type') in ('key_press', 'key_release'):
                        self._execute_key_action(act)
                    elif act.get('action_type') == 'screenshot':
                        self._execute_screenshot_action()
                    elif act.get('action_type') == 'analyze_docs':
                        try:
                            # wait for the UI to settle
                            self._execute_analyze_docs_action()
                        except Exception:
                            logging.exception("[Playback] analyze_docs action failed")
                    elif act.get('action_type') == 'support_docs':
                        try:
                            self._execute_support_docs_action()
                        except Exception:
                            logging.exception("[Playback] support_docs action failed")
                    elif act.get('action_type') == 'support_docs':
                        try:
                            self._execute_support_docs_action()
                        except Exception:
                            logging.exception("[Playback] support_docs action failed")
                    elif act.get('action_type') == 'loop':
                        # loop trigger informs iteration; optionally update status or current variable context
                        try:
                            var_name = act.get('payload') or act.get('key_name') or act.get('key')
                            if var_name:
                                logging.info(f"[Playback] Loop marker encountered for variable '{var_name}'")
                                pending_loop_variable = var_name
                        except Exception:
                            pass
                    elif act.get('action_type') == 'search':
                        try:
                            search_ok = self._execute_search_action(act)
                        except Exception:
                            logging.exception("[Playback] search action failed")
                            search_ok = False
                        if not search_ok:
                            # Fallback: attempt to navigate Home/Dashboard via Rekognition after scrolling to top
                            try:
                                from .recovery_helpers import navigate_home_via_rekognition
                                navigate_home_via_rekognition(self.app)
                            except Exception:
                                pass
                            # Abort current iteration and restart from loop/search segment
                            break
                    elif act.get('action_type') == 'subplaylist':
                        try:
                            self._execute_subplaylist_action(act)
                        except Exception:
                            logging.exception("[Playback] subplaylist action failed")
                    
                    prev_time = act.get('timestamp', 0)
                
                # After completing one run of the playlist
                # Upload supporting documents (analyzer screenshots + downloads) for THIS iteration
                try:
                    if hasattr(self.app, 'upload_supporting_documents_to_openai_s3'):
                        self.app.upload_supporting_documents_to_openai_s3()
                except Exception:
                    pass
                
                if input_data and (any(a.get('variable_name') for a in actions) or any(a.get('action_type') == 'loop' for a in actions)):
                    # Move to next row, skipping rows with empty required fields
                    current_row = getattr(self.app, 'current_data_row', 0) + 1
                    # Skip invalid rows (e.g., empty invoice number)
                    while current_row < len(input_data):
                        row = input_data[current_row]
                        if self._is_row_valid(row):
                            # Found a valid row, use it
                            break
                        else:
                            # Skip this invalid row and check next
                            logging.info(f"[Playback] Skipping row {current_row + 1} - missing required fields")
                            current_row += 1
                    
                    if current_row >= len(input_data):
                        self.app.status_var.set('All records processed')
                        break
                    self.app.current_data_row = current_row
                    logging.info(f"[Playback] Advancing to next data row: {self.app.current_data_row}/{len(input_data)}")
                    # Reset timing for next iteration
                    first_iteration = False
                    prev_time = 0
                else:
                    # No variable data, just run once
                    break
            
            # Mark direct integration uploads (and only mark as completed for existing playlists)
            # If this run used actions from MySQL, treat as existing playlist and complete uploads.
            # If actions came from SQLite (new/unsaved playlist), do NOT mark completed here;
            # completion should happen only after the user clicks Save.
            if playback_completed:
                self._handle_playback_completion()
            self.app.status_var.set('Idle')
        finally:
            # Always clear active flag when the playback thread exits
            try:
                self.app.is_playback_active = False
            except Exception:
                pass
        
        # Get input data based on environment
        input_data = None
        try:
            import os
            env = os.getenv("ENV", "local").lower()  # Default to local if not set
            logging.info(f"Current environment: {env}")
            
            if env == "local":
                # In local development, read from Excel file
                import pandas as pd
                local_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'local_data', 'test_invoices.xlsx')
                logging.info(f"Looking for local file: {local_file}")
                if os.path.exists(local_file):
                    input_data = pd.read_excel(local_file).to_dict('records')
                    logging.info(f"Loaded {len(input_data)} records from local Excel file: {input_data}")
                else:
                    logging.warning(f"Local data file not found: {local_file}")
                    # Create sample data for testing
                    input_data = [{'invoice': '1008'}, {'invoice': '1009'}]
                    logging.info("Using default test data")
            else:
                from mysql.mysql_client import get_direct_upload_by_id
                upload_id = getattr(self.app, 'current_direct_upload_id', None)
                if upload_id:
                    upload = get_direct_upload_by_id(int(upload_id))
                    if upload and upload.get('s3_bucket') and upload.get('s3_key'):
                        try:
                            import boto3
                            import openpyxl
                            s3 = boto3.client('s3')
                            tmp_path = f"/tmp/{upload['s3_key'].rsplit('/', 1)[-1]}"
                            s3.download_file(upload['s3_bucket'], upload['s3_key'], tmp_path)
                            logging.info(f"[Playback] Downloaded Excel from S3: s3://{upload['s3_bucket']}/{upload['s3_key']}")
                            wb = openpyxl.load_workbook(tmp_path, data_only=True)
                            sheet = wb.active
                            rows = list(sheet.iter_rows(values_only=True))
                            if rows:
                                headers = [str(h).strip() if h is not None else f"col_{i}" for i, h in enumerate(rows[0])]
                                input_data = [{headers[i]: row[i] for i in range(len(headers))} for row in rows[1:]]
                            else:
                                input_data = []
                            logging.info(f"[Playback] Extracted {len(input_data)} rows directly from Excel")
                        except Exception as e:
                            logging.error(f"[Playback] Failed to download/parse Excel from S3: {e}")
                            try:
                                import json
                                if upload and upload.get('extracted_data'):
                                    input_data = json.loads(upload.get('extracted_data'))
                                    logging.info(f"[Playback] Fell back to {len(input_data)} rows from table")
                            except Exception:
                                pass
                        finally:
                            try:
                                if 'tmp_path' in locals() and os.path.exists(tmp_path):
                                    os.remove(tmp_path)
                            except Exception:
                                pass
                    elif upload and upload.get('extracted_data'):
                        import json
                        input_data = json.loads(upload.get('extracted_data'))
                        logging.info(f"[Playback] Loaded {len(input_data)} rows from table (no S3 info)")
        except Exception as e:
            logging.error(f"Error loading input data: {e}")
        
        # Expose input data for downstream click execution
        try:
            self.app.variable_input_data = input_data
            logging.info(f"[Playback] variable_input_data set with {len(input_data) if input_data else 0} rows")
        except Exception:
            pass
            
        # Determine if any actions require variable data
        has_variables = any(act.get('variable_name') for act in actions)
        
        # If we have variable inputs but no data, warn user
        if (has_variables or any(a.get('action_type') == 'loop' for a in actions)) and not input_data:
            self.app.status_var.set('No data found in direct integration upload')
            return
            
        # Determine loop start: first 'loop' or 'search' action
        try:
            start_idx = next((i for i, a in enumerate(actions) if a.get('action_type') in ('loop', 'search')), None)
        except Exception:
            start_idx = None
        # Keep playing until all data rows are processed or stopped
        first_iteration = True
        while True:
            # If a loop marker is present, we will apply its variable to the first subsequent click
            pending_loop_variable = None
            # For iterations after the first, seed timing so the loop segment starts at 0
            if (not first_iteration) and (start_idx is not None):
                try:
                    prev_time = actions[start_idx].get('timestamp', 0) or 0
                except Exception:
                    prev_time = 0
            for idx, act in enumerate(actions):
                # On subsequent iterations, skip actions before the first loop/search marker
                if (not first_iteration) and (start_idx is not None) and (idx < start_idx):
                    continue
                # Periodic heartbeat so the reaper doesn't stop the instance during active work
                try:
                    # 60s default; can be adjusted via env HB_INTERVAL_SEC
                    hb_interval = float(os.getenv("HB_INTERVAL_SEC", "60"))
                    self.app.send_heartbeat_throttled(min_interval_sec=hb_interval)
                except Exception:
                    pass
                # Honor stop requests or non-playing states
                if self._stop_requested or self.app.status_var.get() not in ('Playing', 'Paused'):
                    playback_completed = False
                    return

                # If paused, wait until resumed
                with self._pause_condition:
                    while self._is_paused and not self._stop_requested:
                        try:
                            self._pause_condition.wait(timeout=0.2)
                        except Exception:
                            break
                    if self._stop_requested:
                        playback_completed = False
                        return
                    
                # Wait for timing interval with global min and cap
                interval = max(act.get('timestamp', 0) - prev_time, 0)
                wait_capped = min(interval, self._max_wait_per_action)
                wait_for = max(self._min_wait_per_action, wait_capped)
                logging.info(f"[Playback] Action {idx+1}/{len(actions)} waiting {wait_for:.2f}s (raw={interval:.2f}, ts={act.get('timestamp',0):.2f})")
                time_start = time.time()
                # Sleep in small chunks so pause can take effect promptly
                slept = 0.0
                chunk = 0.1
                while slept < wait_for:
                    # Allow responsive pause/stop
                    with self._pause_condition:
                        if self._is_paused or self._stop_requested:
                            break
                    remaining = wait_for - slept
                    dt = chunk if remaining > chunk else remaining
                    time.sleep(dt)
                    slept += dt
                # If we paused during wait, re-loop so the pause waiter handles the state
                with self._pause_condition:
                    if self._is_paused or self._stop_requested:
                        continue
                time_end = time.time()
                logging.info(f"[Playback] Action {idx+1} wait complete (slept {(time_end-time_start):.2f}s)")
                    
                # Execute action
                if act.get('action_type') == 'click':
                    # If this click already has a variable_name, consume any pending loop variable
                    # to avoid applying it again to a following click (which causes double entry).
                    try:
                        if act.get('variable_name') and pending_loop_variable:
                            logging.info(f"[Playback] Click carries variable '{act.get('variable_name')}', clearing pending loop variable '{pending_loop_variable}'")
                            pending_loop_variable = None
                    except Exception:
                        pass

                    # If this click does not carry a variable_name but we have a pending
                    # loop variable, apply it to this click and then clear the pending flag.
                    effective_act = act
                    try:
                        if pending_loop_variable and not act.get('variable_name'):
                            effective_act = dict(act)
                            effective_act['variable_name'] = pending_loop_variable
                            logging.info(f"[Playback] Applying loop variable '{pending_loop_variable}' to next click at ({act.get('x')},{act.get('y')})")
                            pending_loop_variable = None
                    except Exception:
                        pass

                    result = self._execute_click(effective_act)
                    if result is False:  # No more data rows
                        break
                elif act.get('action_type') in ('key_press', 'key_release'):
                    self._execute_key_action(act)
                elif act.get('action_type') == 'screenshot':
                    self._execute_screenshot_action()
                elif act.get('action_type') == 'analyze_docs':
                    try:
                        # wait for the UI to settle
                        self._execute_analyze_docs_action()
                    except Exception:
                        logging.exception("[Playback] analyze_docs action failed")
                elif act.get('action_type') == 'support_docs':
                    try:
                        self._execute_support_docs_action()
                    except Exception:
                        logging.exception("[Playback] support_docs action failed")
                elif act.get('action_type') == 'support_docs':
                    try:
                        self._execute_support_docs_action()
                    except Exception:
                        logging.exception("[Playback] support_docs action failed")
                elif act.get('action_type') == 'loop':
                    # loop trigger informs iteration; optionally update status or current variable context
                    try:
                        var_name = act.get('payload') or act.get('key_name') or act.get('key')
                        if var_name:
                            logging.info(f"[Playback] Loop marker encountered for variable '{var_name}'")
                            pending_loop_variable = var_name
                    except Exception:
                        pass
                elif act.get('action_type') == 'search':
                    try:
                        search_ok = self._execute_search_action(act)
                    except Exception:
                        logging.exception("[Playback] search action failed")
                        search_ok = False
                    if not search_ok:
                        # Fallback: attempt to navigate Home/Dashboard via Rekognition after scrolling to top
                        try:
                            from .recovery_helpers import navigate_home_via_rekognition
                            navigate_home_via_rekognition(self.app)
                        except Exception:
                            pass
                        # Abort current iteration and restart from loop/search segment
                        break
                elif act.get('action_type') == 'subplaylist':
                    try:
                        self._execute_subplaylist_action(act)
                    except Exception:
                        logging.exception("[Playback] subplaylist action failed")
                    
                prev_time = act.get('timestamp', 0)
                
            # After completing one run of the playlist
            # Upload supporting documents (analyzer screenshots + downloads) for THIS iteration
            try:
                if hasattr(self.app, 'upload_supporting_documents_to_openai_s3'):
                    self.app.upload_supporting_documents_to_openai_s3()
            except Exception:
                pass

            if input_data and (any(a.get('variable_name') for a in actions) or any(a.get('action_type') == 'loop' for a in actions)):
                # Move to next row, skipping rows with empty required fields
                current_row = getattr(self.app, 'current_data_row', 0) + 1
                # Skip invalid rows (e.g., empty invoice number)
                while current_row < len(input_data):
                    row = input_data[current_row]
                    if self._is_row_valid(row):
                        # Found a valid row, use it
                        break
                    else:
                        # Skip this invalid row and check next
                        logging.info(f"[Playback] Skipping row {current_row + 1} - missing required fields")
                        current_row += 1
                
                if current_row >= len(input_data):
                    self.app.status_var.set('All records processed')
                    break
                self.app.current_data_row = current_row
                logging.info(f"[Playback] Advancing to next data row: {self.app.current_data_row}/{len(input_data)}")
                # Reset timing for next iteration
                first_iteration = False
                prev_time = 0
            else:
                # No variable data, just run once
                break
            
        # Mark direct integration uploads (and only mark as completed for existing playlists)
        # If this run used actions from MySQL, treat as existing playlist and complete uploads.
        # If actions came from SQLite (new/unsaved playlist), do NOT mark completed here;
        # completion should happen only after the user clicks Save.
        if playback_completed:
            self._handle_playback_completion()
        self.app.status_var.set('Idle')

    def pause_playlist(self):
        """Pause playlist playback."""
        try:
            with self._pause_condition:
                self._is_paused = True
                self.app.status_var.set('Paused')
        except Exception:
            try:
                self.app.status_var.set('Paused')
            except Exception:
                pass

    def stop_playlist(self):
        """Stop playback and reset state flags."""
        try:
            with self._pause_condition:
                self._stop_requested = True
                self._is_paused = False
                self._pause_condition.notify_all()
        except Exception:
            self._stop_requested = True
        # Clear active flag so UI re-enables Play
        try:
            self.app.is_playback_active = False
        except Exception:
            pass

    def _execute_analyze_docs_action(self):
        """Capture current screen and run supporting-docs flow during playback."""
        try:
            from .actions import execute_analyze_docs_action
        except Exception:
            try:
                logging.exception('[Playback] Failed to import analyze-docs action')
            except Exception:
                pass
            return
        def _stop():
            return bool(getattr(self, '_stop_requested', False))
        def _set_status(text):
            try:
                self.app.status_var.set(text)
            except Exception:
                pass
        try:
            logging.info(f"[Playback] Starting analyze_docs action (status={getattr(self.app, 'status_var', None).get() if hasattr(self.app, 'status_var') else None}, stop={getattr(self, '_stop_requested', False)})")
        except Exception:
            pass
        execute_analyze_docs_action(self.app, stop_requested=_stop, set_status=_set_status)

    def _execute_support_docs_action(self):
        """Dedicated Get Supporting Docs: Xero/Sage routing via helper; else generic analyze-docs."""
        try:
            try:
                wait_time = float(os.getenv("SCREENSHOT_PRE_DELAY_SEC", "5.0"))
            except Exception:
                wait_time = 5.0

            route_supporting_docs(
                self.app,
                sage_handler=None,  # default Sage flow in router
                fallback_generic=None,
                sleep_before=wait_time,
                allow_xero_fallback=False,  # keep previous behavior: no generic fallback for Xero here
            )
        finally:
            try:
                if self.app.status_var.get() != 'Paused':
                    self.app.status_var.set('Playing')
            except Exception:
                pass
    def _execute_search_action(self, act: dict) -> bool:
        """Execute a 'search' trigger: use OpenAI to find coordinates for text and click/type value."""
        # Always restore status to 'Playing' so the outer loop doesn't break
        try:
            import json
            from .openai_analyzer import OpenAIAnalyzer
            import pyautogui as _pg
            import time as _time
        except Exception:
            return False

        try:
            payload = act.get('payload')
            try:
                params = json.loads(payload) if isinstance(payload, str) else (payload or {})
            except Exception:
                params = {}
            # Primary query name comes from payload.query; fallback to action key fields
            query = (params.get('query') or '').strip()
            if not query:
                try:
                    # MySQL format uses 'key' or 'key_name' fields
                    query = (act.get('key') or act.get('key_name') or '').strip()
                except Exception:
                    query = ''
            value = params.get('value')
            if not query:
                return False

            # Prefer the explicit value recorded with this action; fall back to dataset-derived value by name
            query_to_find = None
            # 1) Use payload value if provided (most recent entered during recording)
            if isinstance(value, str) and value.strip():
                query_to_find = value.strip()
            else:
                # 2) Try to derive from current row using the saved name (query)
                try:
                    data = getattr(self.app, 'variable_input_data', None)
                    current_row = getattr(self.app, 'current_data_row', 0)
                    if isinstance(data, list) and 0 <= current_row < len(data) and query:
                        row = data[current_row]
                        if isinstance(row, dict):
                            # Validate row has all fields populated before using it
                            if not self._is_row_valid(row):
                                logging.warning(f"[Playback] Search action skipped - row {current_row + 1} has empty fields")
                                return False
                            # direct key or normalized match
                            def _norm_key(s: str) -> str:
                                try:
                                    return ''.join(ch.lower() for ch in str(s) if ch.isalnum())
                                except Exception:
                                    return str(s).lower()
                            if query in row:
                                query_to_find = row.get(query)
                            else:
                                target = _norm_key(query)
                                for k in row.keys():
                                    if _norm_key(k) == target:
                                        query_to_find = row.get(k)
                                        break
                except Exception:
                    query_to_find = query_to_find
            query_to_find = str(query_to_find or '').strip()
            if not query_to_find:
                return
            try:
                self.app.status_var.set(f"Searching '{query_to_find}'...")
            except Exception:
                pass

            analyzer = getattr(self.app, 'screenshot_manager', None)
            analyzer = analyzer.openai_analyzer if analyzer else OpenAIAnalyzer(self.app)
            screenshot_path = analyzer.capture_full_resolution_screenshot()
            if not screenshot_path:
                return False
            # Choose search engine
            try:
                from .constants import USE_AWS_REKOGNITION_FOR_SEARCH
            except Exception:
                USE_AWS_REKOGNITION_FOR_SEARCH = False
            coords = None
            if USE_AWS_REKOGNITION_FOR_SEARCH:
                try:
                    logging.info("[Playback] Search engine: AWS Rekognition (flag enabled)")
                except Exception:
                    pass
                # Disable 'first match' early-return for generic search to avoid clicking headers/top areas.
                # This mirrors the manual search path behavior in `app.py`.
                try:
                    import os as _os
                except Exception:
                    _os = None
                if _os is not None:
                    _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:
                    coords = analyzer.find_text_coordinates_rekognition(screenshot_path, query_to_find)
            else:
                # If we have a likely row hint (invoice number) in the current row, use the context-aware OpenAI search first
                try:
                    row_hint = None
                    data = getattr(self.app, 'variable_input_data', None)
                    current_row = getattr(self.app, 'current_data_row', 0)
                    if isinstance(data, list) and 0 <= current_row < len(data):
                        row = data[current_row]
                        if isinstance(row, dict):
                            # Validate row before using it for hint
                            if not self._is_row_valid(row):
                                logging.warning(f"[Playback] Search hint skipped - row {current_row + 1} has empty fields")
                            else:
                                # Try common invoice/ref field names
                                for k in ['invoice', 'invoiceNumber', 'invoice_no', 'invoice_num', 'ref', 'reference']:
                                    if k in row and str(row[k]).strip():
                                        row_hint = str(row[k]).strip()
                                        break
                    if row_hint:
                        coords = analyzer.find_text_coordinates(screenshot_path, query_to_find, row_hint=row_hint)
                except Exception:
                    coords = None
                if coords is None:
                    try:
                        logging.info("[Playback] Search engine: OpenAI Vision (flag disabled)")
                    except Exception:
                        pass
                    coords = analyzer.find_text_coordinates(screenshot_path, query_to_find)
            if not coords or len(coords) != 2:
                return False
            x, y = int(coords[0]), int(coords[1])
            try:
                restore = self._temporarily_hide_app()
                try:
                    # Debounce redundant click at the same coordinates as the most recent click
                    last_pos = getattr(self, "_last_click_pos", None)
                    last_ts = getattr(self, "_last_click_ts", 0.0)
                    debounce_window = getattr(self, "_debounce_window_sec", 0.60)
                    now = _time.time()
                    if last_pos == (x, y) and (now - last_ts) < debounce_window:
                        try:
                            logging.info(f"[Playback] Search skipping redundant click within {debounce_window:.2f}s at ({x},{y})")
                        except Exception:
                            pass
                    else:
                        _pg.moveTo(x, y, duration=0.4)
                        _pg.click(x, y)
                        try:
                            self._last_click_pos = (x, y)
                            self._last_click_ts = _time.time()
                            # Suppress the immediate following click action regardless of coordinates
                            # (common when recordings include a 'search' action followed by a 'click' action).
                            # The search already clicked at the correct location, so we should suppress
                            # the next click action even if its coordinates differ (they may be stale from recording).
                            # Default suppression window: 2.0 seconds (override via SUPPRESS_CLICK_AFTER_SEARCH_SEC).
                            self._suppress_next_click_pos = (x, y)  # Keep for coordinate matching if they happen to match
                            self._suppress_next_click_any = True  # Flag to suppress next click regardless of coordinates
                            try:
                                import os as __os
                                suppress_sec = float(__os.getenv("SUPPRESS_CLICK_AFTER_SEARCH_SEC", "2.0"))
                            except Exception:
                                suppress_sec = 2.0
                            self._suppress_next_click_until = _time.time() + max(0.2, suppress_sec)
                        except Exception:
                            pass
                finally:
                    try:
                        restore()
                    except Exception:
                        pass
                # Brief settle so subsequent actions continue smoothly
                try:
                    _time.sleep(self._post_search_click_stabilize)
                except Exception:
                    pass
                # Only type when an additional value exists and is different from the search value
                if value is not None and value != '' and str(value).strip() != query_to_find:
                    _time.sleep(self._focus_delay_after_click)
                    _pg.typewrite(str(value), interval=0.02)
            except Exception:
                return False
        finally:
            # Ensure status is restored so the main loop continues
            try:
                if self.app.status_var.get() != 'Paused':
                    self.app.status_var.set('Playing')
            except Exception:
                pass
        return True

    def _handle_playback_completion(self) -> None:
        """Finalize steps for a completed playback run.
        This centralizes the logic so it can be reused for normal completion and
        for edge-cases like existing playlists with zero actions.
        """
        try:
            source = getattr(self.app, 'playlist_actions_source', None)
            if source == 'mysql':
                # Mark direct integration uploads as completed
                try:
                    self._mark_uploads_completed_for_current_playlist()
                except Exception:
                    pass
                # Also clear any local SQLite rows for this playlist name
                try:
                    pl_name = self.app.selected_playlist.get() if hasattr(self.app, 'selected_playlist') else None
                    if pl_name and hasattr(self.app, 'playlist_manager') and self.app.playlist_manager:
                        try:
                            self.app.playlist_manager._clear_local_playlist_data(pl_name)
                            logging.info(f"[Playback] Cleared local SQLite data for playlist '{pl_name}' after completion")
                        except Exception:
                            pass
                except Exception:
                    pass
            else:
                logging.info("[Playback] Skipping completion mark because playlist actions came from SQLite (unsaved)")
        except Exception:
            pass

        # Upload supporting documents (analyzer screenshots + downloads) to OpenAI S3
        try:
            if hasattr(self.app, 'upload_supporting_documents_to_openai_s3'):
                self.app.upload_supporting_documents_to_openai_s3()
        except Exception:
            pass

        # Archive logs/screenshots for this session before closing apps
        try:
            self.app.archive_session_artifacts_to_s3()
        except Exception:
            pass
        # Close browser windows after completion/cancel/error scenarios
        try:
            self.app._close_browser_processes()
        except Exception:
            pass
        # Close any launched desktop applications as well
        try:
            self.app._close_desktop_app_processes()
        except Exception:
            pass
        # Best-effort cleanup: remove any remaining screenshots for this playlist
        try:
            pl_name = self.app.selected_playlist.get()
            self._cleanup_screenshot_folder(pl_name)
        except Exception:
            logging.exception("[Playback] Cleanup of screenshot folder failed")
        
        # Clear direct integration-related state and restart polling for next uploads
        try:
            setattr(self.app, 'current_direct_upload_id', None)
            setattr(self.app, 'user_interaction_upload_id', None)
            setattr(self.app, 'user_interaction_mode', False)
            setattr(self.app, 'variable_input_data', None)
            setattr(self.app, 'current_application_id', None)
            setattr(self.app, 'current_application_name', None)
            setattr(self.app, 'current_company_id', None)
            setattr(self.app, 'playlist_actions_source', None)
            setattr(self.app, 'current_playlist_mode', None)
            # Also clear the UI's playlist selection so it doesn't display old name
            try:
                if hasattr(self.app, 'selected_playlist') and self.app.selected_playlist:
                    self.app.selected_playlist.set('Select playlist')
                if hasattr(self.app, 'playlist_dropdown') and self.app.playlist_dropdown:
                    try:
                        self.app.playlist_dropdown.set('Select playlist')
                    except Exception:
                        pass
            except Exception:
                pass
            try:
                self.app.create_widgets()
            except Exception:
                pass
            # Ensure minimized view shows the waiting label (no playlist) by clearing list and reasserting selection
            try:
                self.app.playlists = []
                if hasattr(self.app, 'selected_playlist') and self.app.selected_playlist:
                    self.app.selected_playlist.set('Select playlist')
            except Exception:
                pass
        except Exception:
            pass

        try:
            if hasattr(self.app, 'direct_integration_watcher') and self.app.direct_integration_watcher:
                self.app.direct_integration_watcher.restart_polling()
                logging.info("[Playback] Restarted DirectIntegration watcher after completion")
            else:
                from .direct_integration import DirectIntegrationWatcher
                self.app.direct_integration_watcher = DirectIntegrationWatcher(self.app)
                self.app.direct_integration_watcher.start()
        except Exception as e:
            logging.error(f"[Playback] Failed to restart DirectIntegration watcher: {e}")

        # Release instance lock when playback completes
        try:
            self.app.release_instance_lock()
        except Exception:
            pass

        logging.info("[Playback] Playback completed successfully")

    def _cleanup_screenshot_folder(self, playlist_name: str) -> None:
        """Remove any leftover screenshot files for the given playlist.
        We already delete per-file after successful S3 upload + DB write; this is a
        safety net to ensure the folder is empty when playback finishes.
        """
        try:
            if not playlist_name or playlist_name == 'Select playlist':
                return
            folder = os.path.join('screenshots', str(playlist_name))
            if not os.path.isdir(folder):
                return
            from .constants import KEEP_SCREENSHOTS_FOR_DEBUG
            removed = 0
            for name in os.listdir(folder):
                try:
                    # Delete only files, keep subfolders like playback_debug
                    full = os.path.join(folder, name)
                    if (not KEEP_SCREENSHOTS_FOR_DEBUG) and os.path.isfile(full) and name.lower().endswith(('.png', '.jpg', '.jpeg')):
                        os.remove(full)
                        removed += 1
                except Exception:
                    logging.exception(f"[Playback] Failed removing leftover file: {name}")
            # Attempt to remove the folder if empty (ignore errors if not empty)
            try:
                if (not KEEP_SCREENSHOTS_FOR_DEBUG) and (not os.listdir(folder)):
                    os.rmdir(folder)
            except Exception:
                pass
            logging.info(f"[Playback] Screenshot cleanup completed for '{playlist_name}' - removed {removed} files")
        except Exception:
            logging.exception("[Playback] Unexpected error during screenshot cleanup")

    def _ensure_dpi_awareness(self):
        """Make the process DPI aware on Windows so client rect math is correct."""
        try:
            if sys.platform == 'win32':
                ctypes.windll.user32.SetProcessDPIAware()
        except Exception:
            pass

    def _is_mostly_black(self, img) -> bool:
        """Heuristic: true if the image is effectively all black (no content)."""
        try:
            gray = img.convert('L')
            lo, hi = gray.getextrema()
            return hi < 5
        except Exception:
            return False

    def _capture_active_window_client_to_file(self, path: str) -> bool:
        """Capture the active window's client area (excludes title/borders) to a file.
        Returns True on success, False on error.
        """
        try:
            if sys.platform != 'win32':
                return False
            user32 = ctypes.windll.user32
            # Foreground window
            h_wnd = user32.GetForegroundWindow()
            if not h_wnd:
                return False
            # Get client rect size
            class RECT(ctypes.Structure):
                _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long)]
            class POINT(ctypes.Structure):
                _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
            rect = RECT()
            if user32.GetClientRect(h_wnd, ctypes.byref(rect)) == 0:
                return False
            # Convert client origin to screen coordinates
            pt = POINT(0, 0)
            if user32.ClientToScreen(h_wnd, ctypes.byref(pt)) == 0:
                return False
            left = int(pt.x)
            top = int(pt.y)
            width = int(rect.right - rect.left)
            height = int(rect.bottom - rect.top)
            right = left + width
            bottom = top + height
            # Capture bbox (region) using pyautogui for better composition compatibility
            try:
                time.sleep(0.05)  # small settle
                img = pyautogui.screenshot(region=(left, top, width, height))
                if self._is_mostly_black(img):
                    logging.warning("[Playback] Active window client capture appears black; will fallback")
                    return False
                img.save(path, 'PNG')
                return True
            except Exception as e:
                logging.error(f"[Playback] Region capture failed for client bbox: {e}")
                return False
        except Exception as e:
            logging.error(f"[Playback] Active window client capture failed: {e}")
            return False

    def _get_window_client_bbox(self, hwnd):
        """Return (left, top, right, bottom) for a window's client area in screen coords."""
        try:
            user32 = ctypes.windll.user32
            class RECT(ctypes.Structure):
                _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long), ("right", ctypes.c_long), ("bottom", ctypes.c_long)]
            class POINT(ctypes.Structure):
                _fields_ = [("x", ctypes.c_long), ("y", ctypes.c_long)]
            rect = RECT()
            if user32.GetClientRect(hwnd, ctypes.byref(rect)) == 0:
                return None
            pt = POINT(0, 0)
            if user32.ClientToScreen(hwnd, ctypes.byref(pt)) == 0:
                return None
            left = int(pt.x)
            top = int(pt.y)
            right = left + int(rect.right - rect.left)
            bottom = top + int(rect.bottom - rect.top)
            return (left, top, right, bottom)
        except Exception:
            return None

    def _temporarily_hide_app(self):
        """Temporarily hide the Tk app window so it won't appear in screenshots.
        Returns a restore callable to bring the window back."""
        def _noop():
            pass
        try:
            try:
                self.app.attributes('-topmost', False)
            except Exception:
                pass
            try:
                self.app.withdraw()
            except Exception:
                pass
            time.sleep(0.1)
            def _restore():
                try:
                    self.app.deiconify()
                    self.app.attributes('-topmost', True)
                except Exception:
                    pass
            return _restore
        except Exception:
            return _noop

    def _screenshot_region(self, left: int, top: int, width: int, height: int):
        """Capture a region of the screen reliably. Prefer mss (avoids black frames
        with GPU-accelerated apps); fall back to pyautogui if mss is unavailable.
        Returns a PIL.Image instance or None on failure.
        """
        try:
            try:
                import mss  # type: ignore
                from PIL import Image as _Image
                with mss.mss() as sct:
                    monitor = {"left": int(left), "top": int(top), "width": int(width), "height": int(height)}
                    sct_img = sct.grab(monitor)
                    img = _Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRA")
                    return img
            except Exception:
                # Fallback to pyautogui
                try:
                    import pyautogui as _pg
                    return _pg.screenshot(region=(int(left), int(top), int(width), int(height)))
                except Exception:
                    return None
        except Exception:
            return None

    def _active_window_monitor_bbox(self):
        """Return the bounding box (left, top, width, height) of the monitor that contains
        the center of the active window. Fallback to primary if unknown.
        """
        try:
            import mss
            user32 = ctypes.windll.user32
            hwnd = user32.GetForegroundWindow()
            if hwnd:
                # Get window rect
                rect = ctypes.wintypes.RECT()
                if user32.GetWindowRect(hwnd, ctypes.byref(rect)):
                    cx = int((rect.left + rect.right) / 2)
                    cy = int((rect.top + rect.bottom) / 2)
                    with mss.mss() as sct:
                        for mon in sct.monitors[1:]:  # skip 0 (virtual), iterate physical
                            if mon['left'] <= cx < mon['left'] + mon['width'] and mon['top'] <= cy < mon['top'] + mon['height']:
                                return (mon['left'], mon['top'], mon['width'], mon['height'])
            # Fallback to primary
            with mss.mss() as sct:
                mon = sct.monitors[1]
                return (mon['left'], mon['top'], mon['width'], mon['height'])
        except Exception:
            return (0, 0, pyautogui.size().width, pyautogui.size().height)

    def _grab_fullscreen_image(self):
        """Capture fullscreen image honoring SCREENSHOT_MONITOR_INDEX.
        -1 = active window's monitor; 1..N = specific monitor; else primary.
        Returns PIL.Image.
        """
        try:
            from .constants import SCREENSHOT_MONITOR_INDEX
        except Exception:
            SCREENSHOT_MONITOR_INDEX = -1
        try:
            import mss
            from PIL import Image as _Image
            with mss.mss() as sct:
                if SCREENSHOT_MONITOR_INDEX == -1:
                    left, top, width, height = self._active_window_monitor_bbox()
                    mon = {"left": int(left), "top": int(top), "width": int(width), "height": int(height)}
                elif isinstance(SCREENSHOT_MONITOR_INDEX, int) and SCREENSHOT_MONITOR_INDEX >= 1 and SCREENSHOT_MONITOR_INDEX < len(sct.monitors):
                    mon = sct.monitors[int(SCREENSHOT_MONITOR_INDEX)]
                else:
                    mon = sct.monitors[1]  # primary
                sct_img = sct.grab({"left": int(mon['left']), "top": int(mon['top']), "width": int(mon['width']), "height": int(mon['height'])})
                return _Image.frombytes("RGB", sct_img.size, sct_img.bgra, "raw", "BGRA")
        except Exception:
            # Fallback to pyautogui full screen
            try:
                return pyautogui.screenshot()
            except Exception:
                return None

    def _capture_browser_window_to_file(self, path: str) -> bool:
        """Try to capture the client area of a visible browser window (Chrome/Edge/Firefox).
        We select the largest visible candidate and exclude our Tk app window.
        """
        try:
            if sys.platform != 'win32':
                return False
            user32 = ctypes.windll.user32

            EnumWindows = user32.EnumWindows
            IsWindowVisible = user32.IsWindowVisible
            GetClassNameW = user32.GetClassNameW
            GetWindowTextW = user32.GetWindowTextW

            app_hwnd = None
            try:
                app_hwnd = int(self.app.winfo_id())
            except Exception:
                app_hwnd = None

            candidates = []
            allowed_classes = {"Chrome_WidgetWin_1", "MozillaWindowClass", "ApplicationFrameWindow"}
            browser_title_markers = [" - Google Chrome", " - Microsoft Edge", "Mozilla Firefox"]

            @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
            def _enum_proc(hwnd, lParam):
                try:
                    if not IsWindowVisible(hwnd):
                        return True
                    if app_hwnd and hwnd == app_hwnd:
                        return True
                    # Class name
                    cls_buf = ctypes.create_unicode_buffer(256)
                    GetClassNameW(hwnd, cls_buf, 256)
                    cls_name = cls_buf.value
                    # Window title
                    ttl_buf = ctypes.create_unicode_buffer(512)
                    GetWindowTextW(hwnd, ttl_buf, 512)
                    title = ttl_buf.value
                    is_browser = (cls_name in allowed_classes) or any(m in title for m in browser_title_markers)
                    if not is_browser:
                        return True
                    bbox = self._get_window_client_bbox(hwnd)
                    if not bbox:
                        return True
                    left, top, right, bottom = bbox
                    width = max(0, right - left)
                    height = max(0, bottom - top)
                    area = width * height
                    if area > 0:
                        candidates.append((area, hwnd, bbox, cls_name, title))
                except Exception:
                    pass
                return True

            EnumWindows(_enum_proc, 0)

            if not candidates:
                return False
            # Pick the largest area
            candidates.sort(key=lambda t: t[0], reverse=True)
            _, hwnd, bbox, cls_name, title = candidates[0]
            try:
                time.sleep(0.05)
                left, top, right, bottom = bbox
                width = max(0, right - left)
                height = max(0, bottom - top)
                img = self._screenshot_region(left, top, width, height)
                if img is None or self._is_mostly_black(img):
                    logging.warning("[Playback] Browser window capture appears black/empty; will fallback")
                    return False
                img.save(path, 'PNG')
                logging.info(f"[Playback] Saved browser screenshot: {path} ({cls_name} | {title})")
                return True
            except Exception as e:
                logging.error(f"[Playback] Region capture failed for browser bbox: {e}")
                return False
        except Exception as e:
            logging.error(f"[Playback] Browser window capture failed: {e}")
            return False

    def _capture_browser_full_page_to_file(self, path: str) -> bool:
        """Capture a full-height screenshot of the current browser page by scrolling
        and stitching client-area screenshots. Returns False on failure so caller can
        fall back to other strategies."""
        try:
            if sys.platform != 'win32':
                return False
            user32 = ctypes.windll.user32

            candidates = []
            allowed_classes = {"Chrome_WidgetWin_1", "MozillaWindowClass", "ApplicationFrameWindow"}
            browser_title_markers = [" - Google Chrome", " - Microsoft Edge", "Mozilla Firefox"]

            EnumWindows = user32.EnumWindows
            IsWindowVisible = user32.IsWindowVisible
            GetClassNameW = user32.GetClassNameW
            GetWindowTextW = user32.GetWindowTextW

            app_hwnd = None
            try:
                app_hwnd = int(self.app.winfo_id())
            except Exception:
                app_hwnd = None

            @ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
            def _enum_proc(hwnd, lParam):
                try:
                    if not IsWindowVisible(hwnd):
                        return True
                    if app_hwnd and hwnd == app_hwnd:
                        return True
                    cls_buf = ctypes.create_unicode_buffer(256)
                    GetClassNameW(hwnd, cls_buf, 256)
                    cls_name = cls_buf.value
                    ttl_buf = ctypes.create_unicode_buffer(512)
                    GetWindowTextW(hwnd, ttl_buf, 512)
                    title = ttl_buf.value
                    is_browser = (cls_name in allowed_classes) or any(m in title for m in browser_title_markers)
                    if not is_browser:
                        return True
                    bbox = self._get_window_client_bbox(hwnd)
                    if not bbox:
                        return True
                    left, top, right, bottom = bbox
                    width = max(0, right - left)
                    height = max(0, bottom - top)
                    area = width * height
                    if area > 0:
                        candidates.append((area, hwnd, bbox))
                except Exception:
                    pass
                return True

            EnumWindows(_enum_proc, 0)
            if not candidates:
                return False
            candidates.sort(key=lambda t: t[0], reverse=True)
            _, hwnd, bbox = candidates[0]

            # Try to focus the browser
            try:
                user32.SetForegroundWindow(hwnd)
            except Exception:
                pass

            left, top, right, bottom = bbox
            width = max(0, right - left)
            height = max(0, bottom - top)

            # Move to browser and go to top
            try:
                import pyautogui as _pg
                
                _pg.hotkey('ctrl', 'home')
                time.sleep(0.5)
            except Exception:
                pass

            from PIL import Image, ImageChops, Image as _Image
            segments = []
            max_pages = 30
            for i in range(max_pages):
                try:
                    img = self._screenshot_region(left, top, width, height)
                    if img is None:
                        break
                    # If first frame is black, abort full-page path
                    if i == 0 and self._is_mostly_black(img):
                        logging.warning("[Playback] Full-page first segment is black; aborting full-page capture")
                        return False
                    segments.append(img)
                    # Scroll one page
                    try:
                        import pyautogui as _pg
                        _pg.press('pagedown')
                        time.sleep(0.6)
                    except Exception:
                        pass
                    if i >= 1:
                        diff = ImageChops.difference(segments[-1], segments[-2])
                        if not diff.getbbox():
                            break
                except Exception:
                    break

            if not segments:
                return False

            # Stitch vertically with overlap
            overlap = 60
            total_h = sum(img.height for img in segments)
            stitched_h = total_h - overlap * (len(segments) - 1)
            stitched = _Image.new('RGB', (width, max(1, stitched_h)), color=(255, 255, 255))
            offset_y = 0
            for idx, img in enumerate(segments):
                if idx == 0:
                    stitched.paste(img, (0, offset_y))
                    offset_y += img.height
                else:
                    crop = img.crop((0, overlap, width, img.height)) if img.height > overlap else img
                    stitched.paste(crop, (0, offset_y - overlap))
                    offset_y += img.height - overlap

            stitched.save(path, 'PNG')
            logging.info(f"[Playback] Saved full-page browser screenshot: {path}")
            return True
        except Exception as e:
            logging.error(f"[Playback] Full-page browser capture failed: {e}")
            return False

    def _execute_click(self, action):
        """Execute a click action."""
        x, y = action.get('x'), action.get('y')
        if x is None or y is None:
            logging.error(f"[Playback] Click action missing coordinates: {action}")
            return False
        # Normalize coordinates to integers for consistent comparison with search action suppression
        x, y = int(x), int(y)
        variable_name = action.get('variable_name')
        logging.info(f"[Playback] Executing click at ({x},{y}) var={variable_name}")
        
        # If a recent search action already clicked these exact coordinates, suppress a duplicate
        # click and treat it as successful focus. This avoids back-to-back clicks at the same spot
        # when a 'search' action is followed by an explicit 'click' action.
        suppress_pos = getattr(self, '_suppress_next_click_pos', None)
        suppress_until = getattr(self, '_suppress_next_click_until', 0.0)
        suppress_any = getattr(self, '_suppress_next_click_any', False)
        now_ts = time.time()
        # Suppress if: (1) coordinates match AND time window active, OR (2) suppress_any flag is set (search clicked, suppress next click regardless of coords)
        coord_match_suppress = suppress_pos == (x, y) and now_ts < suppress_until
        suppress_active = suppress_any and now_ts < suppress_until  # Suppress next click after search regardless of coordinates
        # Log coordinate comparison to diagnose if search and click coordinates differ
        if suppress_pos is not None or suppress_any:
            try:
                coord_match = suppress_pos == (x, y) if suppress_pos is not None else False
                time_left = suppress_until - now_ts if suppress_until > 0 else 0
                logging.info(f"[Playback] Suppression check: search_clicked={suppress_pos}, click_action=({x},{y}), match={coord_match}, suppress_any={suppress_any}, time_left={time_left:.2f}s, will_suppress={suppress_active}")
            except Exception:
                pass
        if suppress_active:
            try:
                if suppress_pos == (x, y):
                    logging.info(f"[Playback] Suppressing duplicate click at ({x},{y}) due to recent search click at same location (expires in {suppress_until - now_ts:.2f}s)")
                else:
                    logging.info(f"[Playback] Suppressing click at ({x},{y}) - search already clicked at {suppress_pos}, preventing stale click action (expires in {suppress_until - now_ts:.2f}s)")
            except Exception:
                pass
            # Clear suppression so it doesn't affect subsequent unrelated clicks
            try:
                self._suppress_next_click_pos = None
                self._suppress_next_click_until = 0.0
                self._suppress_next_click_any = False
            except Exception:
                pass
            # Early return: search already clicked this location, skip the duplicate click entirely
            # This prevents the non-blocking click from executing
            try:
                self.show_playback_click(x, y)
            except Exception:
                pass
            return True  # Return success since search already handled the click
        
        if variable_name:
            # Get current row data
            data = getattr(self.app, 'variable_input_data', None)
            current_row = getattr(self.app, 'current_data_row', 0)
            try:
                logging.info(f"[Playback] variable_input_data present={bool(data)} len={(len(data) if data else 0)} current_row={current_row}")
            except Exception:
                pass

            if not data:
                logging.warning("[Playback] No input data available for variable typing")
                self.app.status_var.set('No variable data available')
                return False
            if current_row >= len(data):
                logging.info("[Playback] Current row >= data length; marking processed")
                self.app.status_var.set('All records processed')
                return False

            # Click the field and type value from current row
            # New flow: Click1 → paste value immediately → small pause → continue
            logging.info(f"[Playback] Click1 at ({x},{y}) to focus (will paste immediately after)")
            if suppress_active:
                # Treat as already focused by prior search click
                click_success = True
            else:
                click_success = self._safe_click(x, y)
            
            if click_success:
                logging.info(f"[Playback] Click1 ({x},{y}) clicked successfully")
                t_start = time.time()
                logging.info(f"[Playback] Click1 wait start ({t_start:.2f}s)")
                time.sleep(1.00)
                logging.info(f"[Playback] post-Click1 wait complete ({time.time()-t_start:.2f}s)")

                # Capture proof screenshot after Click1
                try:
                    os.makedirs('screenshots/playback_debug', exist_ok=True)
                    proof_path = os.path.join('screenshots/playback_debug', f"proof_{int(time.time()*1000)}.png")
                    pyautogui.screenshot(proof_path)
                    logging.info(f"[Playback] Saved focus proof screenshot: {proof_path}")
                except Exception as e_ss:
                    logging.warning(f"[Playback] Could not save proof screenshot: {e_ss}")
            else:
                logging.error(f"[Playback] Focus click at ({x},{y}) failed")
            row_data = data[current_row] if current_row < len(data) else {}
            try:
                logging.info(f"[Playback] Current row data: {row_data}")
            except Exception:
                pass
            # Validate row has all fields populated before using it
            if isinstance(row_data, dict) and not self._is_row_valid(row_data):
                logging.warning(f"[Playback] Click action skipped - row {current_row + 1} has empty fields")
                return False
            # Case-insensitive, normalized key lookup so 'Invoice' maps to 'invoice'
            def _norm_key(s: str) -> str:
                try:
                    return ''.join(ch.lower() for ch in str(s) if ch.isalnum())
                except Exception:
                    return str(s).lower()
            value_raw = None
            try:
                if isinstance(row_data, dict) and variable_name:
                    # Exact key
                    if variable_name in row_data:
                        value_raw = row_data.get(variable_name)
                    else:
                        # Normalized match across keys
                        target = _norm_key(variable_name)
                        for k in row_data.keys():
                            if _norm_key(k) == target:
                                value_raw = row_data.get(k)
                                try:
                                    logging.info(f"[Playback] Mapped variable '{variable_name}' to column '{k}' via normalized match")
                                except Exception:
                                    pass
                                break
            except Exception:
                pass
            value = '' if value_raw is None else str(value_raw)
            logging.info(f"[Playback] Pasting value for var='{variable_name}' -> '{value}' at ({x},{y}) row={current_row}")
            if value == '' or value is None:
                logging.warning(f"[Playback] Warning: extracted value is empty for var='{variable_name}' row={current_row}")
            # Remember context for screenshots
            try:
                self._last_variable_context = {'name': variable_name, 'value': value}
            except Exception:
                pass
            # Clear field and paste/type value (clipboard with fallback to typing)
            clipboard_ok = False
            try:
                import pyperclip
                pyperclip.copy(value)
                clipboard_ok = True
                logging.info("[Playback] Copied value to clipboard for paste path")
            except Exception as e:
                logging.warning(f"[Playback] pyperclip.copy failed: {e}")
            try:
                import pyautogui as _pg
                # Clear existing content
                _pg.hotkey('ctrl', 'a')
                time.sleep(0.1)
                _pg.press('delete')
                time.sleep(0.1)
                # Paste if clipboard available; otherwise type the value directly
                if clipboard_ok:
                    _pg.hotkey('ctrl', 'v')
                    logging.info("[Playback] Inserted value via Ctrl+V")
                else:
                    _pg.write(value, interval=0.02)
                    logging.info("[Playback] Inserted value via direct typing fallback")
            except Exception as e:
                logging.error(f"[Playback] Input insertion failed: {e}")
            # Do NOT press Enter automatically; user controls next step
            logging.info(f"[Playback] Post-type pause {self._post_type_pause:.2f}s before continuing")
            time.sleep(self._post_type_pause)
            # No topmost toggling in this test flow
        else:
            # Normal click
            logging.info("[Playback] Normal click")
            if suppress_active:
                click_success = True
            else:
                click_success = self._safe_click(x, y)
            if not click_success:
                logging.error(f"[Playback] Normal click at ({x},{y}) failed")
            
        self.show_playback_click(x, y)

    def _execute_key_action(self, action):
        """Execute a keyboard action."""
        key = action.get('key_name') or action.get('key')  # Handle both MySQL and legacy formats
        key = SPECIAL_KEY_MAP.get(key, key)
        
        if action.get('action_type') == 'key_press':
            pyautogui.keyDown(key)
        else:
            pyautogui.keyUp(key)

    def _execute_screenshot_action(self):
        """Capture a screenshot of the active browser window's page (client area) during playback.
        The filename includes variable context and timestamp.
        """
        try:
            # Provide a small pre-capture wait to ensure page content stabilizes
            try:
                time.sleep(self._pre_screenshot_wait)
            except Exception:
                pass
            pl_name = self.app.selected_playlist.get()
            if not pl_name or pl_name == 'Select playlist':
                return
            # Build target folder
            folder = os.path.join('screenshots', str(pl_name))
            os.makedirs(folder, exist_ok=True)
            # Gather context
            var_name = (self._last_variable_context.get('name') if self._last_variable_context else None) or 'novar'
            var_value = (self._last_variable_context.get('value') if self._last_variable_context else None) or 'novalue'
            # Sanitize for filename
            def _sanitize(text: str) -> str:
                try:
                    import re
                    t = str(text)
                    t = re.sub(r'[^A-Za-z0-9_.-]', '_', t)
                    # limit length to keep filenames manageable
                    return t[:80]
                except Exception:
                    return 'value'
            safe_name = _sanitize(var_name)
            safe_value = _sanitize(var_value)
            ts_ms = int(time.time() * 1000)
            # Filename includes indicator when we use full-page capture
            try:
                from .constants import FULL_PAGE_BROWSER_SCREENSHOT
                _fp = bool(FULL_PAGE_BROWSER_SCREENSHOT)
            except Exception:
                _fp = True
            suffix = "_fullpage" if _fp else ""
            filename = f"playback_{ts_ms}_var-{safe_name}_val-{safe_value}{suffix}.png"
            path = os.path.join(folder, filename)
            # Hide our app and attempt capture based on configured strategy
            restore = self._temporarily_hide_app()
            try:
                from .constants import (
                    BROWSER_ONLY_SCREENSHOT,
                    FULL_PAGE_BROWSER_SCREENSHOT,
                    FORCE_FULLSCREEN_SCREENSHOT,
                    FULLPAGE_WINDOW_TITLE_CONTAINS,
                    FULLPAGE_CONTENT_TOP_OFFSET,
                    FULLPAGE_CONTENT_BOTTOM_OFFSET,
                    FULLPAGE_OVERLAP_PX,
                    FULLPAGE_SCROLL_PX,
                    FULLPAGE_SCROLL_PAUSE,
                    FULLPAGE_MAX_SHOTS,
                    USE_PAGEDOWN_FOR_FULLPAGE,
                )
                captured = False
                # Small pre-capture delay to allow browser paint to complete
                try:
                    time.sleep(0.3)
                except Exception:
                    pass
                if FORCE_FULLSCREEN_SCREENSHOT:
                    # Debug: force fullscreen only
                    try:
                        img = self._grab_fullscreen_image()
                        if img is None:
                            raise RuntimeError("no image")
                        base, ext = os.path.splitext(path)
                        fb_path = f"{base}_fullscreen{ext}"
                        img.save(fb_path, 'PNG')
                        logging.info(f"[Playback] Forced full-screen screenshot: {fb_path}")
                        path = fb_path
                        captured = True
                    except Exception as e:
                        logging.error(f"[Playback] Forced fullscreen failed: {e}")
                        captured = False
                elif FULL_PAGE_BROWSER_SCREENSHOT:
                    # Try v2 scroll+stitch first (matches the standalone script behavior)
                    try:
                        from .fullpage_screenshot_v2 import save_fullpage_screenshot_v2
                        save_fullpage_screenshot_v2(
                            path,
                            window_title_contains=(FULLPAGE_WINDOW_TITLE_CONTAINS or None),
                            activate=True,
                            maximize=True,
                            delay_seconds=0.5,
                            content_top_offset=int(FULLPAGE_CONTENT_TOP_OFFSET),
                            content_bottom_offset=int(FULLPAGE_CONTENT_BOTTOM_OFFSET),
                            overlap_px=int(FULLPAGE_OVERLAP_PX),
                            scroll_px=int(FULLPAGE_SCROLL_PX),
                            scroll_pause=float(FULLPAGE_SCROLL_PAUSE),
                            max_shots=int(FULLPAGE_MAX_SHOTS),
                            use_pagedown=bool(USE_PAGEDOWN_FOR_FULLPAGE),
                        )
                        logging.info("[Playback] v2 full-page capture succeeded")
                        captured = True
                    except Exception as e:
                        logging.warning(f"[Playback] v2 full-page capture failed: {e}")
                        # Fallback to internal full-page method (pagedown + stitch)
                        captured = self._capture_browser_full_page_to_file(path)
                if not captured:
                    captured = self._capture_browser_window_to_file(path)
                # Always attempt active-window client capture as a safe fallback
                if not captured:
                    captured = self._capture_active_window_client_to_file(path)
                # As a last resort, always attempt full-screen capture (tagged)
                if not captured:
                    try:
                        img = self._grab_fullscreen_image()
                        if img is None:
                            raise RuntimeError("no image")
                        base, ext = os.path.splitext(path)
                        fb_path = f"{base}_fullscreen{ext}"
                        img.save(fb_path, 'PNG')
                        logging.info(f"[Playback] Saved full-screen screenshot (fallback): {fb_path}")
                        path = fb_path
                        captured = True
                    except Exception as e:
                        logging.error(f"[Playback] Failed to capture screenshot: {e}")
                if not captured:
                    logging.warning("[Playback] All screenshot methods failed; no image saved")
            finally:
                try:
                    restore()
                except Exception:
                    pass

            # If saved, upload to S3 and record in manage file process table
            try:
                s3_url = None
                if os.path.exists(path):
                    # Log current env flags
                    try:
                        logging.info(f"[Playback] Screenshot ready for upload. LOCAL_DEV={LOCAL_DEV} AWS_REGION={AWS_REGION}")
                    except Exception:
                        pass
                    # Resolve step details early so variables are available across both branches
                    try:
                        step_details = getattr(self.app, 'current_di_step_details', None) or {}
                        try:
                            step_number = int(step_details.get('stepNumber') or 1)
                        except Exception:
                            step_number = 1
                    except Exception:
                        step_details = {}
                        step_number = 1
                    if not LOCAL_DEV:
                        try:
                            import time as _t
                            import re as _re
                            s3 = boto3.client('s3', region_name=AWS_REGION)
                            # Destination: big-pond-openai/openai/<region>:<userId>/Step{step}-{description}/<timestamp>_<filename>
                            user_uuid = getattr(self.app, 'current_user_id', None) or ''
                            # step_details/step_number already resolved above
                            raw_desc = step_details.get('description') or ''
                            safe_desc = _re.sub(r'[^A-Za-z0-9 _.-]', '_', str(raw_desc)).strip() or 'step'
                            folder = f"Step{step_number}-{safe_desc}"
                            ts = int(_t.time()*1000)
                            bucket = 'big-pond-openai'
                            key = f"openai/{AWS_REGION}:{user_uuid}/{folder}/{ts}_{os.path.basename(path)}"
                            s3.upload_file(path, bucket, key)
                            s3_url = f"s3://{bucket}/{key}"
                            logging.info(f"[Playback] Uploaded screenshot to {s3_url}")
                        except Exception:
                            s3_url = None
                            logging.exception("[Playback] S3 upload failed")
                    else:
                        logging.info("[Playback] LOCAL_DEV=True, skipping S3 upload")
                    # Write manage file processing row if we have user context, and aggregate keys
                    try:
                        user_id = getattr(self.app, 'current_user_id', None)
                        if user_id:
                            # Determine bucket/key pair (fixed per requirement)
                            bucket = 'big-pond-openai'
                            if s3_url and s3_url.startswith('s3://'):
                                key_only = s3_url.split('://', 1)[1].split('/', 1)[1]
                            else:
                                key_only = os.path.basename(path)
                            logging.info(f"[Playback] Upserting manage row: user={user_id} step={step_number} key={key_only}")
                            # Insert one new row per screenshot
                            db_res = add_screenshot_record(
                                user_id=user_id,
                                step_number=step_number,
                                bucket=bucket,
                                key=key_only,
                                description=f"Playback screenshot for {pl_name}",
                                di_step_details=step_details,
                                flow_type_fallback='analyze',
                            )
                            logging.info(f"[Playback] Manage upsert result: {db_res}")
                            # If uploaded and DB write succeeded, remove local file
                            # Delete local file only when not debugging
                            from .constants import KEEP_SCREENSHOTS_FOR_DEBUG
                            if (not KEEP_SCREENSHOTS_FOR_DEBUG) and s3_url and isinstance(db_res, dict) and (db_res.get('updated') or db_res.get('inserted')):
                                try:
                                    os.remove(path)
                                    logging.info(f"[Playback] Deleted local screenshot: {path}")
                                except Exception:
                                    logging.exception("[Playback] Failed to delete local screenshot")
                    except Exception:
                        logging.exception("[Playback] Manage upsert failed")
            except Exception:
                logging.exception("[Playback] Unexpected error in screenshot upload/upsert flow")
        except Exception:
            pass
    
    def _mark_local_uploads_completed(self):
        """Mark local direct integration uploads as completed after successful playback."""
        try:
            from mysql.mysql_client import get_mysql_connection, set_direct_upload_status
            
            # Get the current playlist name
            pl_name = self.app.selected_playlist.get()
            if not pl_name or pl_name == 'Select playlist':
                return
            
            # Find local uploads (no S3 info) for this playlist that are unprocessed or processing
            connection = get_mysql_connection()
            cursor = connection.cursor()
            
            query = """
                SELECT id FROM direct_integration_uploads 
                WHERE processed IN (0, 2) 
                AND playlist_name = %s 
                AND (s3_bucket IS NULL OR s3_bucket = '' OR s3_key IS NULL OR s3_key = '')
            """
            cursor.execute(query, (pl_name,))
            local_uploads = cursor.fetchall()
            connection.close()
            
            # Mark each local upload as completed
            for (upload_id,) in local_uploads:
                set_direct_upload_status(
                    upload_id, 
                    1, 
                    step_details={"playback_completed": True, "completed_at": time.time()}
                )
                logging.info(f"[Playback] Marked local upload id={upload_id} as completed after successful playback")
                
        except Exception as e:
            logging.error(f"Failed to mark local uploads as completed: {e}")

    

    def _mark_uploads_completed_for_current_playlist(self):
        """Mark any direct integration uploads for the current playlist as completed (processed=1).

        This covers both local uploads (no S3) and S3-backed uploads associated by playlist_name.
        """
        try:
            from mysql.mysql_client import get_mysql_connection, get_playlist_id_by_name
            
            pl_name = self.app.selected_playlist.get()
            if not pl_name or pl_name == 'Select playlist':
                return
            
            # Resolve playlist_id from name to match uploads linked by ID
            playlist_id = None
            try:
                playlist_id = get_playlist_id_by_name(pl_name)
                logging.info(f"[Playbook] Resolved playlist_id {playlist_id} for playlist name '{pl_name}'")
            except Exception as e:
                logging.warning(f"[Playbook] Could not resolve playlist_id for '{pl_name}': {e}")
                playlist_id = None

            connection = get_mysql_connection()
            cursor = connection.cursor()
            
            # Prefer to update by the exact upload id captured when this DI job started
            upload_id = getattr(self.app, 'current_direct_upload_id', None)
            if upload_id:
                query = """
                    UPDATE direct_integration_uploads
                    SET processed = 1,
                        error_message = NULL,
                        updated_at = NOW()
                    WHERE id = %s
                """
                logging.info(f"[Playback] Completing direct upload by id: {upload_id}")
                cursor.execute(query, (int(upload_id),))
            else:
                # Fallback: Mark any uploads for this playlist as completed when playback finishes
                # Match by name OR playlist_id; update any status except already 1
                if playlist_id:
                    query = """
                        UPDATE direct_integration_uploads
                        SET processed = 1,
                            error_message = NULL,
                            updated_at = NOW()
                        WHERE processed <> 1
                          AND (playlist_name = %s OR playlist_id = %s)
                    """
                    logging.info(f"[Playback] Executing update query with playlist_name='{pl_name}' and playlist_id={playlist_id}")
                    cursor.execute(query, (pl_name, int(playlist_id)))
                else:
                    query = """
                        UPDATE direct_integration_uploads
                        SET processed = 1,
                            error_message = NULL,
                            updated_at = NOW()
                        WHERE processed <> 1
                          AND playlist_name = %s
                    """
                    logging.info(f"[Playback] Executing update query with playlist_name='{pl_name}' only")
                    cursor.execute(query, (pl_name,))
            rows_updated = cursor.rowcount
            connection.commit()
            
            logging.info(f"[Playback] Marked {rows_updated} direct integration upload(s) as completed for playlist '{pl_name}' (playlist_id={playlist_id})")
            
            # If no rows were updated, let's check what records exist
            if rows_updated == 0:
                if playlist_id:
                    cursor.execute(
                        "SELECT id, playlist_name, playlist_id, processed FROM direct_integration_uploads WHERE playlist_name = %s OR playlist_id = %s",
                        (pl_name, int(playlist_id))
                    )
                else:
                    cursor.execute(
                        "SELECT id, playlist_name, playlist_id, processed FROM direct_integration_uploads WHERE playlist_name = %s",
                        (pl_name,)
                    )
                existing_records = cursor.fetchall()
                logging.warning(f"[Playback] No rows updated for playlist '{pl_name}'. Existing records: {existing_records}")
        except Exception as e:
            logging.error(f"Failed to mark direct integration uploads as completed: {e}")
        finally:
            if 'cursor' in locals() and cursor:
                try:
                    cursor.close()
                except Exception:
                    pass
            if 'connection' in locals() and connection:
                try:
                    connection.close()
                except Exception:
                    pass

    def show_playback_click(self, x, y, idx=None):
        """Show playback click in the live clicks view."""
        if not hasattr(self.app, 'live_clicks_text') or not self.app.live_clicks_text or not self.app.live_clicks_text.winfo_exists():
            return
            
        self.app.live_clicks_text.config(state='normal')
        self.app.live_clicks_text.delete('1.0', 'end')
        
        if hasattr(self.app, 'playback_clicks') and self.app.playback_clicks:
            for i, (cx, cy, ts) in enumerate(self.app.playback_clicks):
                if idx is not None and i == idx:
                    self.app.live_clicks_text.insert(
                        'end',
                        f'>> Click {i+1}: x={cx}, y={cy}, t={ts:.2f}s <<\n',
                        'highlight'
                    )
                else:
                    self.app.live_clicks_text.insert('end', f'Click {i+1}: x={cx}, y={cy}, t={ts:.2f}s\n')
            self.app.live_clicks_text.tag_config('highlight', background='#ffe066', foreground='#222')
        else:
            self.app.live_clicks_text.insert('end', f'Playing: x={x}, y={y}\n')
            
        self.app.live_clicks_text.see('end')
        self.app.live_clicks_text.config(state='disabled')

    def _execute_subplaylist_action(self, action):
        """Execute a subplaylist action by id or fallback configuration.

        Xero-specific Attach files logic has been moved to xero_supporting_docs.play_supporting_docs_attach_files
        and is invoked only from the dedicated Get Supporting Docs flow when application_id == 6.
        """
        try:
            # Use configured fallback id first, otherwise the action's subplaylist_id
            try:
                from .constants import SUBPLAYLIST_TARGET_PLAYLIST_ID
                target_playlist_id = int(SUBPLAYLIST_TARGET_PLAYLIST_ID)
            except Exception:
                target_playlist_id = None

            if target_playlist_id is None:
                target_playlist_id = action.get('subplaylist_id')

            if not target_playlist_id:
                logging.warning("[Playback] No playlist id available for subplaylist action")
                return
            self._execute_playlist_by_id(int(target_playlist_id))
        except Exception as e:
            logging.error(f"[Playback] Error executing subplaylist action: {e}")

    def _execute_playlist_by_id(self, playlist_id):
        """Execute a playlist by ID without changing the UI selection."""
        try:
            from mysql.mysql_client import get_playlist_actions, get_playlist_by_id
            
            # Get playlist info
            playlist = get_playlist_by_id(playlist_id)
            if not playlist:
                logging.error(f"[Playback] Playlist ID {playlist_id} not found")
                return
            
            # Get actions for subplaylist
            actions = get_playlist_actions(playlist_id)
            if not actions:
                logging.warning(f"[Playback] No actions found for subplaylist ID {playlist_id}")
                return
            
            # Execute subplaylist actions
            self._execute_actions_sequence(actions)
            
        except Exception as e:
            logging.error(f"[Playback] Error executing playlist by ID {playlist_id}: {e}")

    def _execute_actions_sequence(self, actions):
        """Execute a sequence of actions without full playlist context."""
        prev_time = 0
        
        for idx, act in enumerate(actions):
            # Check if playback should stop
            if self.app.status_var.get() != 'Playing':
                break
                
            # Wait for timing interval
            interval = max(act.get('timestamp', 0) - prev_time, 0)
            wait_capped = min(interval, self._max_wait_per_action)
            wait_for = max(self._min_wait_per_action, wait_capped)
            
            if wait_for > 0:
                time.sleep(wait_for)
            
            # Execute action
            if act.get('action_type') == 'click':
                self._execute_click(act)
            elif act.get('action_type') in ('key_press', 'key_release'):
                self._execute_key_action(act)
            elif act.get('action_type') == 'screenshot':
                self._execute_screenshot_action()
            elif act.get('action_type') == 'subplaylist':
                # Handle nested subplaylists
                self._execute_subplaylist_action(act)
            elif act.get('action_type') == 'analyze_docs':
                try:
                    self._execute_analyze_docs_action()
                except Exception:
                    logging.exception("[Playback] analyze_docs action failed in subplaylist")
            elif act.get('action_type') == 'search':
                try:
                    self._execute_search_action(act)
                except Exception:
                    logging.exception("[Playback] search action failed in subplaylist")
            
            prev_time = act.get('timestamp', 0)

    def pause_playlist(self):
        """Pause playlist playback."""
        self.app.status_var.set('Paused')
        # TODO: Implement actual pause functionality