"""Boris prompts - planning and prompt crafting (merged from planner + crafter)."""
import json
import logging
import os
import re
import shutil
import subprocess
import sys
from datetime import datetime

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

from state import Milestone, Plan, UIMilestone, UIPlan

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

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

# Resolve claude command to full path 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.

    Searches multiple locations to handle both editable installs (source dir)
    and regular pip installs (site-packages).
    """
    global _boris_prompt_cache
    if _boris_prompt_cache is not None:
        return _boris_prompt_cache

    search_paths = [
        _BORIS_PROMPT_PATH,  # Next to this .py file (editable install / source)
        os.path.join(sys.prefix, "boris_prompt.md"),  # sys.prefix (some data_files installs)
        os.path.join(os.path.dirname(shutil.which("boris") or ""), "boris_prompt.md"),  # Next to boris script
    ]

    for path in search_paths:
        try:
            with open(path, "r", encoding="utf-8") as f:
                _boris_prompt_cache = f.read().strip()
            logger.debug("Loaded Boris prompt from %s (%d chars)", path, len(_boris_prompt_cache))
            return _boris_prompt_cache
        except (FileNotFoundError, OSError, TypeError):
            continue

    logger.warning("boris_prompt.md not found in any search path: %s", search_paths)
    _boris_prompt_cache = ""
    return _boris_prompt_cache

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


def _clean_output(text: str) -> str:
    """Strip ANSI escape codes from text for safe embedding in prompts."""
    return _ANSI_RE.sub("", text)


def _list_project_files(project_dir: str, max_files: int = 500) -> str:
    """Walk project_dir and return formatted file listing.

    Caps output at *max_files* entries to keep prompts within Claude CLI limits.
    """
    skip_dirs = {".git", "__pycache__", "node_modules", ".boris", ".venv", "venv"}
    file_list = []
    truncated = False

    for root, dirs, files in os.walk(project_dir):
        # Filter out hidden and skip dirs
        dirs[:] = [d for d in dirs if d not in skip_dirs and not d.startswith(".")]
        rel_root = os.path.relpath(root, project_dir)
        for fname in sorted(files):
            if len(file_list) >= max_files:
                truncated = True
                break
            if rel_root == ".":
                file_list.append(fname)
            else:
                file_list.append(os.path.join(rel_root, fname))
        if truncated:
            break

    if not file_list:
        return "(empty project)"

    result = "\n".join(f"  {f}" for f in file_list)
    if truncated:
        result += f"\n  ... (truncated — showing {max_files} of many files)"
    return result


# --- Planning (from planner.py) ---


def create_plan(task: str, project_dir: str, turbo: bool = False) -> Plan:
    """Break a task into milestones using Claude CLI.

    Args:
        task: The task description.
        project_dir: Path to the project directory.
        turbo: When True, emphasize minimal dependencies and maximum parallelism.
    """
    file_listing = _list_project_files(project_dir)

    if turbo:
        dependency_instructions = (
            "CRITICAL DEPENDENCY RULES (turbo/parallel mode):\n"
            "- Only add a dependency if the milestone TRULY requires output from another milestone to function.\n"
            "- Two milestones that share a common dependency should NOT depend on each other unless one genuinely needs the other's output.\n"
            "- Maximize parallelism in the dependency graph. Independent features, pages, or modules that share the same foundation should run in parallel.\n"
            "- Do NOT chain milestones linearly unless there is a real data/code dependency between them.\n\n"
            "Example of WRONG dependencies (overly sequential):\n"
            '  M1: Project Setup (depends_on: [])\n'
            '  M2: Auth Backend (depends_on: ["M1"])\n'
            '  M3: Login Page (depends_on: ["M2"])\n'
            '  M4: Dashboard Page (depends_on: ["M3"])  <-- WRONG: only needs M2, not M3\n'
            '  M5: Profile Page (depends_on: ["M4"])    <-- WRONG: only needs M2, not M4\n\n'
            "Example of CORRECT dependencies (maximizes parallelism):\n"
            '  M1: Project Setup (depends_on: [])\n'
            '  M2: Auth Backend (depends_on: ["M1"])\n'
            '  M3: Login Page (depends_on: ["M2"])\n'
            '  M4: Dashboard Page (depends_on: ["M2"])  <-- parallel with M3\n'
            '  M5: Profile Page (depends_on: ["M2"])    <-- parallel with M3, M4\n\n'
        )
    else:
        dependency_instructions = (
            "Order by dependency: foundation first, then features that depend on it.\n"
        )

    prompt = (
        "You are a technical project planner. Break this task into ordered milestones.\n"
        "Each milestone must be a concrete, buildable unit that DaveLoop can execute independently.\n"
        f"{dependency_instructions}\n"
        f"Task: {task}\n"
        f"Project directory: {project_dir}\n"
        f"Existing files:\n{file_listing}\n\n"
        "Return ONLY a JSON array (no other text) of milestones:\n"
        "[\n"
        "  {\n"
        '    "id": "M1",\n'
        '    "title": "Short title",\n'
        '    "description": "Detailed description of what to build",\n'
        '    "depends_on": [],\n'
        '    "acceptance_criteria": ["criterion 1", "criterion 2"],\n'
        '    "files_to_create": ["file1.py"],\n'
        '    "files_to_modify": []\n'
        "  }\n"
        "]"
    )

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

        import sys as _sys
        import threading
        import time

        print("  [Boris] Planning phase - Claude is thinking...", flush=True)
        print(flush=True)

        # Run Claude CLI in a thread so we can show a heartbeat while waiting
        plan_result = {"stdout": "", "returncode": None, "error": None}

        def _run_claude():
            try:
                # Use shell=False for reliable stdin piping on Windows.
                # shell=True routes through cmd.exe which can mangle special chars.
                cmd = [CLAUDE_CMD, "-p", "--no-session-persistence"]
                result = subprocess.run(
                    cmd,
                    input=prompt,
                    capture_output=True,
                    timeout=600,
                    env=env,
                    encoding="utf-8",
                    errors="replace",
                )
                plan_result["stdout"] = result.stdout
                plan_result["returncode"] = result.returncode
                if result.returncode != 0:
                    # Capture both stderr and stdout for diagnostics -
                    # Claude CLI may write errors to either stream.
                    err = result.stderr.strip()
                    if not err:
                        err = result.stdout.strip()[:500]
                    plan_result["error"] = err
            except subprocess.TimeoutExpired:
                plan_result["error"] = "timeout"
            except FileNotFoundError:
                plan_result["error"] = "not_found"

        thread = threading.Thread(target=_run_claude, daemon=True)
        thread.start()

        # Heartbeat: show Boris is alive every 10 seconds
        elapsed = 0
        spinner = ["|", "/", "-", "\\"]
        while thread.is_alive():
            idx = (elapsed // 2) % len(spinner)
            _sys.stdout.write(f"\r  [Boris] {spinner[idx]} Claude is planning... ({elapsed}s)")
            _sys.stdout.flush()
            time.sleep(2)
            elapsed += 2

        # Clear spinner line
        _sys.stdout.write("\r" + " " * 60 + "\r")
        _sys.stdout.flush()

        # Handle errors
        if plan_result["error"] == "timeout":
            raise subprocess.TimeoutExpired(CLAUDE_CMD, 600)
        if plan_result["error"] == "not_found":
            raise FileNotFoundError(CLAUDE_CMD)
        if plan_result["returncode"] != 0:
            raise RuntimeError(f"Claude CLI failed (exit {plan_result['returncode']}): {plan_result['error']}")

        response = plan_result["stdout"].strip()
        logger.debug("Claude raw response: %s", response[:500])

        # Parse milestones from response and show them live
        print("  [Boris] Plan received! Parsing milestones...", flush=True)

        # Show milestone IDs/titles as we find them in the JSON
        for id_match in re.finditer(r'"id"\s*:\s*"(M\d+)"', response):
            print(f"  [Boris]   Found milestone {id_match.group(1)}", flush=True)
        print(flush=True)

        # Extract JSON from response - handle markdown code blocks and surrounding text
        json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
        if json_match:
            response = json_match.group(1).strip()
        else:
            # Try to find raw JSON array
            array_match = re.search(r"\[.*\]", response, re.DOTALL)
            if array_match:
                response = array_match.group(0).strip()

        milestones_data = json.loads(response)

        milestones = [
            Milestone(
                id=m["id"],
                title=m["title"],
                description=m["description"],
                depends_on=m.get("depends_on", []),
                acceptance_criteria=m.get("acceptance_criteria", []),
                files_to_create=m.get("files_to_create", []),
                files_to_modify=m.get("files_to_modify", []),
            )
            for m in milestones_data
        ]

        plan = Plan(task=task, milestones=milestones)

        # Save plan as markdown
        plan_path = _save_plan_markdown(plan, task)
        logger.info("Plan saved to %s", plan_path)

        print(f"  [Boris] Plan saved to {plan_path}", flush=True)

        return plan

    except json.JSONDecodeError as e:
        raise RuntimeError(f"Failed to parse Claude's plan response as JSON: {e}") from e
    except subprocess.TimeoutExpired:
        raise RuntimeError("Claude CLI timed out while generating plan")
    except FileNotFoundError:
        raise RuntimeError(
            f"'{CLAUDE_CMD}' command not found. Is Claude CLI installed?"
        )


def _save_plan_markdown(plan: Plan, task: str) -> str:
    """Save a human-readable markdown version of the plan. Returns filepath."""
    os.makedirs(_PLANS_DIR, exist_ok=True)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"plan_{timestamp}.md"
    filepath = os.path.join(_PLANS_DIR, filename)

    lines = [
        f"# Boris Plan",
        f"",
        f"**Task:** {task}",
        f"**Created:** {plan.created_at}",
        f"**Milestones:** {len(plan.milestones)}",
        f"",
        f"---",
        f"",
    ]

    for m in plan.milestones:
        lines.append(f"## {m.id}: {m.title}")
        lines.append(f"")
        lines.append(f"{m.description}")
        lines.append(f"")
        if m.depends_on:
            lines.append(f"**Depends on:** {', '.join(m.depends_on)}")
            lines.append(f"")
        lines.append(f"**Acceptance Criteria:**")
        for c in m.acceptance_criteria:
            lines.append(f"- {c}")
        lines.append(f"")
        if m.files_to_create:
            lines.append(f"**Files to create:** {', '.join(m.files_to_create)}")
        if m.files_to_modify:
            lines.append(f"**Files to modify:** {', '.join(m.files_to_modify)}")
        lines.append(f"")
        lines.append(f"---")
        lines.append(f"")

    with open(filepath, "w", encoding="utf-8") as f:
        f.write("\n".join(lines))

    return filepath


def load_plan_from_file(filepath: str) -> Plan:
    """Parse a saved plan markdown file back into a Plan object.

    Reads the markdown format produced by _save_plan_markdown and reconstructs
    the Plan with its Milestone objects.
    """
    if not os.path.exists(filepath):
        raise FileNotFoundError(f"Plan file not found: {filepath}")

    with open(filepath, "r", encoding="utf-8") as f:
        content = f.read()

    # Extract task from **Task:** line
    task_match = re.search(r"\*\*Task:\*\*\s*(.+)", content)
    task = task_match.group(1).strip() if task_match else "Unknown task"

    # Extract created_at from **Created:** line
    created_match = re.search(r"\*\*Created:\*\*\s*(.+)", content)
    created_at = created_match.group(1).strip() if created_match else datetime.now().isoformat()

    # Split into milestone sections by ## M<N>: headers
    milestone_sections = re.split(r"(?=^## M\d+:)", content, flags=re.MULTILINE)

    milestones = []
    for section in milestone_sections:
        # Only process sections that start with a milestone header
        header_match = re.match(r"^## (M\d+):\s*(.+)", section)
        if not header_match:
            continue

        m_id = header_match.group(1)
        m_title = header_match.group(2).strip()

        # Extract description: text between the header and the first **bold** field or ---
        # Skip the header line, then collect lines until we hit a **field:** or ---
        lines = section.split("\n")
        desc_lines = []
        in_desc = False
        for line in lines[1:]:  # skip header line
            stripped = line.strip()
            if not stripped:
                if in_desc:
                    desc_lines.append("")
                continue
            if stripped.startswith("**") or stripped == "---":
                break
            desc_lines.append(stripped)
            in_desc = True
        description = "\n".join(desc_lines).strip()

        # Extract depends_on
        depends_match = re.search(r"\*\*Depends on:\*\*\s*(.+)", section)
        depends_on = []
        if depends_match:
            deps_str = depends_match.group(1).strip()
            depends_on = [d.strip() for d in deps_str.split(",") if d.strip()]

        # Extract acceptance criteria (bulleted list after **Acceptance Criteria:**)
        criteria = []
        criteria_match = re.search(r"\*\*Acceptance Criteria:\*\*\s*\n((?:\s*- .+\n?)+)", section)
        if criteria_match:
            for line in criteria_match.group(1).strip().split("\n"):
                line = line.strip()
                if line.startswith("- "):
                    criteria.append(line[2:].strip())

        # Extract files to create
        files_create_match = re.search(r"\*\*Files to create:\*\*\s*(.+)", section)
        files_to_create = []
        if files_create_match:
            files_to_create = [f.strip() for f in files_create_match.group(1).split(",") if f.strip()]

        # Extract files to modify
        files_modify_match = re.search(r"\*\*Files to modify:\*\*\s*(.+)", section)
        files_to_modify = []
        if files_modify_match:
            files_to_modify = [f.strip() for f in files_modify_match.group(1).split(",") if f.strip()]

        milestones.append(Milestone(
            id=m_id,
            title=m_title,
            description=description,
            depends_on=depends_on,
            acceptance_criteria=criteria,
            files_to_create=files_to_create,
            files_to_modify=files_to_modify,
        ))

    if not milestones:
        raise RuntimeError(f"No milestones found in plan file: {filepath}")

    return Plan(task=task, milestones=milestones, created_at=created_at)


# --- Prompt Crafting (from crafter.py) ---


def craft_prompt(milestone: Milestone, plan: Plan, project_dir: str,
                  turbo: bool = False, sibling_milestones: list[Milestone] | None = None) -> str:
    """Build a detailed prompt for DaveLoop to execute a milestone.

    This is the most critical function in Boris. DaveLoop needs everything
    in ONE prompt - context, spec, integration points, acceptance criteria.

    Args:
        milestone: The milestone to build a prompt for.
        plan: The full plan for context.
        project_dir: Path to the project directory.
        turbo: When True, adds parallel execution warnings.
        sibling_milestones: Other milestones running concurrently in the same turbo batch.
    """
    sections = []

    # SCOPE LOCK - tell DaveLoop exactly what to build, loud and clear
    sections.append(f"# YOUR TASK: {milestone.id} - {milestone.title}")
    sections.append("")
    sections.append(f"You are building ONE milestone only: **{milestone.id}: {milestone.title}**")
    sections.append("Do NOT build the entire project. Do NOT build other milestones.")
    sections.append(f"Build ONLY what is described below for {milestone.id}.")
    sections.append("")

    # Files scope - explicit
    all_files = (milestone.files_to_create or []) + (milestone.files_to_modify or [])
    if all_files:
        sections.append(f"**Files you may touch:** {', '.join(all_files)}")
        sections.append("**Do NOT create or modify any other files.**")
        sections.append("")

    # Turbo mode: parallel execution warning
    if turbo and sibling_milestones:
        sections.append("## PARALLEL EXECUTION WARNING")
        sections.append("")
        sections.append("**You are running in PARALLEL with other DaveLoops.** Other milestones")
        sections.append("are being built AT THE SAME TIME by sibling DaveLoop processes.")
        sections.append("Modifying files outside your scope WILL cause conflicts or silent overwrites.")
        sections.append("")
        sections.append("**Sibling milestones running concurrently:**")
        for sib in sibling_milestones:
            sib_files = (sib.files_to_create or []) + (sib.files_to_modify or [])
            sib_files_str = ", ".join(sib_files) if sib_files else "(no specific files)"
            sections.append(f"- **{sib.id}: {sib.title}** - Files: {sib_files_str}")
        sections.append("")
        # Collect all sibling files into a flat list for the explicit prohibition
        all_sibling_files = []
        for sib in sibling_milestones:
            all_sibling_files.extend(sib.files_to_create or [])
            all_sibling_files.extend(sib.files_to_modify or [])
        if all_sibling_files:
            unique_sibling_files = sorted(set(all_sibling_files))
            sections.append("**DO NOT touch these files (owned by sibling milestones):**")
            for sf in unique_sibling_files:
                sections.append(f"- {sf}")
            sections.append("")
        sections.append("**Rules:**")
        sections.append("1. ONLY modify files listed in YOUR files_to_create/files_to_modify scope above.")
        sections.append("2. Do NOT modify shared config files (package.json, routing tables, etc.) unless they are explicitly in YOUR scope.")
        sections.append("3. If you need a shared file changed, add a TODO comment instead of editing it.")
        sections.append("")

    # What to build
    sections.append(f"## What to Build")
    sections.append(milestone.description)
    sections.append("")

    # Files to Create
    if milestone.files_to_create:
        sections.append("## Files to Create")
        for f in milestone.files_to_create:
            sections.append(f"- {f}")
        sections.append("")

    # Files to Modify
    if milestone.files_to_modify:
        sections.append("## Files to Modify")
        for f in milestone.files_to_modify:
            sections.append(f"- {f}")
        sections.append("")

    # Acceptance Criteria - right after the spec, before context
    sections.append("## Acceptance Criteria")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    # Verification
    sections.append("## Verification")
    sections.append("After implementation, verify by:")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("When complete, ensure the code runs without errors.")
    sections.append("")

    # Context section - AFTER the task spec so DaveLoop reads the task first
    sections.append("---")
    sections.append("## Background Context (for reference only - do NOT build all of this)")
    sections.append(f"Overall project: {plan.task}")
    sections.append(f"Project directory: {project_dir}")
    sections.append("")

    # What Already Exists
    completed = [m for m in plan.milestones if m.status == "completed"]
    if completed:
        sections.append("### Completed Milestones")
        for m in completed:
            files = m.files_to_create + m.files_to_modify
            file_str = ", ".join(files) if files else "(no specific files)"
            sections.append(f"- {m.id}: {m.title} - Files: {file_str}")
        sections.append("")

    # Current file listing
    sections.append("### Current Project Files")
    file_tree = _list_project_files(project_dir)
    sections.append(file_tree)
    sections.append("")

    # Integration Points
    if milestone.depends_on:
        sections.append("### Integration Points")
        for dep_id in milestone.depends_on:
            dep = next((m for m in plan.milestones if m.id == dep_id), None)
            if dep:
                sections.append(
                    f"- {dep.id}: {dep.title} - "
                    f"Files: {', '.join(dep.files_to_create) if dep.files_to_create else 'none'}. "
                    f"Integrate with this existing code."
                )
        sections.append("")

    # Final reminder
    sections.append("---")
    sections.append(f"**REMINDER: Build ONLY {milestone.id}: {milestone.title}. Nothing else.**")

    return "\n".join(sections)


def craft_correction(output: str, milestone: Milestone, plan: Plan, reason: str) -> str:
    """Build a correction prompt for DaveLoop after a failed or off-plan attempt."""
    sections = []

    sections.append(f"## Correction Required: {milestone.title}")
    sections.append("")
    sections.append(f"The previous attempt to build '{milestone.title}' had issues.")
    sections.append(f"**Problem:** {reason}")
    sections.append("")

    # Output tail (last 200 lines), cleaned of ANSI codes
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    tail = output_lines[-200:] if len(output_lines) > 200 else output_lines
    sections.append("## Output from Previous Attempt (last 200 lines)")
    sections.append("```")
    sections.append("\n".join(tail))
    sections.append("```")
    sections.append("")

    sections.append("Please fix the issues and complete the milestone.")
    sections.append("")

    # Include same context as craft_prompt
    sections.append(f"## Project Context")
    sections.append(f"You are working on: {plan.task}")
    sections.append(f"Project directory: {plan.task}")
    sections.append("")

    # Current Milestone
    sections.append(f"## Current Milestone: {milestone.title}")
    sections.append(milestone.description)
    sections.append("")

    # Acceptance Criteria
    sections.append("## Acceptance Criteria")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    # Boundaries
    sections.append("## Boundaries")
    sections.append("Do NOT modify files outside this milestone's scope.")
    sections.append("Do NOT refactor or 'improve' existing working code.")
    sections.append(f"Focus ONLY on: {milestone.title}")
    sections.append("")

    # Verification
    sections.append("## Verification")
    for criterion in milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("When complete, ensure the code runs without errors.")

    return "\n".join(sections)


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


def create_ui_plan(task: str, project_dir: str) -> UIPlan:
    """Create UI testing milestones using Claude CLI.

    Claude already knows the project from the task + file listing.
    It decides the project type and test tool itself.
    """
    file_listing = _list_project_files(project_dir)

    prompt = (
        "You are a UI/QA testing expert. This project was just built and needs UI testing and polish.\n\n"
        f"Original task: {task}\n"
        f"Current files:\n{file_listing}\n\n"
        "First, determine:\n"
        "- project_type: \"web\", \"android\", \"ios\", or \"cross-platform\"\n"
        "- test_tool: \"playwright\" (for web) or \"maestro\" (for Android/iOS)\n\n"
        "Then create ordered UI testing milestones. Each milestone tests ONE user flow or UI aspect.\n"
        "Focus on:\n"
        "1. Core user flows (login, main feature, navigation)\n"
        "2. Visual polish (alignment, spacing, responsive/adaptive)\n"
        "3. Edge cases (empty states, error states, loading states)\n"
        "4. Accessibility (contrast, labels, screen reader)\n\n"
        "Return ONLY a JSON object (no other text):\n"
        "{\n"
        '  "project_type": "web",\n'
        '  "test_tool": "playwright",\n'
        '  "milestones": [\n'
        "    {\n"
        '      "id": "UI1",\n'
        '      "title": "Test Login Flow",\n'
        '      "description": "Test the login flow...",\n'
        '      "test_commands": ["npx playwright test login.spec.ts"],\n'
        '      "acceptance_criteria": ["Login form displays correctly", "Can enter credentials and submit"]\n'
        "    }\n"
        "  ]\n"
        "}"
    )

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

        import threading
        import time

        print("  [Boris] UI Planning phase - Claude is thinking...", flush=True)
        print(flush=True)

        plan_result = {"stdout": "", "returncode": None, "error": None}

        def _run_claude():
            try:
                cmd = [CLAUDE_CMD, "-p", "--no-session-persistence"]
                result = subprocess.run(
                    cmd,
                    input=prompt,
                    capture_output=True,
                    timeout=600,
                    env=env,
                    encoding="utf-8",
                    errors="replace",
                )
                plan_result["stdout"] = result.stdout
                plan_result["returncode"] = result.returncode
                if result.returncode != 0:
                    err = result.stderr.strip()
                    if not err:
                        err = result.stdout.strip()[:500]
                    plan_result["error"] = err
            except subprocess.TimeoutExpired:
                plan_result["error"] = "timeout"
            except FileNotFoundError:
                plan_result["error"] = "not_found"

        thread = threading.Thread(target=_run_claude, daemon=True)
        thread.start()

        elapsed = 0
        spinner = ["|", "/", "-", "\\"]
        while thread.is_alive():
            idx = (elapsed // 2) % len(spinner)
            sys.stdout.write(f"\r  [Boris] {spinner[idx]} Claude is creating UI plan... ({elapsed}s)")
            sys.stdout.flush()
            time.sleep(2)
            elapsed += 2

        sys.stdout.write("\r" + " " * 60 + "\r")
        sys.stdout.flush()

        if plan_result["error"] == "timeout":
            raise subprocess.TimeoutExpired(CLAUDE_CMD, 600)
        if plan_result["error"] == "not_found":
            raise FileNotFoundError(CLAUDE_CMD)
        if plan_result["returncode"] != 0:
            raise RuntimeError(f"Claude CLI failed (exit {plan_result['returncode']}): {plan_result['error']}")

        response = plan_result["stdout"].strip()
        logger.debug("Claude UI plan raw response: %s", response[:500])

        print("  [Boris] UI Plan received! Parsing...", flush=True)

        # Extract JSON from response
        json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
        if json_match:
            response = json_match.group(1).strip()
        else:
            # Try to find raw JSON object or array
            obj_match = re.search(r"\{.*\}", response, re.DOTALL)
            if obj_match:
                response = obj_match.group(0).strip()

        data = json.loads(response)

        # Claude tells us what the project is and what tool to use
        project_type = data.get("project_type", "web")
        test_tool = data.get("test_tool", "playwright")
        milestones_data = data.get("milestones", data if isinstance(data, list) else [])

        print(f"  [Boris] Project type: {project_type}, tool: {test_tool}", flush=True)

        ui_milestones = [
            UIMilestone(
                id=m["id"],
                title=m["title"],
                description=m["description"],
                test_tool=m.get("test_tool", test_tool),
                test_commands=m.get("test_commands", []),
                acceptance_criteria=m.get("acceptance_criteria", []),
            )
            for m in milestones_data
        ]

        for um in ui_milestones:
            print(f"  [Boris]   {um.id}: {um.title}", flush=True)
        print(flush=True)

        return UIPlan(
            project_type=project_type,
            test_tool=test_tool,
            milestones=ui_milestones,
        )

    except json.JSONDecodeError as e:
        raise RuntimeError(f"Failed to parse Claude's UI plan response as JSON: {e}") from e
    except subprocess.TimeoutExpired:
        raise RuntimeError("Claude CLI timed out while generating UI plan")
    except FileNotFoundError:
        raise RuntimeError(f"'{CLAUDE_CMD}' command not found. Is Claude CLI installed?")


def craft_ui_prompt(ui_milestone: UIMilestone, ui_plan: UIPlan, plan: Plan, project_dir: str) -> str:
    """Build a prompt for DaveLoop in UI Tester Mode.

    Boris keeps it simple: scope the task, tell DaveLoop it's in UI test mode,
    and let DaveLoop figure out the tooling. DaveLoop knows Playwright/Maestro.
    """
    tool_name = "Playwright" if ui_plan.test_tool == "playwright" else "Maestro"
    sections = []

    # MODE DECLARATION - short and clear
    sections.append(f"# MODE: UI TESTER (DaveLoop v1.4)")
    sections.append(f"# Tool: {tool_name}")
    sections.append("")
    sections.append("You are in UI TESTER MODE. You do NOT build new features.")
    sections.append("You TEST existing UI, FIND issues, and FIX visual/UX problems.")
    sections.append("")

    # Task scope
    sections.append(f"## Your Task: {ui_milestone.id} - {ui_milestone.title}")
    sections.append("")
    sections.append(ui_milestone.description)
    sections.append("")

    # Test commands (if the plan specified any)
    if ui_milestone.test_commands:
        sections.append("## Test Commands")
        for cmd in ui_milestone.test_commands:
            sections.append(f"- `{cmd}`")
        sections.append("")

    # Acceptance criteria
    sections.append("## Acceptance Criteria")
    for criterion in ui_milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    # Reporting markers so Boris can parse issues/fixes from output
    sections.append("## Reporting")
    sections.append("Report issues as: `ISSUE FOUND: <description>`")
    sections.append("Report fixes as: `FIX APPLIED: <description>`")
    sections.append("")

    # Brief context
    sections.append("---")
    sections.append(f"Project: {plan.task}")
    sections.append(f"Directory: {project_dir}")
    sections.append(f"Type: {ui_plan.project_type}")
    sections.append("")

    # File listing so DaveLoop knows what exists
    sections.append("### Current Files")
    sections.append(_list_project_files(project_dir))
    sections.append("")

    sections.append(f"**Do NOT build new features. Test {ui_milestone.id} only.**")

    return "\n".join(sections)


def craft_ui_correction(output: str, ui_milestone: UIMilestone, ui_plan: UIPlan, reason: str) -> str:
    """Build a correction prompt for DaveLoop in UI Tester Mode after an off-plan or failed attempt."""
    tool_name = "Playwright" if ui_plan.test_tool == "playwright" else "Maestro"
    sections = []

    sections.append(f"# MODE: UI TESTER (DaveLoop v1.4) - CORRECTION")
    sections.append(f"# Tool: {tool_name}")
    sections.append("")
    sections.append(f"## Correction Required: {ui_milestone.title}")
    sections.append(f"**Problem:** {reason}")
    sections.append("")

    # Output tail
    clean = _clean_output(output)
    output_lines = clean.strip().splitlines()
    tail = output_lines[-200:] if len(output_lines) > 200 else output_lines
    sections.append("## Previous Output (last 200 lines)")
    sections.append("```")
    sections.append("\n".join(tail))
    sections.append("```")
    sections.append("")

    sections.append(f"Fix the issues and complete {ui_milestone.id}: {ui_milestone.title}")
    sections.append(ui_milestone.description)
    sections.append("")

    sections.append("## Acceptance Criteria")
    for criterion in ui_milestone.acceptance_criteria:
        sections.append(f"- {criterion}")
    sections.append("")

    sections.append("Report issues: `ISSUE FOUND: <description>`")
    sections.append("Report fixes: `FIX APPLIED: <description>`")
    sections.append("")
    sections.append("**Do NOT build new features. UI testing only.**")

    return "\n".join(sections)
