"""Base implementation for Lintro plugins.

This module provides the BaseToolPlugin class, which implements common
functionality for all Lintro tool plugins.

Example:
    >>> from lintro.plugins.base import BaseToolPlugin
    >>> from lintro.plugins.protocol import ToolDefinition
    >>> from lintro.plugins.registry import register_tool
    >>>
    >>> @register_tool
    ... class MyPlugin(BaseToolPlugin):
    ...     @property
    ...     def definition(self) -> ToolDefinition:
    ...         return ToolDefinition(name="my-tool", description="My tool")
    ...
    ...     def check(self, paths, options):
    ...         # Implementation
    ...         pass
"""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import TYPE_CHECKING

import click
from loguru import logger

from lintro.config.lintro_config import LintroConfig
from lintro.models.core.tool_result import ToolResult
from lintro.plugins.execution_preparation import (
    DEFAULT_TIMEOUT,
    build_config_args,
    get_defaults_config_args,
    get_effective_timeout,
    get_enforce_cli_args,
    get_enforced_settings,
    get_executable_command,
    get_lintro_config,
    prepare_execution,
    should_use_lintro_config,
    verify_tool_version,
)
from lintro.plugins.file_discovery import (
    DEFAULT_EXCLUDE_PATTERNS,
    discover_files,
    get_cwd,
    setup_exclude_patterns,
    validate_paths,
)
from lintro.plugins.protocol import ToolDefinition
from lintro.plugins.subprocess_executor import (
    run_subprocess,
    run_subprocess_streaming,
    validate_subprocess_command,
)

if TYPE_CHECKING:
    from collections.abc import Callable

    from lintro.plugins.file_processor import AggregatedResult, FileProcessingResult


@dataclass
class ExecutionContext:
    """Context for tool execution containing prepared files and metadata.

    This dataclass encapsulates the common preparation steps needed before
    running a tool, eliminating duplicate boilerplate across tool implementations.

    Attributes:
        files: List of absolute file paths to process.
        rel_files: List of file paths relative to cwd.
        cwd: Working directory for command execution.
        early_result: If set, return this result immediately.
        timeout: Timeout value for subprocess execution.
    """

    files: list[str] = field(default_factory=list)
    rel_files: list[str] = field(default_factory=list)
    cwd: str | None = None
    early_result: ToolResult | None = None
    timeout: int = DEFAULT_TIMEOUT

    @property
    def should_skip(self) -> bool:
        """Check if execution should be skipped due to early result.

        Returns:
            True if early_result is set and execution should be skipped.
        """
        return self.early_result is not None


