"""Shared base for plugins backed by a command-line tool.

Provides the ``tool_name()`` / ``is_available()`` / ``tool_version()``
triple so that ``Environment``, ``ProjectEnvironment``, and
``ScmEnvironment`` share a single implementation instead of duplicating
the ``shutil.which`` logic.

The three async helper instance methods — ``_run_json_command``,
``_run_text_command``, and ``_run_bool_command`` — use native
``asyncio.create_subprocess_exec`` so that plugin I/O never blocks
the event loop.  Each method applies ``self._transport`` to
transform command arguments and working directories before
launching the subprocess.
"""

import asyncio
import json
import logging
import re
import shutil
import subprocess
from pathlib import Path
from typing import Any, Self

from packaging.version import InvalidVersion, Version

from porringer.core.path import ensure_system_path
from porringer.core.plugin_schema.runtime import RuntimeConsumer, RuntimeContext
from porringer.core.schema import Plugin, PluginParameters
from porringer.core.transport import LocalTransport, Transport


class ToolBasedPlugin(Plugin):
    """Intermediate base for plugins backed by a CLI tool.

    Subclasses that override `tool_name()` to return a non-`None`
    string get automatic availability detection via `shutil.which`.
    Subclasses with `tool_name() → None` (the default) are always
    considered available.
    """

    def with_transport(self, transport: Transport) -> Self:
        """Create a new instance of this plugin using a different transport."""
        parameters = PluginParameters(distribution=self._distribution, transport=transport)
        return type(self)(parameters)

    @classmethod
    def tool_name(cls) -> str | None:
        """Return the CLI executable name this plugin wraps.

        Override to declare which command-line tool the plugin uses.
        The base `is_available()` implementation uses this value
        with `shutil.which` to test whether the tool is on PATH.

        Returns `None` for plugins that are not backed by a single CLI
        tool (the default).  Those plugins are always considered available.
        """
        return None

    @classmethod
    def is_available(cls) -> bool:
        """Check if the underlying tool is available on the system.

        On the first call, synchronizes the process ``PATH`` with
        the operating system's authoritative state (e.g. the Windows
        registry) so that tools installed after process startup are
        discoverable.  When ``tool_name()`` returns a string,
        ``shutil.which`` is used to verify the executable exists on
        PATH.  When ``tool_name()`` returns ``None``, the plugin is
        always considered available.
        """
        ensure_system_path()

        name = cls.tool_name()
        if name is None:
            return True
        return shutil.which(name) is not None

    def query_availability(self, runtime_context: RuntimeContext | None = None) -> bool:
        """Unified availability check respecting platform, PATH, runtime context, and transport.

        Encapsulates the full decision tree so that every call-site
        (``list_packages``, ``build_plugin_info``, ``BackendResolver``,
        ``_plugins_discovered_event``) shares one implementation:

        1. ``is_supported()`` — reject unsupported platforms immediately.
        2. When a non-local transport is active, delegate to
           ``self._transport.check_tool()``.
        3. When *runtime_context* is provided **and** the plugin is a
           ``RuntimeConsumer``, delegate to
           ``is_available_for(runtime_context)`` which can probe the
           *target* interpreter (e.g. ``python -m pip`` via pim).
        4. Otherwise fall back to the PATH-based ``is_available()``.

        Args:
            runtime_context: Resolved runtime paths for this execution
                run.  ``None`` means use PATH-only detection.

        Returns:
            ``True`` if the plugin can operate in the current context.
        """
        try:
            if not type(self).is_supported():
                return False
            if not isinstance(self._transport, LocalTransport):
                name = type(self).tool_name()
                return name is None or self._transport.check_tool(name)
            if runtime_context is not None and isinstance(self, RuntimeConsumer):
                return self.is_available_for(runtime_context)
            return self.is_available()
        except Exception:
            logging.getLogger(__name__).warning(
                "Availability check failed for plugin '%s'",
                type(self).__name__,
                exc_info=True,
            )
            return False

    def tool_version(self) -> Version | None:
        """Returns the PEP 440 version of the underlying CLI tool.

        The default implementation runs `<tool_name> --version`, extracts the
        first version-like pattern from the combined stdout/stderr output, and
        parses it as a `Version`.

        The command arguments are transformed by ``self._transport`` so
        that version checks work through WSL, Docker, or other transports.

        Returns `None` when `tool_name()` is `None`, the subprocess
        fails, or the output cannot be parsed as a valid PEP 440 version.

        Subclasses may override this method if their tool's version output
        requires special parsing.

        Returns:
            The parsed tool version, or `None`.
        """
        name = type(self).tool_name()
        if name is None:
            return None

        try:
            args = self._transport.transform_args([name, '--version'])
            result = subprocess.run(
                args,
                capture_output=True,
                text=True,
                timeout=10,
                check=False,
            )
            output = result.stdout + result.stderr
        except OSError, subprocess.SubprocessError:
            return None

        match = re.search(r'v?\d+\.\d+(?:\.\d+)*', output)
        if match is None:
            return None

        try:
            return Version(match.group(0))
        except InvalidVersion:
            return None

    async def _run_json_command(self, args: list[str], *, check: bool = False) -> Any | None:
        """Run a CLI command and parse its stdout as JSON.

        Uses ``asyncio.create_subprocess_exec`` so the event loop is
        never blocked by subprocess I/O.  Command arguments are
        transformed by ``self._transport`` before launching.

        Centralises the common pattern of running a subprocess, reading
        its standard output, and parsing it as JSON while handling the
        three failure modes every plugin must deal with:

        * ``FileNotFoundError`` — the tool is not on PATH.
        * ``OSError`` / ``TimeoutError`` — the tool failed or timed out.
        * ``json.JSONDecodeError`` — the output was not valid JSON.

        When *check* is ``True``, a non-zero exit code is treated as an
        error and ``None`` is returned.

        Args:
            args: Command and arguments (e.g. ``['npm', 'ls', '-g', '--json']``).
            check: Whether to treat a non-zero exit code as an error.

        Returns:
            The parsed JSON value (dict, list, etc.), or ``None`` when
            the command cannot be executed or its output is not valid
            JSON.
        """
        logger = logging.getLogger(f'porringer.{type(self).tool_name()}.json_command')
        transformed = self._transport.transform_args(args)
        try:
            proc = await asyncio.create_subprocess_exec(
                *transformed,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=30)
            stdout = stdout_bytes.decode('utf-8', errors='replace') if stdout_bytes else ''
            if proc.returncode != 0:
                if check:
                    stderr = stderr_bytes.decode('utf-8', errors='replace') if stderr_bytes else ''
                    logger.error('%s exited with code %d: %s', args[0], proc.returncode, stderr)
                else:
                    logger.warning('%s exited with code %d', args[0], proc.returncode)
                return None
            return json.loads(stdout) if stdout.strip() else None
        except FileNotFoundError:
            logger.warning('%s not found on PATH', args[0])
        except (OSError, TimeoutError) as e:
            logger.error('Failed to run %s: %s', args[0], e)
        except json.JSONDecodeError as e:
            logger.warning('Could not parse JSON output from %s: %s', args[0], e)
        return None

    async def _run_text_command(self, args: list[str], *, check: bool = False) -> str | None:
        """Run a CLI command and return its stdout as text.

        Uses ``asyncio.create_subprocess_exec`` so the event loop is
        never blocked by subprocess I/O.  Command arguments are
        transformed by ``self._transport`` before launching.

        Centralises the common pattern of running a subprocess and
        returning its standard output while handling failure modes:

        * ``FileNotFoundError`` — the tool is not on PATH.
        * ``OSError`` / ``TimeoutError`` — the tool failed or timed out.

        When *check* is ``True``, a non-zero exit code is treated as
        an error and ``None`` is returned.

        Args:
            args: Command and arguments.
            check: Whether to treat a non-zero exit code as an error.

        Returns:
            The stdout string, or ``None`` on failure.
        """
        logger = logging.getLogger(f'porringer.{type(self).tool_name()}.text_command')
        transformed = self._transport.transform_args(args)
        try:
            proc = await asyncio.create_subprocess_exec(
                *transformed,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            stdout_bytes, _stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=30)
            stdout = stdout_bytes.decode('utf-8', errors='replace') if stdout_bytes else ''
            if proc.returncode != 0:
                if check:
                    logger.error('%s exited with code %d', args[0], proc.returncode)
                else:
                    logger.warning('%s exited with code %d', args[0], proc.returncode)
                return None
            return stdout
        except FileNotFoundError:
            logger.warning('%s not found on PATH', args[0])
        except (OSError, TimeoutError) as e:
            logger.error('Failed to run %s: %s', args[0], e)
        return None

    async def _run_bool_command(
        self,
        args: list[str],
        *,
        cwd: Path | None = None,
        label: str = 'command',
    ) -> bool:
        """Run a CLI command and return whether it succeeded.

        Uses ``asyncio.create_subprocess_exec`` so the event loop is
        never blocked by subprocess I/O.  Command arguments and the
        working directory are transformed by ``self._transport``
        before launching.

        Logs stdout at info level and stderr at error level on failure.
        Returns ``True`` when the process exits with code 0.

        Args:
            args: Command and arguments.
            cwd: Working directory for the subprocess.
            label: Context label used in the logger name and error messages.

        Returns:
            ``True`` if the process exited cleanly, ``False`` otherwise.
        """
        logger = logging.getLogger(f'porringer.{type(self).tool_name()}.{label}')
        transformed = self._transport.transform_args(args)
        transformed_cwd = self._transport.transform_cwd(cwd)
        try:
            proc = await asyncio.create_subprocess_exec(
                *transformed,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=transformed_cwd,
            )
            stdout_bytes, stderr_bytes = await asyncio.wait_for(proc.communicate(), timeout=300)
            stdout = stdout_bytes.decode('utf-8', errors='replace') if stdout_bytes else ''
            logger.info(stdout)
            if proc.returncode != 0:
                stderr = stderr_bytes.decode('utf-8', errors='replace') if stderr_bytes else ''
                logger.error(stderr)
                return False
        except FileNotFoundError:
            logger.warning('%s not found on PATH', args[0])
            return False
        except (OSError, TimeoutError) as e:
            logger.error('Failed to run %s: %s', label, e)
            return False
        return True
