"""Eval progress display built on pytui's Elm-style App.

Tails events.jsonl (written by evaluate() via logging), derives display state,
renders progress in alternate screen via a subprocess.

Usage:
    with progress_display(output_dir=output_dir):
        await evaluate(dataset, config)

Or detached mode (two terminals):
    # Terminal 1: Run eval (writes events.jsonl via logging)
    python run_eval.py --config config.py

    # Terminal 2: Watch progress
    python -m wafer_core.rollouts.progress_app /path/to/events.jsonl
"""

from __future__ import annotations

import json
import os
import re
import signal
import subprocess
import sys
import time
from collections.abc import Generator
from contextlib import contextmanager
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any

# ── ANSI colors ──────────────────────────────────────────────────────────────

DIM = "\x1b[90m"
RESET = "\x1b[0m"
GREEN = "\x1b[32m"
RED = "\x1b[31m"
YELLOW = "\x1b[33m"
CYAN = "\x1b[36m"
BOLD = "\x1b[1m"
BG_SELECTED = "\x1b[48;5;236m"  # Dark gray background for selected row
BLUE = "\x1b[34m"

# Event types the progress display understands.
# The "message" field in the JSONL record is the event type discriminator.
# Records with unrecognised message values are silently skipped.
KNOWN_EVENT_TYPES = frozenset({
    "eval_start",
    "eval_end",
    "sample_start",
    "sample_end",
    "turn",
    "modal_progress",
    "gepa_iteration",
    "gepa_accepted",
    "gepa_rejected",
    "sample_retry",
})

# Event types in per-sample JSONL files
SAMPLE_EVENT_TYPES = frozenset({
    "text_delta",
    "thinking_delta",
    "assistant_message",
    "tool_execution",
    "llm_call",
    "modal_progress",
    "turn",
    "sample_start",
    "sample_end",
})


# ── Utilities ────────────────────────────────────────────────────────────────

# Regex for matching ANSI escape codes
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")


def _clamp(value: int, min_val: int, max_val: int) -> int:
    """Clamp value to [min_val, max_val]."""
    assert min_val <= max_val, f"min_val ({min_val}) > max_val ({max_val})"
    return max(min_val, min(value, max_val))


def _visible_len(s: str) -> int:
    """Return visible length of string (excluding ANSI codes)."""
    return len(_ANSI_ESCAPE.sub("", s))


def _ansi_slice(s: str, start: int) -> str:
    """Slice a string with ANSI codes from visible position `start`.

    Returns substring starting at visible character `start`, preserving
    any ANSI codes that were active at that position.
    """
    if start <= 0:
        return s

    visible_pos = 0
    i = 0
    active_codes: list[str] = []  # Track active ANSI codes

    while i < len(s) and visible_pos < start:
        # Check for ANSI escape sequence
        match = _ANSI_ESCAPE.match(s, i)
        if match:
            code = match.group()
            if code == RESET:
                active_codes.clear()
            else:
                active_codes.append(code)
            i = match.end()
        else:
            visible_pos += 1
            i += 1

    # Return remaining string with any active codes prepended
    result = s[i:]
    if active_codes:
        result = "".join(active_codes) + result
    return result


# ── State ────────────────────────────────────────────────────────────────────


@dataclass
class SampleState:
    """State of a single sample."""

    id: str
    name: str = ""
    turn: int = 0
    phase: str = ""
    score: float | None = None
    status: str = "started"  # started, complete, retry
    retry_attempt: int = 0
    start_time: float = field(default_factory=time.time)
    last_update: float = field(default_factory=time.time)


@dataclass
class RenderState:
    """Display state derived from events."""

    eval_name: str = ""
    total: int = 0
    completed: int = 0
    samples: dict[str, SampleState] = field(default_factory=dict)
    start_time: float = field(default_factory=time.time)
    gepa_iter: int | None = None
    gepa_total: int | None = None
    gepa_best: float | None = None
    scores: list[float] = field(default_factory=list)


# ── Pure functions ───────────────────────────────────────────────────────────


