"""Coding agent runner for autonomous code modification tasks."""

import json
from pathlib import Path
from typing import Optional, Any
from datetime import datetime, date

from rich.console import Console
from rich.panel import Panel
from rich.markdown import Markdown
from rich.prompt import Prompt

from emdash.agent.coding_toolkit import CodingToolkit
from emdash.agent.tools.base import ToolResult
from emdash.agent.tools.tasks import TaskState
from emdash.agent.tools.spec import SpecState
from emdash.agent.tools.modes import AgentMode, ModeState
from emdash.agent.spec_schema import Spec, SPEC_TEMPLATE
from emdash.agent.providers import get_provider
from emdash.agent.providers.base import ImageContent
from emdash.agent.providers.factory import DEFAULT_MODEL
from emdash.core.config import get_config
from emdash.utils.logger import log
from emdash.cli.pretty_output import (
    is_production_mode,
    get_pretty_output,
    show_session_header,
    start_tool_call,
    end_tool_call,
    show_llm_stats,
)


class SafeJSONEncoder(json.JSONEncoder):
    """JSON encoder that handles non-serializable objects."""

    def default(self, obj: Any) -> Any:
        if isinstance(obj, (datetime, date)):
            return obj.isoformat()
        if hasattr(obj, 'isoformat'):
            return obj.isoformat()
        if isinstance(obj, set):
            return list(obj)
        if isinstance(obj, bytes):
            return obj.decode('utf-8', errors='replace')
        try:
            return str(obj)
        except Exception:
            return f"<non-serializable: {type(obj).__name__}>"


# =============================================================================
# Mode-Specific System Prompts
# =============================================================================

PLAN_MODE_PROMPT = """You are an expert software engineer in PLAN MODE working inside a git repository.

## CRITICAL: Be Proactive
- ALWAYS use tools to explore the codebase FIRST
- NEVER ask "what codebase?" or "what files?" - USE THE TOOLS to find out
- When given a task, immediately start searching for relevant code

## Your Goal
Explore the codebase and create a detailed specification before any code changes.

## Available Tools

### Exploration (USE THESE FIRST)
- **semantic_search**: Find code by meaning/concept - start here!
- **list_files**: Browse directory structure
- **read_file**: Read file contents
- **expand_node**: Get full context of a function/class
- **get_callers/get_callees**: Trace code relationships
- **get_communities**: Find related code clusters

### Specification
- **submit_spec**: Submit your specification in markdown (required before implementation)
- **get_spec**: Retrieve the current spec
- **update_spec**: Refine the spec based on feedback

### Communication
- **ask_followup_question**: Get clarification from the user
- **switch_mode**: Switch to tasks or code mode

## Workflow
1. **Understand** the request fully
2. **Explore** the codebase using graph tools
3. **Identify** all files that need changes
4. **Create** a comprehensive spec with submit_spec
5. **Wait** for user approval

## Spec Format
Write your spec as free-form markdown. Include:

```markdown
# Feature Title

> One-sentence summary

## Problem
What is broken or missing?

## Solution
What are we building?

## Implementation
- Step 1: description
- Step 2: description
- Step 3: description

## Related Files
- `path/to/file.py` - why this file needs changes
- `path/to/other.py` - why this file needs changes

## Edge Cases
- What could go wrong and how to handle it

## Open Questions (optional)
- Any unresolved questions
```

After spec approval, use switch_mode to transition to 'tasks' or 'code'.
"""

TASKS_MODE_PROMPT = """You are an expert software engineer in TASKS MODE.

## Your Goal
Generate a detailed, actionable task list from the approved specification.

## Available Tools

### Reference
- **get_spec**: Get the current specification
- **update_spec**: Update the spec when refinement requires plan changes
- **semantic_search**: Find related code patterns
- **text_search**: Find specific implementations
- **expand_node**: Get full function/class context
- **read_file**: Read file contents

### Communication
- **ask_followup_question**: Get clarification
- **switch_mode**: Switch to plan or code mode

## Workflow
1. **Review** the spec with get_spec
2. **Analyze** the how_it_works steps
3. **Break down** each step into atomic tasks
4. **Order** tasks by dependencies
5. **Output** a comprehensive task list

## When Refining Tasks
If the user asks to modify or refine the tasks with feedback that affects the underlying plan:
1. First use **update_spec** to update the spec with the new requirements
2. Then regenerate the task list based on the updated spec
This ensures the spec and tasks stay in sync.

## Task List Format
Generate tasks in this format:

```markdown
# Implementation Tasks for: [Feature Name]

## Phase 1: [Phase Name]
- [ ] Task 1: Description
  - File: path/to/file.py
  - Details: What exactly to do
- [ ] Task 2: Description
  ...

## Phase 2: [Phase Name]
...
```

Each task should be:
- Atomic (one clear action)
- Specific (exact file and location)
- Ordered (dependencies respected)
- Testable (clear success criteria)

## IMPORTANT: After generating tasks
After outputting the task list, you MUST:
1. Use **ask_followup_question** to ask the user if they want to proceed to implementation
2. Wait for user confirmation before switching modes
3. Only use switch_mode to 'code' AFTER the user explicitly approves

Example question: "I've generated the implementation tasks above. Would you like to proceed to coding, or would you like to refine the tasks first?"

DO NOT automatically switch to code mode without user confirmation.
"""

