"""Abstract base class for quality gate checks.

All quality checks inherit from BaseCheck and implement the required methods.
This enables the Open/Closed principle - add new checks without modifying
existing code.
"""

import logging
import os
import shutil
import subprocess
from abc import ABC, abstractmethod
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from typing import Any, ClassVar, Dict, List, Optional

from slopmop.core.result import CheckResult, CheckStatus, ScopeInfo
from slopmop.subprocess.runner import SubprocessResult, SubprocessRunner, get_runner

logger = logging.getLogger(__name__)


class GateLevel(Enum):
    """Gate execution level — controls which commands include this gate.

    Every gate has a level that determines when it runs:

    SWAB — Runs on every commit.  Fast, local, no network or PR context
           required.  ``sm swab`` runs all SWAB-level gates.
           This is the default for all gates.

    SCOUR — Runs during thorough validation (PR readiness, CI).
            May require network access, PR context (e.g. unresolved
            comments), or expensive dependency auditing.
            ``sm scour`` runs ALL gates (SWAB + SCOUR).

    The naming comes from cleaning: a swab is a quick daily pass,
    a scour is the deep clean before inspection.
    """

    SWAB = "swab"
    SCOUR = "scour"


class ToolContext(Enum):
    """How a gate resolves the external tools it needs.

    Every gate must declare a tool_context so the framework knows how to
    locate executables and what to do when a project lacks a virtual
    environment.

    Categories:

    PURE — No external tools.  Pure Python analysis (AST, regex, file
           scanning).  Always runnable.  Examples: bogus-tests.py, code-sprawl,
           gate-dodging.

    SM_TOOL — Tool ships with slop-mop (bundled via pipx / pip dependency).
              Resolved via ``find_tool(name)`` → project venv → VIRTUAL_ENV
              → PATH.  The tool does NOT need to import the target project's
              code.  Examples: black, vulture, radon, bandit, pip-audit.

    PROJECT — Tool must run inside the target project's Python environment
              because it imports project code (pytest loads conftest.py and
              test fixtures, coverage instruments project modules, jinja2
              compiles project templates).  Resolved via
              ``get_project_python()``.  When no project venv exists the
              gate **warns and skips** instead of failing — with an
              actionable message telling the user exactly how to create one.

    NODE — Tool is resolved via npm/npx from the project's node_modules.
           Requires ``package.json`` at project root.  Examples: eslint,
           jest, prettier.
    """

    PURE = "pure"
    SM_TOOL = "sm_tool"
    PROJECT = "project"
    NODE = "node"


def find_tool(name: str, project_root: str) -> Optional[str]:
    """Find a tool executable, preferring the project's own environment.

    Resolution order:
    1. project_root/venv/bin/<name>  — local venv (highest priority)
    2. project_root/.venv/bin/<name> — local .venv
    3. $VIRTUAL_ENV/bin/<name>       — currently-activated venv
    4. shutil.which(<name>)          — system PATH (e.g. pipx-installed sm)

    When sm is installed via pipx, step 4 finds pipx's bundled tools.
    Steps 1-3 ensure the project's own tools are preferred, which matters
    for tools like pytest (plugins), bandit, or semgrep where version
    differences or missing plugins can affect results.

    Args:
        name: Executable name (e.g. "vulture", "pyright").
        project_root: Project root directory.

    Returns:
        Absolute path to the executable, or None if not found.
    """
    root = Path(project_root)
    for venv_dir in ["venv", ".venv"]:
        candidate = root / venv_dir / "bin" / name
        if candidate.exists():
            return str(candidate)
        # Windows
        candidate = root / venv_dir / "Scripts" / f"{name}.exe"
        if candidate.exists():
            return str(candidate)

    # Check the currently activated venv (e.g. user ran `source venv/bin/activate`
    # but the venv lives outside project_root, or sm is invoked via pipx)
    virtual_env = os.environ.get("VIRTUAL_ENV")
    if virtual_env:
        candidate = Path(virtual_env) / "bin" / name
        if candidate.exists():
            return str(candidate)
        candidate = Path(virtual_env) / "Scripts" / f"{name}.exe"
        if candidate.exists():
            return str(candidate)

    return shutil.which(name)


