"""Boris engine - execution and monitoring (merged from executor + monitor)."""
import concurrent.futures
import enum
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional

# Force unbuffered stdout for real-time output on Windows
os.environ.setdefault("PYTHONUNBUFFERED", "1")

from state import Milestone, UIMilestone

IS_WINDOWS = sys.platform == "win32"
logger = logging.getLogger("boris.engine")

# Regex to strip ANSI escape codes
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[a-zA-Z]")

# Directories (relative to Boris install dir)
_BORIS_DIR = os.path.dirname(os.path.abspath(__file__))
_LOGS_DIR = os.path.join(_BORIS_DIR, "logs")
_BORIS_PROMPT_PATH = os.path.join(_BORIS_DIR, "boris_prompt.md")

# Resolve commands to full paths so shell=False works on Windows (.cmd files)
CLAUDE_CMD = shutil.which("claude") or "claude"

# Cached Boris system prompt
_boris_prompt_cache = None


def _load_boris_prompt() -> str:
    """Load Boris's management prompt from boris_prompt.md. Cached after first load."""
    global _boris_prompt_cache
    if _boris_prompt_cache is not None:
        return _boris_prompt_cache
    try:
        with open(_BORIS_PROMPT_PATH, "r", encoding="utf-8") as f:
            _boris_prompt_cache = f.read().strip()
        logger.debug("Loaded Boris prompt: %d chars", len(_boris_prompt_cache))
    except FileNotFoundError:
        logger.warning("boris_prompt.md not found at %s", _BORIS_PROMPT_PATH)
        _boris_prompt_cache = ""
    return _boris_prompt_cache
DAVELOOP_CMD = shutil.which("daveloop") or "daveloop"

# Limits
DEFAULT_MAX_ITERATIONS = 15


def _clean_output(text: str) -> str:
    """Strip ANSI escape codes and non-ASCII chars for clean analysis."""
    text = _ANSI_RE.sub("", text)
    # Replace non-ASCII (emoji, box-drawing) with spaces
    text = text.encode("ascii", errors="replace").decode("ascii")
    return text


# --- Worker Status (B7: Swarm Dashboard) ---


def _write_worker_status(project_dir: str, milestone_id: str, status: dict):
    """Write worker status to .boris/workers/ for the swarm dashboard."""
    try:
        status_dir = Path(project_dir) / ".boris" / "workers"
        status_dir.mkdir(parents=True, exist_ok=True)
        status_file = status_dir / f"{milestone_id}.json"
        status["updated_at"] = time.time()
        status_file.write_text(json.dumps(status, indent=2), encoding="utf-8")
    except OSError:
        pass  # Non-critical: dashboard is informational only


def read_worker_statuses(project_dir: str) -> dict:
    """Read all worker status files from .boris/workers/. Returns {milestone_id: status_dict}."""
    statuses = {}
    status_dir = Path(project_dir) / ".boris" / "workers"
    if not status_dir.exists():
        return statuses
    for status_file in status_dir.glob("*.json"):
        try:
            data = json.loads(status_file.read_text(encoding="utf-8"))
            milestone_id = status_file.stem
            statuses[milestone_id] = data
        except (json.JSONDecodeError, OSError):
            pass
    return statuses


def clear_worker_statuses(project_dir: str):
    """Remove all worker status files (call after a batch completes)."""
    status_dir = Path(project_dir) / ".boris" / "workers"
    if not status_dir.exists():
        return
    for status_file in status_dir.glob("*.json"):
        try:
            status_file.unlink()
        except (FileNotFoundError, OSError):
            pass


# --- Execution (from executor.py) ---


@dataclass
class ExecutionResult:
    output: str
    exit_code: int
    resolved: bool
    log_path: Optional[str] = None


