"""Base connector interface for MCP-style tool bridges.

This module provides the abstract base class for all tool connectors,
including secure command execution, validation, and result handling.

Security Features:
- Command allowlist/blocklist enforcement
- Shell injection prevention
- Sandboxed subprocess execution
- Timeout enforcement
"""

from __future__ import annotations

import asyncio
import os
import re
import threading
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from enum import StrEnum
from pathlib import Path
from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Final, Self

from pydantic import BaseModel, Field, field_validator, model_validator

from aicippy.exceptions import (
    CommandExecutionError,
    ConnectorTimeoutError,
    ErrorContext,
)
from aicippy.types import (
    JsonDict,
    ToolResultDict,
)
from aicippy.utils.correlation import get_correlation_id
from aicippy.utils.logging import get_logger

if TYPE_CHECKING:
    from collections.abc import Sequence

logger = get_logger(__name__)


# ============================================================================
# Constants
# ============================================================================

# Shell metacharacters that may indicate injection when found in command arguments
DANGEROUS_SHELL_CHARS: Final[frozenset[str]] = frozenset(
    {";", "|", "&", "`", "$", "(", ")", "{", "}", "<", ">", "\\", "\n", "\r"}
)

# Compiled patterns matching known destructive / system-level commands
DANGEROUS_COMMAND_PATTERNS: Final[tuple[re.Pattern[str], ...]] = (
    re.compile(r"\brm\s+-rf\s+/"),
    re.compile(r"\brm\s+-fr\s+/"),
    re.compile(r"\bmkfs\b", re.IGNORECASE),
    re.compile(r"\bdd\s+if=", re.IGNORECASE),
    re.compile(r"\bformat\s+[a-zA-Z]:", re.IGNORECASE),
    re.compile(r"\bfdisk\b", re.IGNORECASE),
    re.compile(r"\bshutdown\b", re.IGNORECASE),
    re.compile(r"\breboot\b", re.IGNORECASE),
    re.compile(r"\bhalt\b", re.IGNORECASE),
    re.compile(r"\bkill\s+-9\s+-1\b"),
    re.compile(r"\bchmod\s+(-[a-zA-Z]*\s+)*777\s+/"),
    re.compile(r">\s*/dev/sd[a-z]", re.IGNORECASE),
    re.compile(r"\bwipefs\b", re.IGNORECASE),
    re.compile(r"\bsystemctl\s+(disable|stop|mask)\s+(firewalld|iptables|ufw)", re.IGNORECASE),
)


# ============================================================================
# Enums
# ============================================================================


class ConnectorStatus(StrEnum):
    """Status of a connector."""

    AVAILABLE = "available"
    UNAVAILABLE = "unavailable"
    ERROR = "error"
    DISABLED = "disabled"


# ============================================================================
# Data Classes
# ============================================================================


@dataclass(frozen=True, slots=True)
class ToolResult:
    """Immutable result from a tool execution.

    Attributes:
        success: Whether the execution was successful.
        output: Standard output from the command.
        error: Error message if execution failed.
        execution_time_ms: Execution duration in milliseconds.
        metadata: Additional execution metadata.
        exit_code: Process exit code if applicable.

    """

    success: bool
    output: str
    error: str | None = None
    execution_time_ms: int = 0
    metadata: JsonDict = field(default_factory=dict)
    exit_code: int | None = None

    @classmethod
    def success_result(
        cls,
        output: str,
        execution_time_ms: int = 0,
        metadata: JsonDict | None = None,
        exit_code: int = 0,
    ) -> Self:
        """Create a successful result.

        Args:
            output: Command output.
            execution_time_ms: Execution duration.
            metadata: Additional metadata.
            exit_code: Process exit code.

        Returns:
            ToolResult indicating success.

        """
        return cls(
            success=True,
            output=output,
            execution_time_ms=execution_time_ms,
            metadata=metadata or {},
            exit_code=exit_code,
        )

    @classmethod
    def error_result(
        cls,
        error: str,
        output: str = "",
        execution_time_ms: int = 0,
        exit_code: int | None = None,
    ) -> Self:
        """Create an error result.

        Args:
            error: Error description.
            output: Any partial output.
            execution_time_ms: Execution duration.
            exit_code: Process exit code if available.

        Returns:
            ToolResult indicating failure.

        """
        return cls(
            success=False,
            output=output,
            error=error,
            execution_time_ms=execution_time_ms,
            exit_code=exit_code,
        )

    def to_dict(self) -> ToolResultDict:
        """Convert to dictionary representation."""
        return ToolResultDict(
            success=self.success,
            output=self.output,
            error=self.error,
            execution_time_ms=self.execution_time_ms,
            metadata=self.metadata,
        )