def _format_time(seconds: float) -> str:
    """Format seconds as H:MM:SS or M:SS."""
    h = int(seconds) // 3600
    m = int(seconds) % 3600 // 60
    s = int(seconds) % 60
    if h:
        return f"{h}:{m:02d}:{s:02d}"
    return f"{m}:{s:02d}"


def _format_bar(progress: float, width: int) -> str:
    """Render a progress bar with unicode blocks."""
    if width <= 0:
        return ""
    filled = progress * width
    full_blocks = int(filled)
    partial_idx = int(8 * filled) % 8
    partial = " ▏▎▍▌▋▊▉"[partial_idx] if partial_idx else ""
    bar = "█" * full_blocks + partial
    return bar.ljust(width)


def derive_state(events: list[dict[str, Any]]) -> RenderState:
    """Derive current display state from event stream. Stateless."""
    state = RenderState()

    for event in events:
        event_type = event.get("message")
        if event_type is None or event_type not in KNOWN_EVENT_TYPES:
            continue

        sample_id = event.get("sample_id", "")

        if event_type == "eval_start":
            state.eval_name = event.get("eval_name", "eval")
            state.total = event.get("total", 0)
            if ts := event.get("timestamp"):
                from datetime import datetime

                state.start_time = datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp()
            else:
                state.start_time = time.time()

        elif event_type == "sample_start":
            state.samples[sample_id] = SampleState(
                id=sample_id,
                name=event.get("sample_name", sample_id),
                last_update=time.time(),
                phase="starting",
            )

        elif event_type == "turn":
            if sample_id in state.samples:
                if "turn" in event:
                    state.samples[sample_id].turn = event["turn"]
                state.samples[sample_id].last_update = time.time()
                status = event.get("status", "running")
                state.samples[sample_id].phase = status

        elif event_type == "modal_progress":
            if sample_id in state.samples:
                state.samples[sample_id].phase = event.get("phase", "")
                state.samples[sample_id].last_update = time.time()

        elif event_type == "sample_end":
            if sample_id in state.samples:
                state.samples[sample_id].status = "complete"
                score = event.get("score")
                if score is not None:
                    state.samples[sample_id].score = score
                    state.scores.append(score)
                state.completed += 1

        elif event_type == "sample_retry":
            if sample_id in state.samples:
                state.samples[sample_id].status = "retry"
                state.samples[sample_id].retry_attempt = event.get("attempt", 1)

        elif event_type == "gepa_iteration":
            state.gepa_iter = event.get("iter")
            state.gepa_total = event.get("total")
            state.gepa_best = event.get("best")

        elif event_type == "eval_end":
            pass  # Handled by update() -> Cmd.quit()

    return state


def _format_sample_row(
    sample: SampleState,
    width: int,
    selected: bool = False,
    marquee_offset: int = 0,
) -> tuple[str, int]:
    """Format a single sample row.

    Returns (formatted_row, overflow_chars) where overflow_chars is how many
    characters extend beyond the visible width (for marquee animation).
    """
    # For selected rows, don't truncate name - show full content with marquee
    if selected:
        name = sample.name
    else:
        name = sample.name[:25].ljust(25)

    turn_info = f"T:{sample.turn}"

    if sample.status == "complete":
        if sample.score is not None:
            icon = f"{GREEN}✓{RESET}" if sample.score > 0.5 else f"{RED}✗{RESET}"
            row = f"  {name} {turn_info:>4}  {icon} score={sample.score:.2f}"
        else:
            row = f"  {name} {turn_info:>4}  {GREEN}✓{RESET} done"
    elif sample.status == "retry":
        row = f"  {name} {turn_info:>4}  {YELLOW}⟳{RESET} retry (attempt {sample.retry_attempt})"
    else:
        phase = sample.phase or "running"
        row = f"  {name} {turn_info:>4}  {CYAN}{phase}...{RESET}"

    # Calculate visible length and overflow
    visible_len = _visible_len(row)
    overflow = max(0, visible_len - width)

    if selected:
        # Apply marquee offset for selected rows (ANSI-aware slicing)
        if marquee_offset > 0 and overflow > 0:
            offset = min(marquee_offset, overflow)
            # Skip the leading indent (2 spaces), slice the rest, re-add indent
            row = "  " + _ansi_slice(row, 2 + offset)

        # Add selection highlight
        row = f"{BG_SELECTED}{row}{RESET}"

    return row, overflow