def _boris_commentary(reasoning: dict, step_num: int, accomplishments: list):
    """Boris reports what DaveLoop accomplished so far and what he's cooking next."""
    known = reasoning.get("KNOWN", "").strip()
    hypothesis = reasoning.get("HYPOTHESIS", "").strip()
    next_action = reasoning.get("NEXT", "").strip()

    print(flush=True)
    print(f"  [Boris] === DaveLoop Check-in #{step_num} ===", flush=True)

    # What DaveLoop accomplished since last reasoning block
    if accomplishments:
        print(f"  [Boris] Done so far:", flush=True)
        for a in accomplishments:
            print(f"  [Boris]   - {a}", flush=True)
    elif step_num == 1:
        print(f"  [Boris] Starting up, gathering info.", flush=True)

    # What DaveLoop knows now
    if known:
        print(f"  [Boris] Knows: {known}", flush=True)

    # What DaveLoop is about to do
    if hypothesis:
        print(f"  [Boris] Thinking: {hypothesis}", flush=True)
    if next_action:
        print(f"  [Boris] Next: {next_action}", flush=True)

    print(f"  [Boris] ===========================", flush=True)
    print(flush=True)


def _parse_accomplishment(clean_line: str) -> Optional[str]:
    """Parse a DaveLoop output line into a human-readable accomplishment, or None."""
    # File writes
    write_match = re.search(r"Write\((.+?)\)", clean_line)
    if write_match:
        return f"Created {write_match.group(1).strip()}"

    # File edits
    edit_match = re.search(r"Edit\((.+?)\)", clean_line)
    if edit_match:
        return f"Edited {edit_match.group(1).strip()}"

    # Bash commands
    bash_match = re.search(r"Bash\((.+?)\)", clean_line)
    if bash_match:
        cmd = bash_match.group(1).strip()
        if "test" in cmd.lower() or "pytest" in cmd.lower():
            return f"Ran tests: {cmd}"
        elif "mkdir" in cmd.lower():
            return f"Created directory: {cmd}"
        elif "pip install" in cmd.lower() or "npm install" in cmd.lower():
            return f"Installed dependencies: {cmd}"
        else:
            return f"Ran: {cmd}"

    # Tool results - success/failure
    if "? ?" in clean_line or "PASS" in clean_line.upper():
        return "Verification passed"
    if "? ?" in clean_line or "FAIL" in clean_line.upper():
        if "test" in clean_line.lower():
            return "Test failed - will retry"

    # DaveLoop iteration markers
    if "ITERATION" in clean_line.upper() and re.search(r"\d+", clean_line):
        iter_match = re.search(r"(\d+)", clean_line)
        if iter_match:
            return f"DaveLoop iteration {iter_match.group(1)}"

    # RESOLVED signal
    if "[DAVELOOP:RESOLVED]" in clean_line:
        return "DaveLoop reports: RESOLVED"

    return None


def _check_off_rail(clean_line: str, milestone: Milestone) -> Optional[str]:
    """Detect if DaveLoop is going off-rail. Returns interrupt message or None."""
    lower = clean_line.lower()

    # Detect building wrong files
    allowed_files = set((milestone.files_to_create or []) + (milestone.files_to_modify or []))
    if allowed_files:
        # Check for Write tool creating files outside scope
        write_match = re.search(r"Write\((.+?)\)", clean_line)
        if write_match:
            written_file = write_match.group(1).strip()
            # Normalize: strip path prefixes, compare basenames
            written_base = os.path.basename(written_file)
            allowed_bases = {os.path.basename(f) for f in allowed_files}
            if written_base and written_base not in allowed_bases:
                return (
                    f"wait - you are creating {written_file} which is outside the scope of "
                    f"{milestone.id}. Only touch: {', '.join(allowed_files)}. "
                    f"Focus on {milestone.id}: {milestone.title} only."
                )

    # Detect building entire project / other milestones
    off_rail_phrases = [
        "build the entire project",
        "build everything",
        "implement all milestones",
        "implement all features",
        "building the full",
    ]
    for phrase in off_rail_phrases:
        if phrase in lower:
            return (
                f"wait - you are going off-rail. You are only building {milestone.id}: "
                f"{milestone.title}. Do NOT build the entire project. "
                f"Focus ONLY on {milestone.id}."
            )

    # Detect mentioning other milestone IDs in action context
    other_ms = re.findall(r"\bM\d+\b", clean_line)
    for m_id in other_ms:
        if m_id != milestone.id and ("building" in lower or "implement" in lower or "creating" in lower):
            return (
                f"wait - you mentioned {m_id} but you should only be working on "
                f"{milestone.id}: {milestone.title}. Stay in scope."
            )

    return None