# Standard directories to exclude from scope counting
SCOPE_EXCLUDED_DIRS = {
    "node_modules",
    ".git",
    "venv",
    ".venv",
    "__pycache__",
    ".pytest_cache",
    "dist",
    "build",
    ".tox",
    "htmlcov",
    "cursor-rules",
    ".mypy_cache",
    "logs",
    ".slopmop",
    ".egg-info",
}


def count_source_scope(
    project_root: str,
    include_dirs: Optional[List[str]] = None,
    extensions: Optional[set[str]] = None,
    exclude_dirs: Optional[set[str]] = None,
) -> ScopeInfo:
    """Count source files and lines in target directories.

    Provides a fast, lightweight scan for scope metrics — no parsing,
    just file counting and line counting.  Used by checks to report
    how many files/LOC they examined.

    Args:
        project_root: Project root directory
        include_dirs: Directories to scan (relative to root). Defaults to ["."]
        extensions: File extensions to include (e.g. {".py"}). None = all source files
        exclude_dirs: Additional directories to exclude (merged with SCOPE_EXCLUDED_DIRS)

    Returns:
        ScopeInfo with file and line counts
    """
    root = Path(project_root)
    dirs = include_dirs or ["."]
    excluded = SCOPE_EXCLUDED_DIRS | (exclude_dirs or set())

    total_files = 0
    total_lines = 0

    for dir_name in dirs:
        scan_path = root / dir_name
        if not scan_path.exists():
            continue

        for file_path in scan_path.rglob("*"):
            if not file_path.is_file():
                continue

            # Skip excluded directories
            parts = set(file_path.relative_to(root).parts)
            if parts & excluded:
                continue

            # Skip .egg-info directories (not exact match, contains pattern)
            rel_str = str(file_path.relative_to(root))
            if ".egg-info" in rel_str:
                continue

            # Filter by extension if specified
            if extensions and file_path.suffix not in extensions:
                continue

            total_files += 1
            try:
                content = file_path.read_text(errors="replace")
                total_lines += content.count("\n") + (
                    1 if content and not content.endswith("\n") else 0
                )
            except (OSError, UnicodeDecodeError):
                pass  # Skip unreadable files

    return ScopeInfo(files=total_files, lines=total_lines)


class Flaw(Enum):
    """AI character flaws that checks are designed to catch.

    These represent the fundamental weaknesses in LLM-generated code:
    - OVERCONFIDENCE: "Trust me, it works" - untested assumptions
    - DECEPTIVENESS: "Look, I wrote tests!" - theater over substance
    - LAZINESS: "I'll clean that up later" - mess left behind
    - MYOPIA: "But I fixed the bug!" - tunnel vision, missing big picture
    """

    OVERCONFIDENCE = ("overconfidence", "💯", "Overconfidence")
    DECEPTIVENESS = ("deceptiveness", "🎭", "Deceptiveness")
    LAZINESS = ("laziness", "🦥", "Laziness")
    MYOPIA = ("myopia", "👓", "Myopia")

    def __init__(self, key: str, emoji: str, display_name: str):
        self.key = key
        self.emoji = emoji
        self._display_name = display_name

    @property
    def display(self) -> str:
        return f"{self.emoji} {self._display_name}"

    @property
    def display_name(self) -> str:
        return self._display_name


class GateCategory(Enum):
    """Categories for organizing quality gates.

    All checks are categorized by the AI character flaw they detect.
    Language is an implementation detail, not an organizing principle.
    """

    # Flaw-based categories
    OVERCONFIDENCE = ("overconfidence", "💯", "Overconfidence")
    DECEPTIVENESS = ("deceptiveness", "🎭", "Deceptiveness")
    LAZINESS = ("laziness", "🦥", "Laziness")
    MYOPIA = ("myopia", "👓", "Myopia")

    # Other categories
    GENERAL = ("general", "🔧", "General")

    def __init__(self, key: str, emoji: str, display_name: str):
        self.key = key
        self.emoji = emoji
        self._display_name = display_name

    @property
    def display(self) -> str:
        return f"{self.emoji} {self._display_name}"

    @property
    def display_name(self) -> str:
        """Human-readable category name."""
        return self._display_name

    @classmethod
    def from_key(cls, key: str) -> Optional["GateCategory"]:
        """Get category by key string (e.g. 'laziness' -> LAZINESS)."""
        for cat in cls:
            if cat.key == key:
                return cat
        return None