# ── Viewport formatting ──────────────────────────────────────────────────────


def format_sample_event(
    event: dict[str, Any], last_turn: int | None = None
) -> tuple[str | None, int | None]:
    """Format a per-sample JSONL event into a readable line.

    Returns (formatted_line, new_turn_number).
    formatted_line is None if the event should not be displayed.
    new_turn_number is the turn number if this event changes it, else None.
    """
    msg = event.get("message", "")

    if msg == "text_delta":
        # Streaming text — return raw text for accumulation
        return event.get("text", ""), None

    elif msg == "thinking_delta":
        # Extended thinking — show dimmed
        text = event.get("text", "")
        return (f"{DIM}{text}{RESET}" if text else None), None

    elif msg == "assistant_message":
        # Final assistant message — skip if we've been streaming
        # The caller handles dedup
        return None, None

    elif msg == "tool_execution":
        tool = event.get("tool_name", "tool")
        duration = event.get("duration_ms", 0)
        summary = event.get("result_summary") or {}
        is_error = event.get("is_error", False)
        color = RED if is_error else CYAN

        # Format tool-specific summary
        if tool == "edit" and isinstance(summary, dict):
            path = summary.get("path", "")
            # Show just filename, not full path
            filename = path.split("/")[-1] if path else ""
            added = summary.get("lines_added", 0)
            removed = summary.get("lines_removed", 0)
            diff_info = f"{filename} +{added}/-{removed}"
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {diff_info}", None
        elif tool == "bash" and isinstance(summary, dict):
            cmd = summary.get("command", "")
            # Truncate long commands
            cmd_short = cmd[:50] + "…" if len(cmd) > 50 else cmd
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {cmd_short}", None
        elif tool == "write" and isinstance(summary, dict):
            path = summary.get("path", "")
            filename = path.split("/")[-1] if path else ""
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {filename}", None
        else:
            # Fallback: show summary dict or empty
            status = str(summary) if summary else ""
            return f"{color}→ {tool}{RESET} ({duration / 1000:.1f}s) {status}", None

    elif msg == "llm_call":
        provider = event.get("provider", "")
        model = event.get("model", "")
        tokens_in = event.get("tokens_in", 0)
        tokens_out = event.get("tokens_out", 0)
        duration = event.get("duration_ms", 0)
        model_short = model.split("/")[-1] if model else provider
        return (
            f"{DIM}◆ {model_short} {tokens_in}→{tokens_out} tok ({duration / 1000:.1f}s){RESET}",
            None,
        )

    elif msg == "modal_progress":
        phase = event.get("phase", "")
        return (f"{YELLOW}⟳ {phase}...{RESET}" if phase else None), None

    elif msg == "turn":
        turn = event.get("turn")
        # Only show turn separator when turn number changes
        if turn is not None and turn != last_turn:
            return f"{DIM}── turn {turn} ──{RESET}", turn
        return None, turn if turn is not None else last_turn

    return None, None


# ── Render function ──────────────────────────────────────────────────────────


def render_header(state: RenderState, width: int) -> str:
    """Render the progress header line."""
    if state.gepa_iter is not None:
        header = (
            f"GEPA iter {state.gepa_iter}/{state.gepa_total or '?'} │ best: {state.gepa_best:.0%}"
            if state.gepa_best
            else f"GEPA iter {state.gepa_iter}/{state.gepa_total or '?'}"
        )
    else:
        elapsed = time.time() - state.start_time
        if state.total > 0:
            progress = state.completed / state.total
            bar_width = min(30, width - 50)
            bar = _format_bar(progress, bar_width)
            eta = ""
            if state.completed > 0 and progress < 1.0:
                remaining = (elapsed / progress) - elapsed
                eta = f" <{_format_time(remaining)}"
            header = (
                f"{state.eval_name}: {BOLD}{state.completed}/{state.total}{RESET} "
                f"|{bar}| {100 * progress:3.0f}% "
                f"[{_format_time(elapsed)}{eta}]"
            )
        else:
            header = f"{state.eval_name}: {state.completed} samples [{_format_time(elapsed)}]"

    return header[:width]