CODE_MODE_PROMPT = """You are an expert software engineer in CODE MODE working inside a git repository.

## CRITICAL: Plan Before You Code
- STOP and THINK before making any changes
- Use **update_todo_list** to create a plan FIRST (before any file modifications)
- Break complex tasks into small, clear steps
- Only proceed with implementation after you have a plan

## Workflow (MUST FOLLOW IN ORDER)

### Phase 1: Understand with Graph Tools (2-5 tool calls)
Use the CODE GRAPH to understand the codebase structure:
- **semantic_search**: Find code by meaning/concept - START HERE!
- **expand_node**: Get full context of a function/class (signature, body, docstring)
- **get_callers**: Find what calls a function (impact analysis)
- **get_callees**: Find what a function calls (dependencies)
- **text_search**: Search by exact name in code

DO NOT use list_files or grep for exploration - use the graph tools!

### Phase 2: Plan (REQUIRED before any edits)
- Call **update_todo_list** with your planned steps
- Each step should be small and specific
- Example: "1. Add import for X, 2. Create function Y, 3. Update call site Z"

### Phase 3: Execute (one step at a time)
- Work through your todo list sequentially
- Mark each item complete as you finish it
- **read_file** → **apply_diff** → verify

### Phase 4: Complete
- Run tests if available
- Call attempt_completion with summary

## Available Tools

### Graph Exploration (Phase 1 - USE THESE!)
- **semantic_search**: Find code by meaning - "authentication handling", "database connection"
- **text_search**: Find code by exact name - "LoginService", "handleAuth"
- **expand_node**: Get full details of a function/class found in search
- **get_callers**: "Who calls this function?" - essential for impact analysis
- **get_callees**: "What does this function call?" - understand dependencies
- **get_file_dependencies**: What files import/are imported by a file

### File Operations (Phase 3)
- **read_file**: Read file before editing (required!)
- **apply_diff**: Modify files using search/replace blocks
- **write_to_file**: Create new files
- **grep**: Search for exact string patterns (use sparingly)

### Planning & Completion
- **update_todo_list**: Create/update your implementation plan
- **attempt_completion**: Signal task completion

## Example: Understanding Before Changing

```
Task: "Add rate limiting to the login endpoint"

Good approach:
1. semantic_search("login endpoint authentication") → finds LoginController.login
2. expand_node("LoginController.login") → see full implementation
3. get_callers("LoginController.login") → see what calls it
4. update_todo_list([...]) → plan the changes
5. read_file + apply_diff → make changes
```

## Anti-Patterns to AVOID
- ❌ Using list_files to explore - use semantic_search instead!
- ❌ Using grep for discovery - use text_search or semantic_search!
- ❌ Reading many files without purpose
- ❌ Making changes without a todo list
- ❌ Skipping expand_node when you need to understand a function

## Diff Format
```
<<<<<<< SEARCH
existing code to find
=======
replacement code
>>>>>>> REPLACE
```
"""

# Map modes to prompts
MODE_PROMPTS = {
    AgentMode.PLAN: PLAN_MODE_PROMPT,
    AgentMode.TASKS: TASKS_MODE_PROMPT,
    AgentMode.CODE: CODE_MODE_PROMPT,
}


