"""Plugin system for mcpaudit — Load custom rules from external directories.

Allows users to extend mcpaudit with custom security rules by placing
Python files in a plugin directory. Each plugin file must define a
``register_rules()`` function that returns a list of Rule instances.

Zero external dependencies. Uses importlib for dynamic loading.
"""

from __future__ import annotations

import ast
import importlib.util
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable

from .models import Finding, Severity
from .rules import Rule


@dataclass
class PluginInfo:
    """Metadata about a loaded plugin file."""

    filepath: Path
    name: str
    rules_count: int
    errors: list[str] = field(default_factory=list)


class PluginRule(Rule):
    """Wrapper that adapts an external check function to the Rule interface.

    Plugin authors provide a dict with rule metadata and a ``check`` callable.
    PluginRule bridges that to the abstract Rule base class used by the scanner.
    """

    def __init__(
        self,
        rule_id: str,
        name: str,
        severity: Severity,
        description: str,
        check_fn: Callable[[ast.Module, str], list[Finding]],
        cwe_id: str = "",
        detailed_desc: str = "",
    ) -> None:
        self._rule_id = rule_id
        self._name = name
        self._severity = severity
        self._description = description
        self._check_fn = check_fn
        self._cwe_id = cwe_id
        self._detailed_desc = detailed_desc or description

    @property
    def rule_id(self) -> str:
        return self._rule_id

    @property
    def name(self) -> str:
        return self._name

    @property
    def severity(self) -> Severity:
        return self._severity

    @property
    def description(self) -> str:
        return self._description

    @property
    def cwe(self) -> str:
        return self._cwe_id

    @property
    def detailed_description(self) -> str:
        return self._detailed_desc

    def check(self, tree: ast.Module, source: str) -> list[Finding]:
        """Delegate to the plugin's check function."""
        return self._check_fn(tree, source)


def validate_plugin(rule: Rule) -> list[str]:
    """Validate that a plugin rule has all required fields.

    Args:
        rule: A Rule instance to validate.

    Returns:
        List of validation error strings. Empty if valid.
    """
    errors: list[str] = []

    if not rule.rule_id:
        errors.append("rule_id is empty")
    elif not rule.rule_id.startswith("PLUG-"):
        errors.append(
            f"rule_id '{rule.rule_id}' must start with 'PLUG-' prefix"
        )

    if not rule.name:
        errors.append("name is empty")

    if not isinstance(rule.severity, Severity):
        errors.append(f"severity must be a Severity enum, got {type(rule.severity)}")

    if not rule.description:
        errors.append("description is empty")

    if not callable(getattr(rule, "check", None)):
        errors.append("check method is not callable")

    return errors


def load_plugin_file(filepath: Path) -> list[Rule]:
    """Load a single plugin file and return its rules.

    The plugin file must define a ``register_rules()`` function that returns
    a list of Rule instances (or dicts that PluginRule can wrap).

    Args:
        filepath: Path to the .py plugin file.

    Returns:
        List of Rule instances from this plugin.

    Raises:
        ValueError: If the plugin file has no register_rules function.
        ImportError: If the plugin file cannot be loaded.
    """
    if not filepath.exists():
        raise FileNotFoundError(f"Plugin file not found: {filepath}")

    if not filepath.suffix == ".py":
        raise ValueError(f"Plugin file must be .py: {filepath}")

    module_name = f"mcpaudit_plugin_{filepath.stem}"

    spec = importlib.util.spec_from_file_location(module_name, filepath)
    if spec is None or spec.loader is None:
        raise ImportError(f"Cannot load plugin: {filepath}")

    module = importlib.util.module_from_spec(spec)
    sys.modules[module_name] = module
    try:
        spec.loader.exec_module(module)
    except Exception as exc:
        del sys.modules[module_name]
        raise ImportError(f"Error loading plugin {filepath}: {exc}") from exc

    register_fn = getattr(module, "register_rules", None)
    if register_fn is None:
        del sys.modules[module_name]
        raise ValueError(
            f"Plugin {filepath.name} has no register_rules() function"
        )

    if not callable(register_fn):
        del sys.modules[module_name]
        raise ValueError(
            f"register_rules in {filepath.name} is not callable"
        )

    raw_rules = register_fn()
    if not isinstance(raw_rules, list):
        del sys.modules[module_name]
        raise ValueError(
            f"register_rules() in {filepath.name} must return a list"
        )

    rules: list[Rule] = []
    for item in raw_rules:
        if isinstance(item, Rule):
            rules.append(item)
        elif isinstance(item, dict):
            rules.append(_dict_to_plugin_rule(item, filepath.name))
        else:
            raise ValueError(
                f"register_rules() in {filepath.name} returned "
                f"unsupported type: {type(item)}"
            )

    return rules