# ── Elm architecture (subprocess entry point) ────────────────────────────────


@dataclass(frozen=True)
class Model:
    """Immutable model for the pytui App.

    Uses bubbles-style cursor tracking:
    - selected_sample_id: stable selection by ID (not index)
    - viewport_scroll: scroll offset for detail viewport

    Note: list_scroll is computed dynamically in view() to keep selection visible.
    """

    events: tuple[dict[str, Any], ...] = ()
    done: bool = False

    # Focus state
    focus: str = "list"  # "list" or "viewport"

    # Sample list state (bubbles table pattern)
    selected_sample_id: str | None = None  # Track by ID, not index

    # Marquee state for selected row (scrolls long text horizontally)
    marquee_offset: int = 0  # Current scroll offset for selected row text

    # Viewport state
    viewport_lines: tuple[str, ...] = ()  # Formatted lines for viewport
    viewport_scroll: int = 0  # Scroll position in viewport
    viewport_auto_follow: bool = True  # Auto-scroll to bottom on new content

    # Viewport marquee state (for scrolling long lines when focused)
    viewport_marquee_offset: int = 0  # Horizontal scroll for viewport lines

    # For streaming text accumulation
    streaming_text: str = ""

    # For deduping turn separators
    last_turn: int | None = None


@dataclass(frozen=True)
class NewEvent:
    """New line from events.jsonl."""

    line: str


@dataclass(frozen=True)
class SampleEvent:
    """New line from per-sample JSONL file."""

    line: str


@dataclass(frozen=True)
class MarqueeTick:
    """Periodic tick for marquee animation on selected sample row."""

    pass


@dataclass(frozen=True)
class ViewportMarqueeTick:
    """Periodic tick for marquee animation on viewport lines."""

    pass


def _parse_event(line: str) -> dict[str, Any] | None:
    try:
        return json.loads(line)
    except json.JSONDecodeError:
        return None


def _get_active_samples(events: tuple[dict[str, Any], ...]) -> list[SampleState]:
    """Get list of active (non-complete) samples from events.

    Returns samples sorted by name (stable order) not last_update (jumping order).
    """
    state = derive_state(list(events))
    active = [s for s in state.samples.values() if s.status != "complete" and s.phase]
    # Sort by sample name for stable order (not last_update which causes jumping)
    active.sort(key=lambda s: s.name)
    return active


def _find_sample_index(active: list[SampleState], sample_id: str | None) -> int:
    """Find index of sample_id in active list, or 0 if not found."""
    if sample_id is None:
        return 0
    for i, s in enumerate(active):
        if s.id == sample_id:
            return i
    return 0


def _get_sample_at_index(active: list[SampleState], idx: int) -> SampleState | None:
    """Get sample at index, or None if out of bounds."""
    if 0 <= idx < len(active):
        return active[idx]
    return None


def _handle_marquee_tick(model: Model) -> tuple[Model, Any]:
    """Handle marquee tick for selected row animation."""
    from ._pytui import Cmd

    if not model.selected_sample_id:
        return model, Cmd.none()

    # Get the selected sample to check if it has overflow
    active = _get_active_samples(model.events)
    selected_idx = _find_sample_index(active, model.selected_sample_id)
    sample = _get_sample_at_index(active, selected_idx)
    if not sample:
        return model, Cmd.none()

    # Check how much overflow this sample has (use typical terminal width)
    _, overflow = _format_sample_row(sample, 80, selected=True, marquee_offset=0)
    if overflow <= 0:
        # No overflow, reset offset
        if model.marquee_offset != 0:
            return replace(model, marquee_offset=0), Cmd.none()
        return model, Cmd.none()

    # Advance marquee with pause at start and end
    # offset < 0: pause at start (show beginning)
    # offset 0..overflow: scrolling
    # offset > overflow: pause at end (show end)
    pause_ticks = 5  # Ticks to pause at start/end
    new_offset = model.marquee_offset + 1
    if new_offset > overflow + pause_ticks:
        new_offset = -pause_ticks  # Snap back to start pause

    return replace(model, marquee_offset=new_offset), Cmd.none()


