"""Plugin implementation"""

import asyncio
import json
import logging
import re
import shutil
import subprocess
from collections.abc import Callable
from pathlib import Path
from typing import override

from porringer.core.plugin_schema.environment import (
    PackageParameters,
)
from porringer.core.plugin_schema.python_environment import PythonEnvironment
from porringer.core.schema import Package, PackageRef, PluginKind, PluginParameters
from porringer.schema import SetupAction, SubActionProgress
from porringer.utility.utility import run_command

# Regex patterns for parsing pip output
_DOWNLOADING_PATTERN = re.compile(
    r'Downloading\s+(\S+)\s+\(([^)]+)\)',
)
_DOWNLOAD_PROGRESS_PATTERN = re.compile(
    r'(\d+(?:\.\d+)?)\s*[kMG]?B.*?(\d+)%',
)
_INSTALLING_PATTERN = re.compile(
    r'Installing collected packages?:\s*(.*)',
)
_ALREADY_SATISFIED_PATTERN = re.compile(
    r'Requirement already satisfied',
)


class PipEnvironment(PythonEnvironment):
    """Represents a Python environment managed by pip.

    Provides methods to install, search, uninstall, upgrade, and list Python packages using pip
    as the backend package manager.

    This plugin can optionally use a Python runtime provider (like pim) for managing
    the underlying Python installation.
    """

    def __init__(self, parameters: PluginParameters) -> None:
        """Initializes the pip environment plugin.

        Args:
            parameters: Plugin parameters including distribution info
        """
        super().__init__(parameters)
        self._cached_packages: list[Package] | None = None
        self._cached_python: str | None = None

    @classmethod
    @override
    def tool_name(cls) -> str:
        """Pip wraps the `pip` CLI."""
        return 'pip'

    @classmethod
    @override
    def is_available(cls) -> bool:
        """Checks if pip is usable.

        Pip can be invoked either as a standalone `pip` executable or
        via `python -m pip`.  A standalone `pip` may be absent in
        uv-created virtual environments even though `python -m pip`
        works perfectly.  This override accepts either form so that the
        plugin is not incorrectly reported as unavailable.

        Returns:
            True if `pip` or `python` is found on PATH, False otherwise.
        """
        return shutil.which('pip') is not None or shutil.which('python') is not None

    @override
    def install_command(self, package: PackageRef) -> list[str]:
        """Returns the CLI command to install a package via pip."""
        return [self.python_command, '-m', 'pip', 'install', package.specifier]

    @override
    def upgrade_command(self, package: PackageRef) -> list[str]:
        """Returns the CLI command to upgrade a package via pip."""
        return [
            self.python_command,
            '-m',
            'pip',
            'install',
            '--upgrade',
            package.specifier,
        ]

    @staticmethod
    @override
    def supports_parallel() -> bool:
        """Pip does not support parallel installs safely due to potential conflicts."""
        return False

    @override
    async def async_install(self, params: PackageParameters) -> Package | None:
        """Asynchronously installs the given package using pip.

        When a progress_callback is provided, streams stderr line-by-line to
        report download and install phases. Otherwise falls back to the simple
        `run_command` path for zero overhead.
        """
        logger = logging.getLogger('porringer.pip.install')
        args = list(self.install_command(params.package))
        if params.dry:
            args.append('--dry-run')

        if params.progress_callback is None:
            # Fast path — no streaming needed
            return await self._async_install_simple(args, params.package, logger)

        return await self._async_install_with_progress(args, params, logger)

    @staticmethod
    async def _async_install_simple(args: list[str], package: PackageRef, logger: logging.Logger) -> Package | None:
        """Install without progress streaming."""
        try:
            result = await run_command(args)
            logger.info(result.stdout)
            if result.returncode != 0:
                logger.error(result.stderr)
                return None
        except TimeoutError:
            logger.error(f'Timeout installing {package.name}')
            return None
        except Exception as e:
            logger.error(f'Failed to install {package.name}: {e}')
            return None
        return Package(name=package.name, version=None)

    @staticmethod
    async def _async_install_with_progress(
        args: list[str],
        params: PackageParameters,
        logger: logging.Logger,
    ) -> Package | None:
        """Install with line-by-line stderr streaming for progress reporting."""
        assert params.progress_callback is not None  # guaranteed by caller

        action = SetupAction(
            description=f'Install {params.package.specifier}',
            kind=PluginKind.PACKAGE,
            installer='pip',
            package=params.package,
        )

        try:
            process = await asyncio.create_subprocess_exec(
                *args,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
        except FileNotFoundError:
            logger.error(f'Python not found while installing {params.package.name}')
            return None

        # Report initial phase
        params.progress_callback(
            SubActionProgress(
                action=action,
                phase='resolving',
                progress=None,
                message=f'Resolving {params.package.specifier}...',
            )
        )

        stderr_lines: list[str] = []
        stdout_data = b''

        async def read_stdout() -> None:
            nonlocal stdout_data
            assert process.stdout is not None
            stdout_data = await process.stdout.read()

        async def read_stderr_lines() -> None:
            assert process.stderr is not None
            assert params.progress_callback is not None
            async for raw_line in process.stderr:
                line = raw_line.decode('utf-8', errors='replace').rstrip()
                stderr_lines.append(line)

                # Emit raw output line for log panel display
                params.progress_callback(
                    SubActionProgress(
                        action=action,
                        phase='running',
                        output=line,
                        stream='stderr',
                    )
                )

                # Also emit parsed progress events for structured updates
                PipEnvironment._parse_progress_line(line, action, params.progress_callback)

        try:
            await asyncio.gather(read_stdout(), read_stderr_lines())
            await process.wait()
        except Exception as e:
            logger.error(f'Failed to install {params.package.name}: {e}')
            return None

        # Emit stdout lines as output events
        stdout_text = stdout_data.decode('utf-8', errors='replace')
        logger.info(stdout_text)
        for line in stdout_text.splitlines():
            stripped = line.rstrip()
            if stripped:
                params.progress_callback(
                    SubActionProgress(
                        action=action,
                        phase='running',
                        output=stripped,
                        stream='stdout',
                    )
                )

        if process.returncode != 0:
            logger.error('\n'.join(stderr_lines))
            return None

        # Report completion
        params.progress_callback(
            SubActionProgress(
                action=action,
                phase='done',
                progress=1.0,
                message=f'Installed {params.package.name}',
            )
        )

        return Package(name=params.package.name, version=None)

    @staticmethod
    def _parse_progress_line(
        line: str,
        action: SetupAction,
        callback: Callable[[SubActionProgress], None],
    ) -> None:
        """Parse a single pip stderr line and emit sub-action progress if relevant.

        This is extracted as a static method for testability.
        """
        # Downloading <url> (<size>)
        match = _DOWNLOADING_PATTERN.search(line)
        if match:
            url = match.group(1)
            size = match.group(2)
            filename = url.rsplit('/', 1)[-1].split('#')[0]
            callback(
                SubActionProgress(
                    action=action,
                    phase='downloading',
                    progress=0.0,
                    message=f'Downloading {filename} ({size})',
                )
            )
            return

        # Lines containing percentage (pip's download bar)
        match = _DOWNLOAD_PROGRESS_PATTERN.search(line)
        if match:
            pct = int(match.group(2))
            callback(
                SubActionProgress(
                    action=action,
                    phase='downloading',
                    progress=pct / 100.0,
                    message=None,
                )
            )
            return

        # Installing collected packages: ...
        match = _INSTALLING_PATTERN.search(line)
        if match:
            packages_str = match.group(1).strip()
            callback(
                SubActionProgress(
                    action=action,
                    phase='installing',
                    progress=None,
                    message=f'Installing {packages_str}',
                )
            )
            return

        # Requirement already satisfied
        if _ALREADY_SATISFIED_PATTERN.search(line):
            callback(
                SubActionProgress(
                    action=action,
                    phase='verifying',
                    progress=1.0,
                    message=line.strip(),
                )
            )
            return

    @override
    def packages(self, *, project_path: Path | None = None) -> list[Package]:
        """Gathers installed packages visible to the active Python.

        When *project_path* is provided, the method discovers the
        project's virtual environment (`<project_path>/.venv`) and
        lists packages from that interpreter.  Otherwise it falls back
        to the runtime-override or the system Python on PATH.

        Tries `python -m pip list --format=json` first.  If the pip
        module is not installed (common in uv-created virtual
        environments), falls back to `importlib.metadata`.

        The result is cached per effective interpreter path so that
        multiple calls within a single sync run don't shell out
        repeatedly.

        Args:
            project_path: Optional project directory.  When set, the
                listing is scoped to the project's `.venv`.

        Returns:
            A list of packages
        """
        # Determine the effective Python interpreter
        effective_python = self.python_command
        if project_path is not None:
            venv_python = self._discover_venv_python(project_path)
            if venv_python is not None:
                effective_python = str(venv_python)

        # Use cached result when the interpreter matches
        if self._cached_packages is not None and self._cached_python == effective_python:
            return self._cached_packages

        logger = logging.getLogger('porringer.pip.packages')

        # Try pip list first
        packages = self._list_packages_via_pip(logger, effective_python)
        if packages is not None:
            self._cached_packages = packages
            self._cached_python = effective_python
            return self._cached_packages

        # Fallback: importlib.metadata (works without pip module installed)
        logger.warning('pip module unavailable for %s, falling back to importlib.metadata', effective_python)
        packages = self._list_packages_via_importlib(logger, effective_python)
        if packages is not None:
            self._cached_packages = packages
            self._cached_python = effective_python
            return self._cached_packages

        self._cached_packages = []
        self._cached_python = effective_python
        return self._cached_packages

    @staticmethod
    def _list_packages_via_pip(logger: logging.Logger, python: str = 'python') -> list[Package] | None:
        """List packages using `python -m pip list --format=json`.

        Args:
            logger: Logger instance.
            python: Python interpreter command or path.

        Returns:
            A list of packages, or `None` if pip is not usable.
        """
        try:
            result = subprocess.run(
                [python, '-m', 'pip', 'list', '--format=json'],
                capture_output=True,
                text=True,
                check=True,
                timeout=30,
            )
            entries: list[dict[str, str]] = json.loads(result.stdout)
            return [
                Package(name=entry['name'], version=entry.get('version'))
                for entry in entries
                if entry.get('name') is not None
            ]
        except subprocess.CalledProcessError as e:
            logger.debug(f'pip list failed (pip module may not be installed): {e}')
            return None
        except (json.JSONDecodeError, KeyError) as e:
            logger.warning(f'Failed to parse pip package list: {e}')
            return []
        except FileNotFoundError:
            logger.warning('Python not found on PATH; cannot list pip packages')
            return []

    @staticmethod
    def _list_packages_via_importlib(logger: logging.Logger, python: str = 'python') -> list[Package] | None:
        """List packages using `importlib.metadata` via subprocess.

        This fallback works in any Python environment, even when the pip
        module is not installed (e.g. uv-created virtual environments).

        Args:
            logger: Logger instance.
            python: Python interpreter command or path.

        Returns:
            A list of packages, or `None` on failure.
        """
        script = (
            'import json, importlib.metadata; '
            'print(json.dumps([{"name": d.metadata.get("Name"), "version": d.version} '
            'for d in importlib.metadata.distributions() '
            'if d.metadata.get("Name") is not None]))'
        )
        try:
            result = subprocess.run(
                [python, '-c', script],
                capture_output=True,
                text=True,
                check=True,
                timeout=30,
            )
            entries: list[dict[str, str]] = json.loads(result.stdout)
            return [
                Package(name=entry['name'], version=entry.get('version'))
                for entry in entries
                if entry.get('name') is not None
            ]
        except subprocess.CalledProcessError as e:
            logger.warning(f'importlib.metadata fallback failed: {e}')
            return None
        except (json.JSONDecodeError, KeyError) as e:
            logger.warning(f'Failed to parse importlib.metadata output: {e}')
            return None
        except FileNotFoundError:
            logger.warning('Python not found on PATH; cannot list packages')
            return None