@dataclass
class ConfigField:
    """Definition of a configuration field for a check.

    The ``permissiveness`` attribute is used by the gate-dodging check
    to determine whether a config change makes a gate *more* permissive.
    Possible values:

    - ``"higher_is_stricter"`` — higher numeric/alpha value = stricter
    - ``"lower_is_stricter"``  — lower numeric/alpha value = stricter
    - ``"fewer_is_stricter"``  — fewer list items = stricter
    - ``"more_is_stricter"``   — more list items = stricter
    - ``"fail_is_stricter"``   — severity hierarchy: fail > warn
    - ``"true_is_stricter"``   — boolean True = stricter
    - ``None`` — neutral / not a strictness knob (default)
    """

    name: str
    field_type: str  # "boolean", "integer", "string", "string[]"
    default: Any
    description: str = ""
    required: bool = False
    min_value: Optional[int] = None  # For integers
    max_value: Optional[int] = None  # For integers
    choices: Optional[List[str]] = None  # For enums
    permissiveness: Optional[str] = None  # See class docstring


# Standard config fields that all gates have
STANDARD_CONFIG_FIELDS = [
    ConfigField(
        name="enabled",
        field_type="boolean",
        default=False,
        description="Whether this gate is enabled",
        permissiveness="true_is_stricter",
    ),
    ConfigField(
        name="auto_fix",
        field_type="boolean",
        default=False,
        description="Automatically fix issues when possible",
    ),
    ConfigField(
        name="config_file_path",
        field_type="string",
        default=None,
        description="Path to tool's native config file (e.g., pytest.ini, .bandit)",
        required=False,
    ),
]