def _get_max_viewport_overflow(model: Model, width: int) -> int:
    """Get the maximum overflow across all visible viewport lines."""
    if not model.viewport_lines:
        return 0

    max_overflow = 0
    for line in model.viewport_lines:
        visible_len = _visible_len(line)
        overflow = max(0, visible_len - width)
        max_overflow = max(max_overflow, overflow)
    return max_overflow


def _handle_viewport_marquee_tick(model: Model) -> tuple[Model, Any]:
    """Handle marquee tick for viewport lines animation."""
    from ._pytui import Cmd

    # Only animate when viewport is focused
    if model.focus != "viewport":
        if model.viewport_marquee_offset != 0:
            return replace(model, viewport_marquee_offset=0), Cmd.none()
        return model, Cmd.none()

    # Check if any lines have overflow (use typical terminal width)
    max_overflow = _get_max_viewport_overflow(model, 80)
    if max_overflow <= 0:
        if model.viewport_marquee_offset != 0:
            return replace(model, viewport_marquee_offset=0), Cmd.none()
        return model, Cmd.none()

    # Advance marquee with pause at start and end
    pause_ticks = 8  # Longer pause for viewport (more content to read)
    new_offset = model.viewport_marquee_offset + 1
    if new_offset > max_overflow + pause_ticks:
        new_offset = -pause_ticks  # Snap back to start pause

    return replace(model, viewport_marquee_offset=new_offset), Cmd.none()


