#!/usr/bin/env python3
"""
VeoxEvolver — Sklearn-style Evolution Client with Rich Terminal Dashboard
=========================================================================

Professional-grade client for the VEOX Evolution REST API with stunning UX.

Quick Start::

    from veox_client import VeoxEvolver

    evolver = VeoxEvolver("binary")
    evolver.fit(max_generations=10, population_size=20)

    print(evolver.best_fitness_)     # Best score
    print(evolver.best_pipeline_)    # Best genome as readable string
    print(evolver.champions_)        # All champions
    evolver.save("my_results.json")  # Persist results

With auto-managed server::

    with VeoxEvolver.serve(port=8090) as evolver:
        evolver.category = "binary"
        evolver.fit(max_generations=10)

The `fit()` call blocks until the evolution completes (or fails / times out),
while rendering a live ``rich`` dashboard in the terminal showing real-time
progress, fitness curves, champion table, ETA, and engine statistics.
"""

import json as _json
import os
import signal
import subprocess
import sys
import time
import warnings
import requests
from contextlib import contextmanager
from dataclasses import dataclass, field, asdict
from typing import Any, Callable, Dict, List, Optional

# ── rich imports ──────────────────────────────────────────────────────────────
from rich.console import Console
from rich.live import Live
from rich.table import Table
from rich.panel import Panel
from rich.layout import Layout
from rich.text import Text
from rich.align import Align
from rich import box


# ═══════════════════════════════════════════════════════════════════════════════
#  CONSTANTS & METADATA
# ═══════════════════════════════════════════════════════════════════════════════

_API_VERSION = "v1"
_DEFAULT_POLL_INTERVAL = 1.5
_MAX_RETRIES = 3
_RETRY_BACKOFF = [0.1, 0.2, 0.4]

# DNA animation frames for the evolution spinner
_DNA_FRAMES = ["🧬", "🔬", "⚗️ ", "🧪", "🔭", "💡"]

# Algorithm family display metadata
_FAMILY_META = {
    "binary":       {"icon": "🎯", "color": "#22d3ee", "metric": "AUC"},
    "regression":   {"icon": "📈", "color": "#34d399", "metric": "R²"},
    "trading":      {"icon": "📊", "color": "#f59e0b", "metric": "Sharpe"},
    "optimization": {"icon": "⚡", "color": "#a78bfa", "metric": "Best Value"},
    "outlier":      {"icon": "🔍", "color": "#f472b6", "metric": "AUC"},
    "time_series":  {"icon": "⏰", "color": "#60a5fa", "metric": "MASE"},
    "timeseries":   {"icon": "⏰", "color": "#60a5fa", "metric": "MASE"},
    "signal_separation":        {"icon": "📡", "color": "#fb923c", "metric": "SINR dB"},
    "signal_separation_analog": {"icon": "📡", "color": "#fb923c", "metric": "SINR dB"},
}

_VALID_CATEGORIES = {"binary", "regression", "trading", "optimization",
                     "outlier", "time_series", "timeseries", "signal_separation",
                     "signal_separation_analog"}


# ═══════════════════════════════════════════════════════════════════════════════
#  DATACLASSES
# ═══════════════════════════════════════════════════════════════════════════════

@dataclass
class Champion:
    """A champion pipeline discovered by the evolution engine."""
    hash: str = ""
    generation: int = 0
    train_score: Optional[float] = None
    valid_score: Optional[float] = None
    score: Optional[float] = None
    genome_str: str = ""
    timestamp: Any = None


@dataclass
class EvolutionResult:
    """
    Result container returned by ``VeoxEvolver.fit()``.

    Properties
    ----------
    success : bool
        True if evolution completed with at least 1 evaluation.
    evals_per_minute : float
        Average evaluations per minute.
    """
    job_id: str = ""
    status: str = ""
    best_fitness: float = -9999.0
    total_evals: int = 0
    generation: int = 0
    max_generations: int = 0
    champions: List[Dict] = field(default_factory=list)
    history: List[float] = field(default_factory=list)
    error_count: int = 0
    timeout_count: int = 0
    elapsed_seconds: float = 0.0
    category: str = ""

    def to_dict(self) -> dict:
        """Convert to a plain dictionary."""
        return asdict(self)

    def to_json(self, indent: int = 2) -> str:
        """Serialize to a JSON string."""
        return _json.dumps(self.to_dict(), indent=indent, default=str)

    @property
    def success(self) -> bool:
        """True if evolution completed with at least 1 evaluation."""
        return self.status in ("completed", "timeout") and self.total_evals > 0

    @property
    def evals_per_minute(self) -> float:
        """Average evaluations per minute."""
        if self.elapsed_seconds <= 0:
            return 0.0
        return self.total_evals / (self.elapsed_seconds / 60.0)


# ═══════════════════════════════════════════════════════════════════════════════
#  HELPER FUNCTIONS
# ═══════════════════════════════════════════════════════════════════════════════

def _sparkline(values: List[float], width: int = 50) -> str:
    """Return a unicode sparkline string for a list of floats."""
    if not values:
        return ""
    chars = "▁▂▃▄▅▆▇█"
    lo, hi = min(values), max(values)
    rng = hi - lo if hi != lo else 1.0
    recent = values[-width:]
    return "".join(
        chars[min(int((v - lo) / rng * (len(chars) - 1)), len(chars) - 1)]
        for v in recent
    )


def _fmt_score(val: Any) -> str:
    """Pretty-print a score value."""
    if val is None:
        return "—"
    try:
        v = float(val)
        if abs(v) > 99990:
            return "—"
        return f"{v:+.4f}"
    except (ValueError, TypeError):
        return str(val)[:12]


def _elapsed_str(seconds: float) -> str:
    """Format seconds to MM:SS or HH:MM:SS."""
    if seconds >= 3600:
        return time.strftime("%H:%M:%S", time.gmtime(seconds))
    return time.strftime("%M:%S", time.gmtime(seconds))