class CodingAgentRunner:
    """Agent runner with three modes: plan, tasks, and code.

    Modes:
    - PLAN: Explore codebase and create specifications
    - TASKS: Generate implementation task lists
    - CODE: Execute code changes

    Features:
    - Mode switching via tool or CLI command
    - Path-sandboxed file operations
    - Fuzzy diff matching for reliable modifications
    - Context compaction for long sessions
    """

    def __init__(
        self,
        repo_root: Path,
        model: str = DEFAULT_MODEL,
        include_graph_tools: bool = True,
        verbose: bool = True,
        max_iterations: Optional[int] = None,
        context_threshold: float = 0.6,
        initial_mode: AgentMode = AgentMode.CODE,
        save_spec_path: Optional[Path] = None,
    ):
        """Initialize coding agent.

        Args:
            repo_root: Root directory of the repository
            model: LLM model to use
            include_graph_tools: Whether to include graph exploration tools
            verbose: Whether to print tool calls
            max_iterations: Maximum tool call iterations (default from config: EMDASH_MAX_ITERATIONS)
            context_threshold: Fraction of context to trigger compaction
            initial_mode: Starting mode (plan, tasks, or code)
            save_spec_path: Path to save spec
        """
        self.repo_root = repo_root.resolve()
        self.model = model
        self.provider = get_provider(model)
        self.save_spec_path = save_spec_path
        self.context_threshold = context_threshold

        # Use config default if not provided
        config = get_config()
        self.max_iterations = max_iterations if max_iterations is not None else config.agent.max_iterations

        # Check LOG_LEVEL - only show verbose output (Context Frame) in DEBUG mode
        import os
        log_level = os.getenv("LOG_LEVEL", "INFO").upper()
        if log_level == "DEBUG":
            self.verbose = verbose  # Use passed value when DEBUG
        else:
            self.verbose = False  # Suppress verbose output by default

        # Initialize mode state
        ModeState.reset()
        self.mode_state = ModeState.get_instance()
        self.mode_state.set_mode(initial_mode)

        # Initialize toolkit with mode tools
        self.toolkit = CodingToolkit(
            repo_root=self.repo_root,
            include_graph_tools=include_graph_tools,
            plan_mode=True,  # Always include spec tools
            save_spec_path=save_spec_path,
        )

        # Register mode tools
        self._register_mode_tools()

        self.messages: list[dict] = []
        self.console = Console()

        # Context management
        self.context_limit = self.provider.get_context_limit()
        self.summarization_count = 0
        self.original_task: str = ""

        # Token tracking
        self._total_input_tokens = 0
        self._total_output_tokens = 0

        # Custom agent support
        self._custom_agent = None  # CustomAgent or None for default

        # Session context service (for AST-based context)
        # Only enable if graph tools are enabled (context service needs Kuzu DB)
        self._context_service = None
        self._terminal_id = None
        config = get_config()
        if config.context.enabled and include_graph_tools:
            try:
                from emdash.context import ContextService
                self._context_service = ContextService(repo_root=str(self.repo_root))
                self._terminal_id = ContextService.get_terminal_id()
                log.info(f"Session context enabled (terminal_id={self._terminal_id[:8]}...)")
            except Exception as e:
                log.warning(f"Failed to initialize context service: {e}")

        # Initialize with system prompt for current mode
        self._update_system_prompt()

        # Show pretty session header in production mode
        if is_production_mode():
            vision_support = self.provider.supports_vision() if hasattr(self.provider, 'supports_vision') else False
            provider_name = getattr(self.provider, '_provider', 'openai')
            show_session_header(
                model=model,
                provider=provider_name,
                context_window=self.context_limit,
                vision_support=vision_support,
            )

    def _register_mode_tools(self):
        """Register mode switching tools."""
        from emdash.agent.tools.modes import SwitchModeTool, GetModeTool
        self.toolkit.register_tool(SwitchModeTool())
        self.toolkit.register_tool(GetModeTool())

    def set_custom_agent(self, agent) -> None:
        """Set or clear the custom agent.

        Args:
            agent: CustomAgent to activate, or None to return to default
        """
        self._custom_agent = agent
        self._update_system_prompt()

        if self.verbose:
            if agent:
                self.console.print(f"[cyan]Switched to {agent.name} agent[/cyan]")
            else:
                self.console.print("[cyan]Switched to default agent[/cyan]")

    def get_current_agent_name(self):
        """Get name of current custom agent, or None if using default.

        Returns:
            Agent name string or None
        """
        return self._custom_agent.name if self._custom_agent else None

    def _update_system_prompt(self, query: str | None = None, skip_context: bool = False):
        """Update system prompt based on current mode or custom agent.

        Args:
            query: Optional query to re-rank context by relevance
            skip_context: If True, don't re-fetch session context (use existing)
        """
        if self._custom_agent:
            # Wrap custom agent prompt with base coding context
            agent_prompt = self._custom_agent.prompt
            prompt = f"""You are a coding assistant working inside a git repository.

## Your Role
{agent_prompt}

## Context
- You are working in the repository: {self.repo_root.name}
- You have access to tools to explore code, read/write files, and run commands
- ALWAYS use tools to investigate before asking questions
- Use semantic_search, read_file, list_files to explore the codebase
- Use write_to_file, apply_diff to make changes
- Use execute_command to run tests, builds, etc.

Be proactive - search and explore the code rather than asking what codebase or files to look at."""
        else:
            # Use mode-based prompt
            mode = self.mode_state.get_mode()
            prompt = MODE_PROMPTS[mode]

        # Add custom rules if available (.emdash/rules/*.md)
        from emdash.agent.rules import load_rules
        custom_rules = load_rules(self.repo_root)
        if custom_rules:
            prompt = f"{prompt}\n\n{custom_rules}"

        # Add spec context if available (only for default agents)
        if not self._custom_agent:
            spec_state = SpecState.get_instance()
            mode = self.mode_state.get_mode()
            if spec_state.spec and mode != AgentMode.PLAN:
                spec_md = spec_state.get_markdown()
                prompt = f"{prompt}\n\n## Current Specification\n\n{spec_md}"

        # Add session context if available (re-ranked by query if provided)
        # Skip if skip_context=True (e.g., during mode switch to avoid re-fetching)
        if not skip_context:
            session_context = self._get_session_context_prompt(query=query)
            if session_context:
                prompt = f"{prompt}\n\n{session_context}"

        self.system_prompt = prompt

        # Update first message if messages exist
        if self.messages:
            self.messages[0] = {"role": "system", "content": self.system_prompt}
        else:
            self.messages = [{"role": "system", "content": self.system_prompt}]

    def _get_session_context_prompt(self, query: str | None = None) -> str:
        """Get session context to inject into system prompt.

        Args:
            query: Optional query to re-rank context by relevance

        Returns:
            Formatted context string or empty string if no context
        """
        if not self._context_service or not self._terminal_id:
            return ""

        try:
            context_items = self._context_service.get_context_items(self._terminal_id)

            if not context_items:
                return ""

            return self._context_service.get_context_prompt(self._terminal_id, query=query)

        except Exception as e:
            log.warning(f"Failed to get session context: {e}")
            return ""

    def _get_limited_messages(self) -> list[dict]:
        """Get messages limited to max_context_messages.

        Keeps the system prompt (first message) and the last N messages
        where N is configured via EMDASH_MAX_CONTEXT_MESSAGES.

        Ensures we don't break tool call sequences - a 'tool' message must
        always follow an 'assistant' message with tool_calls.

        Returns:
            List of messages to send to LLM
        """
        config = get_config()
        max_messages = config.agent.max_context_messages

        if len(self.messages) <= max_messages + 1:  # +1 for system prompt
            return self.messages

        # Keep system prompt + last N messages
        system_prompt = self.messages[0]
        non_system_messages = self.messages[1:]

        # Start from the end and work backwards
        start_idx = max(0, len(non_system_messages) - max_messages)

        # Ensure we don't start on a 'tool' message (would be orphaned)
        # Walk forward until we find a non-tool message
        while start_idx < len(non_system_messages):
            msg = non_system_messages[start_idx]
            if msg.get("role") != "tool":
                break
            start_idx += 1

        # If we walked past all messages, just return what we have
        if start_idx >= len(non_system_messages):
            return self.messages

        recent_messages = non_system_messages[start_idx:]

        log.info(
            "Limiting messages from {} to {} (max_context_messages={})",
            len(self.messages),
            len(recent_messages) + 1,
            max_messages,
        )

        return [system_prompt] + recent_messages

    def set_mode(self, mode: AgentMode):
        """Set the current mode (for CLI slash commands).

        Args:
            mode: The mode to switch to
        """
        old_mode = self.mode_state.get_mode()
        if old_mode != mode:
            self.mode_state.set_mode(mode)
            # Update prompt but skip context re-fetch (already loaded at run() start)
            self._update_system_prompt(skip_context=True)

            if self.verbose:
                self.console.print(
                    f"[cyan]Switched from {old_mode.value} to {mode.value} mode[/cyan]"
                )

            # Add mode switch context to conversation
            if len(self.messages) > 1:
                self.messages.append({
                    "role": "user",
                    "content": f"[Mode switched to {mode.value}. Continue with the task using {mode.value} mode tools.]",
                })

    def get_mode(self) -> AgentMode:
        """Get current mode."""
        return self.mode_state.get_mode()

    def get_token_usage(self) -> tuple[int, int]:
        """Get total token usage (input, output)."""
        return self._total_input_tokens, self._total_output_tokens

    def run(self, task: str, images: list[ImageContent] | None = None) -> str:
        """Run a task in the current mode.

        Args:
            task: Description of what to accomplish
            images: Optional list of images to include with the task

        Returns:
            Final response or completion summary
        """
        # Reset state for new task (pass task as query for context re-ranking)
        self._update_system_prompt(query=task)
        self.messages = [{"role": "system", "content": self.system_prompt}]
        self.original_task = task
        self.summarization_count = 0
        self.pending_images = images or []  # Store images for _run_loop
        TaskState.reset()

        # Configure spec state
        SpecState.reset()
        spec_state = SpecState.get_instance()
        spec_state.configure(save_path=self.save_spec_path)

        # Add user task
        mode = self.mode_state.get_mode()
        self.messages.append({
            "role": "user",
            "content": f"[{mode.value.upper()} MODE]\n\n{task}",
        })

        return self._run_loop()

    def _run_loop(self) -> str:
        """Main agent loop."""
        tools = self.toolkit.get_all_schemas()
        task_state = TaskState.get_instance()
        spec_state = SpecState.get_instance()
        iterations = 0

        while iterations < self.max_iterations:
            iterations += 1

            # Check for context compaction
            if self._should_summarize():
                self._summarize_context()

            # Check if task was completed
            if task_state.completed:
                self._preview_reranked_context()
                return self._format_completion(task_state)

            # Check if there's a pending question
            if task_state.pending_question:
                self._preview_reranked_context()
                return self._handle_question(task_state)

            # Check if spec was submitted and needs approval (in plan mode)
            mode = self.mode_state.get_mode()
            if mode == AgentMode.PLAN and spec_state.spec and not spec_state.approved:
                self._preview_reranked_context()
                return self._handle_spec_approval(spec_state)

            # Call LLM
            try:
                # Limit messages to configured max to reduce token costs
                messages_to_send = self._get_limited_messages()
                log.info(
                    "Agent request iteration={} messages={} messages_sent={} tools={} mode={}",
                    iterations,
                    len(self.messages),
                    len(messages_to_send),
                    len(tools),
                    mode.value,
                )
                # Include images on first iteration only
                images_to_send = getattr(self, 'pending_images', None)
                response = self.provider.chat(messages_to_send, tools=tools, images=images_to_send)
                # Track token usage
                self._total_input_tokens += response.input_tokens
                self._total_output_tokens += response.output_tokens

                # Show LLM stats in production mode
                if is_production_mode() and (response.input_tokens > 0 or response.output_tokens > 0):
                    show_llm_stats(
                        input_tokens=response.input_tokens,
                        output_tokens=response.output_tokens,
                    )

                # Clear images after first send
                if images_to_send:
                    self.pending_images = None
            except Exception as e:
                error_str = str(e).lower()
                if any(x in error_str for x in ["context", "token", "too long"]):
                    self._summarize_context()
                    continue
                raise

            # Add assistant message
            self.messages.append(self.provider.format_assistant_message(response))

            # Process tool calls
            if response.tool_calls:
                for tool_call in response.tool_calls:
                    result = self._execute_tool_call(tool_call)
                    result_json = json.dumps(result, cls=SafeJSONEncoder)

                    # Truncate large results
                    if len(result_json) > 30000:
                        result_json = result_json[:30000] + '...[truncated]"}'

                    self.messages.append(
                        self.provider.format_tool_result(tool_call.id, result_json)
                    )

                    # Check for mode switch
                    if tool_call.name == "switch_mode" and result.get("success"):
                        new_mode_str = result.get("data", {}).get("current_mode")
                        if new_mode_str:
                            new_mode = AgentMode(new_mode_str)
                            # Update system prompt WITHOUT re-fetching context
                            # Context was already set at start of run()
                            self._update_system_prompt(skip_context=True)
                            # Refresh tools for new mode
                            tools = self.toolkit.get_all_schemas()

                    # Check for completion or question
                    if task_state.completed:
                        self._preview_reranked_context()
                        return self._format_completion(task_state)
                    if task_state.pending_question:
                        self._preview_reranked_context()
                        return self._handle_question(task_state)

                    # Check if spec was just submitted (plan mode)
                    mode = self.mode_state.get_mode()
                    if mode == AgentMode.PLAN and spec_state.spec and not spec_state.approved:
                        self._preview_reranked_context()
                        return self._handle_spec_approval(spec_state)

                # Update session context after each iteration with tool calls
                self._update_session_context()
            else:
                # No tool calls - check for content
                content = response.content or ""

                # If empty response in PLAN mode after exploration, prompt for summary
                if not content.strip() and mode == AgentMode.PLAN and iterations > 1:
                    self.messages.append({
                        "role": "user",
                        "content": "Please summarize your findings and create a specification using submit_spec, or explain what you've discovered about the codebase.",
                    })
                    continue  # Try again with the prompt

                self._preview_reranked_context()
                return content

        self._preview_reranked_context()
        return "Maximum iterations reached. Task may be incomplete."

    def chat(self, message: str, images: list[ImageContent] | None = None) -> str:
        """Continue conversation.

        Args:
            message: User's message
            images: Optional list of images to include with the message

        Returns:
            Agent's response
        """
        task_state = TaskState.get_instance()
        spec_state = SpecState.get_instance()

        # Reset completion state for new user input - allows conversation to continue
        if task_state.completed:
            task_state.completed = False
            task_state.completion_summary = None

        # Store images for _run_loop
        self.pending_images = images or []

        # Handle response to pending question
        if task_state.pending_question:
            task_state.user_response = message
            task_state.pending_question = None

            # Check if user is approving tasks->code transition
            mode = self.mode_state.get_mode()
            if mode == AgentMode.TASKS:
                # Check for affirmative responses to proceed to coding
                affirmative_keywords = ["yes", "proceed", "code", "implement", "start", "go ahead", "ok", "okay", "sure", "do it"]
                message_lower = message.lower().strip()
                if any(kw in message_lower for kw in affirmative_keywords):
                    self.mode_state.tasks_to_code_approved = True

        # Handle spec approval response
        mode = self.mode_state.get_mode()
        if mode == AgentMode.PLAN and spec_state.spec and not spec_state.approved:
            should_continue = self._process_spec_approval(message)
            if not should_continue:
                return "Task cancelled."

            if spec_state.approved:
                self.messages.append({"role": "user", "content": message})
                return self._run_loop()

            # Refinement feedback
            self.messages.append({
                "role": "user",
                "content": f"Please update the spec based on this feedback: {message}",
            })
            return self._run_loop()

        self.messages.append({"role": "user", "content": message})
        return self._run_loop()

    def _handle_spec_approval(self, spec_state: SpecState) -> str:
        """Handle spec submission and request user approval."""
        spec = spec_state.spec
        if not spec:
            return "No spec to approve."

        # Format spec as markdown
        spec_md = spec.to_markdown()

        # Show the spec in a panel
        self.console.print()
        self.console.print(Panel(
            Markdown(spec_md),
            title=f"[bold cyan]Specification: {spec.title}[/bold cyan]",
            border_style="cyan",
        ))

        if spec_state.save_path:
            self.console.print(f"[dim]Saved to: {spec_state.save_path}[/dim]")

        # Return message for CLI to display menu
        return f"**Spec Ready for Review**\n\nI've created a specification for **{spec.title}**."

    def _process_spec_approval(self, user_input: str) -> bool:
        """Process user's spec approval response."""
        spec_state = SpecState.get_instance()
        input_lower = user_input.lower().strip()

        # Handle approval commands (from interactive menu or direct input)
        approval_triggers = [
            "yes", "y", "approve", "ok", "proceed", "lgtm",
            "generate implementation tasks",
            "start implementing",
        ]

        if any(trigger in input_lower for trigger in approval_triggers):
            spec_state.approve()
            if self.verbose:
                self.console.print(Panel(
                    "[green]Spec approved![/green]",
                    border_style="green",
                ))
            return True

        # Handle abort
        if input_lower in ("no", "n", "abort", "cancel", "stop"):
            if self.verbose:
                self.console.print("[yellow]Spec rejected. Task cancelled.[/yellow]")
            return False

        # Handle refinement - check for "refine the spec:" prefix
        if "refine the spec:" in input_lower or "please refine" in input_lower:
            # Extract the actual feedback
            if ":" in user_input:
                feedback = user_input.split(":", 1)[1].strip()
            else:
                feedback = user_input
            spec_state.add_refinement(feedback)
            return True

        # Treat as refinement feedback
        spec_state.add_refinement(user_input)
        return True

    def _execute_tool_call(self, tool_call) -> dict:
        """Execute a tool call."""
        name = tool_call.name
        try:
            args = json.loads(tool_call.arguments)
        except json.JSONDecodeError:
            args = {}

        # Start pretty output tracking (production mode)
        tool_info = None
        if is_production_mode():
            tool_info = start_tool_call(name, args)

        result = self.toolkit.execute(name, **args)

        # End pretty output tracking
        if tool_info is not None:
            result_str = json.dumps(result.data, cls=SafeJSONEncoder) if result.success else result.error
            end_tool_call(tool_info, result=result_str, success=result.success)
        elif self.verbose:
            # Fall back to verbose panel output if not in production mode
            self._print_tool_call(name, args, result)

        if result.success:
            return {
                "success": True,
                "data": result.data,
                "suggestions": result.suggestions,
            }
        else:
            return {
                "success": False,
                "error": result.error,
                "suggestions": result.suggestions,
            }

    def _print_tool_call(self, name: str, args: dict, result: ToolResult):
        """Print verbose tool call output."""
        lines = []

        for key, value in args.items():
            if isinstance(value, str) and len(value) > 100:
                value = value[:100] + "..."
            lines.append(f"[dim]{key}:[/dim] {value}")

        if result.success:
            data = result.data
            if "content" in data:
                lines.append(f"\n[green]Read {data.get('lines_shown', '?')} lines[/green]")
            elif "blocks_applied" in data:
                lines.append(f"\n[green]Applied {data['blocks_applied']} diff blocks[/green]")
            elif "current_mode" in data:
                lines.append(f"\n[green]Mode: {data['current_mode']}[/green]")
            elif "results" in data:
                lines.append(f"\n[green]Found {len(data['results'])} results[/green]")
            else:
                lines.append(f"\n[green]Success[/green]")
        else:
            lines.append(f"\n[red]Error: {result.error}[/red]")

        content = "\n".join(lines) if lines else "Executed"
        style = "green" if result.success else "red"

        panel = Panel(
            content,
            title=f"[cyan bold]{name}[/cyan bold]",
            title_align="left",
            border_style=style,
            padding=(0, 1),
        )
        self.console.print(panel)

    def _handle_question(self, task_state: TaskState) -> str:
        """Handle a pending followup question."""
        question = task_state.pending_question
        self.console.print(Panel(
            question,
            title="[bold yellow]Question[/bold yellow]",
            border_style="yellow",
        ))
        return f"**Question:** {question}"

    def _format_completion(self, task_state: TaskState) -> str:
        """Format completion summary."""
        summary = task_state.completion_summary or "Task completed."
        self.console.print(Panel(
            summary,
            title="[bold green]Task Complete[/bold green]",
            border_style="green",
        ))
        return summary

    def _estimate_tokens(self, text: str) -> int:
        """Estimate token count."""
        return len(text) // 4

    def _get_context_size(self) -> int:
        """Get current context size."""
        total = 0
        for msg in self.messages:
            content = msg.get("content") or ""
            if isinstance(content, str):
                total += self._estimate_tokens(content)
            elif isinstance(content, list):
                for block in content:
                    if isinstance(block, dict):
                        total += self._estimate_tokens(str(block))
            if "tool_calls" in msg and msg["tool_calls"]:
                total += self._estimate_tokens(json.dumps(msg["tool_calls"], cls=SafeJSONEncoder))
        return total

    def _should_summarize(self) -> bool:
        """Check if context should be compacted."""
        current = self._get_context_size()
        threshold = int(self.context_limit * self.context_threshold)
        return current > threshold

    def _summarize_context(self):
        """Compact the conversation context."""
        if self.verbose:
            self.console.print(
                f"[yellow]Compacting context ({self._get_context_size()} tokens)...[/yellow]"
            )

        summary_lines = [f"Original task: {self.original_task}", ""]
        files_read = set()
        files_modified = set()

        for msg in self.messages[1:]:
            if msg.get("role") == "tool":
                continue
            if "tool_calls" in msg:
                for tc in msg.get("tool_calls", []):
                    func = tc.get("function", {})
                    name = func.get("name", "")
                    args = func.get("arguments", "{}")
                    try:
                        args_dict = json.loads(args)
                    except:
                        args_dict = {}

                    if name == "read_file":
                        files_read.add(args_dict.get("path", ""))
                    elif name in ("write_to_file", "apply_diff"):
                        files_modified.add(args_dict.get("path", ""))

        if files_read:
            summary_lines.append(f"Files read: {', '.join(files_read)}")
        if files_modified:
            summary_lines.append(f"Files modified: {', '.join(files_modified)}")

        summary = "\n".join(summary_lines)
        mode = self.mode_state.get_mode()

        self.messages = [
            {"role": "system", "content": MODE_PROMPTS[mode]},
            {"role": "user", "content": self.original_task},
            {"role": "assistant", "content": f"## Context Summary\n\n{summary}\n\nContinuing..."},
            {"role": "user", "content": "Continue with the task."},
        ]

        self.summarization_count += 1

        if self.verbose:
            self.console.print(
                f"[green]Context compacted to {self._get_context_size()} tokens[/green]"
            )

    def reset(self):
        """Reset the agent state including messages and context."""
        self._update_system_prompt()
        self.messages = [{"role": "system", "content": self.system_prompt}]
        self.original_task = ""
        self.summarization_count = 0
        self.toolkit.reset_session()
        TaskState.reset()
        SpecState.reset()

        # Clear session context
        if self._context_service and self._terminal_id:
            try:
                self._context_service.clear_context(self._terminal_id)
            except Exception as e:
                log.debug(f"Failed to clear context: {e}")

    def _update_session_context(self):
        """Update session context with modified files and exploration steps."""
        if not self._context_service or not self._terminal_id:
            return

        try:
            # Get modified files (for TouchedAreasProvider)
            modified_files = self._context_service.detect_modified_files()

            # Get exploration steps from toolkit session (for ExploredAreasProvider)
            exploration_steps = []
            if hasattr(self.toolkit, 'get_exploration_steps'):
                exploration_steps = self.toolkit.get_exploration_steps()

            # Log modified files
            if modified_files:
                log.info(f"Context update: {len(modified_files)} modified files")
                for f in modified_files[:5]:
                    log.info(f"  Modified: {f}")
                if len(modified_files) > 5:
                    log.info(f"  ... and {len(modified_files) - 5} more files")

            # Build context frame box with detailed info
            from emdash.context.providers.explored_areas import ExploredAreasProvider

            def estimate_tokens(text: str) -> int:
                """Estimate token count using heuristic."""
                return int(len(text) // 3.451)

            lines = []
            adding_tokens = 0

            # Section 1: What's being ADDED to context
            lines.append("[bold cyan]◆ Adding to Context[/bold cyan]")

            # Modified files being added
            if modified_files:
                lines.append(f"  [dim]Modified Files ({len(modified_files)}):[/dim]")
                for f in modified_files[:5]:
                    # Show relative path
                    try:
                        rel_path = Path(f).relative_to(self.repo_root)
                        lines.append(f"    [yellow]• {rel_path}[/yellow]")
                        adding_tokens += estimate_tokens(str(rel_path))
                    except ValueError:
                        lines.append(f"    [yellow]• {f}[/yellow]")
                        adding_tokens += estimate_tokens(f)
                if len(modified_files) > 5:
                    lines.append(f"    [dim]... and {len(modified_files) - 5} more[/dim]")
                    # Estimate tokens for remaining files
                    for f in modified_files[5:]:
                        adding_tokens += estimate_tokens(f)

            # Exploration steps being added
            if exploration_steps:
                tool_counts = {}
                entities_by_file: dict[str, list[str]] = {}
                total_entities = 0

                for step in exploration_steps:
                    tool_name = step.tool_name if hasattr(step, "tool_name") else step.get("tool_name", "unknown")
                    entities = step.entities_discovered if hasattr(step, "entities_discovered") else step.get("entities_discovered", [])
                    tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1
                    total_entities += len(entities)

                    # Group entities by file
                    for entity in entities:
                        if isinstance(entity, dict):
                            file_path = entity.get("file_path") or entity.get("path") or "unknown"
                            qname = entity.get("qualified_name") or str(entity)
                            # Estimate tokens for this entity (qname + type + file)
                            entity_text = f"{qname} {entity.get('entity_type', '')} {file_path}"
                            adding_tokens += estimate_tokens(entity_text)
                        else:
                            file_path = "unknown"
                            qname = str(entity)
                            adding_tokens += estimate_tokens(qname)
                        if file_path not in entities_by_file:
                            entities_by_file[file_path] = []
                        entities_by_file[file_path].append(qname)

                lines.append(f"  [dim]Exploration ({len(exploration_steps)} steps, {total_entities} entities):[/dim]")

                # Show tools used
                for tool_name, count in sorted(tool_counts.items(), key=lambda x: -x[1]):
                    relevance = ExploredAreasProvider.TOOL_RELEVANCE.get(tool_name, 0.2)
                    relevance_color = "green" if relevance >= 0.6 else "yellow" if relevance >= 0.4 else "dim"
                    lines.append(f"    [{relevance_color}]{tool_name}[/{relevance_color}] ×{count}")

                # Show discovered entities grouped by file
                if entities_by_file and total_entities > 0:
                    lines.append("  [dim]Discovered Entities:[/dim]")
                    shown_files = 0
                    for file_path, ents in sorted(entities_by_file.items())[:3]:
                        try:
                            rel_path = Path(file_path).relative_to(self.repo_root) if file_path != "unknown" else file_path
                        except ValueError:
                            rel_path = file_path
                        lines.append(f"    [blue]{rel_path}[/blue]")
                        for ent in ents[:2]:
                            # Remove file: prefix for cleaner display
                            display_ent = ent[5:] if ent.startswith("file:") else ent
                            lines.append(f"      [dim]→[/dim] {display_ent}")
                        if len(ents) > 2:
                            lines.append(f"      [dim]... +{len(ents) - 2} more[/dim]")
                        shown_files += 1
                    if len(entities_by_file) > 3:
                        lines.append(f"    [dim]... +{len(entities_by_file) - 3} more files[/dim]")

                # Fallback: show visited files from session if no entities captured
                elif hasattr(self.toolkit, 'session') and self.toolkit.session:
                    visited = self.toolkit.session.visited_files
                    if visited:
                        lines.append("  [dim]Visited Files:[/dim]")
                        for f in sorted(visited)[:5]:
                            try:
                                rel_path = Path(f).relative_to(self.repo_root)
                                lines.append(f"    [blue]• {rel_path}[/blue]")
                            except ValueError:
                                lines.append(f"    [blue]• {f}[/blue]")
                        if len(visited) > 5:
                            lines.append(f"    [dim]... +{len(visited) - 5} more[/dim]")

            if not modified_files and not exploration_steps:
                lines.append("  [dim](none)[/dim]")

            # Update section header with token count
            if adding_tokens > 0:
                lines[0] = f"[bold cyan]◆ Adding to Context[/bold cyan] [dim]~{adding_tokens} tokens[/dim]"

            # Section 2: What's being READ from context
            lines.append("")
            reading_tokens = 0

            try:
                context_items = self._context_service.get_context_items(self._terminal_id)
                if context_items:
                    # Calculate tokens for context items
                    for item in context_items:
                        # Estimate based on how context is formatted in prompt
                        item_text = f"{item.entity_type}: {item.qualified_name} File: {item.file_path or ''}"
                        if item.description:
                            item_text += f" {item.description[:200]}"
                        if item.neighbors:
                            item_text += f" Related: {', '.join(item.neighbors[:5])}"
                        reading_tokens += estimate_tokens(item_text)

                    lines.append(f"[bold green]◆ Reading from Context[/bold green] [dim]~{reading_tokens} tokens[/dim]")

                    # Group by file
                    items_by_file: dict[str, list] = {}
                    for item in context_items:
                        fp = item.file_path or "unknown"
                        if fp not in items_by_file:
                            items_by_file[fp] = []
                        items_by_file[fp].append(item)

                    lines.append(f"  [dim]Context Items ({len(context_items)} total):[/dim]")

                    # Show items grouped by file
                    shown = 0
                    for file_path, items in sorted(items_by_file.items(), key=lambda x: -max(i.score for i in x[1]))[:4]:
                        try:
                            rel_path = Path(file_path).relative_to(self.repo_root) if file_path != "unknown" else file_path
                        except ValueError:
                            rel_path = file_path
                        lines.append(f"    [blue]{rel_path}[/blue]")
                        for item in sorted(items, key=lambda x: -x.score)[:3]:
                            score_color = "green" if item.score >= 0.6 else "yellow" if item.score >= 0.4 else "dim"
                            lines.append(f"      [{score_color}]●[/{score_color}] {item.qualified_name} [dim]({item.entity_type}, {item.score:.2f})[/dim]")
                        if len(items) > 3:
                            lines.append(f"      [dim]... +{len(items) - 3} more[/dim]")
                        shown += 1
                    if len(items_by_file) > 4:
                        remaining = len(context_items) - sum(len(items_by_file[f]) for f in list(items_by_file.keys())[:4])
                        lines.append(f"    [dim]... +{len(items_by_file) - 4} more files ({remaining} items)[/dim]")
                else:
                    lines.append("[bold green]◆ Reading from Context[/bold green]")
                    lines.append("  [dim](empty - no context items yet)[/dim]")
            except Exception as e:
                lines.append("[bold green]◆ Reading from Context[/bold green]")
                lines.append(f"  [red](error reading context: {e})[/red]")

            # Only show Context Frame panel if verbose mode
            if self.verbose:
                self.console.print(Panel(
                    "\n".join(lines),
                    title="[bold blue]Context Frame[/bold blue]",
                    title_align="left",
                    border_style="blue",
                    padding=(0, 1),
                ))

            # Update context with both sources
            if modified_files or exploration_steps:
                self._context_service.update_context(
                    terminal_id=self._terminal_id,
                    modified_files=modified_files,
                    exploration_steps=exploration_steps,
                )
        except Exception as e:
            log.warning(f"Failed to update session context: {e}")

    def _preview_reranked_context(self):
        """Preview re-ranked context items for the current task.

        This logs what items would be kept after re-ranking by relevance
        to the current task, helping users understand context filtering.
        Runs in a background thread to not block the response.
        """
        if not self._context_service or not self._terminal_id:
            return

        # Only run if we have a task to re-rank against
        if not hasattr(self, 'original_task') or not self.original_task:
            return

        import threading

        def _rerank_background():
            try:
                from emdash.context.reranker import rerank_context_items

                # Get all context items
                context_items = self._context_service.get_context_items(self._terminal_id)
                if not context_items:
                    return

                # Re-rank by current task relevance
                rerank_context_items(
                    context_items,
                    self.original_task,
                    top_k=self._context_service._max_items
                )
                # The rerank_context_items function already logs the results
            except Exception as e:
                log.debug(f"Re-rank preview failed: {e}")

        # Run in background thread
        thread = threading.Thread(target=_rerank_background, daemon=True)
        thread.start()