def update(model: Model, msg: object) -> tuple[Model, Any]:
    from ._pytui import Cmd, KeyPress

    match msg:
        case KeyPress(key="\x03"):  # Ctrl+C: cancel eval and quit
            # Signal parent process to cancel the eval
            os.kill(os.getppid(), signal.SIGINT)
            return model, Cmd.quit()

        case KeyPress(key="q"):  # q: detach (quit TUI, eval continues)
            return model, Cmd.quit()

        # Focus switching
        case KeyPress(key="\t"):  # Tab
            new_focus = "viewport" if model.focus == "list" else "list"
            # Reset viewport marquee when switching focus
            return replace(model, focus=new_focus, viewport_marquee_offset=-8), Cmd.none()

        # Navigation in list focus
        case KeyPress(key="j" | "\x1b[B") if model.focus == "list":  # Down
            active = _get_active_samples(model.events)
            if not active:
                return model, Cmd.none()

            # Find current index, move down
            current_idx = _find_sample_index(active, model.selected_sample_id)
            new_idx = _clamp(current_idx + 1, 0, len(active) - 1)
            new_sample = active[new_idx]

            # Reset viewport and marquee when selection changes
            if new_sample.id != model.selected_sample_id:
                return replace(
                    model,
                    selected_sample_id=new_sample.id,
                    marquee_offset=-5,  # Start with pause at beginning
                    viewport_lines=(),
                    viewport_scroll=0,
                    viewport_auto_follow=True,
                    streaming_text="",
                    last_turn=None,
                ), Cmd.none()
            return model, Cmd.none()

        case KeyPress(key="k" | "\x1b[A") if model.focus == "list":  # Up
            active = _get_active_samples(model.events)
            if not active:
                return model, Cmd.none()

            # Find current index, move up
            current_idx = _find_sample_index(active, model.selected_sample_id)
            new_idx = _clamp(current_idx - 1, 0, len(active) - 1)
            new_sample = active[new_idx]

            if new_sample.id != model.selected_sample_id:
                return replace(
                    model,
                    selected_sample_id=new_sample.id,
                    marquee_offset=-5,  # Start with pause at beginning
                    viewport_lines=(),
                    viewport_scroll=0,
                    viewport_auto_follow=True,
                    streaming_text="",
                    last_turn=None,
                ), Cmd.none()
            return model, Cmd.none()

        # Navigation in viewport focus
        case KeyPress(key="j" | "\x1b[B") if model.focus == "viewport":  # Down
            # Clamp to content length to prevent phantom scroll past end
            max_scroll = max(0, len(model.viewport_lines) - 1)
            new_scroll = min(model.viewport_scroll + 1, max_scroll)
            return replace(
                model, viewport_scroll=new_scroll, viewport_auto_follow=False
            ), Cmd.none()

        case KeyPress(key="k" | "\x1b[A") if model.focus == "viewport":  # Up
            new_scroll = max(0, model.viewport_scroll - 1)
            return replace(
                model, viewport_scroll=new_scroll, viewport_auto_follow=False
            ), Cmd.none()

        case KeyPress(key="G") if model.focus == "viewport":  # Jump to bottom
            new_scroll = max(0, len(model.viewport_lines) - 1)
            return replace(model, viewport_scroll=new_scroll, viewport_auto_follow=True), Cmd.none()

        case KeyPress(key="g"):  # gg = top (simplified, just g for now)
            if model.focus == "viewport":
                return replace(model, viewport_scroll=0, viewport_auto_follow=False), Cmd.none()

        # Events from events.jsonl
        case NewEvent(line=line):
            event = _parse_event(line)
            if event is None:
                return model, Cmd.none()
            new_events = model.events + (event,)
            if event.get("message") == "eval_end":
                return replace(model, events=new_events, done=True), Cmd.quit()

            new_model = replace(model, events=new_events)

            # Auto-select first sample if none selected
            if model.selected_sample_id is None:
                active = _get_active_samples(new_events)
                if active:
                    new_model = replace(new_model, selected_sample_id=active[0].id)

            return new_model, Cmd.none()

        # Events from per-sample JSONL file
        case SampleEvent(line=line):
            event = _parse_event(line)
            if event is None:
                return model, Cmd.none()

            formatted, new_turn = format_sample_event(event, model.last_turn)
            msg_type = event.get("message", "")

            # Handle streaming text accumulation
            if msg_type == "text_delta":
                new_streaming = model.streaming_text + (formatted or "")
                return replace(model, streaming_text=new_streaming), Cmd.none()

            # Non-delta event: flush streaming text and add formatted line
            flushed_lines = [l for l in model.streaming_text.split("\n") if l]
            event_line = [formatted] if formatted else []
            new_lines = model.viewport_lines + tuple(flushed_lines) + tuple(event_line)

            # Auto-scroll if enabled
            new_scroll = (
                max(0, len(new_lines) - 1) if model.viewport_auto_follow else model.viewport_scroll
            )

            return replace(
                model,
                viewport_lines=new_lines,
                viewport_scroll=new_scroll,
                streaming_text="",
                last_turn=new_turn if new_turn is not None else model.last_turn,
            ), Cmd.none()

        # Marquee tick for selected row animation
        case MarqueeTick():
            return _handle_marquee_tick(model)

        # Marquee tick for viewport lines animation
        case ViewportMarqueeTick():
            return _handle_viewport_marquee_tick(model)

    return model, Cmd.none()