def _send_interrupt(process, message: str, boris_log):
    """Send a text interrupt to DaveLoop's stdin."""
    try:
        if process.stdin and not process.stdin.closed:
            process.stdin.write(f"{message}\n".encode("utf-8"))
            process.stdin.flush()
            log_msg = f"[Boris INTERRUPT] {message}"
            print(f"\n  {log_msg}\n", flush=True)
            logger.warning(log_msg)
            if boris_log:
                boris_log.write(f"\n{log_msg}\n")
    except (OSError, BrokenPipeError):
        logger.debug("Could not send interrupt to DaveLoop stdin")


def run(prompt: str, project_dir: str, max_iterations: int = None,
        milestone: Milestone = None) -> ExecutionResult:
    """Spawn DaveLoop with the crafted prompt. Boris manages, DaveLoop builds.

    If milestone is provided, Boris monitors DaveLoop's reasoning blocks in real-time
    and intervenes via text interrupt if DaveLoop goes off-rail.
    """
    max_iter = max_iterations if max_iterations is not None else DEFAULT_MAX_ITERATIONS
    temp_file = None
    boris_log = None

    try:
        # Write prompt to temp file for DaveLoop
        temp_file = tempfile.NamedTemporaryFile(
            mode="w", suffix=".txt", delete=False, encoding="utf-8"
        )
        temp_file.write(prompt)
        temp_file.close()

        # Boris spawns DaveLoop as the worker
        cmd = [
            DAVELOOP_CMD,
            "-f", temp_file.name,
            "-d", project_dir,
            "-m", str(max_iter),
            "-v",
        ]

        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"

        print(f"  [Boris] Spawning DaveLoop: max_iter={max_iter}, project={project_dir}", flush=True)
        logger.info("Spawning DaveLoop: max_iter=%d, project=%s", max_iter, project_dir)

        # Write initial worker status for dashboard (B7)
        if milestone:
            _write_worker_status(project_dir, milestone.id, {
                "milestone_id": milestone.id,
                "title": milestone.title,
                "state": "starting",
                "started_at": time.time(),
                "actions": 0,
                "interrupts": 0,
            })
        logger.debug("Prompt length: %d chars", len(prompt))

        # Boris's own log for this execution
        log_path = _setup_log(project_dir)
        boris_log = open(log_path, "w", encoding="utf-8")
        boris_log.write(f"=== Boris Execution Log ===\n")
        boris_log.write(f"Timestamp: {datetime.now().isoformat()}\n")
        boris_log.write(f"Project: {project_dir}\n")
        boris_log.write(f"Max iterations: {max_iter}\n")
        boris_log.write(f"Prompt file: {temp_file.name}\n")
        boris_log.write(f"Prompt length: {len(prompt)} chars\n")
        boris_log.write(f"===========================\n\n")

        # Run DaveLoop with real-time streaming + stdin for interrupts
        # bufsize=0 disables OS-level pipe buffering for real-time output on Windows
        process = subprocess.Popen(
            cmd,
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            cwd=project_dir,
            env=env,
            bufsize=0,
        )

        output_lines = []
        # Reasoning block parser state
        in_reasoning = False
        reasoning_lines = []
        reasoning_count = 0
        accomplishments = []       # tracks what DaveLoop did since last reasoning block
        all_accomplishments = []   # cumulative for the whole run
        interrupt_count = 0
        MAX_INTERRUPTS = 3  # After 3 interrupts, let DaveLoop finish and fail at verdict
        # Off-rail detection is suppressed during prompt echo phase.
        # DaveLoop echoes/processes the prompt at startup, which contains sibling
        # milestone IDs (from the PARALLEL EXECUTION WARNING section). Without this
        # guard, _check_off_rail() fires false positives on the prompt's own text.
        # We suppress until DaveLoop starts actual work (first reasoning block) or
        # after a generous line threshold.
        offrail_active = False
        OFFRAIL_WARMUP_LINES = 80  # Lines before off-rail activates even without reasoning

        for raw_line in process.stdout:
            line = raw_line.decode("utf-8", errors="replace")
            sys.stdout.write(line)
            sys.stdout.flush()
            output_lines.append(line)
            boris_log.write(line)

            # Parse cleaned line for monitoring
            clean = _clean_output(line).strip()

            # --- Track accomplishments between reasoning blocks ---
            if not in_reasoning:
                acc = _parse_accomplishment(clean)
                if acc:
                    accomplishments.append(acc)
                    all_accomplishments.append(acc)

            # Activate off-rail detection after warmup threshold (prompt echo complete)
            if not offrail_active and len(output_lines) >= OFFRAIL_WARMUP_LINES:
                offrail_active = True

            # --- Reasoning block detection ---
            if "REASONING" in clean and ("===" in clean or "---" in clean or "KNOWN" in clean):
                in_reasoning = True
                reasoning_lines = []
                # First reasoning block means DaveLoop is doing real work - activate off-rail
                if not offrail_active:
                    offrail_active = True
                continue

            if in_reasoning:
                # Check for end of reasoning block
                if clean.startswith("===") or clean.startswith("---") or clean.startswith("└"):
                    in_reasoning = False
                    reasoning_count += 1
                    # Parse collected reasoning
                    reasoning = {}
                    for rl in reasoning_lines:
                        for key in ("KNOWN", "UNKNOWN", "HYPOTHESIS", "NEXT", "WHY"):
                            if rl.startswith(key + ":") or rl.startswith(key + " :"):
                                reasoning[key] = rl.split(":", 1)[1].strip()
                    if reasoning:
                        _boris_commentary(reasoning, reasoning_count, accomplishments)
                    # Update worker status for dashboard (B7)
                    if milestone:
                        _write_worker_status(project_dir, milestone.id, {
                            "milestone_id": milestone.id,
                            "title": milestone.title,
                            "state": "working",
                            "started_at": time.time(),
                            "reasoning_blocks": reasoning_count,
                            "actions": len(all_accomplishments),
                            "interrupts": interrupt_count,
                            "last_action": all_accomplishments[-1] if all_accomplishments else None,
                        })
                    # Reset per-block accomplishments, keep cumulative
                    accomplishments = []
                    reasoning_lines = []
                else:
                    reasoning_lines.append(clean)

            # --- Off-rail detection (suppressed during prompt echo phase) ---
            if milestone and interrupt_count < MAX_INTERRUPTS and offrail_active:
                interrupt_msg = _check_off_rail(clean, milestone)
                if interrupt_msg:
                    _send_interrupt(process, interrupt_msg, boris_log)
                    interrupt_count += 1
                    if interrupt_count >= MAX_INTERRUPTS:
                        warn = (
                            f"[Boris] Sent {MAX_INTERRUPTS} interrupts. "
                            f"DaveLoop keeps going off-rail. Terminating process."
                        )
                        print(f"\n  {warn}\n", flush=True)
                        logger.warning("Terminating DaveLoop process after %d ignored interrupts", MAX_INTERRUPTS)
                        if boris_log:
                            boris_log.write(f"\n{warn}\n")
                        # Hard kill: terminate the process since interrupts are being ignored
                        process.terminate()
                        try:
                            process.wait(timeout=10)
                        except subprocess.TimeoutExpired:
                            process.kill()
                            process.wait(timeout=5)
                        output = "".join(output_lines)
                        boris_log.write(f"\n=== DaveLoop FORCE KILLED after {MAX_INTERRUPTS} ignored interrupts ===\n")
                        boris_log.close()
                        boris_log = None  # prevent double-close in finally
                        return ExecutionResult(
                            output=output,
                            exit_code=-1,
                            resolved=False,
                            log_path=log_path,
                        )

        process.wait()
        output = "".join(output_lines)

        # Boris end-of-run summary
        if all_accomplishments:
            print(flush=True)
            print(f"  [Boris] === DaveLoop Run Complete ===", flush=True)
            print(f"  [Boris] Total actions tracked: {len(all_accomplishments)}", flush=True)
            for a in all_accomplishments:
                print(f"  [Boris]   - {a}", flush=True)
            print(f"  [Boris] =============================", flush=True)
            print(flush=True)

        boris_log.write(f"\n=== DaveLoop exit code: {process.returncode} ===\n")
        if interrupt_count > 0:
            boris_log.write(f"Boris interrupts sent: {interrupt_count}\n")
        if all_accomplishments:
            boris_log.write(f"Tracked accomplishments: {len(all_accomplishments)}\n")
            for a in all_accomplishments:
                boris_log.write(f"  - {a}\n")

        # Check if DaveLoop resolved the milestone
        resolved = "[DAVELOOP:RESOLVED]" in output

        # Also find DaveLoop's own log path
        daveloop_log = None
        log_match = re.search(r"Logs:\s*(\S+)", output)
        if log_match:
            daveloop_log = log_match.group(1)
            boris_log.write(f"DaveLoop log: {daveloop_log}\n")

        logger.info(
            "DaveLoop finished. Exit code: %d, Resolved: %s, Interrupts: %d",
            process.returncode, resolved, interrupt_count
        )

        return ExecutionResult(
            output=output,
            exit_code=process.returncode,
            resolved=resolved,
            log_path=log_path,
        )

    except FileNotFoundError:
        return ExecutionResult(
            output=f"Error: '{DAVELOOP_CMD}' not found. Install with: pip install daveloop",
            exit_code=127,
            resolved=False,
        )
    except subprocess.SubprocessError as e:
        return ExecutionResult(
            output=f"Subprocess error: {e}",
            exit_code=1,
            resolved=False,
        )
    finally:
        if temp_file is not None:
            try:
                os.unlink(temp_file.name)
            except OSError:
                pass
        if boris_log is not None:
            try:
                boris_log.close()
            except OSError:
                pass


