"""Chat agent with file operations, command execution, and log viewing."""

import fnmatch
import json
import os
import re
import subprocess
from typing import TYPE_CHECKING, Any, List, Optional, Tuple

from typing_extensions import TypedDict

from ._llm_client import ToolCall, ToolDefinition

if TYPE_CHECKING:
    from ._llm_client import LLMClient


class FileEdit(TypedDict):
    """Represents a staged file edit."""

    file_path: str
    original_content: str
    new_content: str
    description: str


class StagedChanges(TypedDict):
    """Container for staged file changes."""

    edits: List[FileEdit]


# Tool definitions for the LLM
AGENT_TOOLS: List[ToolDefinition] = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "Read the contents of a file in the project",
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {
                        "type": "string",
                        "description": "Path to the file relative to project root",
                    },
                    "start_line": {
                        "type": "integer",
                        "description": "Optional start line number (1-indexed)",
                    },
                    "end_line": {
                        "type": "integer",
                        "description": "Optional end line number (1-indexed)",
                    },
                },
                "required": ["file_path"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "stage_file_edit",
            "description": (
                "Stage a targeted file edit using find-and-replace. "
                "Specify the exact text to find (old_content) and what to replace it with (new_content). "
                "The old_content must match exactly (including whitespace/indentation). "
                "For new files, leave old_content empty."
            ),
            "parameters": {
                "type": "object",
                "properties": {
                    "file_path": {
                        "type": "string",
                        "description": "Path to the file relative to project root",
                    },
                    "old_content": {
                        "type": "string",
                        "description": "The exact text to find and replace (empty string for new files)",
                    },
                    "new_content": {
                        "type": "string",
                        "description": "The text to replace old_content with",
                    },
                    "description": {
                        "type": "string",
                        "description": "Brief description of the change",
                    },
                },
                "required": ["file_path", "old_content", "new_content", "description"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "list_files",
            "description": "List files in a directory",
            "parameters": {
                "type": "object",
                "properties": {
                    "directory": {
                        "type": "string",
                        "description": ("Directory path relative to project root. Empty string for root."),
                    },
                    "recursive": {
                        "type": "boolean",
                        "description": "Whether to list files recursively",
                    },
                },
                "required": [],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "run_command",
            "description": "Execute a shell command in the project directory",
            "parameters": {
                "type": "object",
                "properties": {
                    "command": {
                        "type": "string",
                        "description": "The command to execute",
                    },
                    "timeout": {
                        "type": "integer",
                        "description": "Timeout in seconds (default 30)",
                    },
                },
                "required": ["command"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "get_logs",
            "description": "Get recent backend or frontend logs from the running Dash app",
            "parameters": {
                "type": "object",
                "properties": {
                    "log_type": {
                        "type": "string",
                        "enum": ["backend", "frontend", "both"],
                        "description": "Type of logs to retrieve",
                    },
                    "lines": {
                        "type": "integer",
                        "description": "Number of recent log lines to return (default 50)",
                    },
                },
                "required": ["log_type"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "search_files",
            "description": "Search for files containing a pattern",
            "parameters": {
                "type": "object",
                "properties": {
                    "pattern": {
                        "type": "string",
                        "description": "Search pattern (regex supported)",
                    },
                    "file_pattern": {
                        "type": "string",
                        "description": "Glob pattern for files to search (e.g., '*.py')",
                    },
                },
                "required": ["pattern"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "apply_staged_changes",
            "description": (
                "DO NOT USE THIS TOOL unless the user EXPLICITLY asks you to apply changes. "
                "The user has an Apply button in the UI - they will click it themselves. "
                "NEVER call this tool after staging changes. NEVER call it proactively. "
                "Only call if user says something like 'apply the changes' or 'save the changes'."
            ),
            "parameters": {
                "type": "object",
                "properties": {},
                "required": [],
            },
        },
        "cache_control": {"type": "ephemeral"},  # Cache all tools
    },
]


class ChatAgent:
    """Agent that processes chat messages and executes tools.

    The agent maintains staged file changes that can be applied when the user
    confirms. It also captures backend and frontend logs for debugging.
    """

    # Directories to skip when listing/searching files
    SKIP_DIRS = {
        "__pycache__",
        "node_modules",
        "venv",
        ".venv",
        "env",
        ".env",
        ".git",
        ".tox",
        "dist",
        "build",
        "*.egg-info",
    }

    # Dangerous command patterns to block
    DANGEROUS_PATTERNS = [
        "rm -rf /",
        "rm -rf /*",
        "mkfs",
        "> /dev/",
        "dd if=",
        ":(){:|:&};:",  # Fork bomb
        "chmod -R 777 /",
    ]

    # Threshold for filtering large files with Haiku (in lines)
    LARGE_FILE_THRESHOLD = 100

    def __init__(self, project_path: str):
        """Initialize the chat agent.

        Args:
            project_path: Absolute path to the project directory
        """
        self.project_path = os.path.abspath(project_path)
        self.staged_changes: StagedChanges = {"edits": []}
        self.backend_logs: List[str] = []
        self.frontend_logs: List[str] = []
        # Context for intelligent file filtering
        self._llm_client: Optional["LLMClient"] = None
        self._user_query: Optional[str] = None

    def set_context(self, llm_client: "LLMClient", user_query: str) -> None:
        """Set context for intelligent file filtering.

        Args:
            llm_client: LLM client for Haiku calls
            user_query: Current user query for relevance filtering
        """
        self._llm_client = llm_client
        self._user_query = user_query

    def _should_skip_dir(self, dirname: str) -> bool:
        """Check if a directory should be skipped."""
        if dirname.startswith("."):
            return True
        for pattern in self.SKIP_DIRS:
            if fnmatch.fnmatch(dirname, pattern):
                return True
        return False

    def _resolve_path(self, relative_path: str) -> str:
        """Resolve relative path to absolute, ensuring it's within project.

        Args:
            relative_path: Path relative to project root

        Returns:
            Absolute path within the project

        Raises:
            ValueError: If resolved path is outside project directory
        """
        if not relative_path:
            return self.project_path

        # Normalize and join with project path
        abs_path = os.path.normpath(os.path.join(self.project_path, relative_path))

        # Security check: ensure path is within project
        if not abs_path.startswith(self.project_path):
            raise ValueError("Path outside project directory")

        return abs_path

    async def execute_tool(self, tool_call: ToolCall) -> str:
        """Execute a tool call and return the result.

        Args:
            tool_call: The tool call to execute

        Returns:
            String result of the tool execution
        """
        func_name = tool_call["function"]["name"]
        try:
            args = json.loads(tool_call["function"]["arguments"])
        except json.JSONDecodeError as e:
            return f"Error parsing arguments: {e}"

        try:
            if func_name == "read_file":
                return await self._read_file(
                    args["file_path"],
                    args.get("start_line"),
                    args.get("end_line"),
                )
            elif func_name == "stage_file_edit":
                return await self._stage_file_edit(
                    args["file_path"],
                    args["old_content"],
                    args["new_content"],
                    args["description"],
                )
            elif func_name == "list_files":
                return await self._list_files(
                    args.get("directory", ""),
                    args.get("recursive", False),
                )
            elif func_name == "run_command":
                return await self._run_command(
                    args["command"],
                    args.get("timeout", 30),
                )
            elif func_name == "get_logs":
                return await self._get_logs(
                    args["log_type"],
                    args.get("lines", 50),
                )
            elif func_name == "search_files":
                return await self._search_files(
                    args["pattern"],
                    args.get("file_pattern", "*"),
                )
            elif func_name == "apply_staged_changes":
                return await self._apply_staged_changes()
            else:
                return f"Unknown tool: {func_name}"
        except Exception as e:
            return f"Error executing {func_name}: {str(e)}"

    async def _filter_file_with_haiku(
        self,
        file_path: str,
        lines: List[str],
        user_query: str,
    ) -> List[Tuple[int, str]]:
        """Use Haiku to identify relevant lines in a file.

        Args:
            file_path: Path to the file (for context)
            lines: All lines in the file
            user_query: The user's query to determine relevance

        Returns:
            List of (line_number, line_content) tuples for relevant lines
        """
        if not self._llm_client:
            # No LLM client, return all lines
            return [(i + 1, line) for i, line in enumerate(lines)]

        # Create numbered content for Haiku to analyze
        numbered_content = "".join(f"{i + 1}: {line}" for i, line in enumerate(lines))

        filter_prompt = f"""Analyze this file and identify which line ranges are relevant to the user's query.

User's query: {user_query}

File: {file_path}
```
{numbered_content}
```

Return ONLY a JSON array of line ranges that are relevant. Each range should be [start_line, end_line] (inclusive, 1-indexed).
Include some context lines around the most relevant sections.
If the entire file is relevant, return [[1, {len(lines)}]].
If nothing is relevant, return [].

Example response: [[10, 25], [45, 60], [100, 120]]

Return ONLY the JSON array, no explanation."""

        try:
            response = await self._llm_client.chat_completion(
                messages=[{"role": "user", "content": filter_prompt}],
                tools=None,
                model="claude-haiku-4-5",
                max_tokens=500,
                temperature=0.0,
            )

            content = response["choices"][0]["message"].get("content", "")
            # Parse JSON response
            # Handle potential markdown code blocks
            content = content.strip()
            if content.startswith("```"):
                content = content.split("\n", 1)[1] if "\n" in content else content
                content = content.rsplit("```", 1)[0] if "```" in content else content
                content = content.strip()

            ranges = json.loads(content)

            if not ranges:
                return []

            # Collect relevant lines from all ranges
            relevant_lines: List[Tuple[int, str]] = []
            seen_lines: set[int] = set()

            for start, end in ranges:
                for line_num in range(start, end + 1):
                    if 1 <= line_num <= len(lines) and line_num not in seen_lines:
                        relevant_lines.append((line_num, lines[line_num - 1]))
                        seen_lines.add(line_num)

            # Sort by line number
            relevant_lines.sort(key=lambda x: x[0])
            return relevant_lines

        except Exception:
            # On any error, fall back to returning all lines
            return [(i + 1, line) for i, line in enumerate(lines)]

    async def _read_file(
        self,
        file_path: str,
        start_line: Optional[int] = None,
        end_line: Optional[int] = None,
    ) -> str:
        """Read file contents.

        Args:
            file_path: Path to file relative to project root
            start_line: Optional start line (1-indexed)
            end_line: Optional end line (1-indexed)

        Returns:
            File contents or error message
        """
        abs_path = self._resolve_path(file_path)

        if not os.path.exists(abs_path):
            return f"File not found: {file_path}"

        if os.path.isdir(abs_path):
            return f"Path is a directory, not a file: {file_path}"

        try:
            with open(abs_path, encoding="utf-8") as f:
                lines = f.readlines()
        except UnicodeDecodeError:
            return f"Cannot read file (not a text file): {file_path}"
        except PermissionError:
            return f"Permission denied: {file_path}"

        # If specific lines requested, return those
        if start_line or end_line:
            start_idx = (start_line - 1) if start_line else 0
            end_idx = end_line if end_line else len(lines)
            lines = lines[start_idx:end_idx]

            # Add line numbers for context
            actual_start = start_line or 1
            numbered_lines = [f"{actual_start + i}: {line}" for i, line in enumerate(lines)]
            return "".join(numbered_lines)

        # For large files, use Haiku to filter relevant lines
        if len(lines) > self.LARGE_FILE_THRESHOLD and self._user_query and self._llm_client:
            relevant_lines = await self._filter_file_with_haiku(file_path, lines, self._user_query)

            if relevant_lines and len(relevant_lines) < len(lines):
                # Format with line numbers and indicate filtering
                result_parts = [f"[Showing {len(relevant_lines)} relevant lines out of {len(lines)} total]\n\n"]

                prev_line_num = 0
                for line_num, line_content in relevant_lines:
                    # Add ellipsis for gaps
                    if prev_line_num > 0 and line_num > prev_line_num + 1:
                        result_parts.append("...\n")
                    result_parts.append(f"{line_num}: {line_content}")
                    prev_line_num = line_num

                return "".join(result_parts)

        return "".join(lines)

    async def _stage_file_edit(
        self, file_path: str, old_content: str, new_content: str, description: str
    ) -> str:
        """Stage a targeted file edit using find-and-replace.

        Args:
            file_path: Path to file relative to project root
            old_content: Text to find and replace (empty for new files)
            new_content: Text to replace old_content with
            description: Brief description of the change

        Returns:
            Confirmation message
        """
        abs_path = self._resolve_path(file_path)
        original_content = ""
        resulting_content = ""

        # Check if file already has staged changes - work from that version
        existing_staged = None
        for edit in self.staged_changes["edits"]:
            if edit["file_path"] == file_path:
                existing_staged = edit
                break

        if existing_staged:
            # Apply change to already-staged content
            original_content = existing_staged["original_content"]
            current_content = existing_staged["new_content"]
        elif os.path.exists(abs_path):
            try:
                with open(abs_path, encoding="utf-8") as f:
                    original_content = f.read()
                    current_content = original_content
            except (UnicodeDecodeError, PermissionError) as e:
                return f"Cannot read existing file: {e}"
        else:
            # New file
            current_content = ""
            original_content = ""

        # Handle new file creation
        if not old_content:
            if current_content:
                return f"Error: old_content is empty but file already exists. Specify text to replace."
            resulting_content = new_content
        else:
            # Find and replace
            if old_content not in current_content:
                # Try to find a close match and give helpful error
                lines_to_find = old_content.strip().split('\n')[0][:50]
                return (
                    f"Error: old_content not found in {file_path}. "
                    f"Looking for text starting with: '{lines_to_find}...'. "
                    f"Make sure the text matches exactly including whitespace."
                )

            # Check for multiple matches
            count = current_content.count(old_content)
            if count > 1:
                return (
                    f"Error: old_content found {count} times in {file_path}. "
                    f"Please provide more context to make the match unique."
                )

            resulting_content = current_content.replace(old_content, new_content, 1)

        # Update or add staged edit
        if existing_staged:
            existing_staged["new_content"] = resulting_content
            existing_staged["description"] = description
            return f"Updated staged edit for {file_path}: {description}"

        # Add new staged edit
        self.staged_changes["edits"].append(
            {
                "file_path": file_path,
                "original_content": original_content,
                "new_content": resulting_content,
                "description": description,
            }
        )
        return f"Staged edit for {file_path}: {description}"

    async def _list_files(self, directory: str, recursive: bool) -> str:
        """List files in directory.

        Args:
            directory: Directory path relative to project root
            recursive: Whether to list recursively

        Returns:
            Newline-separated list of files
        """
        abs_path = self._resolve_path(directory)

        if not os.path.exists(abs_path):
            return f"Directory not found: {directory or '.'}"

        if not os.path.isdir(abs_path):
            return f"Not a directory: {directory}"

        files: List[str] = []

        if recursive:
            for root, dirs, filenames in os.walk(abs_path):
                # Filter out directories to skip
                dirs[:] = [d for d in dirs if not self._should_skip_dir(d)]

                for filename in filenames:
                    if not filename.startswith("."):
                        file_path = os.path.join(root, filename)
                        rel_path = os.path.relpath(file_path, self.project_path)
                        files.append(rel_path)
        else:
            try:
                for item in os.listdir(abs_path):
                    if item.startswith("."):
                        continue
                    item_path = os.path.join(abs_path, item)
                    rel_path = os.path.relpath(item_path, self.project_path)
                    if os.path.isdir(item_path):
                        files.append(f"{rel_path}/")
                    else:
                        files.append(rel_path)
            except PermissionError:
                return f"Permission denied: {directory or '.'}"

        if not files:
            return "(empty directory)"

        return "\n".join(sorted(files))

    async def _run_command(self, command: str, timeout: int) -> str:
        """Execute shell command.

        Args:
            command: Shell command to execute
            timeout: Timeout in seconds

        Returns:
            Command output or error message
        """
        # Security: block dangerous commands
        for pattern in self.DANGEROUS_PATTERNS:
            if pattern in command:
                return f"Command blocked for safety: contains '{pattern}'"

        try:
            result = subprocess.run(
                command,
                shell=True,
                cwd=self.project_path,
                capture_output=True,
                text=True,
                timeout=timeout,
            )

            output_parts = []
            if result.stdout:
                output_parts.append(result.stdout)
            if result.stderr:
                output_parts.append(f"\nSTDERR:\n{result.stderr}")
            if result.returncode != 0:
                output_parts.append(f"\nReturn code: {result.returncode}")

            return "".join(output_parts) if output_parts else "(no output)"

        except subprocess.TimeoutExpired:
            return f"Command timed out after {timeout} seconds"
        except Exception as e:
            return f"Command failed: {str(e)}"

    async def _get_logs(self, log_type: str, lines: int) -> str:
        """Get recent logs.

        Args:
            log_type: Type of logs ("backend", "frontend", or "both")
            lines: Number of recent lines to return

        Returns:
            Formatted log output
        """
        result: List[str] = []

        if log_type in ("backend", "both"):
            backend = self.backend_logs[-lines:] if self.backend_logs else ["(no backend logs captured)"]
            result.append("=== Backend Logs ===")
            result.extend(backend)

        if log_type in ("frontend", "both"):
            frontend = self.frontend_logs[-lines:] if self.frontend_logs else ["(no frontend logs captured)"]
            if result:
                result.append("")  # Blank line separator
            result.append("=== Frontend Logs ===")
            result.extend(frontend)

        return "\n".join(result)

    async def _search_files(self, pattern: str, file_pattern: str) -> str:
        """Search files for pattern.

        Args:
            pattern: Regex pattern to search for
            file_pattern: Glob pattern for files to search

        Returns:
            Matching lines with file:line: prefix
        """
        try:
            regex = re.compile(pattern, re.IGNORECASE)
        except re.error as e:
            return f"Invalid regex pattern: {e}"

        matches: List[str] = []
        max_matches = 100

        for root, dirs, files in os.walk(self.project_path):
            # Filter out directories to skip
            dirs[:] = [d for d in dirs if not self._should_skip_dir(d)]

            for filename in files:
                if not fnmatch.fnmatch(filename, file_pattern):
                    continue

                file_path = os.path.join(root, filename)
                rel_path = os.path.relpath(file_path, self.project_path)

                try:
                    with open(file_path, encoding="utf-8") as f:
                        for line_num, line in enumerate(f, 1):
                            if regex.search(line):
                                matches.append(f"{rel_path}:{line_num}: {line.strip()}")
                                if len(matches) >= max_matches:
                                    matches.append(f"... (truncated at {max_matches} matches)")
                                    return "\n".join(matches)
                except (UnicodeDecodeError, PermissionError):
                    # Skip binary files and files we can't read
                    continue

        return "\n".join(matches) if matches else "No matches found"

    async def _apply_staged_changes(self) -> str:
        """Apply all staged changes to disk.

        Returns:
            Summary of applied changes
        """
        if not self.staged_changes["edits"]:
            return "No staged changes to apply."

        results = self.apply_staged_changes()

        if not results:
            return "No changes were applied."

        lines = []
        success_count = 0
        fail_count = 0

        for file_path, success, message in results:
            if success:
                lines.append(f"✓ {file_path}: {message}")
                success_count += 1
            else:
                lines.append(f"✗ {file_path}: {message}")
                fail_count += 1

        summary = f"Applied {success_count} file(s)"
        if fail_count > 0:
            summary += f", {fail_count} failed"

        lines.insert(0, summary)
        return "\n".join(lines)

    def get_staged_changes(self) -> StagedChanges:
        """Return current staged changes.

        Returns:
            StagedChanges containing all pending file edits
        """
        return self.staged_changes

    def clear_staged_changes(self) -> None:
        """Clear all staged changes."""
        self.staged_changes = {"edits": []}

    def apply_staged_changes(self) -> List[Tuple[str, bool, str]]:
        """Apply all staged changes to disk.

        Returns:
            List of (file_path, success, message) tuples
        """
        results: List[Tuple[str, bool, str]] = []

        for edit in self.staged_changes["edits"]:
            abs_path = self._resolve_path(edit["file_path"])
            try:
                # Ensure directory exists
                dir_path = os.path.dirname(abs_path)
                if dir_path:
                    os.makedirs(dir_path, exist_ok=True)

                with open(abs_path, "w", encoding="utf-8") as f:
                    f.write(edit["new_content"])

                results.append((edit["file_path"], True, "Applied successfully"))
            except Exception as e:
                results.append((edit["file_path"], False, str(e)))

        # Clear staged changes after applying
        self.clear_staged_changes()
        return results

    def revert_staged_change(self, file_path: str) -> bool:
        """Remove a specific file from staged changes.

        Args:
            file_path: Path of file to remove from staging

        Returns:
            True if file was found and removed, False otherwise
        """
        for i, edit in enumerate(self.staged_changes["edits"]):
            if edit["file_path"] == file_path:
                self.staged_changes["edits"].pop(i)
                return True
        return False

    def add_backend_log(self, log_line: str) -> None:
        """Add a backend log line.

        Args:
            log_line: Log line to add
        """
        self.backend_logs.append(log_line)
        # Keep last 1000 lines
        if len(self.backend_logs) > 1000:
            self.backend_logs = self.backend_logs[-1000:]

    def add_frontend_log(self, log_line: str) -> None:
        """Add a frontend log line.

        Args:
            log_line: Log line to add
        """
        self.frontend_logs.append(log_line)
        # Keep last 1000 lines
        if len(self.frontend_logs) > 1000:
            self.frontend_logs = self.frontend_logs[-1000:]