def _render_viewport(model: Model, width: int, viewport_height: int, list_height: int) -> list[str]:
    """Render the viewport section showing selected sample's output."""
    lines: list[str] = []

    if model.selected_sample_id:
        # Include any streaming text as a "virtual" line at the end
        display_lines = list(model.viewport_lines)
        if model.streaming_text:
            # Show streaming text (last 200 chars to keep it manageable)
            stream_preview = model.streaming_text[-200:]
            if len(model.streaming_text) > 200:
                stream_preview = "..." + stream_preview
            display_lines.append(f"{CYAN}{stream_preview}{RESET}")

        if display_lines:
            # Clamp scroll (bubbles pattern)
            max_scroll = max(0, len(display_lines) - viewport_height)
            scroll = _clamp(model.viewport_scroll, 0, max_scroll)

            # Render visible lines with marquee for overflow
            visible = display_lines[scroll : scroll + viewport_height]
            marquee_offset = (
                max(0, model.viewport_marquee_offset) if model.focus == "viewport" else 0
            )

            for line in visible:
                visible_len = _visible_len(line)
                overflow = visible_len - width

                if overflow > 0 and marquee_offset > 0:
                    # Apply marquee: slice from offset position (ANSI-aware)
                    offset = min(marquee_offset, overflow)
                    sliced = _ansi_slice(line, offset)
                    # Truncate to width after slicing
                    if _visible_len(sliced) > width:
                        # Crude truncation - find approx position
                        lines.append(sliced[: width + 20] + "…")
                    else:
                        lines.append(sliced)
                elif overflow > 0:
                    # No marquee active, just truncate with indicator
                    lines.append(line[: width + 20] + "…")
                else:
                    lines.append(line)
        else:
            lines.append(f"{DIM}  (waiting for events...){RESET}")

        # Pad viewport
        target_len = viewport_height
    else:
        # No sample selected
        lines.append(f"{DIM}  (select a sample to view its output){RESET}")
        target_len = viewport_height

    while len(lines) < target_len:
        lines.append("")

    return lines