@dataclass
class BaseToolPlugin(ABC):
    """Base class providing common functionality for tool plugins.

    This class implements the boilerplate that most tools need:
    - Subprocess execution with safety validation
    - File discovery and filtering
    - Version checking
    - Config injection support
    - Working directory computation

    Subclasses must implement:
    - definition property: Return a ToolDefinition with tool metadata
    - check() method: Check files for issues

    Optionally override:
    - fix() method: Fix issues (only if definition.can_fix=True)
    - set_options() method: Custom option validation

    Attributes:
        options: Current tool options (merged from defaults and runtime).
        exclude_patterns: Patterns to exclude from file discovery.
        include_venv: Whether to include virtual environment files.
    """

    options: dict[str, object] = field(default_factory=dict, init=False)
    exclude_patterns: list[str] = field(default_factory=list, init=False)
    include_venv: bool = field(default=False, init=False)

    def __post_init__(self) -> None:
        """Initialize plugin with defaults from definition."""
        # Initialize options from definition defaults
        self.options = dict(self.definition.default_options)

        # Set up exclude patterns
        self._setup_defaults()

    @property
    @abstractmethod
    def definition(self) -> ToolDefinition:
        """Return the tool definition.

        Must be implemented by subclasses.

        Returns:
            ToolDefinition containing all tool metadata.
        """
        ...

    @property
    def name(self) -> str:
        """Return the tool name from definition.

        Returns:
            str: Tool name.
        """
        return self.definition.name

    # -------------------------------------------------------------------------
    # Public API
    # -------------------------------------------------------------------------

    def set_options(self, **kwargs: object) -> None:
        """Set tool-specific options.

        Args:
            **kwargs: Tool-specific options.

        Raises:
            ValueError: If an option value is invalid.
        """
        from lintro.enums.tool_option_key import ToolOptionKey

        for key, value in kwargs.items():
            if key == ToolOptionKey.TIMEOUT.value:
                if value is not None and not isinstance(value, (int, float)):
                    raise ValueError("Timeout must be a number or None")
                kwargs[key] = float(value) if value is not None else None
            if key == ToolOptionKey.EXCLUDE_PATTERNS.value and not isinstance(
                value,
                list,
            ):
                raise ValueError("Exclude patterns must be a list")
            if key == ToolOptionKey.INCLUDE_VENV.value and not isinstance(value, bool):
                raise ValueError("Include venv must be a boolean")

        self.options.update(kwargs)

        # Update specific attributes
        if ToolOptionKey.EXCLUDE_PATTERNS.value in kwargs:
            patterns = kwargs[ToolOptionKey.EXCLUDE_PATTERNS.value]
            if isinstance(patterns, list):
                self.exclude_patterns = list(patterns)
        if ToolOptionKey.INCLUDE_VENV.value in kwargs:
            self.include_venv = bool(kwargs[ToolOptionKey.INCLUDE_VENV.value])

    @abstractmethod
    def check(self, paths: list[str], options: dict[str, object]) -> ToolResult:
        """Check files for issues.

        Args:
            paths: List of file or directory paths to check.
            options: Tool-specific options that override defaults.

        Returns:
            ToolResult containing check results and any issues found.
        """
        ...

    def fix(self, paths: list[str], options: dict[str, object]) -> ToolResult:
        """Fix issues in files.

        Default implementation raises NotImplementedError if can_fix=False.
        Override in subclasses that support fixing.

        Args:
            paths: List of file or directory paths to fix.
            options: Tool-specific options that override defaults.

        Returns:
            ToolResult containing fix results and any remaining issues.

        Raises:
            NotImplementedError: If the tool doesn't support fixing.
        """
        if not self.definition.can_fix:
            raise NotImplementedError(
                f"{self.definition.name} does not support fixing issues",
            )
        raise NotImplementedError("Subclass must implement fix()")

    # -------------------------------------------------------------------------
    # Protected Methods - For use by subclasses
    # -------------------------------------------------------------------------

    def _setup_defaults(self) -> None:
        """Set up default options and patterns."""
        self.exclude_patterns = setup_exclude_patterns(self.exclude_patterns)

        # Set default timeout if not specified
        if "timeout" not in self.options:
            self.options["timeout"] = self.definition.default_timeout

    def _discover_files(
        self,
        paths: list[str],
        show_progress: bool = True,
    ) -> list[str]:
        """Discover files matching the tool's patterns.

        Args:
            paths: Input paths to search.
            show_progress: Whether to show a progress spinner during discovery.

        Returns:
            List of matching file paths.
        """
        return discover_files(
            paths=paths,
            definition=self.definition,
            exclude_patterns=self.exclude_patterns,
            include_venv=self.include_venv,
            show_progress=show_progress,
        )

    def _run_subprocess(
        self,
        cmd: list[str],
        timeout: int | float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
    ) -> tuple[bool, str]:
        """Run a subprocess command safely.

        Args:
            cmd: Command and arguments to run.
            timeout: Timeout in seconds (defaults to tool's timeout).
            cwd: Working directory for command execution.
            env: Environment variables for the subprocess.

        Returns:
            Tuple of (success, output) where success indicates return code 0.
        """
        effective_timeout = self._get_effective_timeout(timeout)
        return run_subprocess(cmd, effective_timeout, cwd, env)

    def _run_subprocess_streaming(
        self,
        cmd: list[str],
        timeout: int | float | None = None,
        cwd: str | None = None,
        env: dict[str, str] | None = None,
        line_handler: Callable[[str], None] | None = None,
    ) -> tuple[bool, str]:
        """Run a subprocess command with optional line-by-line streaming.

        This method allows real-time output processing by calling the line_handler
        callback for each line of output as it is produced by the subprocess.

        Args:
            cmd: Command and arguments to run.
            timeout: Timeout in seconds (defaults to tool's timeout).
            cwd: Working directory for command execution.
            env: Environment variables for the subprocess.
            line_handler: Optional callback called for each line of output.

        Returns:
            Tuple of (success, output) where success indicates return code 0.
        """
        effective_timeout = self._get_effective_timeout(timeout)
        return run_subprocess_streaming(cmd, effective_timeout, cwd, env, line_handler)

    def _get_effective_timeout(self, timeout: int | float | None = None) -> float:
        """Get the effective timeout value.

        Args:
            timeout: Override timeout value, or None to use default.

        Returns:
            Timeout value in seconds.
        """
        return get_effective_timeout(
            timeout,
            self.options,
            self.definition.default_timeout,
        )

    def _validate_subprocess_command(self, cmd: list[str]) -> None:
        """Validate a subprocess command for safety.

        Args:
            cmd: Command and arguments to validate.
        """
        validate_subprocess_command(cmd)

    def _validate_paths(self, paths: list[str]) -> None:
        """Validate that paths exist and are accessible.

        Args:
            paths: Paths to validate.
        """
        validate_paths(paths)

    def _get_cwd(self, paths: list[str]) -> str | None:
        """Get common parent directory for paths.

        Args:
            paths: Paths to compute common parent for.

        Returns:
            Common parent directory path, or None if not applicable.
        """
        return get_cwd(paths)

    def _prepare_execution(
        self,
        paths: list[str],
        options: dict[str, object],
        *,
        no_files_message: str = "No files to check.",
    ) -> ExecutionContext:
        """Prepare execution context with common boilerplate steps.

        This method consolidates repeated patterns:
        1. Merge options with defaults
        2. Verify tool version requirements
        3. Validate input paths
        4. Discover files matching patterns
        5. Compute working directory and relative paths

        Args:
            paths: Input paths to process.
            options: Runtime options to merge with defaults.
            no_files_message: Message when no files are found.

        Returns:
            ExecutionContext with files, cwd, and optional early_result.

        Example:
            ctx = self._prepare_execution(paths, options)
            if ctx.should_skip:
                return ctx.early_result

            cmd = self._build_command(ctx.rel_files)
            success, output = self._run_subprocess(cmd, cwd=ctx.cwd)
        """
        logger.debug(f"[{self.name}] Preparing execution for {len(paths)} input paths")

        result = prepare_execution(
            paths=paths,
            options=options,
            definition=self.definition,
            exclude_patterns=self.exclude_patterns,
            include_venv=self.include_venv,
            current_options=self.options,
            no_files_message=no_files_message,
        )

        if "early_result" in result:
            early_result = result["early_result"]
            logger.debug(f"[{self.name}] Early exit: {early_result.output}")
            return ExecutionContext(early_result=early_result)

        files = result.get("files", [])
        timeout = result.get("timeout", DEFAULT_TIMEOUT)
        logger.debug(f"[{self.name}] Ready: {len(files)} files, timeout={timeout}s")

        return ExecutionContext(
            files=files,
            rel_files=result.get("rel_files", []),
            cwd=result.get("cwd"),
            timeout=timeout,
        )

    def _process_files_with_progress(
        self,
        files: list[str],
        processor: Callable[[str], FileProcessingResult],
        timeout: int,
        *,
        label: str = "Processing files",
        progress_threshold: int = 2,
    ) -> AggregatedResult:
        """Process files with optional progress bar.

        This method handles the common pattern of iterating through files,
        calling a processor function for each file, and aggregating results.
        It shows a progress bar when processing multiple files.

        Args:
            files: List of file paths to process.
            processor: Callable that processes a single file and returns
                FileProcessingResult. The processor should handle its own
                exceptions and return appropriate FileProcessingResult.
            timeout: Timeout for each file operation (included in output).
            label: Label for progress bar.
            progress_threshold: Minimum files to show progress bar.

        Returns:
            AggregatedResult with all file processing results.

        Example:
            def process_file(path: str) -> FileProcessingResult:
                try:
                    success, output = self._run_subprocess(cmd + [path])
                    issues = parse_output(output)
                    return FileProcessingResult(
                        success=success,
                        output=output,
                        issues=issues,
                    )
                except subprocess.TimeoutExpired:
                    return FileProcessingResult(
                        success=False,
                        output="",
                        issues=[],
                        skipped=True,
                    )

            result = self._process_files_with_progress(
                files=ctx.files,
                processor=process_file,
                timeout=ctx.timeout,
            )
        """
        from lintro.plugins.file_processor import AggregatedResult

        aggregated = AggregatedResult()

        if len(files) >= progress_threshold:
            with click.progressbar(
                files,
                label=label,
                bar_template="%(label)s  %(info)s",
            ) as bar:
                for file_path in bar:
                    result = processor(file_path)
                    aggregated.add_file_result(file_path, result)
        else:
            for file_path in files:
                result = processor(file_path)
                aggregated.add_file_result(file_path, result)

        return aggregated

    def _get_executable_command(self, tool_name: str) -> list[str]:
        """Get the command prefix to execute a tool.

        Delegates to CommandBuilderRegistry for language-specific logic.
        This satisfies ISP by keeping BaseToolPlugin language-agnostic.

        Args:
            tool_name: Name of the tool executable.

        Returns:
            Command prefix list.
        """
        return get_executable_command(tool_name)

    def _verify_tool_version(self) -> ToolResult | None:
        """Verify that the tool meets minimum version requirements.

        Returns:
            None if version check passes, or a skip result if it fails.
        """
        return verify_tool_version(self.definition)

    # -------------------------------------------------------------------------
    # Lintro Config Support
    # -------------------------------------------------------------------------

    def _get_lintro_config(self) -> LintroConfig:
        """Get the current Lintro configuration.

        Returns:
            The current LintroConfig instance.
        """
        return get_lintro_config()

    def _get_enforced_settings(self) -> dict[str, object]:
        """Get enforced settings as a dictionary.

        Returns:
            Dictionary of enforced settings.
        """
        return get_enforced_settings(lintro_config=self._get_lintro_config())

    def _get_enforce_cli_args(self) -> list[str]:
        """Get CLI arguments for enforced settings.

        Returns:
            List of CLI arguments for enforced settings.
        """
        return get_enforce_cli_args(
            tool_name=self.definition.name,
            lintro_config=self._get_lintro_config(),
        )

    def _get_defaults_config_args(self) -> list[str]:
        """Get CLI arguments for defaults config injection.

        Returns:
            List of CLI arguments for defaults config.
        """
        return get_defaults_config_args(
            tool_name=self.definition.name,
            lintro_config=self._get_lintro_config(),
        )

    def _should_use_lintro_config(self) -> bool:
        """Check if Lintro config should be used for this tool.

        Returns:
            True if Lintro config should be used.
        """
        return should_use_lintro_config(tool_name=self.definition.name)

    def _build_config_args(self) -> list[str]:
        """Build combined CLI arguments for config injection.

        Returns:
            List of combined CLI arguments for config.
        """
        return build_config_args(
            tool_name=self.definition.name,
            lintro_config=self._get_lintro_config(),
        )


__all__ = [
    "DEFAULT_EXCLUDE_PATTERNS",
    "DEFAULT_TIMEOUT",
    "BaseToolPlugin",
    "ExecutionContext",
]
