import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import time
import asyncio
import pathlib
import base64
import signal
import atexit
from pathlib import Path
from datetime import datetime, timezone, timedelta
import uuid

from .models import Workflow, SystemInfo, CodeResult
from . import co_datascientist_api
from .executors import ExecutorFactory
from .kpi_extractor import extract_kpi_from_stdout
from .settings import settings
from .qa_cache import get_answers, QACache
from .user_steering import get_steering_handler, wrap_spinner_with_coordination
from .executors import BaseExecutor
from .environment_detector import EnvironmentDetector
from .docker_generator import DockerGenerator
from .run_name_generator import generate_run_name
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TaskProgressColumn

OUTPUT_FOLDER = "co_datascientist_output"
RESULTS_FOLDER = "results"  # Single folder for all outputs
RUNS_BASE_FOLDER = "runs"  # Inside results/
TIMELINE_FOLDER = "timeline"
BY_PERFORMANCE_FOLDER = "by_performance"


def print_workflow_info(message: str):
    """Print workflow info with consistent formatting"""
    print(f"   {message}")


def print_workflow_step(message: str):
    """Print workflow step with consistent formatting"""
    print(f"   {message}")


def print_workflow_success(message: str):
    """Print workflow success with consistent formatting"""
    print(f"   {message}")


def print_workflow_error(message: str):
    """Print workflow error with consistent formatting"""
    print(f"   {message}")



def ignore_dirs(dir, files):
    """Ignore all output directories when copying to temp for Docker builds."""
    ignore_list = []
    for f in files:
        # Single results/ folder contains all outputs - ignore it
        if f in ("results", "current_runs", "co_datascientist_checkpoints", "co_datascientist_runs"):
            ignore_list.append(f)
    return ignore_list


def copy_to_tmp(code_base_directory):
    """
    SAFELY COPY the code_base_directory to a temporary directory.
    
    IMPORTANT: This is a COPY operation - your original workspace is never modified!
    
    Ignores 'results/' and all legacy output directories to prevent them from being
    included in Docker builds. Returns the path to the copied code base directory.
    """
    import shutil
    import tempfile
    import os
    from pathlib import Path
    
    # Use ~/co-datascientist-tmp instead of /tmp or ~/.cache for Docker BuildKit compatibility
    # Docker BuildKit has restricted access to certain directories due to security policies
    cache_dir = Path.home() / "co-datascientist-tmp"
    cache_dir.mkdir(parents=True, exist_ok=True)
    
    temp_dir = tempfile.mkdtemp(prefix="co-datascientist-", dir=str(cache_dir))
    # Ensure directory is readable by Docker daemon
    os.chmod(temp_dir, 0o755)
    
    temp_code_base_directory = Path(temp_dir) / Path(code_base_directory).name
    shutil.copytree(code_base_directory, temp_code_base_directory, ignore=ignore_dirs, dirs_exist_ok=True)
    # print_info(f"Copied code base directory to temporary location: {temp_code_base_directory}")
    return temp_code_base_directory

def auto_detect_repository_structure(working_directory):
    """
    Auto detect the repository structure and return the code base directory and the docker file directory.
    """
    code_base_directory = Path(working_directory)
    dockerfile_path = Path(working_directory) / 'Dockerfile'

    return code_base_directory, dockerfile_path