class BaseCheck(ABC):
    """Abstract base class for all quality gate checks.

    Subclasses must implement:
    - name: Unique identifier for the check (e.g., 'lint-format')
    - display_name: Human-readable name with emoji
    - category: GateCategory for this check
    - is_applicable(): Whether check applies to current project
    - run(): Execute the check and return result

    Optional overrides:
    - tool_context: ToolContext declaring how tools are resolved
    - depends_on: List of check names this depends on
    - config_schema: Additional config fields beyond standard ones
    - can_auto_fix(): Whether issues can be auto-fixed
    - auto_fix(): Attempt to fix issues automatically
    """

    # Default tool context — subclasses SHOULD override.  PURE is the safest
    # default because it makes no assumptions about tool availability.
    tool_context: ClassVar[ToolContext] = ToolContext.PURE

    # Default gate level — subclasses override to SCOUR for gates that
    # only run during thorough validation (PR readiness, CI).
    level: ClassVar[GateLevel] = GateLevel.SWAB

    def __init__(
        self, config: Dict[str, Any], runner: Optional[SubprocessRunner] = None
    ):
        """Initialize the check.

        Args:
            config: Configuration dictionary for this check
            runner: Subprocess runner to use (default: global runner)
        """
        self.config = config
        self._runner = runner or get_runner()

    @property
    @abstractmethod
    def name(self) -> str:
        """Unique identifier for this check.

        This should be a lowercase, hyphenated string like 'lint-format'.
        Note: Do NOT include the language prefix - that comes from category.
        """

    @property
    @abstractmethod
    def display_name(self) -> str:
        """Human-readable display name with emoji.

        Example: '🎨 Lint & Format (black, isort, flake8)'
        """

    @property
    @abstractmethod
    def category(self) -> GateCategory:
        """The language/type category for this check.

        Returns:
            GateCategory enum value (PYTHON, JAVASCRIPT, or flaw-based category)
        """

    @property
    @abstractmethod
    def flaw(self) -> Flaw:
        """The AI character flaw this check catches.

        Returns:
            Flaw enum value (OVERCONFIDENCE, DECEPTIVENESS, LAZINESS, MYOPIA)
        """

    @property
    def gate_description(self) -> str:
        """One-line description of what this gate does, for README tables.

        This is the single source of truth for the "What It Does" column
        in auto-generated gate tables.  Override in each check to provide
        a concise, emoji-prefixed summary.

        Defaults to ``display_name`` if not overridden.
        """
        return self.display_name

    @property
    def full_name(self) -> str:
        """Full name including category prefix.

        Returns:
            String like 'laziness:sloppy-formatting.py'
        """
        return f"{self.category.key}:{self.name}"

    @property
    def depends_on(self) -> List[str]:
        """List of check names this check depends on.

        Override to specify dependencies. Dependent checks run after their
        dependencies complete successfully.
        """
        return []

    @property
    def superseded_by(self) -> Optional[str]:
        """Full name of check that supersedes this one.

        If another check fully encompasses this check's functionality,
        return its full name (e.g., 'security:full'). This prevents
        recommending a subset check when its superset is already running.

        Returns:
            Full name of superseding check, or None if not superseded
        """
        return None

    @property
    def config_schema(self) -> List[ConfigField]:
        """Additional configuration fields for this check.

        Override to add check-specific config fields beyond the standard ones
        (enabled, auto_fix). Standard fields are automatically included.

        Returns:
            List of ConfigField definitions
        """
        return []

    def get_full_config_schema(self) -> List[ConfigField]:
        """Get complete config schema including standard fields.

        Returns:
            List of all ConfigField definitions (standard + check-specific)
        """
        return STANDARD_CONFIG_FIELDS + self.config_schema

    @abstractmethod
    def is_applicable(self, project_root: str) -> bool:
        """Return True if this check applies to the given project.

        Args:
            project_root: Path to project root directory

        Returns:
            True if check should run, False to skip
        """

    def skip_reason(self, project_root: str) -> str:
        """Return reason why this check is not applicable.

        Called when is_applicable returns False to provide a human-readable
        explanation for why the check was skipped.

        Default implementation provides a generic message based on check type.
        Override for more specific skip reasons.

        Args:
            project_root: Path to project root directory

        Returns:
            Human-readable skip reason
        """
        # Default implementation provides a generic message
        return "Not applicable to this project"

    @abstractmethod
    def run(self, project_root: str) -> CheckResult:
        """Execute the check and return result.

        Args:
            project_root: Path to project root directory

        Returns:
            CheckResult with status, output, and any error info
        """

    def can_auto_fix(self) -> bool:
        """Return True if this check can automatically fix issues.

        Override to enable auto-fix capability.
        """
        return False

    def auto_fix(self, project_root: str) -> bool:
        """Attempt to automatically fix issues.

        Args:
            project_root: Path to project root directory

        Returns:
            True if fix was successful, False otherwise
        """
        return False

    def _create_result(
        self,
        status: CheckStatus,
        duration: float,
        output: str = "",
        error: Optional[str] = None,
        fix_suggestion: Optional[str] = None,
        auto_fixed: bool = False,
        status_detail: Optional[str] = None,
    ) -> CheckResult:
        """Helper to create a CheckResult for this check.

        Args:
            status: Check status
            duration: Execution time in seconds
            output: Check output
            error: Error message if failed
            fix_suggestion: Suggested fix for failures
            auto_fixed: Whether issues were auto-fixed

        Returns:
            CheckResult instance
        """
        return CheckResult(
            name=self.full_name,
            status=status,
            duration=duration,
            output=output,
            error=error,
            fix_suggestion=fix_suggestion,
            auto_fixed=auto_fixed,
            category=self.category.key if self.category else None,
            status_detail=status_detail,
        )

    def _run_command(
        self,
        command: List[str],
        cwd: Optional[str] = None,
        timeout: Optional[int] = None,
        env: Optional[Dict[str, str]] = None,
    ) -> SubprocessResult:
        """Run a command using the subprocess runner.

        When the first element is a bare executable name (not an absolute
        path), it is resolved via find_tool() using cwd as the project root.
        This ensures the project's own tools (from its venv) take priority
        over sm's own bundled dependencies — critical when sm is installed
        via pipx, where bundled pytest won't have framework-specific plugins
        (pytest-django, pytest-asyncio, etc.) that the project relies on.

        Args:
            command: Command to run
            cwd: Working directory (also used as project root for tool lookup)
            timeout: Timeout in seconds
            env: Optional environment variables for the subprocess

        Returns:
            SubprocessResult
        """
        if command and cwd and not Path(command[0]).is_absolute():
            resolved = find_tool(command[0], cwd)
            if resolved:
                command = [resolved, *command[1:]]
        return self._runner.run(command, cwd=cwd, timeout=timeout, env=env)