def _decode_pipeline(genome_str: str) -> str:
    """
    Parse a genome string like 'required=Foo.py|scaler=None|model_1=XGB.py'
    into a clean readable format: 'Foo → XGB'

    Strips None stages, removes .py suffixes, and joins with arrows.
    """
    if not genome_str:
        return ""
    parts = genome_str.split("|")
    active = []
    for part in parts:
        if "=" not in part:
            continue
        slot, val = part.split("=", 1)
        if val in ("None", "", "null"):
            continue
        # Strip .py suffix and common prefixes
        name = val.replace(".py", "")
        # Skip Null* placeholder stages
        if name.startswith("Null"):
            continue
        active.append(name)
    if not active:
        return genome_str[:60]
    return " → ".join(active)


def _estimate_eta(
    elapsed: float,
    evals: int,
    gen: int,
    max_gen: int,
    pop_size: int,
) -> str:
    """Estimate remaining time based on evaluation rate and generation progress."""
    if evals <= 0 or elapsed < 5:
        return "calculating…"

    # Use generation progress if available
    if max_gen > 0 and gen > 0:
        pct_done = gen / max_gen
        if pct_done > 0:
            total_est = elapsed / pct_done
            remaining = max(0, total_est - elapsed)
            return _elapsed_str(remaining)

    # Fallback: estimate from eval rate
    eval_rate = evals / elapsed  # evals/sec
    if eval_rate > 0 and max_gen > 0 and pop_size > 0:
        total_expected = max_gen * pop_size
        remaining_evals = max(0, total_expected - evals)
        remaining = remaining_evals / eval_rate
        return _elapsed_str(remaining)

    return "—"


# ═══════════════════════════════════════════════════════════════════════════════
#  DASHBOARD BUILDER
# ═══════════════════════════════════════════════════════════════════════════════

def _build_dashboard(
    snap: Dict[str, Any],
    elapsed: float,
    job_id: str,
    category: str,
    poll_count: int,
    eval_history: List[int],
    pop_size: int,
    stall_count: int,
) -> Layout:
    """Build the rich Layout dashboard from a snapshot dict."""

    status = snap.get("status", "unknown")
    gen = snap.get("generation", 0)
    max_gen = snap.get("max_generations", 0)
    evals = snap.get("total_evals", 0)
    best = snap.get("best_fitness", -9999.0)
    champions = snap.get("champions", [])
    history = snap.get("history", [])
    inflight = snap.get("inflight", [])
    err_c = snap.get("error_count", 0)
    timeout_c = snap.get("timeout_count", 0)

    # Family metadata
    meta = _FAMILY_META.get(category, {"icon": "🧬", "color": "#c084fc", "metric": "Score"})
    fam_icon = meta["icon"]
    fam_color = meta["color"]
    metric_name = meta["metric"]

    # ── Status styling ────────────────────────────────────────────────
    status_styles = {
        "pending":      ("⏳", "yellow"),
        "initializing": ("⚙️ ", "cyan"),
        "running":      (_DNA_FRAMES[poll_count % len(_DNA_FRAMES)], "green"),
        "completed":    ("✅", "bold green"),
        "failed":       ("❌", "bold red"),
        "timeout":      ("⏰", "bold yellow"),
    }
    s_icon, s_colour = status_styles.get(status, ("❓", "white"))

    # ── Throughput ────────────────────────────────────────────────────
    evals_per_min = (evals / (elapsed / 60.0)) if elapsed > 5 else 0.0
    eta = _estimate_eta(elapsed, evals, gen, max_gen, pop_size)

    # ── Header ────────────────────────────────────────────────────────
    header_text = Text()
    header_text.append("  VEOX ", style="bold white on #6c3fc5")
    header_text.append(f"  {fam_icon} {category.upper()} EVOLUTION  ", style=f"bold {fam_color}")
    header_text.append(f"  Job {job_id[:8]}…  ", style="dim")
    header_text.append(f"  ⏱  {_elapsed_str(elapsed)}  ", style="bold cyan")
    if eta not in ("—", "calculating…"):
        header_text.append(f"  ETA {eta}  ", style="dim cyan")
    header_text.append(f"  {s_icon} {status.upper()}", style=f"bold {s_colour}")

    header = Panel(
        Align.center(header_text),
        style="#6c3fc5",
        box=box.DOUBLE,
        padding=(0, 1),
    )

    # ── Left: Stats panel ─────────────────────────────────────────────
    pct = (gen / max_gen * 100) if max_gen > 0 else 0
    bar_filled = int(pct / 100 * 24)
    bar_str = "█" * bar_filled + "░" * (24 - bar_filled)
    best_color = "green" if best > 0 else ("red" if best < -90 else "yellow")

    stats_lines = [
        f"[bold]📊 Generation:[/bold]  {gen} / {max_gen}",
        f"   [{s_colour}]{bar_str}[/{s_colour}] {pct:.0f}%",
        "",
        f"[bold]🔬 Evaluations:[/bold] {evals}",
        f"[bold]⚡ Throughput:[/bold]  {evals_per_min:.1f}/min",
        "",
        f"[bold]{fam_icon} {metric_name}:[/bold]{' ' * max(1, 12 - len(metric_name))}[bold {best_color}]{_fmt_score(best)}[/bold {best_color}]",
        "",
        f"[bold]💀 Errors:[/bold]      {err_c}",
        f"[bold]⏰ Timeouts:[/bold]    {timeout_c}",
    ]

    if inflight:
        stats_lines.append(f"[bold]🔄 In-flight:[/bold]   {len(inflight)}")
    if stall_count >= 5:
        stats_lines.append("")
        stats_lines.append(f"[bold yellow]⚠ Stalled:[/bold yellow]     {stall_count} polls")

    stats_panel = Panel(
        "\n".join(stats_lines),
        title="[bold]Engine Stats[/bold]",
        border_style="#7c3aed",
        box=box.ROUNDED,
        padding=(1, 2),
    )

    # ── Right: Champions table ────────────────────────────────────────
    champ_table = Table(
        show_header=True,
        header_style=f"bold {fam_color}",
        box=box.SIMPLE_HEAVY,
        row_styles=["", "dim"],
        expand=True,
        padding=(0, 1),
    )
    champ_table.add_column("#", style="dim", width=3, justify="right")
    champ_table.add_column("Gen", width=4, justify="center")
    champ_table.add_column("Train", width=10, justify="right")
    champ_table.add_column("Valid", width=10, justify="right")
    champ_table.add_column("Pipeline", ratio=1)

    for i, c in enumerate(champions[:8]):
        genome = c.get("genome_str", "")
        decoded = _decode_pipeline(genome)
        if len(decoded) > 55:
            decoded = decoded[:52] + "…"
        style = "bold green" if i == 0 else ""
        champ_table.add_row(
            f"{'👑' if i == 0 else str(i+1)}",
            str(c.get("generation", "?")),
            _fmt_score(c.get("train_score")),
            _fmt_score(c.get("valid_score", c.get("v_score"))),
            decoded,
            style=style,
        )

    if not champions:
        champ_table.add_row("—", "—", "—", "—", "[dim]Waiting for first champion…[/dim]")

    champ_panel = Panel(
        champ_table,
        title="[bold]🏆 Champions[/bold]",
        border_style="#7c3aed",
        box=box.ROUNDED,
        padding=(0, 0),
    )

    # ── Bottom: Fitness sparkline + eval rate sparkline ────────────────
    spark = _sparkline(history, width=50)
    if history:
        lo_str = _fmt_score(min(history))
        hi_str = _fmt_score(max(history))
        spark_text = f"  [dim]{lo_str}[/dim]  {spark}  [dim]{hi_str}[/dim]"
    else:
        spark_text = "  [dim]Collecting fitness data…[/dim]"

    eval_spark = _sparkline([float(e) for e in eval_history[-30:]], width=20) if eval_history else ""
    if eval_spark:
        spark_text += f"    [dim]evals:[/dim] {eval_spark}"

    spark_panel = Panel(
        spark_text,
        title="[bold]📈 Fitness Trend[/bold]",
        border_style="#7c3aed",
        box=box.ROUNDED,
        padding=(0, 1),
    )

    # ── Assemble Layout ───────────────────────────────────────────────
    layout = Layout()
    layout.split_column(
        Layout(header, name="header", size=3),
        Layout(name="body", ratio=1),
        Layout(spark_panel, name="sparkline", size=3),
    )
    layout["body"].split_row(
        Layout(stats_panel, name="stats", ratio=1),
        Layout(champ_panel, name="champions", ratio=2),
    )

    return layout