class WorkflowRunner:
    def __init__(self):
        self.workflow: Workflow | None = None
        self.start_timestamp = 0
        self.should_stop_workflow = False
        self._checkpoint_counter: int = 0
        self._run_counter: int = 0
        self._current_hypothesis: str | None = None
        self._run_name: str | None = None  # Memorable name for this workflow run
        self.steering_handler = get_steering_handler()
        self._steering_bar_started: bool = False
        # SPEED OPTIMIZATION: Store base image to reuse for all hypotheses
        self._base_image_tag: str | None = None
        self._entry_command: str | None = None
        self._engine_type: str = "EVOLVE_HYPOTHESIS"
        # Track all Docker images for cleanup on interruption
        self._docker_images_to_cleanup: set[str] = set()
        self._cleanup_registered: bool = False

    def prep_workflow(self, working_directory: str, config: dict):
        """Prep the workflow by SAFELY COPYING the code to temp (original workspace untouched) and setting up Docker."""
        temp_code_base_directory = copy_to_tmp(working_directory)
        code_base_directory, dockerfile_path = auto_detect_repository_structure(temp_code_base_directory)
        
        # Auto-generate Docker setup if Dockerfile doesn't exist
        if not dockerfile_path.exists():
            print("   No Dockerfile found - auto-generating from your environment...")
            dockerfile_path = self._auto_generate_docker_setup(
                working_directory=working_directory,
                temp_directory=temp_code_base_directory,
                config=config
            )
            print("   Docker setup generated automatically")

        return code_base_directory, dockerfile_path
    
    def _auto_generate_docker_setup(self, working_directory: str, temp_directory: Path, config: dict) -> Path:
        """
        Auto-generate Dockerfile and requirements.txt from user's environment.
        User never needs to create these files manually!
        
        Args:
            working_directory: Original project directory
            temp_directory: Temp directory where Docker files will be written
            config: User config containing entry_command
        
        Returns:
            Path to generated Dockerfile
        """
        # Get entry command from config
        entry_command = config.get('entry_command')
        if not entry_command:
            raise ValueError(
                "ERROR: No 'entry_command' specified in config.yaml\n"
                "   Please add: entry_command: 'python your_script.py'\n"
                "   This is the command you normally use to run your code."
            )
        
        # Detect requirements: prefer explicit requirements_file if provided, else auto-detect env
        detector = EnvironmentDetector(working_directory)
        env_snapshot = detector.get_environment_snapshot()

        requirements: list[str] = []
        requirements_file = config.get("requirements_file")
        if requirements_file:
            req_path = Path(requirements_file)
            if not req_path.is_absolute():
                req_path = Path(working_directory) / req_path
            if not req_path.exists():
                raise ValueError(f"requirements_file not found: {req_path}")
            requirements = [
                line.strip()
                for line in req_path.read_text().splitlines()
                if line.strip() and not line.strip().startswith("#")
            ]
            print(f"   Using requirements from {req_path}")
        else:
            # Detect environment from user's current setup
            requirements = env_snapshot['requirements']
        
        # Generate Docker files
        generator = DockerGenerator()
        custom_base_image = config.get('custom_base_image')
        dockerfile_content, requirements_content = generator.generate_dockerfile(
            python_version=env_snapshot['python_version'],
            requirements=requirements,
            entry_command=entry_command,
            working_directory=Path(working_directory),
            custom_base_image=custom_base_image,
        )
        
        # Write to temp directory
        dockerfile_path = generator.write_docker_files(
            temp_directory=temp_directory,
            dockerfile_content=dockerfile_content,
            requirements_content=requirements_content,
        )
        
        return dockerfile_path
    

    def get_evolvable_files(self, working_dir_path: str) -> dict[str, str]:
        """
        Scan a directory for Python files containing CO_DATASCIENTIST blocks.
        Returns a dict of {filename: code} for files containing the blocks.

        Ignores any files inside 'results/' and legacy output directories.

        Args:
            working_dir_path: Path to the working directory to scan.

        Returns:
            Dict mapping filename (relative to working_dir_path) -> code content.

        Raises:
            ValueError: If no files with CO_DATASCIENTIST blocks are found.
        """
        import os

        start_block = "# CO_DATASCIENTIST_BLOCK_START"
        end_block = "# CO_DATASCIENTIST_BLOCK_END"
        code_files = {}

        # Normalize ignore dir names for comparison
        IGNORE_DIRS = {"results", "current_runs", "co_datascientist_checkpoints", "co_datascientist_runs"}

        for root, dirs, files in os.walk(working_dir_path):
            # Remove ignored dirs from traversal
            dirs[:] = [d for d in dirs if d not in IGNORE_DIRS]
            for fname in files:
                if fname.endswith(".py"):
                    # Check if this file is inside an ignored directory
                    rel_dir = os.path.relpath(root, working_dir_path)
                    # rel_dir == '.' for top-level, otherwise it's a path
                    # Split rel_dir into its parts and check for ignore dirs
                    if rel_dir != ".":
                        dir_parts = set(rel_dir.replace("\\", "/").split("/"))
                        if IGNORE_DIRS & dir_parts:
                            continue
                    fpath = os.path.join(root, fname)
                    try:
                        with open(fpath, "r", encoding="utf-8") as f:
                            content = f.read()
                        if start_block in content and end_block in content:
                            # Store relative path for clarity
                            rel_path = os.path.relpath(fpath, working_dir_path)
                            code_files[rel_path] = content
                    except Exception:
                        # Optionally log or print error, but skip unreadable files
                        continue
        if not code_files:
            raise ValueError(
                "ERROR: No CO_DATASCIENTIST blocks found in any Python file in the directory.\n"
                "Please add evolution blocks:\n"
                "  # CO_DATASCIENTIST_BLOCK_START\n"
                "  # Your code here\n"
                "  # CO_DATASCIENTIST_BLOCK_END\n\n"
                "These blocks mark code that will be evolved."
            )

        return code_files

    def compile_docker_image(self, dockerfile_path: str, is_base_image: bool = False):
        """
        Build a Docker image from the Dockerfile in dockerfile_path,
        using code_base_directory as the build context.
        
        Args:
            dockerfile_path: Path to Dockerfile
            is_base_image: If True, this is the base image with dependencies (slower build)
                          If False, this is a lightweight hypothesis image (fast build)
        """
        import subprocess
        import uuid

        if not dockerfile_path.exists():
            raise FileNotFoundError(f"Dockerfile not found at {dockerfile_path}")

        # Generate a unique image tag
        image_tag = f"co-datascientist-{uuid.uuid4().hex[:8]}"
        logging.debug(f"Dockerfile path: {dockerfile_path}")
        
        # Track this image for cleanup
        self._docker_images_to_cleanup.add(image_tag)
        
        # Build the docker image
        build_context = dockerfile_path.parent
        build_cmd = [
            "docker", "build",
            "-t", image_tag,
            "-f", str(dockerfile_path),
            str(build_context)
        ]
        
        if is_base_image:
            print(f"🏗️  Building base image with dependencies...")
        
        try:
            # Capture output to suppress verbose Docker build logs
            result = subprocess.run(
                build_cmd,
                check=True,
                capture_output=True,
                text=True
            )
            
            if is_base_image:
                print(f"✅ Base image built (future builds will be faster)\n")
            
            logging.info(f"Docker image built: {image_tag}")
        except subprocess.CalledProcessError as e:
            print(f"\n❌ Error building Docker image")
            print(f"Error output: {e.stderr}")
            raise

        return image_tag

    def stitch_evolvable_files(self, evolvable_files: dict[str, str], temp_code_base_directory: str):
        """
        Stitch the evolvable files back into the directory where the docker file is (temp!)
        """
        # stitch the evolvable files back into the docker file directory
        for file_path, file_content in evolvable_files.items():
            with open(os.path.join(temp_code_base_directory, file_path), "w") as f:
                f.write(file_content)


    def cleanup_docker_image(self, docker_image_tag: str):
        """
        Remove a Docker image by tag to avoid clutter.
        """
        try:
            subprocess.run(
                ["docker", "rmi", "-f", docker_image_tag],
                check=False,
                capture_output=True,
                text=True
            )
            logging.debug(f"Cleaned up Docker image: {docker_image_tag}")
            # Remove from tracking set
            self._docker_images_to_cleanup.discard(docker_image_tag)
        except Exception as e:
            logging.warning(f"Failed to cleanup Docker image {docker_image_tag}: {e}")
    
    def _cleanup_all_docker_images(self):
        """
        Clean up all Docker images created during this run.
        Called on normal exit or interruption (Ctrl+C).
        """
        if not self._docker_images_to_cleanup:
            return
        
        print("\n🧹 Cleaning up Docker images...")
        images_to_remove = list(self._docker_images_to_cleanup)
        for image_tag in images_to_remove:
            try:
                subprocess.run(
                    ["docker", "rmi", "-f", image_tag],
                    check=False,
                    capture_output=True,
                    text=True,
                    timeout=10
                )
                logging.debug(f"Cleaned up Docker image: {image_tag}")
            except Exception as e:
                logging.warning(f"Failed to cleanup Docker image {image_tag}: {e}")
        
        self._docker_images_to_cleanup.clear()
        print("✅ Docker cleanup complete")
    
    def _register_cleanup_handlers(self):
        """
        Register signal handlers for clean shutdown on Ctrl+C or termination.
        Only register once per WorkflowRunner instance.
        """
        if self._cleanup_registered:
            return
        
        def signal_handler(signum, frame):
            """Handle Ctrl+C and SIGTERM gracefully"""
            signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
            print(f"\n\n⚠️  Received {signal_name} - cleaning up...")
            self._cleanup_all_docker_images()
            print("👋 Workflow interrupted by user")
            sys.exit(0)
        
        # Register signal handlers
        signal.signal(signal.SIGINT, signal_handler)
        signal.signal(signal.SIGTERM, signal_handler)
        
        # Also register atexit for normal exits
        atexit.register(self._cleanup_all_docker_images)
        
        self._cleanup_registered = True
        logging.debug("Registered cleanup handlers for signals and exit")

    async def stich_compile_execute(self, evolvable_files: dict[str, str], temp_code_base_directory: str, docker_file_directory: str, executor: BaseExecutor, is_baseline: bool = False):
        """
        Stitch code, compile Docker image, and execute.
        
        Args:
            evolvable_files: Dictionary of Python files to stitch in
            temp_code_base_directory: Temp directory containing the code
            docker_file_directory: Path to Dockerfile
            executor: Executor to run the code
            is_baseline: If True, build as base image (slow). If False, use fast hypothesis mode.
        """
        # Stitch in the code changes
        self.stitch_evolvable_files(evolvable_files, temp_code_base_directory)
        
        # Build appropriate Docker image (base or hypothesis)
        if is_baseline:
            # Baseline: Build the full base image with dependencies (slow but only once)
            docker_image_tag = self.compile_docker_image(docker_file_directory, is_base_image=True)
            # Store for reuse by hypotheses
            self._base_image_tag = docker_image_tag
        else:
            # Hypothesis: Generate lightweight Dockerfile using base image (fast!)
            if not self._base_image_tag:
                raise RuntimeError("Base image not built yet! Run baseline first.")
            
            # Generate lightweight hypothesis Dockerfile
            docker_gen = DockerGenerator()
            hypothesis_dockerfile = docker_gen.generate_hypothesis_dockerfile(
                base_image_tag=self._base_image_tag,
                entry_command=self._entry_command
            )
            
            # Write the lightweight Dockerfile
            hypothesis_dockerfile_path = docker_gen.write_hypothesis_dockerfile(
                temp_directory=Path(temp_code_base_directory),
                dockerfile_content=hypothesis_dockerfile
            )
            
            # Build the lightweight image (5-10 seconds!)
            docker_image_tag = self.compile_docker_image(hypothesis_dockerfile_path, is_base_image=False)
        
        print("Executing in Docker container...")
        logging.info("Executing code in Docker container")
        try:
            # Run the blocking executor.execute() in a thread pool so it doesn't block the event loop
            code_result = await asyncio.to_thread(executor.execute, docker_image_tag)
            return code_result
        finally:
            # Cleanup hypothesis images, but KEEP the base image for reuse
            if not is_baseline:
                self.cleanup_docker_image(docker_image_tag)


    async def run_workflow(self, working_directory: str, config: dict, spinner=None):
        """Run a complete code evolution workflow.
        """
        # Register cleanup handlers for Ctrl+C and normal exit
        self._register_cleanup_handlers()
        
        self.workflow = Workflow(status_text="Workflow started", user_id="")
        
        # Generate memorable run name for this workflow
        self._run_name = generate_run_name()
        print(f"\n🎯 Run name: {self._run_name}\n")
        
        # Cleanup old temp directories from previous runs
        try:
            cache_dir = Path.home() / "co-datascientist-tmp"
            if cache_dir.exists():
                current_time = time.time()
                # Remove directories older than 1 hour
                for temp_dir in cache_dir.glob("co-datascientist-*"):
                    if temp_dir.is_dir():
                        try:
                            dir_age = current_time - temp_dir.stat().st_mtime
                            if dir_age > 3600:  # 1 hour
                                import shutil
                                shutil.rmtree(temp_dir, ignore_errors=True)
                        except:
                            pass
        except Exception as e:
            logging.warning(f"Failed to cleanup old cache directories: {e}")
        
        # Get absolute path for checkpointing
        project_absolute_path = str(Path(working_directory).absolute())

        evolvable_files = self.get_evolvable_files(working_directory) # get a dict of python files from this dir ... 
        assert len(evolvable_files) > 0, "No blocks found in any Python file in the directory."
        temp_code_base_directory, docker_file_directory = self.prep_workflow(working_directory, config) # COPY user's workspace to temp (original untouched) and find/generate Dockerfile
        
        # Store entry command for hypothesis generation
        self._entry_command = config.get('entry_command')
        self._engine_type = str(config.get('engine', 'EVOLVE_HYPOTHESIS') or 'EVOLVE_HYPOTHESIS').upper()
        
        executor = ExecutorFactory.create_executor(python_path="python", config=config)
        
        # Show GPU availability status
        if hasattr(executor, '_gpu_available'):
            if executor._gpu_available:
                print("\n✅ GPU support: ENABLED (Docker containers will have GPU access)")
            else:
                print("\n💡 GPU support: DISABLED (Docker containers will run CPU-only)")
                if hasattr(executor, '_gpu_status'):
                    print(f"   Reason: {executor._gpu_status}")
        
        # Show air-gap venv status if configured
        if config.get('airgap_venv'):
            print(f"\n✅ Air-gap venv mode: ENABLED")
            print(f"   Venv path: {config.get('airgap_venv')}")
            print(f"   Packages will be loaded from mounted venv (no internet needed)")
        
        print("\nRunning your baseline")
        print("-" * 70)
            
        result = await self.stich_compile_execute(evolvable_files, temp_code_base_directory, docker_file_directory, executor, is_baseline=True)
        
        # Show result details before asserting to help with debugging
        if result.return_code != 0:
            print("\nBaseline execution failed")
            print(f"Return code: {result.return_code}")
            if result.stderr:
                print(f"\nError output:\n{result.stderr}")
            if result.stdout:
                print(f"\nStandard output:\n{result.stdout}")
            assert False, "Baseline docker image failed to execute"
        
        # Show clean baseline result
        print("-" * 70)
        if result.kpi is not None:
            print(f"Baseline KPI: {result.kpi}")
            print(f"Runtime: {result.runtime_ms:.0f}ms ({result.runtime_ms/1000:.1f}s)")
        else:
            print(f"Baseline completed in {result.runtime_ms:.0f}ms ({result.runtime_ms/1000:.1f}s)")
            print("No KPI extracted - add 'print(\"KPI:\", value)' to your code")
        print("-" * 70)
        logging.debug(f"Full baseline result: {result}")

        # TODO: get the types of all these parts of the model of what needs to be sent through! 

        # code_version = initial_response.code_to_run
        # code_version.result = result
        
        self.should_stop_workflow = False
        
        # Wrap spinner for coordination with status bar
        spinner = wrap_spinner_with_coordination(spinner)                
        # try:
            # if spinner:
            #     spinner.text = "Waking up the Co-DataScientist"
                
        self.start_timestamp = time.time()
        # system_info = get_system_info(python_path) #TODO: we may be able to get this from the docker ime... for now just go with None
        system_info = SystemInfo(python_libraries=["None"],python_version="3.13",os=sys.platform)
        # Get engine from config (default to EVOLVE_HYPOTHESIS)
        engine = config.get('engine', 'EVOLVE_HYPOTHESIS').upper()
        # Get cheat checking flag from config (default: False to save API credits)
        enable_cheat_checking = config.get('enable_cheat_checking', False)

        # Engine-specific parameters (currently only Novelty Search needs extra knobs)
        engine_params: dict | None = None
        if engine == "NOVELTY_SEARCH":
            novelty_params: dict = {}
            if "novelty_threshold" in config:
                try:
                    novelty_params["novelty_threshold"] = float(config["novelty_threshold"])
                except Exception:
                    logging.warning("Invalid novelty_threshold in config; ignoring.")
            if "novelty_auto_tune" in config:
                novelty_params["auto_tune_threshold"] = bool(config["novelty_auto_tune"])
            if "novelty_adaptive_method" in config:
                novelty_params["adaptive_method"] = str(config["novelty_adaptive_method"])
            if "novelty_adaptive_percentile" in config:
                try:
                    novelty_params["adaptive_percentile"] = float(config["novelty_adaptive_percentile"])
                except Exception:
                    logging.warning("Invalid novelty_adaptive_percentile in config; ignoring.")
            if "novelty_mad_multiplier" in config:
                try:
                    novelty_params["mad_multiplier"] = float(config["novelty_mad_multiplier"])
                except Exception:
                    logging.warning("Invalid novelty_mad_multiplier in config; ignoring.")
            if novelty_params:
                engine_params = novelty_params
        # Start preflight: send full dict of evolvable files
        preflight = await co_datascientist_api.start_preflight(
            evolvable_files,
            system_info,
            engine,
            enable_cheat_checking,
            engine_params=engine_params,
        )
        self.workflow = preflight.workflow
        # Stop spinner to allow clean input UX
        if spinner:
            spinner.stop()
        # Get observation text
        observation = getattr(preflight, 'observation', '') or ''
        # Clean questions
        questions = [re.sub(r'^\d+\.\s*', '', q.strip()) for q in preflight.questions]
        # Get answers (cached or interactive)
        use_cache = config.get('use_cached_qa', False)
        answers = get_answers(questions, str(working_directory), observation, use_cache)
        # Complete preflight: engine summarizes and starts baseline
        initial_response = await co_datascientist_api.complete_preflight(self.workflow.workflow_id, answers)
        self.workflow = initial_response.workflow
        
        # Show user what the AI understands about their problem
        # Use engine's summary if available, otherwise show Q&A context
        if self.workflow.user_context_summary:
            print(f"\n{self.workflow.user_context_summary}\n")
        elif observation:
            # Show the engine's observation about the code
            print(f"\n{observation}")
            if questions and answers:
                print("\nYour optimization preferences:")
                for q, a in zip(questions, answers):
                    print(f"  • {a}")
            print()  # blank line
        elif questions and answers:
            # Fallback: just show answers if no observation
            print("\nOptimization preferences:")
            for a in answers:
                if a.strip():  # Only non-empty answers
                    print(f"  • {a}")
            print()  # blank line

        # Exception: Failed to process batch results: Code version baseline not found in batch! DEBUG.
        # TODO: ok so we really need to understnad the exact thing that needs to be posted back... this is the sticking piint now.
        code_version = initial_response.code_to_run
        code_version.result = result
        # Submit baseline results
        await co_datascientist_api.finished_running_batch(
            self.workflow.workflow_id,
            "baseline_batch",
            [(code_version.code_version_id, result)],
        )

        # Unified batch system: batch_size=1 for sequential, >1 for parallel
        batch_size = int(config.get('parallel', 1) or 1)
    
        while (not self.workflow.finished and not self.should_stop_workflow):

            # break
            await self._check_user_direction()

            if spinner:
                spinner.text = f"Running {batch_size} programs in parallel..."
                spinner.start()
            
            # Let user know we're generating hypotheses
            print(f"\nGenerating {batch_size} new hypotheses...")
            
            # Request batch from backend and measure generation time
            batch_start = time.time()
            batch_to_run = await co_datascientist_api.get_batch_to_run(self.workflow.workflow_id, batch_size=batch_size)
            batch_elapsed = time.time() - batch_start
            
            self.workflow = batch_to_run.workflow
            # We have a batch to run!
            code_versions = batch_to_run.batch_to_run
            batch_id = batch_to_run.batch_id
            
            # Safety: if backend returns no hypotheses (finished or error), stop cleanly
            if not code_versions:
                if spinner:
                    spinner.stop()
                print("\nNo hypotheses returned from backend (workflow may be finished).")
                break
            
            # Show user how quickly hypotheses were generated
            num_hypotheses = len(code_versions)
            if num_hypotheses > 0:
                time_per_hypothesis = batch_elapsed / num_hypotheses
                print(f"\n{num_hypotheses} hypotheses generated in {batch_elapsed:.1f}s ({time_per_hypothesis:.2f}s per idea)")
                print(f"Batch size: {batch_size}\n")
                
                # Print all hypotheses before testing
                print("Generated Hypotheses:")
                print("-" * 70)
                for i, cv in enumerate(code_versions, 1):
                    hypothesis_text = cv.hypothesis or "No hypothesis description"
                    print(f"{i}. {hypothesis_text}")
                print("-" * 70)
                print(f"\nTesting {num_hypotheses} hypotheses...\n")

            # SPEED OPTIMIZATION: Hypotheses use lightweight Docker builds (5-10s instead of 2-20min!)
            import asyncio
            import shutil

            # PHASE 1: Build all Docker images
            print(f"🏗️  Building {len(code_versions)} Docker images...")
            
            docker_images = []
            for code_version in code_versions:
                evolvable_files = code_version.code  # dict of files to use for this candidate
                # Create a unique temp directory for each code version to avoid race conditions
                # Use ~/co-datascientist-tmp for Docker BuildKit compatibility
                cache_dir = Path.home() / "co-datascientist-tmp"
                cache_dir.mkdir(parents=True, exist_ok=True)
                temp_dir_for_version = tempfile.mkdtemp(
                    prefix=f"co-datascientist-{code_version.code_version_id[:8]}-",
                    dir=str(cache_dir)
                )
                os.chmod(temp_dir_for_version, 0o755)
                temp_code_dir_for_version = Path(temp_dir_for_version) / Path(temp_code_base_directory).name
                shutil.copytree(temp_code_base_directory, temp_code_dir_for_version, ignore=ignore_dirs)
                
                # Stitch in the code changes
                self.stitch_evolvable_files(evolvable_files, temp_code_dir_for_version)
                
                # Generate lightweight hypothesis Dockerfile
                docker_gen = DockerGenerator()
                hypothesis_dockerfile = docker_gen.generate_hypothesis_dockerfile(
                    base_image_tag=self._base_image_tag,
                    entry_command=self._entry_command
                )
                
                # Write the lightweight Dockerfile
                hypothesis_dockerfile_path = docker_gen.write_hypothesis_dockerfile(
                    temp_directory=Path(temp_code_dir_for_version),
                    dockerfile_content=hypothesis_dockerfile
                )
                
                # Build the lightweight image
                docker_image_tag = self.compile_docker_image(hypothesis_dockerfile_path, is_base_image=False)
                
                docker_images.append({
                    'code_version': code_version,
                    'docker_tag': docker_image_tag,
                    'temp_dir': temp_dir_for_version
                })
            
            print(f"✅ All images built")
            print(f"▶️  Running {len(code_versions)} hypotheses...\n")
            
            baseline_runtime_ms = result.runtime_ms  # Use baseline runtime for progress estimation
            
            with Progress(
                SpinnerColumn(),
                TextColumn("[bold blue]{task.description}", justify="left"),
                BarColumn(complete_style="green", finished_style="bold green"),
                TaskProgressColumn(),
            ) as progress:
                
                # Create progress task for each hypothesis
                progress_tasks = {}
                for i, img_info in enumerate(docker_images, 1):
                    cv = img_info['code_version']
                    hyp_short = (cv.hypothesis[:45] + "...") if len(cv.hypothesis) > 45 else cv.hypothesis
                    task_id = progress.add_task(f"H{i}: {hyp_short}", total=100)
                    progress_tasks[cv.code_version_id] = task_id
                
                # Helper function to execute with progress
                async def execute_with_progress(img_info, task_id, img_index):
                    start_time = time.time()
                    done = asyncio.Event()
                    
                    # Background progress updater
                    async def update_progress():
                        baseline_sec = baseline_runtime_ms / 1000
                        
                        while not done.is_set():
                            elapsed = time.time() - start_time
                            # Smooth progress based on baseline time, cap at 95% until done
                            pct = min(95, (elapsed / baseline_sec) * 100)
                            progress.update(task_id, completed=pct)
                            await asyncio.sleep(0.3)
                    
                    updater_task = asyncio.create_task(update_progress())
                    
                    try:
                        # Execute the Docker container
                        code_result = await asyncio.to_thread(
                            executor.execute, 
                            img_info['docker_tag']
                        )
                        
                        # Done! Stop updater and jump to 100%
                        done.set()
                        await updater_task
                        
                        # Update description with result
                        cv = img_info['code_version']
                        if code_result.kpi is not None:
                            hyp_short = (cv.hypothesis[:40] + "...") if len(cv.hypothesis) > 40 else cv.hypothesis
                            progress.update(
                                task_id, 
                                completed=100,
                                description=f"[green]✓[/green] H{img_index}: {hyp_short} | KPI: {code_result.kpi:.4f}"
                            )
                        else:
                            progress.update(task_id, completed=100)
                        
                        return code_result
                        
                    except Exception as e:
                        done.set()
                        await updater_task
                        cv = img_info['code_version']
                        hyp_short = (cv.hypothesis[:40] + "...") if len(cv.hypothesis) > 40 else cv.hypothesis
                        progress.update(
                            task_id, 
                            completed=100,
                            description=f"[red]✗[/red] H{img_index}: {hyp_short} | Failed"
                        )
                        # Return error result instead of raising
                        return CodeResult(
                            stdout=None,
                            stderr=str(e),
                            return_code=-1,
                            runtime_ms=int((time.time() - start_time) * 1000),
                            kpi=None
                        )
                
                # Execute all in parallel with progress tracking
                execution_tasks = []
                for i, img_info in enumerate(docker_images, 1):
                    cv_id = img_info['code_version'].code_version_id
                    task_id = progress_tasks[cv_id]
                    execution_tasks.append(execute_with_progress(img_info, task_id, i))
                
                code_results = await asyncio.gather(*execution_tasks)
            
            print()  # Blank line after progress bars
            
            # Log results for debugging (user-facing output comes later with hypothesis)
            for code_result in code_results:
                logging.debug(f"Code execution result: {code_result}")
            
            # Cleanup docker images and temp dirs
            for img_info in docker_images:
                self.cleanup_docker_image(img_info['docker_tag'])
                try:
                    shutil.rmtree(img_info['temp_dir'])
                except Exception as e:
                    logging.warning(f"Failed to cleanup temp dir {img_info['temp_dir']}: {e}")
            # if spinner:
            #     spinner.stop()

            # try:
            #     self.steering_handler.suspend_bar() ##TODO why? 
            # except Exception:
            #     pass

            # await self._display_batch_info(code_versions, batch_size)
            # executor = ExecutorFactory.create_executor(python_path, config) # We need to maek an execter each and ever time? can we not make one at the start???
            # results = await self._execute_batch(
            #     executor, code_versions, spinner, batch_size, python_path, config
            # ) # feels weird we wrap this again -- surely it just int he executer? ...

            # results = await executor.execute_batch(executor, code_versions, spinner, batch_size, python_path, config)

            if spinner:
                spinner.stop()

            # If execution yielded no results, skip reporting/submit to avoid index errors
            if not code_versions or not code_results:
                logging.warning("Batch returned no code_versions or no code_results; skipping display/submit.")
                continue

            tuples: list[tuple[str, CodeResult]] = []
            for cv, res in zip(code_versions, code_results):
                if res.kpi is None:
                    res.kpi = extract_kpi_from_stdout(res.stdout)
                if self._engine_type in ("MAP_ELITES", "NOVELTY_SEARCH"):
                    res.descriptor = self._parse_descriptor_from_stdout(res.stdout)
                tuples.append((cv.code_version_id, res))

            await self._display_batch_results(code_versions, code_results, batch_size)

            # try:
            #     self.steering_handler.resume_bar()
            # except Exception:
            #     pass

            # if not self._steering_bar_started:
            #     try:
            #         await self.steering_handler.start_listening()
            #         self._steering_bar_started = True
            #     except Exception:
            #         pass

            try:
                if code_versions and code_results:
                    # Save ALL runs in batch to timeline
                    for cv, res in zip(code_versions, code_results):
                        cv.result = res
                        await self._save_run_to_timeline(cv, project_absolute_path, config)
            except Exception as e:
                logging.warning(f"Failed saving runs to timeline: {e}")

            # Submit results (workflow will be updated on next get_batch_to_run call)
            await co_datascientist_api.finished_running_batch(
                self.workflow.workflow_id, batch_id, tuples
            )

            has_meaningful_results = any(
                cv.retry_count == 0 or cv.hypothesis_outcome in ["supported", "refuted", "failed"]
                for cv in code_versions
            )
            if has_meaningful_results:
                try:
                    best_info = await co_datascientist_api.get_workflow_population_best(
                        self.workflow.workflow_id
                    )
                    best_kpi = best_info.get("best_kpi") if best_info else None
                    if best_kpi is not None and spinner:
                        spinner.write(f"Current best KPI: {best_kpi}")

                    best_cv = best_info.get("best_code_version") if best_info else None
                    if best_cv and best_kpi is not None:
                        await self._save_population_best_checkpoint(
                            best_cv, best_kpi, project_absolute_path, config
                        )
                    elif best_kpi is not None and spinner:
                        spinner.write(f"No code version available for checkpoint (KPI: {best_kpi})")
                except Exception:
                    pass
            # pass
    
            # import ipdb; ipdb.set_trace()
            
        
        
            # Stop user steering handler
            # await self.steering_handler.stop_listening()

            if self.should_stop_workflow:
                # Check if this was a baseline failure (already handled) or user stop
                if (hasattr(self.workflow, 'baseline_code') and 
                    self.workflow.baseline_code.result is not None and 
                    self.workflow.baseline_code.result.return_code != 0):
                    # Baseline failure - already handled in _handle_baseline_result, just clean up
                    try:
                        await co_datascientist_api.stop_workflow(self.workflow.workflow_id)
                    except Exception as e:
                        logging.warning(f"Failed to stop workflow on backend: {e}")
                    if spinner:
                        spinner.text = "Workflow failed"
                else:
                    # User-initiated stop
                    await co_datascientist_api.stop_workflow(self.workflow.workflow_id)
                    print_workflow_info("Workflow stopped!.")
                    if spinner:
                        spinner.text = "Workflow stopped"
            # else:
            #     # Normal successful completion
            #     print_workflow_success("Workflow completed successfully.")
            #     if spinner:
            #         spinner.text = "Workflow completed"
        
        # Print summary of where results are saved
        if self._run_name:
            run_path = Path(project_absolute_path) / RESULTS_FOLDER / RUNS_BASE_FOLDER / self._run_name
            print("\n" + "="*70)
            print(f"🎯 Run completed: {self._run_name}")
            print("="*70)
            print(f"\n📁 Results saved in: {run_path}")
            print(f"   📊 All runs: {run_path / TIMELINE_FOLDER}")
            print(f"   🏆 By performance: {run_path / BY_PERFORMANCE_FOLDER}")
            print(f"   ⭐ Best: {run_path / 'best'}")
            print("="*70 + "\n")
        
        # Cleanup: Remove all Docker images at the end of workflow
        if self._docker_images_to_cleanup:
            print("\n🧹 Cleaning up Docker images...")
            self._cleanup_all_docker_images()
        
        # except Exception as e:
        #     if spinner:
        #         spinner.stop()

        #     err_msg = str(e)
        #     # Detect user-facing validation errors coming from backend
        #     if err_msg.startswith("ERROR:"):
        #         # Show concise guidance without stack trace
        #         print_workflow_error(err_msg)
        #         return  # Do not re-raise, end gracefully

        #     # Otherwise, show generic workflow error and re-raise for full trace
        #     print_workflow_error(f"Workflow error: {err_msg}")
        #     raise



    async def _check_user_direction(self):
        """Check for new user direction and update the workflow if needed."""
        try:
            latest_direction = await self.steering_handler.get_latest_direction()
            current_direction = getattr(self.workflow, 'user_direction', None)
            
            # Only update if direction has changed
            if latest_direction != current_direction:
                await co_datascientist_api.update_user_direction(
                    self.workflow.workflow_id, 
                    latest_direction
                )
                # Update local workflow state
                self.workflow.user_direction = latest_direction
                
                # Silent: no echo after steering to keep UI clean
                    
        except Exception as e:
            logging.warning(f"Failed to check user direction: {e}")
    
    async def _display_batch_info(self, code_versions: list, batch_size: int):
        """Silenced: avoid verbose batch info prints."""
        return

    # async def _execute_batch(self, executor, code_versions: list, spinner, batch_size: int, python_path: str, config: dict):
    #     """Execute batch with appropriate concurrency."""
    #     if batch_size == 1:
    #         # Sequential execution with adapted spinner
    #         cv = code_versions[0]
    #         if cv.name != "baseline" and cv.retry_count > 0:
    #             if spinner:
    #                 spinner.text = f"Debugging attempt {cv.retry_count}"
    #                 spinner.start()
    #         elif cv.name != "baseline":
    #             if spinner:
    #                 spinner.text = "Testing hypothesis"
    #                 spinner.start()
            
    #         manifest = config.get('manifest', None)
    #         # MULTI-FILE READY: Use evolved dict directly (already contains all files)
    #         results = []
    #         for cv in code_versions:
    #             # Execute with evolved dict (backend already stitched files)
    #             results.append(executor.execute(cv.code, manifest))
    #         return results
    #     else:
    #         # Parallel execution (existing logic)
    #         if hasattr(executor, 'supports_distributed_execution') and executor.supports_distributed_execution():
    #             if spinner:
    #                 spinner.text = f"Submitting {len(code_versions)} jobs to {executor.platform_name}..."
    #                 spinner.start()
    #             manifest = config.get('manifest', {})
    #             return await executor.execute_batch_distributed(code_versions, manifest)
    #         else:
    #             if spinner:
    #                 spinner.text = f"Running {len(code_versions)} programs in parallel..."
    #                 spinner.start()
    #             manifest = config.get('manifest', None)
    #             def _execute(cv):
    #                 single_executor = ExecutorFactory.create_executor(python_path, config)
    #                 return single_executor.execute(cv.code, manifest)

    #             tasks = [asyncio.to_thread(_execute, cv) for cv in code_versions]
    #             return await asyncio.gather(*tasks, return_exceptions=False)

    async def _display_batch_results(self, code_versions: list, results: list, batch_size: int):
        """Display results adapted to batch size."""
        if batch_size == 1:
            # Sequential mode: existing sequential display logic.
            cv, result = code_versions[0], results[0]
            kpi_value = getattr(result, 'kpi', None) or extract_kpi_from_stdout(result.stdout)
            
            if cv.name != "baseline":
                if kpi_value is not None and result.return_code == 0:
                    baseline_kpi = self._get_baseline_kpi()
                    hypothesis_outcome = baseline_kpi < kpi_value if baseline_kpi is not None else None
                    print("\n" + "-"*70)
                    print(f"Hypothesis: {cv.hypothesis or 'Unknown hypothesis'}")
                    if hypothesis_outcome is True:
                        print(f"   [IMPROVED] KPI: {kpi_value} (baseline: {baseline_kpi})")
                    elif hypothesis_outcome is False:
                        print(f"   [WORSE] KPI: {kpi_value} (baseline: {baseline_kpi})")
                    else:
                        print(f"   [RESULT] KPI: {kpi_value}")
                    print("-"*70)
                else:
                    # Handle failed executions like parallel mode
                    print("\n" + "-"*70)
                    print(f"Hypothesis: {cv.hypothesis or 'Unknown hypothesis'}")
                    if getattr(cv, 'hypothesis_outcome', None) == "failed":
                        print("   [FAILED] After all retries - moving on")
                    else:
                        print("   [DEBUG] Queuing for retry...")
                    print("-"*70)
        else:
            # Parallel mode: existing parallel display logic
            baseline_kpi = self._get_baseline_kpi()
            successful_results = []
            failed_results = []
            
            for cv, res in zip(code_versions, results):
                kpi_value = getattr(res, 'kpi', None) or extract_kpi_from_stdout(res.stdout)
                if hasattr(res, 'kpi') and res.kpi is None:
                    res.kpi = kpi_value
                    
                if kpi_value is not None and res.return_code == 0:
                    hypothesis_outcome = baseline_kpi < kpi_value if baseline_kpi is not None else None
                    successful_results.append((cv, kpi_value, hypothesis_outcome))
                else:
                    failed_results.append((cv, res))
            
            # Display results with thin separator
            print("\n" + "-"*70)
            
            # Display successful results
            for cv, kpi_value, hypothesis_outcome in successful_results:
                print()
                print(f"Hypothesis: {cv.hypothesis or 'Unknown hypothesis'}")
                if hypothesis_outcome is True:
                    print(f"   [IMPROVED] KPI: {kpi_value} (baseline: {baseline_kpi})")
                elif hypothesis_outcome is False:
                    print(f"   [WORSE] KPI: {kpi_value} (baseline: {baseline_kpi})")
                else:
                    print(f"   [RESULT] KPI: {kpi_value}")
            
            # Display failed results - show debugging status
            for cv, res in failed_results:
                print()
                print(f"Hypothesis: {cv.hypothesis or 'Unknown hypothesis'}")
                if getattr(cv, 'hypothesis_outcome', None) == "failed":
                    print("   [FAILED] After all retries - moving on")
                else:
                    print("   [DEBUG] Queuing for retry...")
            
            print("-"*70)

    def _get_baseline_kpi(self):
        """Get baseline KPI for comparison."""
        if self.workflow.baseline_code and self.workflow.baseline_code.result:
            return extract_kpi_from_stdout(self.workflow.baseline_code.result.stdout)
        return None

    async def _handle_baseline_result(self, result: CodeResult, response, spinner=None):
        """Handle result in standard mode (original behavior)"""
        # Check if code execution failed and provide clear feedback
        if result.return_code != 0:
            # Code failed - show error details
            print_workflow_error(f"'{response.code_to_run.name}' failed with exit code {result.return_code}")
            print(f"   DEBUG: stderr = {repr(result.stderr)}")
            print(f"   DEBUG: stdout = {repr(result.stdout)}")
            if result.stderr:
                print("   Error details:")
                # Print each line of stderr with proper indentation
                for line in result.stderr.strip().split('\n'):
                    if spinner:
                        spinner.write(f"      {line}")
                    else:
                        print(f"      {line}")
            
            # For baseline failures, give specific guidance and STOP immediately
            if response.code_to_run.name == "baseline":
                print("   The baseline code failed to run. This will stop the workflow.")
                print("   Check the error above and fix your script before running again.")
                if "ModuleNotFoundError" in (result.stderr or ""):
                    print("   Missing dependencies? Try: pip install <missing-package>")
                
                # Set flag to stop workflow immediately - don't wait for backend
                self.should_stop_workflow = True
                print_workflow_error("Workflow terminated due to baseline failure.")
                return

        else:
            # print("stdout:",result) 
            # Code succeeded - show success message
            kpi_value = extract_kpi_from_stdout(result.stdout)
            if kpi_value is not None:
                msg = f"Completed '{response.code_to_run.name}' | KPI = {kpi_value}"
                if spinner:
                    spinner.write(msg)
                    print("--------------------------------")
                else:
                    print_workflow_success(msg)
            elif response.code_to_run.name == "baseline": ### SO THE QUESTION IS WHY SOMETIMES WE DONT GET the output from the gcloud run... 
                # Debug: baseline succeeded but no KPI extracted
                logging.info(f"Baseline succeeded but no KPI found. Stdout: {result.stdout[:200] if result.stdout else 'None'}...")
                msg = f"Completed '{response.code_to_run.name}' (no KPI found)"
                self.should_stop_workflow = True
                return
            else:
                msg = f"Completed '{response.code_to_run.name}'"
                if spinner:
                    spinner.write(msg)
                else:
                    print_workflow_success(msg)

    async def _save_population_best_checkpoint(self, best_cv, best_kpi: float, project_absolute_path: str, config: dict):
        """Update 'best' symlink to point to the best run in timeline."""
        try:
            if not best_cv or best_kpi is None:
                return

            # Convert best_cv to CodeVersion model if it is raw dict
            from .models import CodeVersion, CodeResult
            if isinstance(best_cv, dict):
                try:
                    if isinstance(best_cv.get("result"), dict):
                        best_cv["result"] = CodeResult.model_validate(best_cv["result"])  # type: ignore
                    best_cv = CodeVersion.model_validate(best_cv)  # type: ignore
                except Exception as e:
                    logging.warning(f"Cannot parse best_code_version payload: {e}")
                    return

            is_databricks = config and config.get('databricks')
            if is_databricks:
                # TODO: Implement Databricks support
                logging.warning("Databricks support for new timeline structure not yet implemented")
                return
            
            # Use run-specific folder inside results/
            results_folder = Path(project_absolute_path) / RESULTS_FOLDER
            runs_base_folder = results_folder / RUNS_BASE_FOLDER
            if not self._run_name:
                self._run_name = generate_run_name()
            
            run_folder = runs_base_folder / self._run_name
            timeline_base = run_folder / TIMELINE_FOLDER
            
            # Find the timeline directory for this code version
            code_version_id = best_cv.code_version_id
            target_dir = None
            
            # Search timeline for matching code_version_id in metadata
            if timeline_base.exists():
                for dir_path in timeline_base.iterdir():
                    if dir_path.is_dir():
                        meta_file = dir_path / "metadata.json"
                        if meta_file.exists():
                            try:
                                meta = json.loads(meta_file.read_text())
                                if meta.get("code_version_id") == code_version_id:
                                    target_dir = dir_path
                                    break
                            except Exception:
                                continue
            
            if target_dir:
                # Update best symlink inside run folder
                best_link = run_folder / "best"
                if best_link.exists() or best_link.is_symlink():
                    best_link.unlink()
                
                # Create relative symlink
                relative_target = Path(TIMELINE_FOLDER) / target_dir.name
                best_link.symlink_to(relative_target)
                
            self._checkpoint_counter += 1
            
        except Exception as e:
            logging.warning(f"Failed updating best checkpoint symlink: {e}")


    async def _save_run_to_timeline(self, code_version, project_absolute_path: str, config: dict):
        """Save run to timeline/ with KPI-based naming and create performance symlink."""
        try:
            if not code_version:
                return

            from .models import CodeVersion, CodeResult
            if isinstance(code_version, dict):
                try:
                    if isinstance(code_version.get("result"), dict):
                        code_version["result"] = CodeResult.model_validate(code_version["result"])  # type: ignore
                    code_version = CodeVersion.model_validate(code_version)  # type: ignore
                except Exception as e:
                    logging.warning(f"Cannot parse code_version payload: {e}")
                    return

            # Extract KPI and hypothesis
            kpi = getattr(code_version.result, "kpi", None) if code_version.result else None
            hypothesis = getattr(code_version, "hypothesis", None)
            
            # Prepare metadata
            meta = {
                "code_version_id": code_version.code_version_id,
                "name": code_version.name,
                "hypothesis": hypothesis,
                "kpi": kpi,
                "stdout": getattr(code_version.result, "stdout", None) if code_version.result else None,
                "sequence": self._run_counter,
            }

            is_databricks = config and config.get('databricks')
            if is_databricks:
                # TODO: Implement Databricks support for new structure
                logging.warning("Databricks support for new timeline structure not yet implemented")
                return
            
            # Create run-specific directory with memorable name inside results/
            results_folder = Path(project_absolute_path) / RESULTS_FOLDER
            results_folder.mkdir(parents=True, exist_ok=True)
            
            runs_base_folder = results_folder / RUNS_BASE_FOLDER
            runs_base_folder.mkdir(parents=True, exist_ok=True)
            
            # Use memorable run name for this workflow
            if not self._run_name:
                self._run_name = generate_run_name()
            
            run_folder = runs_base_folder / self._run_name
            run_folder.mkdir(parents=True, exist_ok=True)
            
            # Create timeline and by_performance directories inside run folder
            timeline_base = run_folder / TIMELINE_FOLDER
            timeline_base.mkdir(parents=True, exist_ok=True)
            
            performance_base = run_folder / BY_PERFORMANCE_FOLDER
            performance_base.mkdir(parents=True, exist_ok=True)
            
            # Format directory name
            dir_name = _format_run_directory_name(self._run_counter, kpi, hypothesis)
            
            # Save to timeline
            timeline_dir = timeline_base / dir_name
            timeline_dir.mkdir(parents=True, exist_ok=True)
            
            code_dict = code_version.code if isinstance(code_version.code, dict) else {"main.py": code_version.code}
            meta["files"] = list(code_dict.keys())
            
            # Write code files
            for filename, content in code_dict.items():
                file_path = timeline_dir / filename
                file_path.parent.mkdir(parents=True, exist_ok=True)
                file_path.write_text(content, encoding="utf-8")
            
            # Write metadata
            (timeline_dir / "metadata.json").write_text(json.dumps(meta, indent=4))
            
            # Create symlink in by_performance (only if KPI exists)
            if kpi is not None:
                kpi_str = _format_kpi_string(kpi)
                hyp_slug = _format_hypothesis_slug(hypothesis)
                perf_link_name = f"{kpi_str}_{hyp_slug}"
                perf_link_path = performance_base / perf_link_name
                
                # Remove existing symlink if present
                if perf_link_path.exists() or perf_link_path.is_symlink():
                    perf_link_path.unlink()
                
                # Create relative symlink to timeline directory
                relative_target = Path("..") / TIMELINE_FOLDER / dir_name
                perf_link_path.symlink_to(relative_target)
            
            self._run_counter += 1
            
        except Exception as e:
            logging.warning(f"Failed saving run to timeline: {e}")

    def _parse_descriptor_from_stdout(self, stdout: str | None) -> list[float] | None:
        """
        Extract a descriptor vector from stdout if present.

        Expected format (single line):
            PREDICTIONS_JSON: [0.1, 0.2, ...]

        If missing or malformed, returns None to keep other engines unaffected.
        """
        if not stdout:
            return None
        try:
            for line in stdout.splitlines():
                if "PREDICTIONS_JSON:" in line:
                    _, payload = line.split("PREDICTIONS_JSON:", 1)
                    return list(json.loads(payload.strip()))
        except Exception:
            return None
        return None