class PythonCheckMixin:
    """Mixin for Python-specific check utilities."""

    # Class-level cache for venv warning (only warn once per project_root)
    _venv_warning_shown: set[str] = set()
    # Cache resolved Python path per project_root
    _python_cache: dict[str, str] = {}

    def _find_python_in_venv(self, venv_path: Path) -> Optional[str]:
        """Find Python executable in a venv directory (Unix or Windows)."""
        for subpath in ["bin/python", "Scripts/python.exe"]:
            python_path = venv_path / subpath
            if python_path.exists():
                return str(python_path)
        return None

    def _cache_and_return(self, project_root: str, python_path: str) -> str:
        """Cache and return the Python path."""
        PythonCheckMixin._python_cache[project_root] = python_path
        return python_path

    def _get_python_version(self, python_path: str) -> str:
        """Get Python version string, or 'unknown version' on error."""
        try:
            result = subprocess.run(
                [python_path, "--version"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            return result.stdout.strip()
        except Exception:
            return "unknown version"

    def has_project_venv(self, project_root: str) -> bool:
        """Check if the project has a discoverable virtual environment.

        Returns True if any of these exist:
        1. project_root/venv/
        2. project_root/.venv/
        3. VIRTUAL_ENV environment variable is set
        """
        root = Path(project_root)
        for venv_dir in ["venv", ".venv"]:
            if (root / venv_dir / "bin" / "python").exists():
                return True
            if (root / venv_dir / "Scripts" / "python.exe").exists():
                return True
        if os.environ.get("VIRTUAL_ENV"):
            venv_path = Path(os.environ["VIRTUAL_ENV"])
            if (venv_path / "bin" / "python").exists():
                return True
            if (venv_path / "Scripts" / "python.exe").exists():
                return True
        return False

    @staticmethod
    def suggest_venv_command(project_root: str) -> str:
        """Suggest the right venv creation command for this project.

        Detects the project's package manager and returns an actionable
        command string.  The user can copy-paste it directly.
        """
        root = Path(project_root)

        # Poetry
        if (root / "poetry.lock").exists():
            return "poetry install"
        # Pipenv
        if (root / "Pipfile").exists():
            return "pipenv install --dev"
        # PDM
        if (root / "pdm.lock").exists():
            return "pdm install"
        # Standard: pyproject.toml or requirements.txt
        if (root / "pyproject.toml").exists():
            return "python3 -m venv venv && source venv/bin/activate && pip install -e '.[dev]'"
        if (root / "requirements.txt").exists():
            return "python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt"
        # Bare minimum
        return "python3 -m venv venv && source venv/bin/activate"

    def check_project_venv_or_warn(
        self, project_root: str, start_time: float
    ) -> Optional[CheckResult]:
        """Return a WARNED result when no project venv is found.

        PROJECT-context checks should call this at the top of ``run()``.
        If a venv *does* exist, returns ``None`` so the caller can
        continue with normal execution.

        Usage::

            result = self.check_project_venv_or_warn(project_root, start_time)
            if result is not None:
                return result
        """
        import time

        if not self.has_project_venv(project_root):
            return self._create_result(  # type: ignore[attr-defined]
                status=CheckStatus.WARNED,
                duration=time.time() - start_time,
                error="No project virtual environment found",
                fix_suggestion=(
                    "Create a venv so this check can run against your project:\n"
                    f"  cd {project_root} && {self.suggest_venv_command(project_root)}"
                ),
            )
        return None

    def get_project_python(self, project_root: str) -> str:
        """Get the Python executable for the project.

        Uses stepped fallback (project-local venvs prioritized):
        1. ./venv/bin/python or ./.venv/bin/python (project-local - highest priority)
        2. VIRTUAL_ENV environment variable (if no project venv exists)
        3. sys.executable (slop-mop's Python - has all bundled tools)
        4. python3/python in PATH (system Python - last resort)

        Warnings are logged once per project when:
        - Using project venv while VIRTUAL_ENV points to a different venv
        - Falling back to system Python or sys.executable (non-venv)
        """
        if project_root in PythonCheckMixin._python_cache:
            return PythonCheckMixin._python_cache[project_root]

        import os
        import shutil
        import sys

        root = Path(project_root)
        should_warn = project_root not in PythonCheckMixin._venv_warning_shown

        # PRIORITY 1: Check common venv locations in project FIRST
        for venv_dir in ["venv", ".venv"]:
            python_path = self._find_python_in_venv(root / venv_dir)
            if python_path:
                # Warn if VIRTUAL_ENV is set to a different location
                virtual_env = os.environ.get("VIRTUAL_ENV")
                if virtual_env and should_warn:
                    project_venv_path = (root / venv_dir).resolve()
                    virtual_env_path = Path(virtual_env).resolve()
                    if project_venv_path != virtual_env_path:
                        logger.warning(
                            f"⚠️  Using project venv: {project_venv_path}\n"
                            f"   VIRTUAL_ENV is set to: {virtual_env_path}\n"
                            "   This is intentional - project venvs take priority."
                        )
                        PythonCheckMixin._venv_warning_shown.add(project_root)
                return self._cache_and_return(project_root, python_path)

        # PRIORITY 2: Fall back to VIRTUAL_ENV if no project venv exists
        virtual_env = os.environ.get("VIRTUAL_ENV")
        if virtual_env:
            python_path = self._find_python_in_venv(Path(virtual_env))
            if python_path:
                if should_warn:
                    logger.warning(
                        f"⚠️  No project venv found. Using VIRTUAL_ENV: {virtual_env}\n"
                        "   Consider creating ./venv or ./.venv with project dependencies."
                    )
                    PythonCheckMixin._venv_warning_shown.add(project_root)
                return self._cache_and_return(project_root, python_path)
            if should_warn:
                logger.warning(
                    f"VIRTUAL_ENV={virtual_env} set but no Python found there. "
                    "Continuing with fallback detection."
                )

        # No venv found - mark as warned and try sm's own Python
        if should_warn:
            PythonCheckMixin._venv_warning_shown.add(project_root)

        # PRIORITY 3: slop-mop's own Python (has all bundled tools: pip-audit,
        # bandit, detect-secrets, etc.).  Preferred over bare system Python which
        # almost certainly does NOT have these modules installed.
        if sys.executable:
            if should_warn:
                version = self._get_python_version(sys.executable)
                logger.warning(
                    f"⚠️  No virtual environment found in {project_root}. "
                    f"Using slop-mop's Python: {sys.executable} ({version}). "
                    "Security scans will work, but pip-audit will only audit "
                    "packages installed in slop-mop's environment. "
                    "Consider creating a venv with project dependencies."
                )
            return self._cache_and_return(project_root, sys.executable)

        # PRIORITY 4: system Python in PATH (last resort)
        for python_name in ["python3", "python"]:
            system_python = shutil.which(python_name)
            if system_python:
                if should_warn:
                    version = self._get_python_version(system_python)
                    logger.warning(
                        f"⚠️  No Python found for slop-mop. "
                        f"Falling back to system: {system_python} ({version}). "
                        "Security tools (pip-audit, bandit) may not be available."
                    )
                return self._cache_and_return(project_root, system_python)

        # Nothing found at all
        if should_warn:
            logger.warning(
                "⚠️  No Python found anywhere. Security checks will fail. "
                "Create a venv in your project or install Python."
            )
        return self._cache_and_return(project_root, "python3")

    def _python_execution_failed_hint(self) -> str:
        """Return helpful hint text for Python execution failures.

        Use this in fix_suggestion when a Python tool fails to run.
        """
        return (
            "If this is a 'python not found' or 'module not found' error, "
            "ensure your project has a venv/ or .venv/ directory with dependencies "
            "installed. slop-mop will auto-detect and use it."
        )

    def has_python_files(self, project_root: str) -> bool:
        """Check if project has Python files."""
        root = Path(project_root)
        return any(root.rglob("*.py"))

    def has_setup_py(self, project_root: str) -> bool:
        """Check if project has setup.py."""
        return (Path(project_root) / "setup.py").exists()

    def has_pyproject_toml(self, project_root: str) -> bool:
        """Check if project has pyproject.toml."""
        return (Path(project_root) / "pyproject.toml").exists()

    def has_requirements_txt(self, project_root: str) -> bool:
        """Check if project has requirements.txt."""
        return (Path(project_root) / "requirements.txt").exists()

    def is_python_project(self, project_root: str) -> bool:
        """Check if this is a Python project."""
        return (
            self.has_python_files(project_root)
            or self.has_setup_py(project_root)
            or self.has_pyproject_toml(project_root)
            or self.has_requirements_txt(project_root)
        )

    def skip_reason(self, project_root: str) -> str:
        """Return reason for skipping Python checks."""
        if not self.has_python_files(project_root):
            return "No Python files found"
        if not (
            self.has_setup_py(project_root)
            or self.has_pyproject_toml(project_root)
            or self.has_requirements_txt(project_root)
        ):
            return "No Python project markers (setup.py, pyproject.toml, or requirements.txt)"
        return "Python check not applicable"

    def measure_scope(self, project_root: str) -> Optional[ScopeInfo]:
        """Measure scope for Python checks — counts .py files and LOC.

        Uses include_dirs from config if available, otherwise scans
        the entire project root.
        """
        config = getattr(self, "config", {})
        include_dirs = config.get("include_dirs") or config.get("src_dirs")
        include_list = list(include_dirs) if include_dirs else None
        return count_source_scope(
            project_root, include_dirs=include_list, extensions={".py"}
        )


class JavaScriptCheckMixin:
    """Mixin for JavaScript-specific check utilities."""

    def has_package_json(self, project_root: str) -> bool:
        """Check if project has package.json."""
        return (Path(project_root) / "package.json").exists()

    def has_js_files(self, project_root: str) -> bool:
        """Check if project has JavaScript files."""
        root = Path(project_root)
        return any(root.rglob("*.js")) or any(root.rglob("*.ts"))

    def is_javascript_project(self, project_root: str) -> bool:
        """Check if this is a JavaScript project.

        Requires package.json at project root — scattered .js files
        (e.g., vendored tools) don't constitute a JS project we can lint.
        """
        return self.has_package_json(project_root)

    def has_node_modules(self, project_root: str) -> bool:
        """Check if node_modules exists."""
        return (Path(project_root) / "node_modules").is_dir()

    def skip_reason(self, project_root: str) -> str:
        """Return reason for skipping JavaScript checks."""
        if not self.has_package_json(project_root):
            return "No package.json found (not a JavaScript/TypeScript project)"
        if not self.has_js_files(project_root):
            return "No JavaScript/TypeScript files found"
        return "JavaScript check not applicable"

    def _detect_package_manager(self, project_root: str) -> str:
        """Detect which package manager the project uses.

        Resolution order: pnpm-lock.yaml → yarn.lock → package-lock.json → npm
        """
        root = Path(project_root)
        if (root / "pnpm-lock.yaml").exists():
            return "pnpm"
        if (root / "yarn.lock").exists():
            return "yarn"
        return "npm"

    def _get_npm_install_command(self, project_root: str) -> List[str]:
        """Build install command with the correct package manager.

        Detects pnpm/yarn/npm from lockfiles and uses the appropriate
        install command. Falls back to npm if no lockfile found.
        Available to all JavaScript checks via the mixin.
        """
        pm = self._detect_package_manager(project_root)

        if pm == "pnpm":
            return ["pnpm", "install", "--no-frozen-lockfile"]
        if pm == "yarn":
            return ["yarn", "install", "--ignore-engines"]

        # npm path
        cmd = ["npm", "install"]

        # Add flags from config (handle string or list)
        # Uses self.config from BaseCheck
        config_flags = getattr(self, "config", {}).get("npm_install_flags", [])
        if isinstance(config_flags, str):
            config_flags = [config_flags]
        cmd.extend(config_flags)

        # Check .npmrc for legacy-peer-deps
        npmrc_path = Path(project_root) / ".npmrc"
        if npmrc_path.exists():
            try:
                content = npmrc_path.read_text()
                # Parse line by line, ignoring comments (# and ;)
                for line in content.splitlines():
                    line = line.strip()
                    # Skip comment lines
                    if line.startswith("#") or line.startswith(";"):
                        continue
                    # Check for active legacy-peer-deps setting
                    if (
                        "legacy-peer-deps=true" in line
                        and "--legacy-peer-deps" not in cmd
                    ):
                        cmd.append("--legacy-peer-deps")
                        break
            except Exception:
                pass  # Ignore .npmrc read errors

        return cmd

    def measure_scope(self, project_root: str) -> Optional[ScopeInfo]:
        """Measure scope for JavaScript checks — counts JS/TS files and LOC.

        Uses include_dirs from config if available, otherwise scans
        the entire project root.
        """
        config = getattr(self, "config", {})
        include_dirs = config.get("include_dirs") or config.get("src_dirs")
        include_list = list(include_dirs) if include_dirs else None
        return count_source_scope(
            project_root,
            include_dirs=include_list,
            extensions={".js", ".ts", ".jsx", ".tsx"},
        )