# ============================================================================
# Configuration
# ============================================================================


class ConnectorConfig(BaseModel):
    """Configuration for a connector with validation.

    Attributes:
        enabled: Whether the connector is enabled.
        timeout_seconds: Command execution timeout.
        max_retries: Maximum retry attempts for transient failures.
        sandbox_mode: Enable sandbox restrictions.
        allowed_commands: Allowlist of command prefixes.
        blocked_commands: Blocklist of command patterns.
        working_directory: Default working directory for commands.
        environment: Additional environment variables.

    """

    enabled: bool = True
    timeout_seconds: Annotated[int, Field(ge=1, le=600)] = 30
    max_retries: Annotated[int, Field(ge=0, le=10)] = 3
    sandbox_mode: bool = False
    allowed_commands: list[str] = Field(default_factory=list)
    blocked_commands: list[str] = Field(default_factory=list)
    working_directory: str | None = None
    environment: dict[str, str] = Field(default_factory=dict)

    model_config = {
        "extra": "forbid",
        "validate_default": True,
        "str_strip_whitespace": True,
    }

    @field_validator("allowed_commands", "blocked_commands", mode="after")
    @classmethod
    def normalize_command_lists(cls, v: list[str]) -> list[str]:
        """Normalize command lists by stripping whitespace."""
        return [cmd.strip().lower() for cmd in v if cmd.strip()]

    @field_validator("working_directory", mode="after")
    @classmethod
    def validate_working_directory(cls, v: str | None) -> str | None:
        """Validate working directory exists if specified."""
        if v is not None and not Path(v).is_dir():
            raise ValueError(f"Working directory does not exist: {v}")  # noqa: TRY003
        return v

    @model_validator(mode="after")
    def validate_command_lists_disjoint(self) -> Self:
        """Ensure allowed and blocked lists don't overlap."""
        allowed_set = set(self.allowed_commands)
        blocked_set = set(self.blocked_commands)
        overlap = allowed_set & blocked_set
        if overlap:
            raise ValueError(f"Commands cannot be both allowed and blocked: {overlap}")  # noqa: TRY003
        return self


# ============================================================================
# Base Connector
# ============================================================================