def _make_filesystem_safe(name):
    return re.sub(r"[/\\?%*:|\"<>\x7F\x00-\x1F]", '_', name)

def _format_hypothesis_slug(hypothesis: str | None, max_length: int = 40) -> str:
    """Convert hypothesis to filesystem-safe slug"""
    if not hypothesis:
        return "no_hypothesis"
    # Remove special chars, replace spaces with underscores
    slug = re.sub(r'[^\w\s-]', '', hypothesis.lower())
    slug = re.sub(r'[-\s]+', '_', slug)
    # Truncate if too long
    if len(slug) > max_length:
        slug = slug[:max_length].rstrip('_')
    return slug or "hypothesis"

def _format_kpi_string(kpi: float | None) -> str:
    """Format KPI for directory name with consistent padding"""
    if kpi is None:
        return "kpi_FAILED"
    # Handle both positive and negative KPIs with consistent formatting
    # Zero-pad to 4 decimals for natural sorting
    if kpi >= 0:
        return f"kpi_{kpi:08.4f}"
    else:
        # Negative KPIs (like loss functions) - use absolute value with neg prefix
        return f"kpi_neg{abs(kpi):08.4f}"

def _format_run_directory_name(sequence: int, kpi: float | None, hypothesis: str | None) -> str:
    """Create directory name: {seq}_kpi_{kpi}_{hypothesis}"""
    seq_str = f"{sequence:04d}"
    kpi_str = _format_kpi_string(kpi)
    hyp_slug = _format_hypothesis_slug(hypothesis)
    return f"{seq_str}_{kpi_str}_{hyp_slug}"

def get_system_info(python_path: str) -> SystemInfo:
    return SystemInfo(
        python_libraries=_get_python_libraries(python_path),
        python_version=_get_python_version(python_path),
        os=sys.platform
    )

def _get_python_libraries(python_path: str) -> list[str]:
    try:
        # Use importlib.metadata to get installed packages (works in all Python 3.8+ environments)
        python_code = """
import importlib.metadata
for dist in importlib.metadata.distributions():
    print(f"{dist.metadata['Name']}=={dist.version}")
"""
        installed_libraries = subprocess.check_output(
            [python_path, "-c", python_code],
            universal_newlines=True
        ).strip()
        return [lib.strip() for lib in installed_libraries.split("\n") if lib.strip()]
    except subprocess.CalledProcessError:
        # If that fails, return empty list
        return []


def _get_python_version(python_path: str) -> str:
    return subprocess.check_output(
        [python_path, "--version"],
        universal_newlines=True
    ).strip()


workflow_runner = WorkflowRunner()
    