def _setup_log(project_dir: str) -> str:
    """Create a log file path for this execution."""
    os.makedirs(_LOGS_DIR, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    return os.path.join(_LOGS_DIR, f"boris_exec_{timestamp}.log")


def _create_worktree(project_dir: str, milestone_id: str) -> tuple:
    """Create a git worktree for a milestone. Returns (worktree_path, branch_name) or None on failure."""
    worktree_path = os.path.join(project_dir, f".boris_worktree_{milestone_id}")
    branch_name = f"boris/{milestone_id}"
    try:
        result = subprocess.run(
            ["git", "worktree", "add", "-b", branch_name, worktree_path],
            cwd=project_dir, capture_output=True, timeout=30,
            encoding="utf-8", errors="replace",
        )
        if result.returncode == 0:
            logger.info("Created worktree for %s at %s", milestone_id, worktree_path)
            return (worktree_path, branch_name)
        else:
            logger.warning("Failed to create worktree for %s: %s", milestone_id, result.stderr.strip())
            return None
    except (subprocess.SubprocessError, OSError) as e:
        logger.warning("Worktree creation error for %s: %s", milestone_id, e)
        return None


def _merge_worktree(project_dir: str, worktree_path: str, branch_name: str, milestone_id: str) -> bool:
    """Merge a worktree branch back into the current branch and clean up. Returns success."""
    try:
        # Merge the branch back
        merge_result = subprocess.run(
            ["git", "merge", branch_name, "--no-edit", "-m",
             f"Merge boris/{milestone_id} worktree back"],
            cwd=project_dir, capture_output=True, timeout=60,
            encoding="utf-8", errors="replace",
        )
        if merge_result.returncode != 0:
            logger.warning("Merge failed for %s: %s", milestone_id, merge_result.stderr.strip())
            # Abort merge on conflict
            subprocess.run(["git", "merge", "--abort"], cwd=project_dir,
                          capture_output=True, timeout=10)
            return False
        return True
    except (subprocess.SubprocessError, OSError) as e:
        logger.warning("Merge error for %s: %s", milestone_id, e)
        return False
    finally:
        _cleanup_worktree(project_dir, worktree_path, branch_name)


def _cleanup_worktree(project_dir: str, worktree_path: str, branch_name: str):
    """Remove a git worktree and its branch."""
    try:
        subprocess.run(["git", "worktree", "remove", worktree_path, "--force"],
                      cwd=project_dir, capture_output=True, timeout=30)
    except (subprocess.SubprocessError, OSError):
        pass
    try:
        subprocess.run(["git", "branch", "-D", branch_name],
                      cwd=project_dir, capture_output=True, timeout=10)
    except (subprocess.SubprocessError, OSError):
        pass


def run_parallel(tasks: list, project_dir: str, max_iterations: int = None,
                 isolation: str = "none") -> list:
    """Run multiple DaveLoop instances in parallel using ThreadPoolExecutor.

    Args:
        tasks: List of (prompt, milestone) tuples.
        project_dir: Working directory for the project.
        max_iterations: Max DaveLoop iterations per milestone.
        isolation: Isolation strategy - "none" (shared dir), "worktree" (git worktrees).

    Returns:
        List of (milestone, ExecutionResult) tuples, one per input task.
    """
    results = []

    if isolation == "worktree" and len(tasks) > 1:
        # Create worktrees for each task
        worktree_map = {}  # milestone_id -> (worktree_path, branch_name)
        for prompt, milestone in tasks:
            wt = _create_worktree(project_dir, milestone.id)
            if wt:
                worktree_map[milestone.id] = wt
            else:
                logger.warning("Worktree failed for %s, falling back to shared dir", milestone.id)

        def _run_one_worktree(prompt_milestone):
            prompt, milestone = prompt_milestone
            wt_info = worktree_map.get(milestone.id)
            work_dir = wt_info[0] if wt_info else project_dir
            result = run(prompt, work_dir, max_iterations, milestone=milestone)
            return (milestone, result)

        with concurrent.futures.ThreadPoolExecutor(max_workers=len(tasks)) as executor:
            futures = {executor.submit(_run_one_worktree, t): t for t in tasks}
            for future in concurrent.futures.as_completed(futures):
                milestone, result = future.result()
                # Merge worktree back if it was used
                wt_info = worktree_map.get(milestone.id)
                if wt_info and result.resolved:
                    wt_path, branch = wt_info
                    merge_ok = _merge_worktree(project_dir, wt_path, branch, milestone.id)
                    if not merge_ok:
                        print(f"  [Boris] WARNING: Merge conflict for {milestone.id} worktree", flush=True)
                        logger.warning("Worktree merge conflict for %s", milestone.id)
                elif wt_info:
                    # Failed milestone - just clean up worktree
                    _cleanup_worktree(project_dir, wt_info[0], wt_info[1])
                results.append((milestone, result))

    else:
        # No isolation or single task - original behavior
        def _run_one(prompt_milestone):
            prompt, milestone = prompt_milestone
            result = run(prompt, project_dir, max_iterations, milestone=milestone)
            return (milestone, result)

        with concurrent.futures.ThreadPoolExecutor(max_workers=len(tasks)) as executor:
            futures = {executor.submit(_run_one, t): t for t in tasks}
            for future in concurrent.futures.as_completed(futures):
                results.append(future.result())

    return results


# --- Monitoring (from monitor.py) ---


class Verdict(enum.Enum):
    RESOLVED = "RESOLVED"
    OFF_PLAN = "OFF_PLAN"
    FAILED = "FAILED"


@dataclass
class VerdictResult:
    verdict: Verdict
    reason: str


def check(result: ExecutionResult, milestone: Milestone) -> VerdictResult:
    """Check execution result and return a verdict."""
    print(f"  [Boris] Evaluating DaveLoop result for {milestone.id}...", flush=True)

    if result.resolved:
        print(f"  [Boris] DaveLoop self-reported RESOLVED for {milestone.id}", flush=True)
        return VerdictResult(verdict=Verdict.RESOLVED, reason="DaveLoop reported resolution")

    if result.exit_code != 0:
        print(f"  [Boris] DaveLoop exited with code {result.exit_code} for {milestone.id}", flush=True)
        # Get tail of output for reason
        lines = result.output.strip().splitlines()
        tail = "\n".join(lines[-20:]) if len(lines) > 20 else result.output.strip()
        return VerdictResult(
            verdict=Verdict.FAILED,
            reason=f"Process exited with code {result.exit_code}. Output tail:\n{tail}",
        )

    # Use Claude for nuanced analysis
    print(f"  [Boris] Running Claude verdict analysis for {milestone.id}...", flush=True)
    return _analyze_with_claude(result.output, milestone)


def _analyze_with_claude(output: str, milestone: Milestone) -> VerdictResult:
    """Use Claude CLI to analyze whether output achieved the milestone."""
    criteria_text = "\n".join(f"- {c}" for c in milestone.acceptance_criteria)
    # Clean and truncate output
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    truncated = "\n".join(output_lines[-300:]) if len(output_lines) > 300 else clean.strip()

    boris_brain = _load_boris_prompt()
    prompt = ""
    if boris_brain:
        prompt += boris_brain + "\n\n---\n\n"
    prompt += (
        f"Analyze this build output. Did it achieve these acceptance criteria:\n"
        f"{criteria_text}\n\n"
        f"Build output:\n{truncated}\n\n"
        f"Answer with exactly one of: RESOLVED, OFF_PLAN, FAILED. "
        f"Then explain why on the next line."
    )

    try:
        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"
        result = subprocess.run(
            [CLAUDE_CMD, "-p", "--no-session-persistence"],
            input=prompt,
            capture_output=True,
            timeout=120,
            env=env,
            encoding="utf-8",
            errors="replace",
        )
        if result.returncode != 0:
            return VerdictResult(
                verdict=Verdict.FAILED,
                reason="Could not analyze output",
            )

        response = result.stdout.strip()
        lines = response.splitlines()
        if not lines:
            return VerdictResult(verdict=Verdict.FAILED, reason="Empty analysis response")

        first_line = lines[0].strip().upper()
        reason = "\n".join(lines[1:]).strip() if len(lines) > 1 else "No explanation provided"

        if "RESOLVED" in first_line:
            return VerdictResult(verdict=Verdict.RESOLVED, reason=reason)
        elif "OFF_PLAN" in first_line:
            return VerdictResult(verdict=Verdict.OFF_PLAN, reason=reason)
        else:
            return VerdictResult(verdict=Verdict.FAILED, reason=reason)

    except (subprocess.SubprocessError, subprocess.TimeoutExpired):
        return VerdictResult(
            verdict=Verdict.FAILED,
            reason="Could not analyze output",
        )


# --- UI Testing (DaveLoop v1.4) ---


# Off-rail phrases specific to UI tester mode (building new stuff instead of testing)
_UI_OFF_RAIL_PHRASES = [
    "add new feature",
    "implement new",
    "create new endpoint",
    "add new screen",
    "build new component",
]


def _parse_ui_accomplishment(clean_line: str, ui_milestone: UIMilestone) -> Optional[str]:
    """Parse a DaveLoop output line into a UI-specific accomplishment, or None.

    Falls back to the standard _parse_accomplishment for non-UI-specific lines.
    """
    clean = clean_line.strip()
    lower = clean.lower()

    # Screenshots
    if "screenshot" in lower and ("saved" in lower or "captured" in lower or "taken" in lower):
        return "Captured screenshot"

    # Playwright/Maestro test runs
    if "maestro test" in lower or "playwright test" in lower:
        return f"Ran UI test: {clean}"

    # Issue tracking
    issue_match = re.search(r"ISSUE FOUND:\s*(.+)", clean)
    if issue_match:
        ui_milestone.issues_found.append(issue_match.group(1))
        return f"Found issue: {issue_match.group(1)}"

    fix_match = re.search(r"FIX APPLIED:\s*(.+)", clean)
    if fix_match:
        ui_milestone.issues_fixed.append(fix_match.group(1))
        return f"Fixed: {fix_match.group(1)}"

    # Fall back to standard parsing
    return _parse_accomplishment(clean)


def _check_ui_off_rail(clean_line: str, ui_milestone: UIMilestone) -> Optional[str]:
    """Detect if DaveLoop is going off-rail in UI tester mode (building instead of testing)."""
    lower = clean_line.lower()

    for phrase in _UI_OFF_RAIL_PHRASES:
        if phrase in lower:
            return (
                f"wait - you are in UI TESTER MODE. You should be testing "
                f"{ui_milestone.id}: {ui_milestone.title}, not building new features. "
                f"Test existing UI and fix visual/UX issues only."
            )

    return None


def run_ui_test(prompt: str, project_dir: str, max_iterations: int = None,
                ui_milestone: UIMilestone = None) -> ExecutionResult:
    """Spawn DaveLoop for UI testing. Thin wrapper around run() with UI-specific monitoring.

    Boris ships DaveLoop the prompt and monitors for UI-specific accomplishments
    and off-rail behavior. DaveLoop handles the actual testing.
    """
    # Use the same run() infrastructure - DaveLoop does the work
    # We just swap in UI-specific off-rail detection via a temporary Milestone adapter
    # so the existing run() monitoring pipeline picks it up.

    # Create a lightweight Milestone-compatible object for run()'s off-rail checks.
    # run() expects a Milestone with files_to_create/files_to_modify for scope checks.
    # For UI testing, we don't restrict files - DaveLoop can touch test files freely.
    adapter = Milestone(
        id=ui_milestone.id if ui_milestone else "UI",
        title=ui_milestone.title if ui_milestone else "UI Test",
        description=ui_milestone.description if ui_milestone else "",
        depends_on=[],
        acceptance_criteria=ui_milestone.acceptance_criteria if ui_milestone else [],
        files_to_create=[],  # No file restrictions in UI test mode
        files_to_modify=[],
    )

    result = run(prompt, project_dir, max_iterations, milestone=adapter)

    # Post-process: scan output for UI-specific markers
    if ui_milestone:
        for line in result.output.splitlines():
            clean = _clean_output(line).strip()
            issue_match = re.search(r"ISSUE FOUND:\s*(.+)", clean)
            if issue_match and issue_match.group(1) not in ui_milestone.issues_found:
                ui_milestone.issues_found.append(issue_match.group(1))
            fix_match = re.search(r"FIX APPLIED:\s*(.+)", clean)
            if fix_match and fix_match.group(1) not in ui_milestone.issues_fixed:
                ui_milestone.issues_fixed.append(fix_match.group(1))

    return result


def check_ui(result: ExecutionResult, ui_milestone: UIMilestone, test_tool: str) -> VerdictResult:
    """Check UI test execution result and return a verdict."""
    print(f"  [Boris] Evaluating UI test result for {ui_milestone.id}...", flush=True)

    if result.resolved:
        print(f"  [Boris] DaveLoop self-reported RESOLVED for {ui_milestone.id}", flush=True)
        return VerdictResult(verdict=Verdict.RESOLVED, reason="DaveLoop reported resolution")

    if result.exit_code != 0:
        print(f"  [Boris] DaveLoop exited with code {result.exit_code} for {ui_milestone.id}", flush=True)
        lines = result.output.strip().splitlines()
        tail = "\n".join(lines[-20:]) if len(lines) > 20 else result.output.strip()
        return VerdictResult(
            verdict=Verdict.FAILED,
            reason=f"Process exited with code {result.exit_code}. Output tail:\n{tail}",
        )

    # Use Claude for UI-specific verdict
    print(f"  [Boris] Running Claude UI verdict analysis for {ui_milestone.id}...", flush=True)
    return _analyze_ui_verdict(result.output, ui_milestone, test_tool)


def _analyze_ui_verdict(output: str, ui_milestone: UIMilestone, test_tool: str) -> VerdictResult:
    """Use Claude CLI to analyze whether UI testing output achieved the milestone."""
    criteria_text = "\n".join(f"- {c}" for c in ui_milestone.acceptance_criteria)
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    truncated = "\n".join(output_lines[-300:]) if len(output_lines) > 300 else clean.strip()

    prompt = (
        f"Analyze this UI testing output. Did DaveLoop:\n"
        f"1. Run the specified tests using {test_tool}?\n"
        f"2. Find and report UI issues?\n"
        f"3. Fix the issues it found?\n"
        f"4. Achieve these acceptance criteria:\n{criteria_text}\n\n"
        f"Test output:\n{truncated}\n\n"
        f"Answer with exactly one of: RESOLVED, OFF_PLAN, FAILED.\n"
        f"Then explain why on the next line."
    )

    try:
        env = os.environ.copy()
        env["PYTHONIOENCODING"] = "utf-8"
        result = subprocess.run(
            [CLAUDE_CMD, "-p", "--no-session-persistence"],
            input=prompt,
            capture_output=True,
            timeout=120,
            env=env,
            encoding="utf-8",
            errors="replace",
        )
        if result.returncode != 0:
            return VerdictResult(verdict=Verdict.FAILED, reason="Could not analyze UI output")

        response = result.stdout.strip()
        lines = response.splitlines()
        if not lines:
            return VerdictResult(verdict=Verdict.FAILED, reason="Empty analysis response")

        first_line = lines[0].strip().upper()
        reason = "\n".join(lines[1:]).strip() if len(lines) > 1 else "No explanation provided"

        if "RESOLVED" in first_line:
            return VerdictResult(verdict=Verdict.RESOLVED, reason=reason)
        elif "OFF_PLAN" in first_line:
            return VerdictResult(verdict=Verdict.OFF_PLAN, reason=reason)
        else:
            return VerdictResult(verdict=Verdict.FAILED, reason=reason)

    except (subprocess.SubprocessError, subprocess.TimeoutExpired):
        return VerdictResult(verdict=Verdict.FAILED, reason="Could not analyze UI output")
