#!/usr/bin/env python3
"""
taskSpinner - webSlinger's native messaging host for file operations and task scheduling
Spins straw (manual tasks) into gold (automated workflows)
"""

VERSION = "1.1.4"

import json
import sys
import struct
import os
import subprocess
import logging
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Dict, Any, Optional
import platform
import psutil
import pytz

# Platform-specific imports for file locking
if platform.system() == 'Windows':
    import msvcrt
else:
    import fcntl

class TaskSpinner:
    def __init__(self):
        self.base_dir = Path.home() / "webSlinger" / "taskSpinner"
        self.scheduled_tasks_dir = self.base_dir / "scheduledTasks"
        self.output_data_dir = self.base_dir / "outputData"
        self.logs_dir = self.base_dir / "logs"
        self.registry_file = self.base_dir / "profileRegistry.json"

        # Create directories if they don't exist
        for directory in [self.scheduled_tasks_dir, self.output_data_dir, self.logs_dir]:
            directory.mkdir(parents=True, exist_ok=True)
        
        # Setup logging
        log_file = self.logs_dir / f"taskSpinner_{datetime.now().strftime('%Y%m%d')}.log"
        logging.basicConfig(
            level=logging.INFO,
            format='%(asctime)s - %(levelname)s - %(message)s',
            handlers=[
                logging.FileHandler(log_file),
                logging.StreamHandler()
            ]
        )
        self.logger = logging.getLogger(__name__)

    def read_message(self) -> Optional[Dict[str, Any]]:
        """Read message from stdin (Chrome extension)"""
        try:
            # Read message length (4 bytes)
            raw_length = sys.stdin.buffer.read(4)
            if not raw_length:
                return None
            
            message_length = struct.unpack('=I', raw_length)[0]
            
            # Read message content
            message = sys.stdin.buffer.read(message_length).decode('utf-8')
            return json.loads(message)
        
        except Exception as e:
            self.logger.error(f"Failed to read message: {e}")
            return None

    def send_message(self, message: Dict[str, Any]) -> None:
        """Send message to stdout (Chrome extension)"""
        try:
            encoded_message = json.dumps(message).encode('utf-8')
            encoded_length = struct.pack('=I', len(encoded_message))
            
            sys.stdout.buffer.write(encoded_length)
            sys.stdout.buffer.write(encoded_message)
            sys.stdout.buffer.flush()
            
        except Exception as e:
            self.logger.error(f"Failed to send message: {e}")

    def detect_chrome_profile(self) -> Optional[str]:
        """Detect the Chrome profile directory name by walking up the process tree to find Chrome.
        Only valid when taskSpinner is launched via native messaging.
        Chrome omits --profile-directory entirely when using the Default profile, so absence
        of the flag (when Chrome is found in the tree) means 'Default'."""
        try:
            process = psutil.Process()
            # Walk up the process tree to find Chrome (may be grandparent via cmd.exe, etc.)
            for _ in range(5):
                process = process.parent()
                if process is None:
                    break
                exe = process.exe().lower()
                if 'chrome' in exe:
                    for arg in process.cmdline():
                        if arg.startswith('--profile-directory='):
                            return arg.split('=', 1)[1]
                    # Chrome found but flag absent — Default profile omits the flag
                    self.logger.info("Chrome found in process tree with no --profile-directory flag - assuming Default profile")
                    return 'Default'
            self.logger.warning("Chrome not found in process tree - cannot detect profile")
        except Exception as e:
            self.logger.warning(f"Could not detect Chrome profile from process tree: {e}")
        return None

    def update_profile_registry(self, api_key_hash: str, profile_name: str) -> None:
        """Write or update the apiKeyHash -> profileName mapping in profileRegistry.json."""
        registry = {}
        if self.registry_file.exists():
            with open(self.registry_file, 'r', encoding='utf-8') as f:
                registry = json.load(f)

        if registry.get(api_key_hash) != profile_name:
            registry[api_key_hash] = profile_name
            with open(self.registry_file, 'w', encoding='utf-8') as f:
                json.dump(registry, f, indent=2)
            self.logger.info(f"Updated profile registry: {api_key_hash[:8]}... -> {profile_name}")

    def load_profile_registry(self) -> Dict[str, str]:
        """Load profileRegistry.json. Exits if the file does not exist."""
        if not self.registry_file.exists():
            self.logger.error(
                "profileRegistry.json not found. "
                "Open Chrome with the webSlinger extension active to create it."
            )
            sys.exit(1)
        with open(self.registry_file, 'r', encoding='utf-8') as f:
            return json.load(f)

    def handle_read_file(self, file_path: str, encoding: str = 'utf-8') -> Dict[str, Any]:
        """Read file content and return to extension"""
        try:
            file_path = Path(file_path).expanduser()
            
            if not file_path.exists():
                return {
                    'success': False,
                    'error': f'File not found: {file_path}'
                }
            
            if not file_path.is_file():
                return {
                    'success': False,
                    'error': f'Path is not a file: {file_path}'
                }
            
            with open(file_path, 'r', encoding=encoding) as f:
                content = f.read()
            
            self.logger.info(f"Successfully read file: {file_path}")
            return {
                'success': True,
                'content': content,
                'path': str(file_path)
            }
            
        except Exception as e:
            self.logger.error(f"Failed to read file {file_path}: {e}")
            return {
                'success': False,
                'error': f'Failed to read file: {str(e)}'
            }

    def handle_write_file(self, file_path: str, content: str, encoding: str = 'utf-8') -> Dict[str, Any]:
        """Write content to file"""
        try:
            file_path = Path(file_path).expanduser()
            
            # Create parent directories if needed
            file_path.parent.mkdir(parents=True, exist_ok=True)
            
            with open(file_path, 'w', encoding=encoding) as f:
                f.write(content)
            
            self.logger.info(f"Successfully wrote file: {file_path}")
            return {
                'success': True,
                'path': str(file_path)
            }
            
        except Exception as e:
            self.logger.error(f"Failed to write file {file_path}: {e}")
            return {
                'success': False,
                'error': f'Failed to write file: {str(e)}'
            }

    def handle_save_schedule(self, schedule_data: Dict[str, Any]) -> Dict[str, Any]:
        """Save scheduled task definition"""
        try:
            api_key_hash = schedule_data.get('apiKeyHash')
            if not api_key_hash:
                raise ValueError("schedule_data missing required field 'apiKeyHash'")

            task_id = schedule_data.get('taskId', 'unknown')
            scheduled_time = schedule_data.get('scheduledTime', datetime.now().isoformat())

            # Schedule files are stored per-user: scheduledTasks/{apiKeyHash}/{filename}
            user_tasks_dir = self.scheduled_tasks_dir / api_key_hash
            user_tasks_dir.mkdir(parents=True, exist_ok=True)

            # Generate filename based on current scheduled time
            time_str = datetime.fromisoformat(scheduled_time.replace('Z', '+00:00')).strftime('%Y-%m-%d_%H%M')
            new_filename = f"{time_str}_{task_id}.json"
            new_file_path = user_tasks_dir / new_filename

            # Check if updating existing task or creating new
            if 'fileName' in schedule_data and schedule_data['fileName']:
                # Updating existing task
                old_filename = schedule_data['fileName']
                old_file_path = user_tasks_dir / old_filename

                # If time changed, delete old file and use new filename
                if old_filename != new_filename:
                    if old_file_path.exists():
                        old_file_path.unlink()
                        self.logger.info(f"Deleted old schedule file: {old_filename}")
                    schedule_data['fileName'] = new_filename
                    self.logger.info(f"Renamed schedule: {old_filename} -> {new_filename}")
                else:
                    self.logger.info(f"Updating existing schedule: {old_filename}")

                filename = new_filename
                file_path = new_file_path
            else:
                # Creating new task
                schedule_data['createdAt'] = datetime.now().isoformat()
                schedule_data['fileName'] = new_filename
                filename = new_filename
                file_path = new_file_path
                self.logger.info(f"Creating new schedule: {new_filename}")

            # Save to file
            with open(file_path, 'w', encoding='utf-8') as f:
                json.dump(schedule_data, f, indent=2)

            self.logger.info(f"Saved scheduled task: {filename}")

            # Check if this task is due within 5 minutes and trigger immediate execution if needed
            if schedule_data.get('enabled', True):
                current_time = datetime.now(timezone.utc)
                next_task_run = self.get_next_run_time(schedule_data, current_time)

                if next_task_run:
                    time_until_run = (next_task_run - current_time).total_seconds() / 60.0    # minutes

                    if 0 <= time_until_run <= 5:
                        self.logger.info(f"Task due in {time_until_run:.1f} minutes - triggering immediate check")
                        self.trigger_immediate_check_async()
                    else:
                        self.logger.debug(f"Task due in {time_until_run:.1f} minutes - no immediate trigger needed")

            return {
                'success': True,
                'fileName': filename,
                'path': str(file_path)
            }

        except Exception as e:
            self.logger.error(f"Failed to save schedule: {e}")
            return {
                'success': False,
                'error': f'Failed to save schedule: {str(e)}'
            }

    def handle_save_output(self, output_data: Dict[str, Any], file_path: str) -> Dict[str, Any]:
        """Save automation output to specified location"""
        try:
            file_path = Path(file_path).expanduser()

            # Create parent directories if needed
            file_path.parent.mkdir(parents=True, exist_ok=True)

            # Add execution metadata
            output_data['savedAt'] = datetime.now().isoformat()

            with open(file_path, 'w', encoding='utf-8') as f:
                json.dump(output_data, f, indent=2)

            self.logger.info(f"Saved automation output: {file_path}")
            return {
                'success': True,
                'path': str(file_path)
            }

        except Exception as e:
            self.logger.error(f"Failed to save output: {e}")
            return {
                'success': False,
                'error': f'Failed to save output: {str(e)}'
            }

    def handle_list_schedules(self, api_key_hash: str) -> Dict[str, Any]:
        """List scheduled tasks belonging to the given user (identified by apiKeyHash)."""
        if not api_key_hash:
            raise ValueError("handle_list_schedules called without api_key_hash")

        schedules = []
        user_tasks_dir = self.scheduled_tasks_dir / api_key_hash

        if not user_tasks_dir.exists():
            self.logger.info(f"No schedule directory for hash {api_key_hash[:8]}...")
            return {'success': True, 'schedules': []}

        for task_file in user_tasks_dir.glob("*.json"):
            try:
                with open(task_file, 'r', encoding='utf-8') as f:
                    task_data = json.load(f)
                schedules.append(task_data)
            except Exception as e:
                self.logger.error(f"Error reading task file {task_file}: {e}")
                continue

        # Sort by scheduled time
        schedules.sort(key=lambda x: x.get('scheduledTime', ''), reverse=False)

        self.logger.info(f"Listed {len(schedules)} scheduled tasks for hash {api_key_hash[:8]}...")
        return {
            'success': True,
            'schedules': schedules
        }

    def handle_delete_schedules(self, filenames: list, api_key_hash: str) -> Dict[str, Any]:
        """Delete scheduled tasks by filename, scoped to the given user."""
        if not api_key_hash:
            raise ValueError("handle_delete_schedules called without api_key_hash")

        deleted_count = 0
        user_tasks_dir = self.scheduled_tasks_dir / api_key_hash

        for filename in filenames:
            task_file = user_tasks_dir / filename

            if task_file.exists():
                task_file.unlink()
                deleted_count += 1
                self.logger.info(f"Deleted schedule: {filename}")
            else:
                self.logger.warning(f"Schedule file not found: {filename}")

        return {
            'success': True,
            'deleted_count': deleted_count
        }

    def handle_update_schedule_error(self, filename: str, error: Optional[str], error_time: Optional[str], api_key_hash: str) -> Dict[str, Any]:
        """Update a scheduled task's lastError and lastErrorTime fields.
        If error is None/empty, clears both fields (used when execution succeeds)."""
        if not filename:
            return {'success': False, 'error': 'No fileName provided'}
        if not api_key_hash:
            raise ValueError("handle_update_schedule_error called without api_key_hash")

        try:
            task_file = self.scheduled_tasks_dir / api_key_hash / filename
            if not task_file.exists():
                return {'success': False, 'error': f'Schedule file not found: {filename}'}

            with open(task_file, 'r', encoding='utf-8') as f:
                task_data = json.load(f)

            if error:
                task_data['lastError'] = error
                task_data['lastErrorTime'] = error_time or datetime.now(timezone.utc).isoformat()
                self.logger.info(f"Recorded error for {filename}: {error}")
            else:
                task_data.pop('lastError', None)
                task_data.pop('lastErrorTime', None)
                self.logger.info(f"Cleared error for {filename}")

            with open(task_file, 'w', encoding='utf-8') as f:
                json.dump(task_data, f, indent=2)

            return {'success': True}

        except Exception as e:
            self.logger.error(f"Failed to update schedule error: {e}")
            return {'success': False, 'error': f'Failed to update schedule error: {str(e)}'}

    def get_next_run_time(self, task_data: Dict[str, Any], current_time: datetime) -> Optional[datetime]:
        """Calculate when the task should next run based on its schedule type"""
        schedule_type = task_data.get('scheduleType', 'once')
        scheduled_time = datetime.fromisoformat(task_data['scheduledTime'].replace('Z', '+00:00'))

        if schedule_type == 'once':
            # One-time task: return the scheduled time if it hasn't passed yet
            if scheduled_time > current_time:
                return scheduled_time
            return None     # Already passed

        elif schedule_type == 'daily':
            # Get execution timezone — required for recurring schedules
            exec_tz_name = task_data.get('executionTimezone')
            if not exec_tz_name:
                raise ValueError(
                    f"Daily schedule '{task_data.get('taskId', '?')}' missing executionTimezone. "
                    f"This indicates a bug in schedule creation."
                )
            exec_tz = pytz.timezone(exec_tz_name)  # Throws UnknownTimeZoneError if invalid

            # Convert current UTC time to execution timezone
            current_local = current_time.astimezone(exec_tz)

            # Find next execution time in execution timezone
            execution_times = task_data.get('executionTimes', [])
            execution_times.sort()

            for exec_time in execution_times:
                exec_hour, exec_min = map(int, exec_time.split(':'))
                # Create next_run in execution timezone
                next_run_local = current_local.replace(hour=exec_hour, minute=exec_min, second=0, microsecond=0)
                if next_run_local > current_local:
                    # Convert back to UTC
                    return next_run_local.astimezone(pytz.UTC).replace(tzinfo=timezone.utc)

            # All times today have passed, use first time tomorrow
            first_time = execution_times[0]
            exec_hour, exec_min = map(int, first_time.split(':'))
            next_run_local = (current_local + timedelta(days=1)).replace(hour=exec_hour, minute=exec_min, second=0, microsecond=0)
            return next_run_local.astimezone(pytz.UTC).replace(tzinfo=timezone.utc)

        elif schedule_type == 'weekly':
            # Get execution timezone — required for recurring schedules
            exec_tz_name = task_data.get('executionTimezone')
            if not exec_tz_name:
                raise ValueError(
                    f"Weekly schedule '{task_data.get('taskId', '?')}' missing executionTimezone. "
                    f"This indicates a bug in schedule creation."
                )
            exec_tz = pytz.timezone(exec_tz_name)

            # Convert current UTC time to execution timezone
            current_local = current_time.astimezone(exec_tz)

            # Find next matching weekday and execution time
            weekdays = sorted(task_data.get('weekdays', []))
            execution_times = sorted(task_data.get('executionTimes', []))

            current_weekday = (current_local.weekday() + 1) % 7     # Sunday=0

            # Try times later today if it's a matching weekday
            if current_weekday in weekdays:
                for exec_time in execution_times:
                    exec_hour, exec_min = map(int, exec_time.split(':'))
                    next_run_local = current_local.replace(hour=exec_hour, minute=exec_min, second=0, microsecond=0)
                    if next_run_local > current_local:
                        return next_run_local.astimezone(pytz.UTC).replace(tzinfo=timezone.utc)

            # Find next matching weekday
            for days_ahead in range(1, 8):
                check_date_local = current_local + timedelta(days=days_ahead)
                check_weekday = (check_date_local.weekday() + 1) % 7
                if check_weekday in weekdays:
                    # Use first execution time for that day
                    first_time = execution_times[0]
                    exec_hour, exec_min = map(int, first_time.split(':'))
                    next_run_local = check_date_local.replace(hour=exec_hour, minute=exec_min, second=0, microsecond=0)
                    return next_run_local.astimezone(pytz.UTC).replace(tzinfo=timezone.utc)

            return None     # Should never reach here

        elif schedule_type == 'monthly':
            # Get execution timezone — required for recurring schedules
            exec_tz_name = task_data.get('executionTimezone')
            if not exec_tz_name:
                raise ValueError(
                    f"Monthly schedule '{task_data.get('taskId', '?')}' missing executionTimezone. "
                    f"This indicates a bug in schedule creation."
                )
            exec_tz = pytz.timezone(exec_tz_name)

            # Convert current UTC time to execution timezone
            current_local = current_time.astimezone(exec_tz)

            monthly_day = task_data.get('monthlyDay', '1')
            execution_times = sorted(task_data.get('executionTimes', []))

            if not execution_times:
                return None

            # Determine target day for current month
            if monthly_day == 'last':
                # Last day of current month
                if current_local.month == 12:
                    next_month = current_local.replace(year=current_local.year + 1, month=1, day=1)
                else:
                    next_month = current_local.replace(month=current_local.month + 1, day=1)
                target_day = (next_month - timedelta(days=1)).day
            else:
                target_day = int(monthly_day)

            # Try times later today if it's the target day
            if current_local.day == target_day:
                for exec_time in execution_times:
                    exec_hour, exec_min = map(int, exec_time.split(':'))
                    next_run_local = current_local.replace(hour=exec_hour, minute=exec_min, second=0, microsecond=0)
                    if next_run_local > current_local:
                        return next_run_local.astimezone(pytz.UTC).replace(tzinfo=timezone.utc)

            # Move to next month
            check_month = current_local.month
            check_year = current_local.year

            # If we're past the target day this month, move to next month
            if current_local.day >= target_day:
                check_month += 1
                if check_month > 12:
                    check_month = 1
                    check_year += 1

            # Calculate target day for check month
            if monthly_day == 'last':
                if check_month == 12:
                    next_month_start = datetime(check_year + 1, 1, 1, tzinfo=exec_tz)
                else:
                    next_month_start = datetime(check_year, check_month + 1, 1, tzinfo=exec_tz)
                target_day = (next_month_start - timedelta(days=1)).day
            else:
                target_day = int(monthly_day)

            # Use first execution time
            first_time = execution_times[0]
            exec_hour, exec_min = map(int, first_time.split(':'))
            next_run_local = exec_tz.localize(datetime(check_year, check_month, target_day, exec_hour, exec_min))
            return next_run_local.astimezone(pytz.UTC).replace(tzinfo=timezone.utc)

        elif schedule_type == 'hourly':
            # Next run is 1 hour from now (or start of window tomorrow if past end)
            next_run = current_time + timedelta(hours=1)
            next_run = next_run.replace(minute=0, second=0, microsecond=0)

            hourly_end = task_data.get('hourlyEnd', '23:59')
            end_hour, end_min = map(int, hourly_end.split(':'))

            # If next hour is past end time, move to start time tomorrow
            if next_run.hour > end_hour or (next_run.hour == end_hour and next_run.minute > end_min):
                hourly_start = task_data.get('hourlyStart', '00:00')
                start_hour, start_min = map(int, hourly_start.split(':'))
                next_run = (next_run + timedelta(days=1)).replace(hour=start_hour, minute=start_min, second=0, microsecond=0)

            return next_run

        elif schedule_type == 'interval':
            # Next run is current time + interval
            interval_value = task_data.get('intervalValue', 30)
            interval_unit = task_data.get('intervalUnit', 'minutes')

            if interval_unit == 'hours':
                return current_time + timedelta(hours=interval_value)
            else:  # minutes
                return current_time + timedelta(minutes=interval_value)

        return None

    def check_scheduled_tasks(self) -> Dict[str, Any]:
        """Check for tasks with next run within 5 minutes"""
        current_time = datetime.now(timezone.utc)
        due_tasks = []
        five_minutes_from_now = current_time + timedelta(minutes=5)

        for task_file in self.scheduled_tasks_dir.glob("*/*.json"):
            try:
                with open(task_file, 'r', encoding='utf-8') as f:
                    task_data = json.load(f)

                if not task_data.get('enabled', True):
                    continue

                # Calculate when this task should next run
                next_run = self.get_next_run_time(task_data, current_time)

                if next_run is None:
                    # Task has no future runs (e.g., one-time task that passed)
                    continue

                # Check if next run is within the next 5 minutes
                if current_time <= next_run <= five_minutes_from_now:
                    # Calculate sleep duration (in seconds)
                    sleep_duration = (next_run - current_time).total_seconds()
                    if sleep_duration < 0:
                        sleep_duration = 0

                    # Mark one-time tasks for deletion
                    schedule_type = task_data.get('scheduleType', 'once')
                    if schedule_type == 'once':
                        task_data['_deleteAfterExecution'] = True

                    due_tasks.append({
                        'fileName': task_file.name,
                        'taskData': task_data,
                        'nextRun': next_run.isoformat(),
                        'sleepDuration': sleep_duration
                    })

                    self.logger.info(f"Task {task_file.name} due at {next_run.isoformat()} (sleep {sleep_duration:.1f}s)")

            except Exception as e:
                self.logger.error(f"Error reading task file {task_file}: {e}")
                continue

        return {
            'success': True,
            'dueTasks': due_tasks,
            'checkTime': current_time.isoformat()
        }

    def find_extension_profile(self, extension_id: str) -> Optional[tuple]:
        """Find which Chrome profile has the extension installed.

        Checks for the given extension_id by directory first (Web Store installs),
        then checks Preferences for the exact ID (unpacked installs),
        then scans Preferences for any extension named 'webSlinger' (fallback).

        Returns (profile_name, found_extension_id) or None.
        """
        chrome_user_data = Path(os.getenv('LOCALAPPDATA')) / 'Google' / 'Chrome' / 'User Data'

        if not chrome_user_data.exists():
            self.logger.warning(f"Chrome user data directory not found: {chrome_user_data}")
            return None

        # Collect profile directories
        profile_dirs = []
        for item in chrome_user_data.iterdir():
            if item.is_dir() and (item.name == 'Default' or item.name.startswith('Profile ')):
                profile_dirs.append(item)

        # Pass 1: Check for the known extension ID in the Extensions directory
        for profile_dir in profile_dirs:
            extensions_dir = profile_dir / 'Extensions' / extension_id
            if extensions_dir.exists():
                self.logger.info(f"Found extension {extension_id} in profile: {profile_dir.name}")
                return (profile_dir.name, extension_id)

        # Pass 2: Check Preferences and Secure Preferences for the exact extension ID.
        # Chrome stores unpacked/developer extensions in Secure Preferences,
        # while Web Store extensions appear in regular Preferences.
        for profile_dir in profile_dirs:
            for prefs_filename in ('Preferences', 'Secure Preferences'):
                prefs_file = profile_dir / prefs_filename
                if not prefs_file.exists():
                    continue
                try:
                    with open(prefs_file, 'r', encoding='utf-8') as f:
                        prefs = json.load(f)

                    extensions = prefs.get('extensions', {}).get('settings', {})
                    if extension_id in extensions:
                        self.logger.info(f"Found extension {extension_id} in {prefs_filename} for profile: {profile_dir.name}")
                        return (profile_dir.name, extension_id)
                except Exception as e:
                    self.logger.debug(f"Could not read {prefs_filename} for {profile_dir.name}: {e}")
                    continue

        # Pass 3: Scan Preferences and Secure Preferences for any extension
        # named "webSlinger" (by manifest name or path).
        # This catches cases where the ID is unknown (e.g., old schedules without extensionId).
        for profile_dir in profile_dirs:
            for prefs_filename in ('Preferences', 'Secure Preferences'):
                prefs_file = profile_dir / prefs_filename
                if not prefs_file.exists():
                    continue
                try:
                    with open(prefs_file, 'r', encoding='utf-8') as f:
                        prefs = json.load(f)

                    extensions = prefs.get('extensions', {}).get('settings', {})
                    for ext_id, ext_data in extensions.items():
                        manifest = ext_data.get('manifest', {})
                        ext_path = ext_data.get('path', '')
                        # Check manifest name (Web Store installs) or path (unpacked installs)
                        if manifest.get('name') == 'webSlinger' or ext_path.lower().endswith('webslinger-extension'):
                            self.logger.info(f"Found webSlinger extension (ID: {ext_id}) in {prefs_filename} for profile: {profile_dir.name}")
                            return (profile_dir.name, ext_id)
                except Exception as e:
                    self.logger.debug(f"Could not read {prefs_filename} for {profile_dir.name}: {e}")
                    continue

        self.logger.warning(f"webSlinger extension not found in any Chrome profile")
        return None

    def execute_browser_automation(self, task_data: Dict[str, Any]) -> Dict[str, Any]:
        """Launch separate Chrome instance for automation"""
        script_name = task_data.get('scriptName')
        data_file_path = task_data.get('dataFilePath')
        target_website = task_data.get('targetWebsite', 'https://www.google.com')
        output_folder = task_data.get('outputFolder', '')

        if not script_name:
            raise ValueError('No script name provided in task data')

        # Determine browser executable
        system = platform.system().lower()
        if system == 'darwin':    # macOS
            browser_cmd = ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
        elif system == 'windows':
            # Try common Chrome installation paths
            chrome_paths = [
                os.path.expandvars(r'%ProgramFiles%\Google\Chrome\Application\chrome.exe'),
                os.path.expandvars(r'%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe'),
                os.path.expandvars(r'%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe'),
            ]
            browser_cmd = None
            for path in chrome_paths:
                if os.path.exists(path):
                    browser_cmd = [path]
                    break
            if not browser_cmd:
                raise FileNotFoundError('Chrome executable not found')
        else:  # Linux
            browser_cmd = ['google-chrome']

        # Look up which Chrome profile owns this schedule via the registry
        api_key_hash = task_data.get('apiKeyHash')
        extension_id = task_data.get('extensionId')

        if not api_key_hash:
            raise ValueError(
                f"Task '{task_data.get('taskId', '?')}' missing apiKeyHash. "
                "This schedule was created before per-user isolation was implemented and cannot be executed."
            )
        if not extension_id:
            raise ValueError(
                f"Task '{task_data.get('taskId', '?')}' missing extensionId. "
                "This indicates a bug in schedule creation."
            )

        registry = self.load_profile_registry()
        profile_name = registry.get(api_key_hash)
        if not profile_name:
            raise ValueError(
                f"No Chrome profile found for apiKeyHash {api_key_hash[:8]}... in profileRegistry.json. "
                "Open Chrome with the matching webSlinger extension to register this profile."
            )

        # URL encode parameters
        from urllib.parse import quote
        encoded_target = quote(target_website, safe='')
        encoded_data = quote(data_file_path, safe='') if data_file_path else ''
        encoded_output = quote(output_folder, safe='') if output_folder else ''

        # Include task fileName so extension can report errors back
        task_filename = task_data.get('fileName', '')
        encoded_task_filename = quote(task_filename, safe='') if task_filename else ''

        extension_url = f"chrome-extension://{extension_id}/automation.html?script={script_name}&target={encoded_target}"

        # Include library source for non-user scripts
        script_source = task_data.get('scriptSource', 'user')
        if script_source and script_source != 'user':
            extension_url += f"&source={quote(script_source, safe='')}"

        if encoded_task_filename:
            extension_url += f"&taskFile={encoded_task_filename}"
        if encoded_data:
            extension_url += f"&data={encoded_data}"
        if encoded_output:
            extension_url += f"&output={encoded_output}"

        # Launch Chrome directly to extension URL
        # Profile is explicitly specified to ensure extension is loaded
        self.logger.info(f"Using Chrome profile: {profile_name}")
        self.logger.info(f"Extension URL: {extension_url}")

        cmd = browser_cmd + [
            f'--profile-directory={profile_name}',
            '--new-window',
            extension_url
        ]

        self.logger.info(f"Command: {' '.join(cmd)}")

        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)

        self.logger.info(f"Launched separate Chrome instance for automation: {script_name}")
        return {
            'success': True,
            'processId': process.pid,
            'taskId': task_data.get('taskId')
        }

    def process_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
        """Process incoming message and route to appropriate handler.
        This is the protocol boundary — every handler must return a response dict
        so the extension doesn't hang. The try/except here is the single catch-all
        for any unhandled exception from handler methods."""
        command = message.get('command')
        message_id = message.get('messageId')

        try:
            # Route to appropriate handler
            if command == 'readFile':
                response = self.handle_read_file(
                    message.get('path', ''),
                    message.get('encoding', 'utf-8')
                )

            elif command == 'writeFile':
                response = self.handle_write_file(
                    message.get('path', ''),
                    message.get('content', ''),
                    message.get('encoding', 'utf-8')
                )

            elif command == 'saveSchedule':
                response = self.handle_save_schedule(message.get('schedule', {}))

            elif command == 'saveOutput':
                response = self.handle_save_output(
                    message.get('data', {}),
                    message.get('filePath', '')
                )

            elif command == 'checkScheduledTasks':
                response = self.check_scheduled_tasks()

            elif command == 'executeAutomation':
                response = self.execute_browser_automation(message.get('taskData', {}))

            elif command == 'listSchedules':
                response = self.handle_list_schedules(message.get('apiKeyHash', ''))

            elif command == 'deleteSchedules':
                response = self.handle_delete_schedules(
                    message.get('filenames', []),
                    message.get('apiKeyHash', '')
                )

            elif command == 'updateScheduleError':
                response = self.handle_update_schedule_error(
                    message.get('fileName', ''),
                    message.get('error'),
                    message.get('errorTime'),
                    message.get('apiKeyHash', '')
                )

            elif command == 'getVersion':
                response = {
                    'success': True,
                    'version': VERSION,
                    'installPath': str(Path(__file__).resolve())
                }

            else:
                response = {
                    'success': False,
                    'error': f'Unknown command: {command}'
                }
        except Exception as e:
            self.logger.error(f"Unhandled error processing '{command}': {e}", exc_info=True)
            response = {
                'success': False,
                'error': f'{type(e).__name__}: {e}'
            }

        # Add messageId to response for routing
        if message_id:
            response['messageId'] = message_id

        return response

    def trigger_immediate_check_async(self):
        """Spawn background process to check/execute tasks immediately (non-blocking)"""
        try:
            subprocess.Popen(
                [sys.executable, __file__, '--check-and-execute'],
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL
            )
            self.logger.info("Triggered immediate task check in background")
        except Exception as e:
            self.logger.error(f"Failed to trigger immediate check: {e}")

    def check_and_execute_scheduled_tasks(self):
        """Standalone mode: Check for due tasks and execute them immediately"""
        lock_file_path = self.base_dir / '.taskspinner.lock'

        # Try to acquire exclusive lock to prevent duplicate execution
        with open(lock_file_path, 'w') as lock_file:
            try:
                if platform.system() == 'Windows':
                    msvcrt.locking(lock_file.fileno(), msvcrt.LK_NBLCK, 1)
                else:
                    fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)

                self.logger.info("Acquired lock - checking for scheduled tasks...")

                # Perform the actual check and execute
                return self._do_check_and_execute()

            except (IOError, OSError):
                # Lock held by another instance - skip execution
                self.logger.info("Another taskSpinner instance is running - skipping")
                return False

    def _do_check_and_execute(self):
        """Internal method: Perform the actual check and execution (called with lock held)"""
        # Exit immediately if no profiles have ever registered — nothing valid to run
        if not self.registry_file.exists():
            self.logger.info("profileRegistry.json not found - no profiles registered yet, exiting")
            return True

        result = self.check_scheduled_tasks()

        if not result.get('success'):
            self.logger.error(f"Failed to check scheduled tasks: {result.get('error')}")
            return False

        due_tasks = result.get('dueTasks', [])

        if not due_tasks:
            self.logger.info("No tasks due within next 5 minutes")
            return True

        self.logger.info(f"Found {len(due_tasks)} task(s) due within 5 minutes")

        for task_info in due_tasks:
            task_data = task_info['taskData']
            task_id = task_data.get('taskId', 'unknown')
            sleep_duration = task_info.get('sleepDuration', 0)
            next_run = task_info.get('nextRun')

            # Sleep until 10 seconds before execution time
            if sleep_duration > 10:
                sleep_time = sleep_duration - 10
                self.logger.info(f"Sleeping {sleep_time:.1f}s (10s before {next_run}) for task: {task_id}")
                time.sleep(sleep_time)
            elif sleep_duration > 0:
                self.logger.info(f"Sleeping {sleep_duration:.1f}s until {next_run} for task: {task_id}")
                time.sleep(sleep_duration)

            # RE-READ task file to get current data (in case it was modified during sleep)
            api_key_hash = task_info['taskData']['apiKeyHash']
            task_file = self.scheduled_tasks_dir / api_key_hash / task_info['fileName']
            if not task_file.exists():
                self.logger.info(f"Task file {task_info['fileName']} no longer exists - skipping")
                continue

            with open(task_file, 'r', encoding='utf-8') as f:
                current_task_data = json.load(f)

            # Check if task is still enabled
            if not current_task_data.get('enabled', True):
                self.logger.info(f"Task {task_id} was disabled - skipping execution")
                continue

            # Recalculate next run time from current task data
            current_time = datetime.now(timezone.utc)
            current_next_run = self.get_next_run_time(current_task_data, current_time)

            if current_next_run is None:
                self.logger.info(f"Task {task_id} has no future runs - skipping")
                continue

            # Check if next run is within 10 seconds
            seconds_until_run = (current_next_run - current_time).total_seconds()

            if seconds_until_run > 10:
                self.logger.info(f"Task {task_id} was rescheduled ({seconds_until_run:.1f}s away) - skipping")
                continue

            # Task is still scheduled within 10 seconds - sleep remaining time if needed
            if seconds_until_run > 0:
                self.logger.info(f"Sleeping final {seconds_until_run:.1f}s until {current_next_run} for task: {task_id}")
                time.sleep(seconds_until_run)

            self.logger.info(f"Executing task: {task_id}")

            # Launch browser automation using CURRENT task data
            exec_result = self.execute_browser_automation(current_task_data)

            if exec_result.get('success'):
                self.logger.info(f"Successfully launched automation for task: {task_id}")

                # Handle one-time vs recurring tasks
                schedule_type = current_task_data.get('scheduleType', 'once')
                if schedule_type == 'once':
                    # One-time task: delete file — if this fails, the task runs again
                    task_file.unlink()
                    self.logger.info(f"Deleted one-time task file: {task_info['fileName']}")
                else:
                    # Recurring task: update next run time — if this fails, the task
                    # would re-run immediately on next cycle, so let it crash
                    next_run_time = self.get_next_run_time(current_task_data, current_time)

                    if next_run_time:
                        current_task_data['scheduledTime'] = next_run_time.isoformat()
                        with open(task_file, 'w', encoding='utf-8') as f:
                            json.dump(current_task_data, f, indent=2)
                        self.logger.info(f"Updated next run for {task_info['fileName']}: {next_run_time.isoformat()}")
            else:
                self.logger.error(f"Failed to launch automation for task {task_id}: {exec_result.get('error')}")

        return True

    def run(self):
        """Main native messaging loop"""
        self.logger.info("taskSpinner started - spinning straw into gold...")

        # Detect profile once for this native messaging session
        profile_name = self.detect_chrome_profile()

        try:
            while True:
                message = self.read_message()
                if message is None:
                    break

                # Update profile registry whenever the message carries an apiKeyHash
                api_key_hash = message.get('apiKeyHash') or (
                    message.get('schedule', {}) or {}
                ).get('apiKeyHash')
                if api_key_hash and profile_name:
                    self.update_profile_registry(api_key_hash, profile_name)

                response = self.process_message(message)
                self.send_message(response)

        except KeyboardInterrupt:
            self.logger.info("taskSpinner stopped by user")
        except Exception as e:
            self.logger.error(f"taskSpinner error: {e}")
        finally:
            self.logger.info("taskSpinner shutting down")

def main():
    """Entry point - supports both standalone and native messaging modes"""
    if len(sys.argv) > 1 and sys.argv[1] == '--check-and-execute':
        # Standalone mode for scheduled task / cron
        spinner = TaskSpinner()
        success = spinner.check_and_execute_scheduled_tasks()
        sys.exit(0 if success else 1)
    else:
        # Native messaging mode
        spinner = TaskSpinner()
        spinner.run()

if __name__ == '__main__':
    main()