def view(model: Model, width: int, height: int) -> list[str]:
    """Render the split view: sample list on top, viewport on bottom.

    Uses bubbles-style bounded rendering:
    - Only render visible rows in each section
    - Keep selected item in view
    - Clamp scroll positions
    """
    state = derive_state(list(model.events))
    lines: list[str] = []

    # Header
    lines.append(render_header(state, width))

    # Calculate layout
    # Sample list gets ~1/3 of space (min 4 lines), viewport gets rest
    list_height = max(4, min(height // 3, 10))
    viewport_height = height - list_height - 3  # -3 for header, separator, footer

    # Get active samples (stable sort by name)
    active = _get_active_samples(model.events)

    # Find selected index
    selected_idx = _find_sample_index(active, model.selected_sample_id)

    # Calculate visible window for sample list (bubbles pattern)
    # Reserve last line for scroll indicator if there will be hidden items
    needs_scroll_indicator = len(active) > list_height - 1
    list_visible_height = list_height - 2 if needs_scroll_indicator else list_height - 1

    # Compute list_scroll to keep selected item visible (derived, not stored in model)
    # Start with scroll that centers selection, then clamp
    list_scroll = max(0, selected_idx - list_visible_height // 2)
    list_scroll = _clamp(list_scroll, 0, max(0, len(active) - list_visible_height))

    # Render visible sample rows
    visible_end = min(list_scroll + list_visible_height, len(active))
    for i in range(list_scroll, visible_end):
        sample = active[i]
        selected = i == selected_idx
        row, _overflow = _format_sample_row(
            sample,
            width,
            selected=selected,
            marquee_offset=model.marquee_offset if selected else 0,
        )
        lines.append(row)

    # Show scroll indicator if there are hidden items (on its own line)
    hidden_above = list_scroll
    hidden_below = max(0, len(active) - visible_end)
    if hidden_above > 0 or hidden_below > 0:
        indicator_parts = []
        if hidden_above > 0:
            indicator_parts.append(f"↑{hidden_above}")
        if hidden_below > 0:
            indicator_parts.append(f"↓{hidden_below}")
        indicator = " ".join(indicator_parts)
        lines.append(f"{DIM}  ... {indicator} more{RESET}")

    # Pad list area
    while len(lines) < list_height:
        lines.append("")

    # Separator with focus indicator and selected sample name
    selected_name = ""
    if model.selected_sample_id:
        sample = _get_sample_at_index(active, selected_idx)
        if sample:
            selected_name = f" [{sample.name[:20]}]"

    if model.focus == "list":
        label = "samples"
    else:
        label = "viewport"
    # Build separator to fill width
    prefix = f"─── {label}{selected_name} "
    fill = "─" * max(0, width - len(prefix))
    lines.append(f"{DIM}{prefix}{fill}{RESET}")

    # Viewport
    lines.extend(_render_viewport(model, width, viewport_height, list_height))

    # Footer
    if model.focus == "list":
        footer = f"{DIM}j/k: select  Tab: viewport  q: detach  ^C: cancel{RESET}"
    else:
        follow_status = "follow" if model.viewport_auto_follow else "manual"
        footer = f"{DIM}j/k: scroll  G: bottom  Tab: list  [{follow_status}]  q: detach  ^C: cancel{RESET}"

    # Pad to fill screen
    while len(lines) < height - 1:
        lines.append("")
    lines.append(footer)

    return lines[:height]


# ── Context manager (spawns subprocess) ──────────────────────────────────────


@contextmanager
def progress_display(
    output_dir: Path | str,
    disable: bool = False,
    debug_log: Path | str | None = None,
) -> Generator[Path, None, None]:
    """Context manager that spawns a pytui progress display in a subprocess.

    The subprocess owns the terminal (alternate screen, raw mode via /dev/tty).
    The parent process writes events.jsonl via logging as usual.

    Args:
        output_dir: Directory containing events.jsonl
        disable: If True, skip progress display (verbose mode)
        debug_log: If set, write pytui debug events to this file
    """
    if disable:
        yield Path(output_dir)
        return

    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    events_file = output_dir / "events.jsonl"

    # Build command
    cmd = [sys.executable, "-m", "wafer_core.rollouts.progress_app", str(events_file)]
    if debug_log is None:
        # Default: write debug log to output_dir
        debug_log = output_dir / "pytui_debug.jsonl"
    if debug_log:
        cmd.extend(["--debug-log", str(debug_log)])

    proc = subprocess.Popen(
        cmd,
        # Don't touch stdout/stderr — child inherits the terminal.
        # pytui opens /dev/tty directly for input, writes to stdout for rendering.
        #
        # We need the subprocess to stay in the same session so it can access
        # /dev/tty for keyboard input. SIGINT handling: pytui is in raw mode
        # so Ctrl+C becomes a regular keypress (^C / \x03) rather than generating
        # SIGINT. The progress app handles KeyPress("\x03") -> Cmd.quit().
        stdin=subprocess.DEVNULL,
    )

    try:
        yield output_dir
    finally:
        # Give the subprocess a chance to clean up its terminal state.
        proc.send_signal(signal.SIGTERM)
        try:
            proc.wait(timeout=5.0)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()

        # Defensive: restore terminal to sane state in case the subprocess
        # didn't clean up (e.g. got SIGKILL, or crashed before restoring).
        # stty sane resets raw mode, re-enables echo, etc.
        tty_fd = os.open("/dev/tty", os.O_RDWR)
        try:
            subprocess.run(["stty", "sane"], stdin=tty_fd)
        finally:
            os.close(tty_fd)


# ── CLI entry point (subprocess runs this) ────────────────────────────────────

if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser()
    parser.add_argument("events_file", help="Path to events.jsonl")
    parser.add_argument("--debug-log", help="Path to debug log file")
    args = parser.parse_args()

    from ._pytui import App, Cmd, Sub

    events_file = args.events_file
    output_dir = Path(events_file).parent
    samples_dir = output_dir / "samples"

    def _subscriptions(model: Model) -> Sub:
        if model.done:
            return Sub.none()

        subs = [Sub.file_tail(events_file, lambda line: NewEvent(line=line))]

        # Tail the selected sample's file if we have one
        if model.selected_sample_id:
            sample_file = samples_dir / f"{model.selected_sample_id}.jsonl"
            if sample_file.exists():
                subs.append(Sub.file_tail(str(sample_file), lambda line: SampleEvent(line=line)))

            # Marquee animation tick (scrolls selected row text)
            subs.append(Sub.every(0.15, lambda: MarqueeTick()))

        # Viewport marquee animation (scrolls long lines when viewport focused)
        if model.focus == "viewport" and model.viewport_lines:
            subs.append(Sub.every(0.12, lambda: ViewportMarqueeTick()))

        return Sub.batch(*subs)

    App(
        init=(Model(), Cmd.none()),
        update=update,
        view=view,
        subscriptions=_subscriptions,
        alternate_screen=False,
        debug_log=args.debug_log,
    ).run()