# ═══════════════════════════════════════════════════════════════════════════════
#  VEOXEVOLVER CLASS
# ═══════════════════════════════════════════════════════════════════════════════

class VeoxEvolver:
    """
    Sklearn-style interface for the VEOX Evolution API.

    Parameters
    ----------
    category : str
        Algorithm family: ``"binary"``, ``"regression"``, ``"trading"``,
        ``"optimization"``, ``"outlier"``, ``"time_series"``.
    api_url : str
        Base URL of the VEOX FastAPI server (no trailing slash).
    verbose : bool
        Whether to display the live dashboard during ``fit()``.

    Attributes (available after ``fit()``)
    ----------------------------------------
    best_fitness_ : float
        Best fitness score achieved by any champion.
    best_pipeline_ : str
        Human-readable best pipeline (decoded genome).
    champions_ : list[dict]
        Top champion pipelines with scores and genome strings.
    history_ : list[float]
        Per-generation best-fitness trace.
    result_ : EvolutionResult
        Full result container with all metrics and metadata.
    job_id_ : str
        UUID of the last submitted job.

    Example
    -------
    >>> evolver = VeoxEvolver("binary")
    >>> evolver.fit(max_generations=10, population_size=20)
    >>> evolver.best_fitness_
    0.8732
    >>> evolver.best_pipeline_
    'XGBoostBinaryClassifier → StandardScaler → StackingFusion'
    """

    # ── Class-level constants ─────────────────────────────────────────
    CATEGORIES = list(_VALID_CATEGORIES)

    def __init__(
        self,
        category: str = "binary",
        api_url: str = "http://127.0.0.1:8090",
        verbose: bool = True,
    ):
        # ── Input validation ──────────────────────────────────────────
        if category not in _VALID_CATEGORIES:
            raise ValueError(
                f"Invalid category '{category}'. "
                f"Choose from: {sorted(_VALID_CATEGORIES)}"
            )

        self.category = category
        self.api_url = api_url.rstrip("/")
        self.verbose = verbose
        self._api_base = f"{self.api_url}/api/{_API_VERSION}/evolution"
        self._console = Console()

        # Fitted attributes (populated after fit)
        self.best_fitness_: float = -9999.0
        self.best_pipeline_: str = ""
        self.champions_: List[Dict] = []
        self.history_: List[float] = []
        self.result_: Optional[EvolutionResult] = None
        self.job_id_: Optional[str] = None
        self.is_fitted_: bool = False

    # ══════════════════════════════════════════════════════════════════
    #  PUBLIC API
    # ══════════════════════════════════════════════════════════════════

    def health_check(self) -> bool:
        """
        Check if the VEOX API server is reachable and healthy.

        Returns
        -------
        bool
            True if the server responds with a 200 status.

        Example
        -------
        >>> evolver = VeoxEvolver("binary")
        >>> evolver.health_check()
        True
        """
        try:
            r = requests.get(f"{self.api_url}/health", timeout=5)
            healthy = r.status_code == 200
            if self.verbose:
                if healthy:
                    self._console.print("[bold green]✅ Server is healthy[/bold green]")
                else:
                    self._console.print(f"[bold red]❌ Server returned HTTP {r.status_code}[/bold red]")
            return healthy
        except requests.RequestException as exc:
            if self.verbose:
                self._console.print(f"[bold red]❌ Cannot reach server:[/bold red] {exc}")
            return False

    @staticmethod
    def list_algorithms() -> List[str]:
        """
        Return supported algorithm categories.

        Returns
        -------
        list[str]
            Available algorithm categories.
        """
        return sorted(_VALID_CATEGORIES)

    def fit(
        self,
        data=None,
        *,
        target_column: str = "target",
        datasets: Optional[List[str]] = None,
        max_generations: int = 100,
        population_size: int = 50,
        num_islands: int = 4,
        timeout_per_eval: int = 30,
        poll_interval: float = _DEFAULT_POLL_INTERVAL,
        max_poll_time: float = 600.0,
        custom_fitness_code: Optional[str] = None,
        config_overrides: Optional[Dict[str, Any]] = None,
        on_poll: Optional[Callable[[Dict[str, Any]], None]] = None,
        on_champion: Optional[Callable[[Dict], None]] = None,
    ) -> "VeoxEvolver":
        """
        Launch an evolution job and monitor it until completion.

        This method:
          1. Validates parameters.
          2. Submits the job to the VEOX API.
          3. Polls the ``/snapshot`` endpoint in a loop.
          4. Renders a live Rich dashboard showing real-time progress.
          5. Returns ``self`` once the job reaches ``completed`` or ``failed``.

        Parameters
        ----------
        datasets : list[str] or None
            Dataset paths (pass ``[]`` or ``None`` for server defaults).
        max_generations : int
            Number of evolutionary generations (≥ 1).
        population_size : int
            Population per island (≥ 2).
        num_islands : int
            Number of parallel islands (≥ 1).
        timeout_per_eval : int
            Per-candidate evaluation timeout in seconds (≥ 1).
        poll_interval : float
            Seconds between snapshot polls (default 1.5).
        max_poll_time : float
            Maximum wall-clock seconds to wait before giving up (default 600).
        custom_fitness_code : str or None
            Optional Python source defining ``compute_fitness(y_prob, y_true)``.
        config_overrides : dict or None
            Arbitrary config overrides passed to the engine.
        on_poll : callable or None
            Callback ``fn(snapshot_dict)`` invoked on every poll tick.
        on_champion : callable or None
            Callback ``fn(champion_dict)`` invoked when a new champion is found.

        Returns
        -------
        self : VeoxEvolver
            Returns self for method chaining (sklearn convention).

        Raises
        ------
        ValueError
            If parameters fail validation.
        RuntimeError
            If job submission fails.
        """
        # ── Validate ──────────────────────────────────────────────────
        self._validate_params(max_generations, population_size, num_islands, timeout_per_eval)

        if datasets is None:
            datasets = []

        # ── Upload client-supplied datasets ───────────────────────────
        if data is not None:
            uploaded_ids = self._upload_datasets(data, target_column)
            datasets = uploaded_ids

        payload = {
            "algorithm_category": self.category,
            "datasets": datasets,
            "max_generations": max_generations,
            "population_size": population_size,
            "num_islands": num_islands,
            "timeout_per_eval": timeout_per_eval,
        }
        if custom_fitness_code is not None:
            payload["custom_fitness_code"] = custom_fitness_code
        if config_overrides:
            payload["config_overrides"] = config_overrides

        # ── Step 1: Submit ────────────────────────────────────────────
        try:
            resp = requests.post(f"{self._api_base}/jobs", json=payload, timeout=10)
            resp.raise_for_status()
        except requests.RequestException as exc:
            self._console.print(f"[bold red]✗ Failed to submit job:[/bold red] {exc}")
            raise RuntimeError(
                f"Job submission failed: {exc}\n"
                f"  → Is the server running at {self.api_url}?\n"
                f"  → Try: evolver.health_check()"
            ) from exc

        data = resp.json()
        self.job_id_ = data["job_id"]
        meta = _FAMILY_META.get(self.category, {"icon": "🧬", "color": "#c084fc"})

        if self.verbose:
            self._console.print(
                f"\n[bold {meta['color']}]{meta['icon']} Evolution job submitted[/bold {meta['color']}]"
                f"  [dim]job_id={self.job_id_}  category={self.category}[/dim]\n"
            )

        # ── Step 2: Poll with live dashboard ──────────────────────────
        t0 = time.time()
        poll_count = 0
        prev_champ_count = 0
        stall_count = 0
        prev_best = -9999.0
        eval_history: List[int] = []
        last_snap: Dict[str, Any] = {
            "status": "pending",
            "generation": 0,
            "max_generations": max_generations,
            "total_evals": 0,
            "best_fitness": -9999.0,
            "champions": [],
            "history": [],
            "inflight": [],
            "error_count": 0,
            "timeout_count": 0,
        }

        def _poll_loop(live=None):
            nonlocal poll_count, prev_champ_count, last_snap, eval_history
            nonlocal stall_count, prev_best
            while True:
                elapsed = time.time() - t0
                if elapsed > max_poll_time:
                    last_snap["status"] = "timeout"
                    break

                snap = self._poll_snapshot(self.job_id_)
                if snap:
                    last_snap = snap

                poll_count += 1
                eval_history.append(last_snap.get("total_evals", 0))

                # Track convergence stall
                current_best = last_snap.get("best_fitness", -9999.0)
                if current_best == prev_best:
                    stall_count += 1
                else:
                    stall_count = 0
                    prev_best = current_best

                # Fire callbacks
                if on_poll and snap:
                    try:
                        on_poll(snap)
                    except Exception:
                        pass

                if on_champion:
                    champs = last_snap.get("champions", [])
                    if len(champs) > prev_champ_count:
                        for c in champs[prev_champ_count:]:
                            try:
                                on_champion(c)
                            except Exception:
                                pass
                        prev_champ_count = len(champs)

                if live:
                    live.update(
                        _build_dashboard(
                            last_snap, elapsed, self.job_id_, self.category,
                            poll_count, eval_history, population_size, stall_count,
                        )
                    )

                status = last_snap.get("status", "")
                if status in ("completed", "failed"):
                    break

                time.sleep(poll_interval)

        if self.verbose:
            with Live(
                _build_dashboard(last_snap, 0, self.job_id_, self.category, 0, [], population_size, 0),
                console=self._console,
                refresh_per_second=2,
                transient=False,
            ) as live:
                _poll_loop(live)
        else:
            _poll_loop()

        # ── Step 3: Harvest results ───────────────────────────────────
        elapsed = time.time() - t0
        self.best_fitness_ = last_snap.get("best_fitness", -9999.0)
        self.champions_ = last_snap.get("champions", [])
        self.history_ = last_snap.get("history", [])
        self.is_fitted_ = True

        # Decode best pipeline
        if self.champions_:
            self.best_pipeline_ = _decode_pipeline(
                self.champions_[0].get("genome_str", "")
            )
        else:
            self.best_pipeline_ = ""

        self.result_ = EvolutionResult(
            job_id=self.job_id_,
            status=last_snap.get("status", "unknown"),
            best_fitness=self.best_fitness_,
            total_evals=last_snap.get("total_evals", 0),
            generation=last_snap.get("generation", 0),
            max_generations=last_snap.get("max_generations", max_generations),
            champions=self.champions_,
            history=self.history_,
            error_count=last_snap.get("error_count", 0),
            timeout_count=last_snap.get("timeout_count", 0),
            elapsed_seconds=elapsed,
            category=self.category,
        )

        # ── Final summary ─────────────────────────────────────────────
        if self.verbose:
            self._print_summary(self.result_)

        return self

    def status(self) -> Dict[str, Any]:
        """
        Quick status poll (no dashboard). Returns the latest snapshot dict.

        Example
        -------
        >>> snap = evolver.status()
        >>> snap["total_evals"]
        42
        """
        if not self.job_id_:
            return {"error": "No job submitted. Call .fit() first."}
        snap = self._poll_snapshot(self.job_id_)
        return snap or {"error": "Snapshot not available"}

    def score(self) -> float:
        """
        Return the best fitness score (sklearn-compatible).

        Raises RuntimeError if not yet fitted.
        """
        self._check_is_fitted()
        return self.best_fitness_

    def to_dict(self) -> Dict[str, Any]:
        """Export results as a plain dictionary."""
        self._check_is_fitted()
        return self.result_.to_dict()

    def to_json(self, indent: int = 2) -> str:
        """Export results as a JSON string."""
        self._check_is_fitted()
        return self.result_.to_json(indent=indent)

    def get_evolution_report(self) -> Dict[str, Any]:
        """
        Fetch the PaperKit-compatible evolution_report.json from the API.

        Returns
        -------
        dict
            Evolution report with trace, winner, and engine_config.
        """
        if not self.job_id_:
            raise RuntimeError("No job submitted. Call .fit() first.")
        try:
            r = requests.get(
                f"{self._api_base}/jobs/{self.job_id_}/history", timeout=10
            )
            r.raise_for_status()
            return r.json()
        except requests.RequestException as exc:
            raise RuntimeError(f"Failed to fetch evolution report: {exc}") from exc

    def get_baseline_report(self) -> Dict[str, Any]:
        """
        Fetch the PaperKit-compatible benchmark_report.json from the API.

        Returns
        -------
        dict
            Baseline report with benchmark metadata and baselines list.
        """
        if not self.job_id_:
            raise RuntimeError("No job submitted. Call .fit() first.")
        try:
            r = requests.get(
                f"{self._api_base}/jobs/{self.job_id_}/baselines", timeout=10
            )
            r.raise_for_status()
            return r.json()
        except requests.RequestException as exc:
            raise RuntimeError(f"Failed to fetch baseline report: {exc}") from exc

    # ══════════════════════════════════════════════════════════════════
    #  PAPERKIT — Paper Build & Download
    # ══════════════════════════════════════════════════════════════════

    def build_paper(
        self,
        *,
        test_mode: bool = False,
        poll_interval: float = 3.0,
        timeout: float = 300.0,
    ) -> str:
        """
        Trigger PaperKit to build a scientific paper from evolution artifacts.

        Sends ``POST /jobs/{job_id}/build_paper`` and polls ``/status``
        until the paper build completes or fails.

        Parameters
        ----------
        test_mode : bool
            If True, uses baked-in mock artifacts (for CI / E2E tests).
        poll_interval : float
            Seconds between status polls (default 3).
        timeout : float
            Maximum seconds to wait for the build (default 300).

        Returns
        -------
        str
            Final paper build status (``"completed"`` or ``"failed"``).

        Raises
        ------
        RuntimeError
            If no job has been submitted, or the build times out.

        Example
        -------
        >>> evolver.fit(max_generations=5)
        >>> evolver.build_paper()
        'completed'
        >>> evolver.download_paper("my_paper.pdf")
        """
        if not self.job_id_:
            raise RuntimeError("No job submitted. Call .fit() first.")

        # Trigger the build
        params = {"test": "true"} if test_mode else {}
        try:
            r = requests.post(
                f"{self._api_base}/jobs/{self.job_id_}/build_paper",
                params=params,
                timeout=15,
            )
            r.raise_for_status()
        except requests.RequestException as exc:
            raise RuntimeError(f"Failed to trigger paper build: {exc}") from exc

        if self.verbose:
            self._console.print(
                "[bold cyan]📄 PaperKit build initiated…[/bold cyan]"
            )

        # Poll until done
        import time as _time
        t0 = _time.time()
        last_step = ""
        while _time.time() - t0 < timeout:
            _time.sleep(poll_interval)
            try:
                sr = requests.get(
                    f"{self._api_base}/jobs/{self.job_id_}/status", timeout=10
                )
                sr.raise_for_status()
                data = sr.json()
            except requests.RequestException:
                continue
            paper_status = data.get("paper_status", "pending")
            step = data.get("paper_step", "")
            progress = data.get("paper_progress", 0.0)

            if self.verbose and step != last_step:
                pct = int(progress * 100)
                self._console.print(
                    f"  [dim]⏳ [{pct:3d}%] {step}[/dim]"
                )
                last_step = step

            if paper_status == "completed":
                if self.verbose:
                    self._console.print(
                        "[bold green]✅ Paper build completed![/bold green]"
                    )
                return "completed"
            elif paper_status == "failed":
                err = data.get("paper_error", "Unknown error")
                if self.verbose:
                    self._console.print(
                        f"[bold red]❌ Paper build failed: {err}[/bold red]"
                    )
                return "failed"

        raise RuntimeError(
            f"Paper build timed out after {timeout}s. "
            f"Last status: {last_step}"
        )

    def download_paper(self, output_path: str = "paper.pdf") -> str:
        """
        Download the generated PDF paper.

        Parameters
        ----------
        output_path : str
            Local file path to save the PDF (default ``"paper.pdf"``).

        Returns
        -------
        str
            Absolute path to the saved file.

        Raises
        ------
        RuntimeError
            If no job submitted or PDF is not yet available.
        """
        if not self.job_id_:
            raise RuntimeError("No job submitted. Call .fit() first.")
        try:
            r = requests.get(
                f"{self._api_base}/jobs/{self.job_id_}/paper_pdf",
                timeout=30,
                stream=True,
            )
            r.raise_for_status()
        except requests.RequestException as exc:
            raise RuntimeError(f"Failed to download paper PDF: {exc}") from exc

        abs_path = os.path.abspath(output_path)
        with open(abs_path, "wb") as f:
            for chunk in r.iter_content(chunk_size=8192):
                f.write(chunk)
        if self.verbose:
            size_kb = os.path.getsize(abs_path) / 1024
            self._console.print(
                f"[bold green]💾 Paper saved to {abs_path} "
                f"({size_kb:.0f} KB)[/bold green]"
            )
        return abs_path

    def download_log(self, output_path: str = "build.log") -> str:
        """
        Download the PaperKit build log.

        Parameters
        ----------
        output_path : str
            Local file path to save the log (default ``"build.log"``).

        Returns
        -------
        str
            Absolute path to the saved file.

        Raises
        ------
        RuntimeError
            If no job submitted or log is not available.
        """
        if not self.job_id_:
            raise RuntimeError("No job submitted. Call .fit() first.")
        try:
            r = requests.get(
                f"{self._api_base}/jobs/{self.job_id_}/paper_log",
                timeout=10,
            )
            r.raise_for_status()
        except requests.RequestException as exc:
            raise RuntimeError(f"Failed to download build log: {exc}") from exc

        abs_path = os.path.abspath(output_path)
        with open(abs_path, "w") as f:
            f.write(r.text)
        if self.verbose:
            self._console.print(
                f"[bold green]📋 Build log saved to {abs_path}[/bold green]"
            )
        return abs_path

    def to_dataframe(self):
        """
        Export champions as a pandas DataFrame.

        Returns
        -------
        pandas.DataFrame
            Champions table with columns: rank, generation, train_score,
            valid_score, pipeline.

        Example
        -------
        >>> df = evolver.to_dataframe()
        >>> df.head()
           rank  generation  train_score  valid_score           pipeline
        0     1           3       0.8732       0.8651  XGBoost → Scaler
        """
        self._check_is_fitted()
        try:
            import pandas as pd
        except ImportError:
            raise ImportError(
                "pandas is required for to_dataframe(). "
                "Install it: pip install pandas"
            )

        rows = []
        for i, c in enumerate(self.champions_):
            rows.append({
                "rank": i + 1,
                "generation": c.get("generation", 0),
                "train_score": c.get("train_score"),
                "valid_score": c.get("valid_score", c.get("v_score")),
                "pipeline": _decode_pipeline(c.get("genome_str", "")),
                "genome_str": c.get("genome_str", ""),
                "hash": c.get("hash", ""),
            })
        return pd.DataFrame(rows)

    def save(self, path: str) -> str:
        """
        Save evolution results to a JSON file.

        Parameters
        ----------
        path : str
            Output file path (e.g. ``"results.json"``).

        Returns
        -------
        str
            Absolute path to the saved file.

        Example
        -------
        >>> evolver.save("my_results.json")
        '/home/user/my_results.json'
        """
        self._check_is_fitted()
        abs_path = os.path.abspath(path)
        with open(abs_path, "w") as f:
            f.write(self.to_json())
        if self.verbose:
            self._console.print(f"[bold green]💾 Results saved to {abs_path}[/bold green]")
        return abs_path

    @classmethod
    def load(cls, path: str) -> "VeoxEvolver":
        """
        Load a previously saved VeoxEvolver result.

        Parameters
        ----------
        path : str
            Path to a saved results JSON file.

        Returns
        -------
        VeoxEvolver
            A fitted evolver with the loaded results.

        Example
        -------
        >>> loaded = VeoxEvolver.load("my_results.json")
        >>> loaded.best_fitness_
        0.8732
        """
        with open(path) as f:
            data = _json.load(f)

        category = data.get("category", "binary")
        if category not in _VALID_CATEGORIES:
            category = "binary"

        ev = cls(category=category, verbose=False)
        ev.job_id_ = data.get("job_id", "")
        ev.best_fitness_ = data.get("best_fitness", -9999.0)
        ev.champions_ = data.get("champions", [])
        ev.history_ = data.get("history", [])
        ev.is_fitted_ = True

        if ev.champions_:
            ev.best_pipeline_ = _decode_pipeline(
                ev.champions_[0].get("genome_str", "")
            )

        ev.result_ = EvolutionResult(
            job_id=data.get("job_id", ""),
            status=data.get("status", "loaded"),
            best_fitness=ev.best_fitness_,
            total_evals=data.get("total_evals", 0),
            generation=data.get("generation", 0),
            max_generations=data.get("max_generations", 0),
            champions=ev.champions_,
            history=ev.history_,
            error_count=data.get("error_count", 0),
            timeout_count=data.get("timeout_count", 0),
            elapsed_seconds=data.get("elapsed_seconds", 0.0),
            category=category,
        )
        return ev

    def get_params(self) -> Dict[str, Any]:
        """Sklearn-compatible: return constructor parameters."""
        return {
            "category": self.category,
            "api_url": self.api_url,
            "verbose": self.verbose,
        }

    def __repr__(self) -> str:
        fitted = "fitted" if self.is_fitted_ else "not fitted"
        score_str = f", best={_fmt_score(self.best_fitness_)}" if self.is_fitted_ else ""
        return f"VeoxEvolver(category='{self.category}', {fitted}{score_str})"

    # ══════════════════════════════════════════════════════════════════
    #  CLASS METHODS
    # ══════════════════════════════════════════════════════════════════

    @classmethod
    @contextmanager
    def serve(cls, port: int = 8090, host: str = "127.0.0.1", **kwargs):
        """
        Context manager for using a running VEOX enclave container.

        This method verifies that the Docker enclave is healthy and yields
        a configured VeoxEvolver. It does NOT start a server — the enclave
        must already be running via Docker.

        Parameters
        ----------
        port : int
            Port the enclave is running on (default 8090).
        host : str
            Host the enclave is bound to (default "127.0.0.1").
        **kwargs
            Extra kwargs passed to the VeoxEvolver constructor.

        Yields
        ------
        VeoxEvolver
            A configured evolver pointing at the enclave.

        Raises
        ------
        RuntimeError
            If the enclave is not running or fails the health check.

        Example
        -------
        >>> # First, start the enclave:
        >>> # bash Private/scripts/run_enclave.sh
        >>> with VeoxEvolver.serve(port=8090) as ev:
        ...     ev.category = "binary"
        ...     ev.fit(max_generations=5)
        ...     print(ev.best_fitness_)
        """
        console = Console()
        api_url = f"http://{host}:{port}"

        # Verify enclave is running
        console.print(f"[dim]Connecting to enclave at {api_url}…[/dim]")
        healthy = False
        for _ in range(5):
            try:
                r = requests.get(f"{api_url}/health", timeout=2)
                if r.status_code == 200:
                    healthy = True
                    break
            except requests.RequestException:
                pass
            time.sleep(1)

        if not healthy:
            raise RuntimeError(
                f"VEOX enclave is not running at {api_url}.\n"
                f"Start the Docker enclave first:\n"
                f"  bash Private/scripts/build_enclave.sh\n"
                f"  bash Private/scripts/run_enclave.sh\n"
                f"Then retry."
            )

        console.print(f"[bold green]✅ Enclave ready at {api_url}[/bold green]")
        ev = cls(api_url=api_url, **kwargs)

        try:
            yield ev
        finally:
            console.print(f"[dim]Disconnected from enclave.[/dim]")

    @classmethod
    def compare(
        cls,
        categories: List[str],
        api_url: str = "http://127.0.0.1:8090",
        *,
        max_generations: int = 5,
        population_size: int = 20,
        num_islands: int = 1,
        timeout_per_eval: int = 30,
        max_poll_time: float = 120.0,
    ) -> List["VeoxEvolver"]:
        """
        Run evolution for multiple families and print a comparison table.

        Parameters
        ----------
        categories : list[str]
            Algorithm families to compare.

        Returns
        -------
        list[VeoxEvolver]
            Fitted evolvers for each category.

        Example
        -------
        >>> results = VeoxEvolver.compare(["binary", "regression"])
        """
        console = Console()
        evolvers = []

        for i, cat in enumerate(categories, 1):
            meta = _FAMILY_META.get(cat, {"icon": "🧬", "color": "#c084fc"})
            console.print(f"\n[bold {meta['color']}]{'━' * 60}[/bold {meta['color']}]")
            console.print(f"[bold {meta['color']}]  {meta['icon']} [{i}/{len(categories)}] {cat.upper()}[/bold {meta['color']}]")
            console.print(f"[bold {meta['color']}]{'━' * 60}[/bold {meta['color']}]\n")

            ev = cls(category=cat, api_url=api_url, verbose=True)
            ev.fit(
                max_generations=max_generations,
                population_size=population_size,
                num_islands=num_islands,
                timeout_per_eval=timeout_per_eval,
                max_poll_time=max_poll_time,
            )
            evolvers.append(ev)

        # Print comparison table
        console.print(f"\n[bold #c084fc]{'━' * 60}[/bold #c084fc]")
        console.print("[bold #c084fc]  📊 COMPARISON RESULTS[/bold #c084fc]")
        console.print(f"[bold #c084fc]{'━' * 60}[/bold #c084fc]\n")

        table = Table(
            show_header=True,
            header_style="bold #c084fc",
            box=box.DOUBLE_EDGE,
            title="Algorithm Family Comparison",
            expand=True,
        )
        table.add_column("Family", style="bold", justify="left")
        table.add_column("Status", justify="center")
        table.add_column("Best Fitness", justify="right")
        table.add_column("Evaluations", justify="right")
        table.add_column("Time", justify="right")
        table.add_column("Evals/min", justify="right")
        table.add_column("Best Pipeline", ratio=1)

        for ev in evolvers:
            r = ev.result_
            if r is None:
                continue
            meta = _FAMILY_META.get(ev.category, {"icon": "🧬"})
            status_icon = "✅" if r.success else ("❌" if r.status == "failed" else "⚠️")
            pipeline = ev.best_pipeline_[:40] + "…" if len(ev.best_pipeline_) > 40 else ev.best_pipeline_
            table.add_row(
                f"{meta['icon']} {ev.category}",
                status_icon,
                _fmt_score(r.best_fitness),
                str(r.total_evals),
                _elapsed_str(r.elapsed_seconds),
                f"{r.evals_per_minute:.1f}",
                pipeline or "—",
            )

        console.print(table)
        console.print()
        return evolvers

    # ══════════════════════════════════════════════════════════════════
    #  PRIVATE HELPERS
    # ══════════════════════════════════════════════════════════════════

    def _check_is_fitted(self):
        """Raise RuntimeError if not fitted."""
        if not self.is_fitted_:
            raise RuntimeError(
                "VeoxEvolver has not been fitted yet. Call .fit() first."
            )

    def _upload_datasets(self, data, target_column: str) -> List[str]:
        """
        Upload client-supplied datasets to the VEOX API.

        Accepts: pd.DataFrame, list of DataFrames, or a CSV file path.
        Data is stored ONLY in /tmp/ on the server (ephemeral in Docker).
        Returns list of dataset_ids for job submission.
        """
        try:
            import pandas as pd
        except ImportError:
            raise ImportError(
                "pandas is required for data upload. Install: pip install pandas"
            )

        # Normalize input to list of (name, DataFrame) tuples
        frames: List = []
        if isinstance(data, pd.DataFrame):
            frames = [("dataset_0", data)]
        elif isinstance(data, (list, tuple)):
            for i, item in enumerate(data):
                if isinstance(item, pd.DataFrame):
                    frames.append((f"dataset_{i}", item))
                elif isinstance(item, str) and os.path.isfile(item):
                    frames.append((os.path.basename(item).replace(".csv", ""), pd.read_csv(item)))
                else:
                    raise ValueError(f"data[{i}] must be a DataFrame or CSV path, got {type(item)}")
        elif isinstance(data, str) and os.path.isfile(data):
            frames = [(os.path.basename(data).replace(".csv", ""), pd.read_csv(data))]
        else:
            raise ValueError(
                f"data must be a DataFrame, list of DataFrames, or CSV path. Got {type(data)}"
            )

        # Validate target column
        for name, df in frames:
            if target_column not in df.columns:
                raise ValueError(
                    f"Dataset '{name}' missing target column '{target_column}'. "
                    f"Columns: {list(df.columns)}"
                )

        # Upload each dataset
        dataset_ids = []
        upload_url = f"{self._api_base}/datasets"

        for name, df in frames:
            csv_str = df.to_csv(index=False)
            payload = {
                "name": name,
                "csv_data": csv_str,
                "target_column": target_column,
                "task_type": self.category,
            }

            try:
                resp = requests.post(upload_url, json=payload, timeout=30)
                resp.raise_for_status()
                result = resp.json()
                dataset_ids.append(result["dataset_id"])
                if self.verbose:
                    self._console.print(
                        f"[bold green]📤 Uploaded[/bold green] {name}: "
                        f"{result['rows']} rows × {result['cols']} cols → "
                        f"[dim]{result['dataset_id']}[/dim]"
                    )
            except requests.RequestException as exc:
                raise RuntimeError(
                    f"Failed to upload dataset '{name}': {exc}\n"
                    f"  → Is the server running at {self.api_url}?"
                ) from exc

        return dataset_ids

    @staticmethod
    def _validate_params(max_generations, population_size, num_islands, timeout_per_eval):
        """Validate fit() parameters before sending to server."""
        errors = []
        if max_generations < 1:
            errors.append(f"max_generations must be ≥ 1, got {max_generations}")
        if population_size < 2:
            errors.append(f"population_size must be ≥ 2, got {population_size}")
        if num_islands < 1:
            errors.append(f"num_islands must be ≥ 1, got {num_islands}")
        if timeout_per_eval < 1:
            errors.append(f"timeout_per_eval must be ≥ 1, got {timeout_per_eval}")
        if errors:
            raise ValueError("Invalid parameters:\n  • " + "\n  • ".join(errors))

    def _poll_snapshot(self, job_id: str) -> Optional[Dict[str, Any]]:
        """Poll /snapshot with retry and exponential backoff."""
        for attempt in range(_MAX_RETRIES):
            try:
                r = requests.get(
                    f"{self._api_base}/jobs/{job_id}/snapshot",
                    timeout=5,
                )
                if r.status_code == 200:
                    return r.json()
                return None
            except requests.RequestException:
                if attempt < _MAX_RETRIES - 1:
                    time.sleep(_RETRY_BACKOFF[attempt])
                    continue
                return None
        return None

    def _print_summary(self, result: EvolutionResult) -> None:
        """Print a beautiful final summary panel."""
        self._console.print()
        meta = _FAMILY_META.get(self.category, {"icon": "🧬", "color": "#c084fc", "metric": "Score"})

        if result.status == "completed":
            icon, style, tag = "✅", "bold green", "COMPLETED"
        elif result.status == "failed":
            icon, style, tag = "❌", "bold red", "FAILED"
        elif result.status == "timeout":
            icon, style, tag = "⏰", "bold yellow", "TIMEOUT"
        else:
            icon, style, tag = "⚠️", "bold yellow", result.status.upper()

        lines = [
            f"[{style}]{icon} {tag}[/{style}]",
            "",
            f"  [bold]Job ID:[/bold]         {result.job_id}",
            f"  [bold]Category:[/bold]       {meta['icon']} {self.category}",
            f"  [bold]Generations:[/bold]    {result.generation} / {result.max_generations}",
            f"  [bold]Evaluations:[/bold]    {result.total_evals}",
            f"  [bold]Elapsed:[/bold]        {_elapsed_str(result.elapsed_seconds)}",
            f"  [bold]Throughput:[/bold]     {result.evals_per_minute:.1f} evals/min",
            f"  [bold]{meta['metric']}:[/bold]{' ' * max(1, 15 - len(meta['metric']))}[bold green]{_fmt_score(result.best_fitness)}[/bold green]",
        ]

        if result.error_count or result.timeout_count:
            lines.append(f"  [bold]Errors:[/bold]        {result.error_count}  |  Timeouts: {result.timeout_count}")

        if self.best_pipeline_:
            lines.append("")
            lines.append(f"  [bold {meta['color']}]🏆 Best Pipeline:[/bold {meta['color']}]")
            pipeline = self.best_pipeline_
            while len(pipeline) > 70:
                lines.append(f"     {pipeline[:70]}")
                pipeline = pipeline[70:]
            lines.append(f"     {pipeline}")

        self._console.print(
            Panel(
                "\n".join(lines),
                title="[bold]Evolution Summary[/bold]",
                border_style="#6c3fc5",
                box=box.DOUBLE,
                padding=(1, 2),
            )
        )
        self._console.print()