def _dict_to_plugin_rule(d: dict[str, Any], source_name: str) -> PluginRule:
    """Convert a dict descriptor to a PluginRule instance.

    Expected keys: rule_id, name, severity, description, check.
    Optional keys: cwe, detailed_description.
    """
    required = ("rule_id", "name", "severity", "description", "check")
    missing = [k for k in required if k not in d]
    if missing:
        raise ValueError(
            f"Plugin dict from {source_name} missing keys: {missing}"
        )

    severity = d["severity"]
    if isinstance(severity, str):
        severity = Severity.from_string(severity)

    return PluginRule(
        rule_id=d["rule_id"],
        name=d["name"],
        severity=severity,
        description=d["description"],
        check_fn=d["check"],
        cwe_id=d.get("cwe", ""),
        detailed_desc=d.get("detailed_description", ""),
    )


def discover_plugins(directory: Path) -> list[Rule]:
    """Discover and load all plugin files from a directory.

    Scans for ``*.py`` files (non-recursive) and loads each one that
    contains a ``register_rules()`` function. Files starting with ``_``
    are skipped.

    Args:
        directory: Path to the plugins directory.

    Returns:
        Combined list of all Rule instances from discovered plugins.

    Raises:
        FileNotFoundError: If the directory does not exist.
    """
    if not directory.exists():
        raise FileNotFoundError(f"Plugin directory not found: {directory}")

    if not directory.is_dir():
        raise NotADirectoryError(f"Not a directory: {directory}")

    rules: list[Rule] = []
    for py_file in sorted(directory.glob("*.py")):
        if py_file.name.startswith("_"):
            continue
        file_rules = load_plugin_file(py_file)
        rules.extend(file_rules)

    return rules


class PluginRegistry:
    """Central registry for managing loaded plugins.

    Tracks which plugins are loaded, their rules, and any errors
    encountered during loading.
    """

    def __init__(self) -> None:
        self._plugins: list[PluginInfo] = []
        self._rules: list[Rule] = []

    @property
    def plugins(self) -> list[PluginInfo]:
        """All loaded plugin info entries."""
        return list(self._plugins)

    @property
    def rules(self) -> list[Rule]:
        """All rules from loaded plugins."""
        return list(self._rules)

    @property
    def total_rules(self) -> int:
        """Total number of plugin rules loaded."""
        return len(self._rules)

    @property
    def total_plugins(self) -> int:
        """Total number of plugin files processed."""
        return len(self._plugins)

    def load_directory(self, directory: Path) -> list[PluginInfo]:
        """Load all plugins from a directory.

        Args:
            directory: Path to the plugins directory.

        Returns:
            List of PluginInfo for each file processed.
        """
        if not directory.exists():
            raise FileNotFoundError(
                f"Plugin directory not found: {directory}"
            )

        results: list[PluginInfo] = []
        for py_file in sorted(directory.glob("*.py")):
            if py_file.name.startswith("_"):
                continue
            info = self.load_file(py_file)
            results.append(info)

        return results

    def load_file(self, filepath: Path) -> PluginInfo:
        """Load a single plugin file.

        Args:
            filepath: Path to the .py plugin file.

        Returns:
            PluginInfo with load results.
        """
        info = PluginInfo(
            filepath=filepath,
            name=filepath.stem,
            rules_count=0,
        )

        try:
            rules = load_plugin_file(filepath)
        except (ValueError, ImportError, FileNotFoundError) as exc:
            info.errors.append(str(exc))
            self._plugins.append(info)
            return info

        # Validate each rule
        valid_rules: list[Rule] = []
        for rule in rules:
            errors = validate_plugin(rule)
            if errors:
                info.errors.extend(
                    f"Rule '{getattr(rule, 'rule_id', '?')}': {e}"
                    for e in errors
                )
            else:
                valid_rules.append(rule)

        info.rules_count = len(valid_rules)
        self._rules.extend(valid_rules)
        self._plugins.append(info)
        return info

    def format_list(self) -> str:
        """Format a text listing of all loaded plugins and rules.

        Returns:
            Human-readable text summary.
        """
        lines: list[str] = []
        lines.append("Loaded Plugins:")
        lines.append(f"{'=' * 60}")

        if not self._plugins:
            lines.append("  (no plugins loaded)")
            return "\n".join(lines)

        for info in self._plugins:
            status = "OK" if not info.errors else "ERRORS"
            lines.append(
                f"  {info.name} ({info.filepath.name}) "
                f"— {info.rules_count} rules [{status}]"
            )
            for err in info.errors:
                lines.append(f"    ERROR: {err}")

        lines.append(f"\nTotal: {self.total_plugins} plugins, "
                      f"{self.total_rules} rules")
        return "\n".join(lines)

    def clear(self) -> None:
        """Remove all loaded plugins and rules."""
        self._plugins.clear()
        self._rules.clear()
