# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Tools System

Built-in tools for the agent plus an extensible architecture
for adding custom tools via skills.

Phase 3 (v2.5.0): Added metrics collection for tool execution monitoring
"""

import inspect
import json
import logging
import os
import platform
import shlex
import subprocess
import time
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Callable, Optional

# Import metrics for tool execution monitoring
from .metrics import get_metrics_collector

# Import centralized pattern safety module
from .pattern_safety import (
    check_shell_safety,
    sanitize_tool_output,
)

logger = logging.getLogger(__name__)

# Security constants
MAX_TOOL_ITERATIONS = 15
DEFAULT_SHELL_TIMEOUT = 60
DEFAULT_HTTP_TIMEOUT = 30
MAX_FILE_READ_SIZE = 50000  # 50KB


@dataclass
class Tool:
    """Represents a tool the agent can use."""

    name: str
    description: str
    input_schema: dict
    handler: Callable[[dict], str]
    category: str = "general"
    requires_confirmation: bool = False  # For dangerous operations


class ToolRegistry:
    """
    Registry of available tools.

    Tools can be:
    - Built-in (defined here)
    - Loaded from skills
    - Added programmatically
    """

    def __init__(self, sandboxed_dirs: Optional[list[str]] = None):
        self.tools: dict[str, Tool] = {}
        # Default sandboxed directories - can be overridden via config
        self.sandboxed_dirs = [
            Path.home(),
            Path("/tmp"),
        ]
        if sandboxed_dirs:
            self.sandboxed_dirs = [Path(d) for d in sandboxed_dirs]

        self._register_builtins()

    # Env vars to strip from child processes (secrets, API keys)
    _SENSITIVE_ENV_PREFIXES = (
        "ANTHROPIC_",
        "OPENAI_",
        "GOOGLE_",
        "AWS_SECRET",
        "DISCORD_BOT_TOKEN",
        "TELEGRAM_BOT_TOKEN",
        "TWILIO_",
        "FAMILIAR_ENCRYPTION_KEY",
        "OWNER_PIN_HASH",
        "HA_SHARED_SECRET",
        "SSO_",
        "SMTP_PASSWORD",
        "DATABASE_URL",
        "REDIS_URL",
    )

    def _safe_env(self) -> dict:
        """Return a filtered copy of os.environ without secrets/API keys."""
        return {
            k: v
            for k, v in os.environ.items()
            if not any(k.startswith(prefix) for prefix in self._SENSITIVE_ENV_PREFIXES)
        } | {"LC_ALL": "C"}

    def _validate_shell_command(self, command: str) -> tuple[bool, str]:
        """
        Validate shell command for dangerous patterns.

        Uses the centralized pattern_safety module for timeout-protected
        pattern matching.

        Returns:
            (is_safe, error_message)
        """
        return check_shell_safety(command)

    def _validate_path(self, path: str, must_exist: bool = False) -> tuple[bool, str, Path]:
        """
        Validate file path is within sandboxed directories.
        Returns (is_valid, error_message, resolved_path).
        """
        try:
            # Expand and resolve path
            expanded = os.path.expanduser(path)
            resolved = Path(expanded).resolve()

            # Check if path is within allowed directories
            is_allowed = False
            for sandbox_dir in self.sandboxed_dirs:
                try:
                    resolved.relative_to(sandbox_dir.resolve())
                    is_allowed = True
                    break
                except ValueError:
                    continue

            if not is_allowed:
                allowed_str = ", ".join(str(d) for d in self.sandboxed_dirs)
                return False, f"Path must be within allowed directories: {allowed_str}", resolved

            # Check for path traversal attempts in the original path
            if ".." in path:
                # Verify the resolved path is still within sandbox after traversal
                pass  # Already checked above with resolve()

            # Check existence if required
            if must_exist and not resolved.exists():
                return False, f"Path does not exist: {resolved}", resolved

            return True, "", resolved

        except Exception as e:
            return False, f"Invalid path: {str(e)}", None

    def _register_builtins(self):
        """Register built-in tools."""

        # Shell command execution
        self.register(
            Tool(
                name="run_shell",
                description="Execute a shell command on the local machine. Returns stdout, stderr, and return code.",
                input_schema={
                    "type": "object",
                    "properties": {
                        "command": {
                            "type": "string",
                            "description": "The shell command to execute",
                        },
                        "timeout": {
                            "type": "integer",
                            "description": "Timeout in seconds (default 60)",
                            "default": 60,
                        },
                    },
                    "required": ["command"],
                },
                handler=self._handle_run_shell,
                category="system",
                requires_confirmation=True,
            )
        )

        # File reading
        self.register(
            Tool(
                name="read_file",
                description="Read the contents of a file",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Path to the file to read"},
                        "max_lines": {
                            "type": "integer",
                            "description": "Maximum lines to return (default all)",
                            "default": None,
                        },
                    },
                    "required": ["path"],
                },
                handler=self._handle_read_file,
                category="filesystem",
            )
        )

        # File writing
        self.register(
            Tool(
                name="write_file",
                description="Write content to a file (creates or overwrites)",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {"type": "string", "description": "Path to write the file"},
                        "content": {"type": "string", "description": "Content to write"},
                        "append": {
                            "type": "boolean",
                            "description": "Append instead of overwrite",
                            "default": False,
                        },
                    },
                    "required": ["path", "content"],
                },
                handler=self._handle_write_file,
                category="filesystem",
            )
        )

        # Directory listing
        self.register(
            Tool(
                name="list_directory",
                description="List files and directories in a path",
                input_schema={
                    "type": "object",
                    "properties": {
                        "path": {
                            "type": "string",
                            "description": "Directory path to list",
                            "default": ".",
                        },
                        "show_hidden": {
                            "type": "boolean",
                            "description": "Include hidden files",
                            "default": False,
                        },
                    },
                },
                handler=self._handle_list_directory,
                category="filesystem",
            )
        )

        # System information
        self.register(
            Tool(
                name="get_system_info",
                description="Get system information: hostname, OS, CPU, memory, disk, uptime",
                input_schema={"type": "object", "properties": {}},
                handler=self._handle_system_info,
                category="system",
            )
        )

        # Current time
        self.register(
            Tool(
                name="get_current_time",
                description="Get current date and time",
                input_schema={
                    "type": "object",
                    "properties": {
                        "timezone": {
                            "type": "string",
                            "description": "Timezone (default: local)",
                            "default": None,
                        }
                    },
                },
                handler=self._handle_current_time,
                category="utility",
            )
        )

        # Memory operations (for the agent's memory system)
        self.register(
            Tool(
                name="remember",
                description="Store a fact in long-term memory for future conversations",
                input_schema={
                    "type": "object",
                    "properties": {
                        "key": {
                            "type": "string",
                            "description": "Short identifier for this memory",
                        },
                        "value": {"type": "string", "description": "The information to remember"},
                        "category": {
                            "type": "string",
                            "description": "Category: user_info, preference, fact, task",
                            "default": "fact",
                        },
                    },
                    "required": ["key", "value"],
                },
                handler=self._handle_remember,
                category="memory",
            )
        )

        self.register(
            Tool(
                name="recall",
                description="Search long-term memory for information",
                input_schema={
                    "type": "object",
                    "properties": {"query": {"type": "string", "description": "Search query"}},
                    "required": ["query"],
                },
                handler=self._handle_recall,
                category="memory",
            )
        )

        # HTTP request (simple)
        self.register(
            Tool(
                name="http_request",
                description="Make an HTTP request (GET or POST)",
                input_schema={
                    "type": "object",
                    "properties": {
                        "url": {"type": "string", "description": "URL to request"},
                        "method": {
                            "type": "string",
                            "description": "HTTP method",
                            "enum": ["GET", "POST"],
                            "default": "GET",
                        },
                        "data": {
                            "type": "string",
                            "description": "POST data (JSON string)",
                            "default": None,
                        },
                    },
                    "required": ["url"],
                },
                handler=self._handle_http_request,
                category="network",
            )
        )

    def register(self, tool: Tool):
        """Register a tool."""
        self.tools[tool.name] = tool
        logger.debug(f"Registered tool: {tool.name}")

    def unregister(self, name: str):
        """Remove a tool."""
        if name in self.tools:
            del self.tools[name]

    def get(self, name: str) -> Optional[Tool]:
        """Get a tool by name."""
        return self.tools.get(name)

    def get_all(self) -> list[Tool]:
        """Get all tools."""
        return list(self.tools.values())

    def get_schemas(self) -> list[dict]:
        """Get tool schemas for LLM."""
        return [
            {"name": t.name, "description": t.description, "input_schema": t.input_schema}
            for t in self.tools.values()
        ]

    def execute(
        self, name: str, input_data: dict[str, Any], context: Optional[dict[str, Any]] = None
    ) -> str:
        """Execute a tool by name with output sanitization and metrics tracking."""
        tool = self.tools.get(name)
        if not tool:
            # Progressive disclosure: suggest enabling a skill instead of bare error
            try:
                from familiar.core.progressive import on_tool_not_found

                skill_loader = context.get("skill_loader") if context else None
                if skill_loader:
                    return on_tool_not_found(name, skill_loader)
            except (ImportError, Exception):
                pass
            return f"Error: Unknown tool '{name}'"

        start_time = time.time()
        success = True
        error_message = None
        try:
            # Strip _context before serializing — it contains non-JSON objects (SecureSession etc.)
            _serializable = (
                {k: v for k, v in input_data.items() if k != "_context"} if input_data else {}
            )
            input_size = len(json.dumps(_serializable))
        except (TypeError, ValueError):
            input_size = len(str(input_data))
        result = ""

        try:
            # RBAC permission check: if user has a role, verify tool access
            if context:
                session = context.get("session")
                if session and hasattr(session, "user_role") and session.user_role:
                    try:
                        from familiar.skills.rbac.skill import _check_perm, _get_role_permissions

                        user_perms = _get_role_permissions(session.user_role)
                        tool_category = (
                            tool.category if hasattr(tool, "category") else name.split("_")[0]
                        )
                        if user_perms and not _check_perm(user_perms, tool_category, name):
                            return f"⛔ Permission denied: your role '{session.user_role}' cannot access {name}"
                    except ImportError:
                        pass  # RBAC skill not installed, skip check

            # Inject context into input_data so tools can access session, agent, etc.
            if context and isinstance(input_data, dict):
                input_data["_context"] = context

            # Pass context to handler if its signature accepts it
            if context:
                try:
                    sig = inspect.signature(tool.handler)
                    if "context" in sig.parameters:
                        result = tool.handler(input_data, context=context)
                    else:
                        result = tool.handler(input_data)
                except (ValueError, TypeError):
                    # Can't inspect (C extension, etc.) — call without context
                    result = tool.handler(input_data)
            else:
                result = tool.handler(input_data)

            # Sanitize output to prevent prompt injection
            result = sanitize_tool_output(result)

        except Exception as e:
            logger.error(f"Tool {name} failed: {e}")
            success = False
            error_message = str(e)
            result = f"Error executing {name}: {str(e)}"

        finally:
            # Record metrics
            end_time = time.time()
            duration_ms = (end_time - start_time) * 1000
            output_size = len(result) if result else 0
            user_id = context.get("user_id") if context else None

            try:
                metrics = get_metrics_collector()
                metrics.record_tool_execution(
                    tool_name=name,
                    duration_ms=duration_ms,
                    success=success,
                    error_message=error_message,
                    input_size=input_size,
                    output_size=output_size,
                    user_id=str(user_id) if user_id else None,
                )
            except Exception as metrics_error:
                logger.debug(f"Failed to record tool metrics: {metrics_error}")

        return result

    # Built-in handlers

    def _handle_run_shell(self, input_data: dict) -> str:
        """Execute shell command with security validation.

        Uses shell=False by default (splits via shlex). Falls back to
        shell=True only for commands that require it and pass validation.
        """
        command = input_data["command"]
        timeout = input_data.get("timeout", DEFAULT_SHELL_TIMEOUT)

        # Always validate against dangerous patterns first
        is_safe, error_msg = self._validate_shell_command(command)
        if not is_safe:
            logger.warning(f"Blocked potentially dangerous command: {command[:100]}...")
            return f"Error: {error_msg}"

        try:
            # Always use shell=False — reject commands that can't be safely split.
            # shell=True enables shell injection even after pattern validation.
            try:
                args = shlex.split(command)
            except ValueError as e:
                return (
                    f"Error: Command could not be parsed safely: {e}\n"
                    "Hint: Avoid unbalanced quotes or complex shell syntax. "
                    "Break the command into simpler parts."
                )

            result = subprocess.run(
                args,
                shell=False,
                capture_output=True,
                text=True,
                timeout=timeout,
                env=self._safe_env(),
            )

            output = result.stdout
            if result.stderr:
                output += f"\n[STDERR]: {result.stderr}"
            if result.returncode != 0:
                output += f"\n[Exit code: {result.returncode}]"

            # Truncate very long output
            if len(output) > MAX_FILE_READ_SIZE:
                output = output[:MAX_FILE_READ_SIZE] + "\n... (output truncated)"

            return output.strip() or "(no output)"

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

    def _handle_read_file(self, input_data: dict) -> str:
        """Read file with path validation."""
        raw_path = input_data["path"]
        max_lines = input_data.get("max_lines")

        # Validate path
        is_valid, error_msg, path = self._validate_path(raw_path, must_exist=True)
        if not is_valid:
            return f"Error: {error_msg}"

        try:
            with open(path, "r") as f:
                if max_lines:
                    lines = []
                    for i, line in enumerate(f):
                        if i >= max_lines:
                            lines.append(f"... ({max_lines} lines shown)")
                            break
                        lines.append(line.rstrip())
                    return "\n".join(lines)
                else:
                    content = f.read()
                    # Truncate very long files
                    if len(content) > MAX_FILE_READ_SIZE:
                        content = (
                            content[:MAX_FILE_READ_SIZE]
                            + f"\n... (truncated at {MAX_FILE_READ_SIZE // 1000}KB)"
                        )
                    return content
        except FileNotFoundError:
            return f"Error: File not found: {path}"
        except PermissionError:
            return f"Error: Permission denied: {path}"
        except UnicodeDecodeError:
            return f"Error: File is not a text file or has invalid encoding: {path}"
        except Exception as e:
            return f"Error reading file: {str(e)}"

    def _handle_write_file(self, input_data: dict) -> str:
        """Write file with path validation and atomic write."""
        raw_path = input_data["path"]
        content = input_data["content"]
        append = input_data.get("append", False)

        # Validate path (don't require existence for writes)
        is_valid, error_msg, path = self._validate_path(raw_path, must_exist=False)
        if not is_valid:
            return f"Error: {error_msg}"

        try:
            # Create directory if needed
            path.parent.mkdir(parents=True, exist_ok=True)

            if append:
                # Append mode - direct write is fine
                with open(path, "a") as f:
                    f.write(content)
                return f"✓ Appended to {path} ({len(content)} bytes)"
            else:
                # Write mode - use atomic write pattern
                tmp_path = path.with_suffix(path.suffix + ".tmp")
                try:
                    with open(tmp_path, "w") as f:
                        f.write(content)
                    tmp_path.replace(path)  # Atomic on POSIX
                    return f"✓ Wrote {path} ({len(content)} bytes)"
                except Exception:
                    # Clean up temp file on failure
                    if tmp_path.exists():
                        tmp_path.unlink()
                    raise

        except PermissionError:
            return f"Error: Permission denied: {path}"
        except Exception as e:
            return f"Error writing file: {str(e)}"

    def _handle_list_directory(self, input_data: dict) -> str:
        """List directory with path validation."""
        raw_path = input_data.get("path", ".")
        show_hidden = input_data.get("show_hidden", False)

        # Validate path
        is_valid, error_msg, path = self._validate_path(raw_path, must_exist=True)
        if not is_valid:
            return f"Error: {error_msg}"

        if not path.is_dir():
            return f"Error: Not a directory: {path}"

        try:
            items = os.listdir(path)

            if not show_hidden:
                items = [i for i in items if not i.startswith(".")]

            items.sort()

            # Add type indicators
            result = []
            for item in items[:100]:  # Limit output
                full_path = path / item
                if full_path.is_dir():
                    result.append(f"📁 {item}/")
                else:
                    try:
                        size = full_path.stat().st_size
                        result.append(f"📄 {item} ({self._format_size(size)})")
                    except OSError:
                        result.append(f"📄 {item}")

            if len(items) > 100:
                result.append(f"... and {len(items) - 100} more items")

            return "\n".join(result) or "(empty directory)"

        except FileNotFoundError:
            return f"Error: Directory not found: {path}"
        except PermissionError:
            return f"Error: Permission denied: {path}"
        except Exception as e:
            return f"Error: {str(e)}"

    def _format_size(self, size: int) -> str:
        for unit in ["B", "KB", "MB", "GB"]:
            if size < 1024:
                return f"{size:.1f}{unit}"
            size /= 1024
        return f"{size:.1f}TB"

    def _handle_system_info(self, input_data: dict) -> str:
        info = []

        # Basic info
        info.append(f"Hostname: {platform.node()}")
        info.append(f"OS: {platform.system()} {platform.release()}")
        info.append(f"Architecture: {platform.machine()}")
        info.append(f"Python: {platform.python_version()}")

        # Uptime
        try:
            uptime = subprocess.run(
                "uptime -p 2>/dev/null || uptime", shell=True, capture_output=True, text=True
            )
            info.append(f"Uptime: {uptime.stdout.strip()}")
        except (subprocess.SubprocessError, OSError):
            pass

        # Memory
        try:
            if platform.system() == "Linux":
                mem = subprocess.run(
                    "free -h | grep Mem", shell=True, capture_output=True, text=True
                )
                if mem.stdout:
                    parts = mem.stdout.split()
                    info.append(f"Memory: {parts[2]} used / {parts[1]} total")
            elif platform.system() == "Darwin":
                # macOS
                mem = subprocess.run(
                    "vm_stat | head -5", shell=True, capture_output=True, text=True
                )
                info.append(f"Memory:\n{mem.stdout}")
        except (subprocess.SubprocessError, OSError, IndexError):
            pass

        # Disk
        try:
            disk = subprocess.run("df -h / | tail -1", shell=True, capture_output=True, text=True)
            if disk.stdout:
                parts = disk.stdout.split()
                info.append(f"Disk: {parts[2]} used / {parts[1]} total ({parts[4]} full)")
        except (subprocess.SubprocessError, OSError, IndexError):
            pass

        # CPU temp (Raspberry Pi)
        try:
            temp = subprocess.run(
                "cat /sys/class/thermal/thermal_zone0/temp 2>/dev/null",
                shell=True,
                capture_output=True,
                text=True,
            )
            if temp.stdout.strip():
                celsius = int(temp.stdout.strip()) / 1000
                info.append(f"CPU Temp: {celsius:.1f}°C")
        except (subprocess.SubprocessError, OSError, ValueError):
            pass

        return "\n".join(info)

    def _handle_current_time(self, input_data: dict) -> str:
        now = datetime.now()
        return now.strftime("%Y-%m-%d %H:%M:%S %Z (%A)")

    def _handle_remember(
        self, input_data: dict[str, Any], context: Optional[dict[str, Any]] = None
    ) -> str:
        """Store in memory - requires context with memory object."""
        if not context or "memory" not in context:
            return "Error: Memory system not available"

        memory = context["memory"]
        key = input_data["key"]
        value = input_data["value"]
        category = input_data.get("category", "fact")

        memory.remember(key, value, category=category)
        return f"✓ Remembered: {key} = {value}"

    def _handle_recall(
        self, input_data: dict[str, Any], context: Optional[dict[str, Any]] = None
    ) -> str:
        """Search memory - requires context with memory object."""
        if not context or "memory" not in context:
            return "Error: Memory system not available"

        memory = context["memory"]
        query = input_data["query"]

        results = memory.search(query)
        if not results:
            return f"No memories found matching '{query}'"

        output = []
        for entry in results[:10]:
            output.append(f"• {entry.key}: {entry.value} [{entry.category}]")

        return "\n".join(output)

    def _handle_http_request(self, input_data: dict) -> str:
        url = input_data["url"]
        method = input_data.get("method", "GET")
        data = input_data.get("data")

        # SSRF protection: block requests to private/internal networks
        from .sanitization import check_ssrf

        is_safe, ssrf_msg = check_ssrf(url)
        if not is_safe:
            return f"Error: {ssrf_msg}"

        try:
            import urllib.error
            import urllib.request

            req = urllib.request.Request(url, method=method)
            req.add_header("User-Agent", "PiAgent/1.0")

            if data:
                req.data = data.encode()
                req.add_header("Content-Type", "application/json")

            with urllib.request.urlopen(req, timeout=30) as response:
                content = response.read().decode()

                # Truncate long responses
                if len(content) > 10000:
                    content = content[:10000] + "\n... (truncated)"

                return f"[{response.status}]\n{content}"

        except urllib.error.HTTPError as e:
            return f"HTTP Error {e.code}: {e.reason}"
        except urllib.error.URLError as e:
            return f"URL Error: {e.reason}"
        except Exception as e:
            return f"Error: {str(e)}"


# Global registry instance
_registry: Optional[ToolRegistry] = None


def get_tool_registry(sandboxed_dirs: Optional[list[str]] = None) -> ToolRegistry:
    """
    Get or create the global tool registry.

    Args:
        sandboxed_dirs: Optional list of allowed directories for file operations.
                       Only used on first call when creating the registry.
    """
    global _registry
    if _registry is None:
        _registry = ToolRegistry(sandboxed_dirs)
    return _registry


def reset_tool_registry():
    """Reset the tool registry (for testing)."""
    global _registry
    _registry = None