class BaseConnector(ABC):
    """Abstract base class for MCP-style tool connectors.

    Provides secure command execution with:
    - Command validation and sanitization
    - Allowlist/blocklist enforcement
    - Subprocess sandboxing with timeouts
    - Structured error handling

    Subclasses must implement:
    - execute(): Execute a command
    - validate_command(): Validate before execution
    - check_availability(): Verify tool availability

    Example:
        class GitConnector(BaseConnector):
            name = "git"
            description = "Git version control"

            async def execute(self, command: str, **kwargs) -> ToolResult:
                is_valid, error = await self.validate_command(command)
                if not is_valid:
                    return ToolResult.error_result(error)
                return await self._run_command(["git"] + shlex.split(command))

    """

    # Class-level connector metadata (override in subclasses)
    name: ClassVar[str] = "base"
    description: ClassVar[str] = "Base connector"
    version: ClassVar[str] = "1.0.0"
    category: ClassVar[str] = "other"

    __slots__ = ("_error_count", "_last_error", "_status", "_status_lock", "config")

    def __init__(self, config: ConnectorConfig | None = None) -> None:
        """Initialize the connector.

        Args:
            config: Connector configuration. Uses defaults if not provided.

        """
        self.config = config or ConnectorConfig()
        self._status: ConnectorStatus = ConnectorStatus.AVAILABLE
        self._last_error: str | None = None
        self._error_count: int = 0
        self._status_lock = threading.Lock()

    @property
    def status(self) -> ConnectorStatus:
        """Get current connector status."""
        return self._status

    @property
    def is_available(self) -> bool:
        """Check if connector is available for use."""
        return self._status == ConnectorStatus.AVAILABLE and self.config.enabled

    @property
    def last_error(self) -> str | None:
        """Get the last error message if any."""
        return self._last_error

    @abstractmethod
    async def execute(self, command: str, **kwargs: Any) -> ToolResult:  # noqa: ANN401
        """Execute a command through this connector.

        Args:
            command: Command string to execute.
            **kwargs: Additional connector-specific arguments.

        Returns:
            ToolResult with execution outcome.

        Raises:
            CommandBlockedError: If command fails validation.
            CommandExecutionError: If execution fails.
            ConnectorTimeoutError: If execution times out.

        """
        ...

    @abstractmethod
    async def validate_command(self, command: str) -> tuple[bool, str]:
        """Validate a command before execution.

        Args:
            command: Command string to validate.

        Returns:
            Tuple of (is_valid, error_message).
            error_message is empty string if valid.

        """
        ...

    @abstractmethod
    async def check_availability(self) -> bool:
        """Check if the connector's underlying tool is available.

        Returns:
            True if the tool is available and functional.

        """
        ...

    def _sanitize_command(self, command: str) -> str:
        """Sanitize a command string for safe logging.

        Removes potentially sensitive information.

        Args:
            command: Raw command string.

        Returns:
            Sanitized command string.

        """
        # Mask potential secrets in environment variable assignments
        return re.sub(
            r"(API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)[=:]\S+",
            r"\1=[REDACTED]",
            command,
            flags=re.IGNORECASE,
        )

    def _has_shell_injection(self, command: str) -> bool:
        """Check if command contains potential shell injection patterns.

        Scans for dangerous shell metacharacters that could allow
        command chaining or subshell execution.

        Args:
            command: Command string to check.

        Returns:
            True if shell injection characters are detected.

        """
        for char in DANGEROUS_SHELL_CHARS:
            if char in command:
                logger.warning(
                    "shell_injection_detected",
                    character=repr(char),
                    command=self._sanitize_command(command)[:100],
                )
                return True
        return False

    def _is_dangerous_command(self, command: str) -> tuple[bool, str]:
        """Check if command matches known dangerous patterns.

        Args:
            command: Command string to check.

        Returns:
            Tuple of (is_dangerous, reason).

        """
        for pattern in DANGEROUS_COMMAND_PATTERNS:
            if pattern.search(command):
                reason = f"Blocked dangerous command pattern: {pattern.pattern}"
                logger.warning(
                    "dangerous_command_blocked",
                    pattern=pattern.pattern,
                    command=self._sanitize_command(command)[:100],
                )
                return True, reason
        return False, ""

    def _is_command_allowed(self, command: str) -> tuple[bool, str]:
        """Check if a command is allowed by configuration.

        Enforces allowlist/blocklist from connector configuration.

        Args:
            command: Command to check.

        Returns:
            Tuple of (is_allowed, error_message).

        """
        cmd_lower = command.strip().lower()

        # Check blocklist first
        for blocked in self.config.blocked_commands:
            if cmd_lower.startswith(blocked):
                return False, f"Command blocked by configuration: {blocked}"

        # If allowlist is configured, command must match
        if self.config.allowed_commands:
            for allowed in self.config.allowed_commands:
                if cmd_lower.startswith(allowed):
                    return True, ""
            return False, "Command not in allowlist"

        return True, ""

    async def _run_command(
        self,
        args: Sequence[str],
        timeout: int | None = None,
        env: dict[str, str] | None = None,
        cwd: str | None = None,
        capture_stderr: bool = True,
    ) -> ToolResult:
        """Run a command as a subprocess with security controls.

        Args:
            args: Command arguments as sequence (no shell expansion).
            timeout: Execution timeout in seconds.
            env: Additional environment variables.
            cwd: Working directory for the command.
            capture_stderr: Whether to capture stderr separately.

        Returns:
            ToolResult with command output.

        Raises:
            ConnectorTimeoutError: If command exceeds timeout.
            CommandExecutionError: If execution fails.

        """
        correlation_id = get_correlation_id()
        start_time = time.monotonic()
        effective_timeout = timeout or self.config.timeout_seconds
        effective_cwd = cwd or self.config.working_directory

        # Validate args to prevent empty commands
        if not args:
            return ToolResult.error_result("Empty command")

        # Build environment
        full_env = os.environ.copy()
        full_env.update(self.config.environment)
        if env:
            full_env.update(env)

        # Sanitize command for logging
        sanitized_cmd = self._sanitize_command(" ".join(args))
        logger.debug(
            "executing_command",
            command=sanitized_cmd,
            timeout=effective_timeout,
            correlation_id=correlation_id,
        )

        try:
            # Create subprocess without shell to prevent injection
            process = await asyncio.create_subprocess_exec(
                *args,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE if capture_stderr else asyncio.subprocess.STDOUT,
                env=full_env,
                cwd=effective_cwd,
            )

            try:
                stdout, stderr = await asyncio.wait_for(
                    process.communicate(),
                    timeout=effective_timeout,
                )
            except TimeoutError as exc:
                # Kill the process on timeout
                process.kill()
                await process.wait()
                execution_time_ms = int((time.monotonic() - start_time) * 1000)

                self._record_error(f"Timeout after {effective_timeout}s")
                logger.warning(
                    "command_timeout",
                    command=sanitized_cmd,
                    timeout=effective_timeout,
                    correlation_id=correlation_id,
                )

                raise ConnectorTimeoutError(
                    message=f"Connector {self.name} timed out after {effective_timeout}s",
                    context=ErrorContext(
                        correlation_id=correlation_id,
                        operation="command_execution",
                        details={"command": sanitized_cmd},
                    ),
                ) from exc

            execution_time_ms = int((time.monotonic() - start_time) * 1000)
            output = stdout.decode("utf-8", errors="replace").strip()
            error_output = stderr.decode("utf-8", errors="replace").strip() if stderr else ""

            if process.returncode == 0:
                self._record_success()
                logger.debug(
                    "command_success",
                    command=sanitized_cmd,
                    execution_time_ms=execution_time_ms,
                    correlation_id=correlation_id,
                )
                return ToolResult.success_result(
                    output=output,
                    execution_time_ms=execution_time_ms,
                    metadata={
                        "exit_code": process.returncode,
                        "stderr": error_output if error_output else None,
                    },
                    exit_code=process.returncode,
                )
            self._record_error(error_output or f"Exit code {process.returncode}")
            logger.warning(
                "command_failed",
                command=sanitized_cmd,
                exit_code=process.returncode,
                error=error_output[:500] if error_output else None,
                correlation_id=correlation_id,
            )
            return ToolResult.error_result(
                error=error_output or f"Command failed with exit code {process.returncode}",
                output=output,
                execution_time_ms=execution_time_ms,
                exit_code=process.returncode,
            )

        except ConnectorTimeoutError:
            raise
        except FileNotFoundError:
            execution_time_ms = int((time.monotonic() - start_time) * 1000)
            error_msg = f"Command not found: {args[0]}"
            self._record_error(error_msg)
            logger.exception(
                "command_not_found",
                command=args[0],
                correlation_id=correlation_id,
            )
            return ToolResult.error_result(
                error=error_msg,
                execution_time_ms=execution_time_ms,
                exit_code=127,  # Standard "command not found" exit code
            )
        except PermissionError as e:
            execution_time_ms = int((time.monotonic() - start_time) * 1000)
            error_msg = f"Permission denied: {e}"
            self._record_error(error_msg)
            logger.exception(
                "command_permission_denied",
                command=sanitized_cmd,
                error=str(e),
                correlation_id=correlation_id,
            )
            return ToolResult.error_result(
                error=error_msg,
                execution_time_ms=execution_time_ms,
                exit_code=126,  # Standard "permission denied" exit code
            )
        except OSError as e:
            execution_time_ms = int((time.monotonic() - start_time) * 1000)
            error_msg = f"OS error executing command: {e}"
            self._record_error(error_msg)
            logger.exception(
                "command_os_error",
                command=sanitized_cmd,
                error=str(e),
                correlation_id=correlation_id,
            )
            raise CommandExecutionError(
                command=sanitized_cmd,
                return_code=-1,
                stderr=error_msg,
                context=ErrorContext(
                    correlation_id=correlation_id,
                    operation="command_execution",
                    details={"command": sanitized_cmd},
                ),
            ) from e

    def _record_success(self) -> None:
        """Record successful execution for status tracking.

        Thread-safe: acquires _status_lock to prevent races between
        concurrent calls updating error count and status.
        """
        with self._status_lock:
            self._error_count = max(0, self._error_count - 1)
            if self._status == ConnectorStatus.ERROR and self._error_count == 0:
                self._status = ConnectorStatus.AVAILABLE
                self._last_error = None

    def _record_error(self, error: str) -> None:
        """Record execution error for status tracking.

        Thread-safe: acquires _status_lock to prevent races between
        concurrent calls updating error count and status.

        Args:
            error: Error message.

        """
        with self._status_lock:
            self._last_error = error
            self._error_count += 1
            error_threshold = 5
            if self._error_count >= error_threshold:
                self._status = ConnectorStatus.ERROR

    def get_info(self) -> JsonDict:
        """Get connector information for status reporting.

        Returns:
            Dictionary with connector metadata and status.

        """
        return {
            "name": self.name,
            "description": self.description,
            "version": self.version,
            "category": self.category,
            "status": self._status.value,
            "enabled": self.config.enabled,
            "sandbox_mode": self.config.sandbox_mode,
            "timeout_seconds": self.config.timeout_seconds,
            "error_count": self._error_count,
            "last_error": self._last_error,
        }

    async def __aenter__(self) -> Self:
        """Async context manager entry."""
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any,  # noqa: ANN401
    ) -> None:
        """Async context manager exit - no cleanup required."""
        return
