"""Unified operation resolution for dry-run and real execution.

Determines the correct operation (install, upgrade, or skip) for a
given action based on the sync strategy and current system state.
Both the dry-run path and the real execution path delegate to
:func:`resolve_operation` so that strategy logic lives in one place.

Also hosts :func:`is_package_installed`, the shared presence-detection
helper used by resolution, dry-run, and execution paths.
"""

import asyncio
import logging
from dataclasses import dataclass
from enum import Enum, auto
from pathlib import Path

import httpx
from packaging.requirements import InvalidRequirement, Requirement
from packaging.utils import canonicalize_name
from packaging.version import InvalidVersion, Version

from porringer.core.plugin_schema.environment import CheckUpdatesParameters, Environment
from porringer.core.plugin_schema.plugin_manager import PluginManager, find_plugin_manager
from porringer.core.plugin_schema.project_environment import ProjectEnvironment
from porringer.core.plugin_schema.runtime import RuntimeContext
from porringer.core.schema import Package, PackageRef, PluginKind
from porringer.schema import (
    SetupAction,
    SetupActionResult,
    SkipReason,
    SyncStrategy,
)
from porringer.utility.exception import PluginError

logger = logging.getLogger(__name__)


class OperationKind(Enum):
    """The resolved operation to perform on a package."""

    INSTALL = auto()
    UPGRADE = auto()
    UNINSTALL = auto()
    SKIP = auto()


@dataclass(slots=True)
class ResolvedOperation:
    """Result of resolving what operation an action requires.

    Produced by :func:`resolve_operation` and consumed by both the
    dry-run reporter and the real execution engine.
    """

    action: SetupAction
    operation: OperationKind
    skip_reason: SkipReason | None = None
    message: str | None = None
    installed_version: str | None = None
    available_version: str | None = None
    plugin_manager: PluginManager | None = None
    """The resolved ``PluginManager`` for plugin-target actions, or
    ``None`` for normal package actions.  Cached here so the caller
    does not need to look it up again."""


def resolved_to_result(resolved: ResolvedOperation) -> SetupActionResult:
    """Map a :class:`ResolvedOperation` to a :class:`SetupActionResult`.

    This is the single mapping used by both the dry-run and real
    execution paths so that the translation lives in one place.

    For ``SKIP`` operations the result carries version metadata and
    the skip reason.  For ``INSTALL`` and ``UPGRADE`` operations
    the result reports success with an optional message.
    """
    if resolved.operation == OperationKind.SKIP:
        logger.debug('resolved to skip: reason=%s message=%s', resolved.skip_reason, resolved.message)
        return SetupActionResult(
            action=resolved.action,
            success=True,
            skipped=True,
            skip_reason=resolved.skip_reason,
            message=resolved.message,
            installed_version=resolved.installed_version,
            available_version=resolved.available_version,
        )

    return SetupActionResult(
        action=resolved.action,
        success=True,
        message=resolved.message,
    )


class PackageCache:
    """Per-phase cache for ``packages()`` / ``installed_plugins()`` results.

    Ensures each environment plugin's ``packages()`` method and each
    ``PluginManager``'s ``installed_plugins()`` method is called at
    most once per unique key, eliminating redundant subprocess or
    filesystem queries when many actions share the same installer.

    Thread-safe via per-key `asyncio.Lock` instances so concurrent
    ``TaskGroup`` tasks that hit the same installer serialise on
    the first query and share its result.
    """

    def __init__(self) -> None:
        """Initialise empty caches and lock registry."""
        self._packages: dict[str, list[Package]] = {}
        self._plugins: dict[str, list[Package]] = {}
        self._locks: dict[str, asyncio.Lock] = {}

    def _lock_for(self, key: str) -> asyncio.Lock:
        """Return (creating if needed) the lock for *key*."""
        return self._locks.setdefault(key, asyncio.Lock())

    async def get_packages(
        self,
        installer: str,
        environment: Environment,
        project_path: Path | None = None,
        runtime_context: RuntimeContext | None = None,
    ) -> list[Package]:
        """Return cached ``packages()`` result, querying on first access.

        The cache key is ``(installer, project_path)`` so that
        project-scoped queries are cached separately from global ones.

        Args:
            installer: The installer/plugin name.
            environment: The environment plugin instance.
            project_path: Optional project directory scope.
            runtime_context: Resolved runtime paths for this execution run.

        Returns:
            The list of installed packages.
        """
        key = f'pkg:{installer}:{project_path}'
        async with self._lock_for(key):
            if key not in self._packages:
                self._packages[key] = await environment.packages(
                    project_path=project_path, runtime_context=runtime_context
                )
            return self._packages[key]

    async def get_plugins(
        self,
        tool_name: str,
        manager: PluginManager,
    ) -> list[Package]:
        """Return cached ``installed_plugins()`` result.

        Args:
            tool_name: The host tool name (cache key).
            manager: The plugin manager instance.

        Returns:
            The list of installed plugin packages.
        """
        key = f'plg:{tool_name}'
        async with self._lock_for(key):
            if key not in self._plugins:
                self._plugins[key] = await manager.installed_plugins()
            return self._plugins[key]

    def invalidate_packages(self, installer: str, project_path: Path | None = None) -> None:
        """Remove cached packages for an installer so next access re-queries.

        Call this after a successful install/upgrade so subsequent
        presence checks see fresh state.

        Args:
            installer: The installer/plugin name.
            project_path: Optional project directory scope.
        """
        key = f'pkg:{installer}:{project_path}'
        self._packages.pop(key, None)

    def invalidate_plugins(self, tool_name: str) -> None:
        """Remove cached plugins for a tool so next access re-queries.

        Args:
            tool_name: The host tool name.
        """
        key = f'plg:{tool_name}'
        self._plugins.pop(key, None)

    def invalidate_all(self) -> None:
        """Clear all cached data.

        Locks are intentionally retained — clearing them while a
        concurrent coroutine holds one would be unsafe.
        """
        self._packages.clear()
        self._plugins.clear()


@dataclass(slots=True)
class ResolutionContext:
    """Optional context for :func:`resolve_operation`.

    Groups the keyword-only parameters so the public API stays concise.
    """

    project_path: Path | None = None
    """Project directory for scoped package queries."""
    project_environments: dict[str, ProjectEnvironment] | None = None
    """Dict of project-environment plugins, used to look up
    ``PluginManager`` instances for plugin-target actions."""
    detect_updates: bool = False
    """When ``True`` and the package is already installed under
    ``MINIMAL`` strategy, check for newer upstream versions."""
    http_client: httpx.AsyncClient | None = None
    """Shared ``httpx.AsyncClient`` for connection pooling across
    concurrent update checks.  ``None`` means each check creates
    its own short-lived client."""
    package_cache: PackageCache | None = None
    """Optional shared cache for ``packages()`` results.  When set,
    multiple actions using the same installer share a single
    ``packages()`` call instead of querying independently."""
    runtime_context: RuntimeContext | None = None
    """Resolved runtime paths.  Threaded through to ``packages()``
    so Python-ecosystem plugins can query packages from the correct
    interpreter."""


async def resolve_operation(
    action: SetupAction,
    environments: dict[str, Environment],
    strategy: SyncStrategy,
    context: ResolutionContext | None = None,
) -> ResolvedOperation:
    """Determine the correct operation for an action.

    Queries the system for installed packages/plugins, applies the
    strategy, and optionally checks for upstream updates.  The
    returned :class:`ResolvedOperation` tells the caller whether to
    install, upgrade, or skip — and carries version metadata for
    display purposes.

    This function is intentionally **side-effect-free**: it reads
    system state but does not modify it.

    Args:
        action: The action to resolve.
        environments: Dict of instantiated environment plugins.
        strategy: The sync strategy.
        context: Optional resolution context with project path,
            project environments, and update-detection flag.

    Returns:
        A fully resolved operation descriptor.
    """
    ctx = context or ResolutionContext()

    if action.installer is None or action.package is None:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.SKIP,
            message='Installer or package not specified',
        )

    # --- Plugin-management actions -----------------------------------------
    if action.plugin_target is not None:
        return await _resolve_plugin_operation(action, environments, strategy, ctx)

    # --- Normal package actions --------------------------------------------
    return await _resolve_package_operation(action, environments, strategy, ctx)


async def _resolve_plugin_operation(
    action: SetupAction,
    environments: dict[str, Environment],
    strategy: SyncStrategy,
    ctx: ResolutionContext,
) -> ResolvedOperation:
    """Resolve the operation for a plugin-management action."""
    assert action.plugin_target is not None
    assert action.package is not None

    manager = find_plugin_manager(action.plugin_target.name, ctx.project_environments)
    if manager is None:
        # No PluginManager found — cannot determine presence, assume install
        return ResolvedOperation(
            action=action,
            operation=OperationKind.INSTALL,
            plugin_manager=None,
            message='PluginManager not available for query',
        )

    # Query installed plugins — use cache when available
    presence = _PresenceResult(
        env_for_updates=environments.get(action.installer) if action.installer else None,
    )
    try:
        if ctx.package_cache is not None:
            installed = await ctx.package_cache.get_plugins(action.plugin_target.name, manager)
        else:
            installed = await manager.installed_plugins()
        presence.is_installed, presence.detail, presence.matched = is_package_installed(action.package, installed)
    except Exception as e:
        logger.debug('Could not check installed plugins for %s: %s', action.plugin_target.name, e)

    return await _apply_strategy(
        action=action,
        strategy=strategy,
        presence=presence,
        detect_updates=ctx.detect_updates,
        plugin_manager=manager,
        http_client=ctx.http_client,
    )


async def _resolve_package_operation(
    action: SetupAction,
    environments: dict[str, Environment],
    strategy: SyncStrategy,
    ctx: ResolutionContext,
) -> ResolvedOperation:
    """Resolve the operation for a normal package action."""
    assert action.installer is not None
    assert action.package is not None

    if action.installer not in environments:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.INSTALL,
            message=f"Installer '{action.installer}' is not available",
        )

    environment = environments[action.installer]
    validator = type(environment).package_name_validator()

    presence = _PresenceResult(env_for_updates=environment)
    try:
        if ctx.package_cache is not None:
            installed_packages = await ctx.package_cache.get_packages(
                action.installer, environment, ctx.project_path, ctx.runtime_context
            )
        else:
            installed_packages = await environment.packages(
                project_path=ctx.project_path, runtime_context=ctx.runtime_context
            )
        presence.is_installed, presence.detail, presence.matched = is_package_installed(
            action.package, installed_packages, validator, action.kind
        )
    except PluginError as e:
        logger.debug('Plugin error checking packages for %s: %s', action.installer, e)
    except Exception as e:
        logger.debug('Could not check installed packages for %s: %s', action.installer, e)

    return await _apply_strategy(
        action=action,
        strategy=strategy,
        presence=presence,
        detect_updates=ctx.detect_updates,
        http_client=ctx.http_client,
    )


@dataclass(slots=True)
class _PresenceResult:
    """Result of querying whether a package/plugin is installed."""

    is_installed: bool = False
    detail: str | None = None
    matched: Package | None = None
    env_for_updates: Environment | None = None
    """Environment plugin to use for upstream update checks."""


async def _apply_strategy(
    *,
    action: SetupAction,
    strategy: SyncStrategy,
    presence: _PresenceResult,
    detect_updates: bool,
    plugin_manager: PluginManager | None = None,
    http_client: httpx.AsyncClient | None = None,
) -> ResolvedOperation:
    """Apply the sync strategy to determine the operation.

    This is the single source of truth for the install/upgrade/skip
    decision.  Both normal packages and plugin-management actions
    share this logic.
    """
    installed_ver = presence.matched.version if presence.matched else None

    if strategy == SyncStrategy.MINIMAL:
        if presence.is_installed:
            # Check for updates if requested (dry-run feature)
            skip_reason = SkipReason.ALREADY_INSTALLED
            available_ver: str | None = None
            msg: str | None = presence.detail

            if detect_updates and presence.env_for_updates is not None:
                try:
                    newer = await check_for_newer_version(
                        presence.env_for_updates,
                        action.package,
                        installed_ver,
                        include_prereleases=action.include_prereleases,
                        http_client=http_client,
                    )
                except UpdateCheckError:
                    newer = None

                if newer is not None:
                    skip_reason = SkipReason.UPDATE_AVAILABLE
                    available_ver = newer
                    pkg_name = action.package.name if action.package else ''
                    msg = f'{pkg_name} {installed_ver} → {available_ver}'

            return ResolvedOperation(
                action=action,
                operation=OperationKind.SKIP,
                skip_reason=skip_reason,
                message=msg,
                installed_version=installed_ver,
                available_version=available_ver,
                plugin_manager=plugin_manager,
            )
        else:
            # Not installed → install
            return ResolvedOperation(
                action=action,
                operation=OperationKind.INSTALL,
                plugin_manager=plugin_manager,
            )

    # LATEST or EXACT strategy
    if presence.is_installed:
        # Check whether this package actually has a newer version
        # available before attempting an upgrade.  When the plugin
        # confirms the package is already at its latest version we
        # can skip the no-op upgrade entirely.
        if presence.env_for_updates is not None:
            try:
                newer = await check_for_newer_version(
                    presence.env_for_updates,
                    action.package,
                    installed_ver,
                    include_prereleases=action.include_prereleases,
                    http_client=http_client,
                )
            except UpdateCheckError:
                pass  # Fall through to unconditional upgrade
            else:
                if newer is not None:
                    pkg_name = action.package.name if action.package else ''
                    return ResolvedOperation(
                        action=action,
                        operation=OperationKind.UPGRADE,
                        message=f'{pkg_name} {installed_ver} → {newer}',
                        installed_version=installed_ver,
                        available_version=newer,
                        plugin_manager=plugin_manager,
                    )
                return ResolvedOperation(
                    action=action,
                    operation=OperationKind.SKIP,
                    skip_reason=SkipReason.ALREADY_LATEST,
                    message=presence.detail,
                    installed_version=installed_ver,
                    plugin_manager=plugin_manager,
                )

        # No environment for update checks, or check failed — upgrade
        # unconditionally.
        return ResolvedOperation(
            action=action,
            operation=OperationKind.UPGRADE,
            message=presence.detail,
            installed_version=installed_ver,
            plugin_manager=plugin_manager,
        )

    # Not installed under LATEST/EXACT → fall back to install
    return ResolvedOperation(
        action=action,
        operation=OperationKind.INSTALL,
        message='not installed, will install instead',
        plugin_manager=plugin_manager,
    )


async def resolve_uninstall_operation(
    action: SetupAction,
    environments: dict[str, Environment],
    context: ResolutionContext | None = None,
) -> ResolvedOperation:
    """Determine whether a package can be uninstalled.

    Checks the system for the package's presence and returns
    ``UNINSTALL`` when found or ``SKIP`` with ``NOT_INSTALLED``
    when the package is absent.

    Unlike :func:`resolve_operation`, this function is not
    strategy-driven — uninstall is an imperative operation.

    Args:
        action: The action describing the package to uninstall.
        environments: Dict of instantiated environment plugins.
        context: Optional resolution context with project path and
            package cache.

    Returns:
        A resolved operation descriptor.
    """
    ctx = context or ResolutionContext()

    if action.installer is None or action.package is None:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.SKIP,
            message='Installer or package not specified',
        )

    # --- Plugin-management actions -----------------------------------------
    if action.plugin_target is not None:
        return await _resolve_plugin_uninstall(action, ctx)

    # --- Normal package actions --------------------------------------------
    if action.installer not in environments:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.SKIP,
            message=f"Installer '{action.installer}' is not available",
        )

    environment = environments[action.installer]
    validator = type(environment).package_name_validator()

    try:
        if ctx.package_cache is not None:
            installed_packages = await ctx.package_cache.get_packages(
                action.installer, environment, ctx.project_path, ctx.runtime_context
            )
        else:
            installed_packages = await environment.packages(
                project_path=ctx.project_path, runtime_context=ctx.runtime_context
            )
        logger.debug('packages query for %s returned %d entries', action.installer, len(installed_packages))
        is_installed, detail, matched = is_package_installed(action.package, installed_packages, validator, action.kind)
        logger.debug(
            "is_package_installed('%s'): found=%s matched=%s",
            action.package.name,
            is_installed,
            matched.name if matched else None,
        )
    except Exception as e:
        logger.debug('Could not check installed packages for %s: %s', action.installer, e)
        is_installed, detail, matched = False, None, None

    if not is_installed:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.SKIP,
            skip_reason=SkipReason.NOT_INSTALLED,
            message=f"'{action.package.name}' is not installed",
        )

    return ResolvedOperation(
        action=action,
        operation=OperationKind.UNINSTALL,
        message=detail,
        installed_version=matched.version if matched else None,
    )


async def _resolve_plugin_uninstall(
    action: SetupAction,
    ctx: ResolutionContext,
) -> ResolvedOperation:
    """Resolve an uninstall operation for a plugin-management action.

    Args:
        action: The action describing the plugin to uninstall.
        ctx: Resolution context with project environments and cache.

    Returns:
        A resolved operation descriptor.
    """
    assert action.plugin_target is not None
    assert action.package is not None

    manager = find_plugin_manager(action.plugin_target.name, ctx.project_environments)
    if manager is None:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.SKIP,
            message=f"No PluginManager found for '{action.plugin_target.name}'",
        )

    try:
        if ctx.package_cache is not None:
            installed = await ctx.package_cache.get_plugins(action.plugin_target.name, manager)
        else:
            installed = await manager.installed_plugins()
        is_installed, detail, _ = is_package_installed(action.package, installed)
    except Exception as e:
        logger.debug('Could not check installed plugins for %s: %s', action.plugin_target.name, e)
        is_installed, detail = False, None

    if not is_installed:
        return ResolvedOperation(
            action=action,
            operation=OperationKind.SKIP,
            skip_reason=SkipReason.NOT_INSTALLED,
            message=f"Plugin '{action.package.name}' is not installed in '{action.plugin_target.name}'",
            plugin_manager=manager,
        )
    return ResolvedOperation(
        action=action,
        operation=OperationKind.UNINSTALL,
        message=detail,
        plugin_manager=manager,
    )


class UpdateCheckError(Exception):
    """The update check could not be performed.

    The caller should fall back to a conservative action
    (e.g. attempt the upgrade anyway).
    """


async def check_for_newer_version(
    env: Environment,
    package: PackageRef | None,
    installed_version: str | None,
    *,
    include_prereleases: bool = False,
    http_client: httpx.AsyncClient | None = None,
) -> str | None:
    """Query the plugin for a newer upstream version.

    Uses the plugin's ``check_updates()`` coroutine.  When
    *http_client* is provided it is forwarded via
    ``CheckUpdatesParameters`` so that concurrent checks share a
    single connection pool.

    Returns the newer version string when one is available, or ``None``
    when the installed version is confirmed up-to-date.

    Raises:
        UpdateCheckError: When the check cannot complete (network
            error, missing package, etc.).
    """
    if package is None:
        raise UpdateCheckError('no package reference')

    try:
        params = CheckUpdatesParameters(
            packages=[package],
            include_prereleases=include_prereleases,
            http_client=http_client,
        )
        updates = await env.check_updates(params)
    except Exception as e:
        logger.debug('check_updates failed for %s via %s: %s', package, env.tool_name(), e)
        raise UpdateCheckError(str(e)) from e

    if not updates or updates[0].version is None:
        # Plugin responded but reported no updates — confirmed up-to-date.
        return None

    latest_ver = updates[0].version

    # Defense-in-depth: reject pre-release versions when the caller
    # did not opt in, even if the plugin failed to filter them.
    if not include_prereleases:
        try:
            if Version(latest_ver).is_prerelease:
                return None
        except InvalidVersion:
            pass  # Non-PEP-440 version string — let the comparison below decide

    # Compare versions when possible to avoid false positives
    if installed_version is not None:
        try:
            if Version(latest_ver) <= Version(installed_version):
                return None
        except InvalidVersion:
            if latest_ver == installed_version:
                return None

    return latest_ver


# ---------------------------------------------------------------------------
# Presence detection
# ---------------------------------------------------------------------------


def is_package_installed(
    package: PackageRef,
    installed_packages: list[Package],
    name_validator: str | None = None,
    kind: PluginKind | None = None,
) -> tuple[bool, str | None, Package | None]:
    """Checks if a package is already installed with a compatible version.

    When *name_validator* is ``'pep440'``, uses PEP 440 canonicalization
    and specifier matching.  Otherwise, uses case-insensitive name
    comparison and simple string version equality.

    For ``RUNTIME`` actions, name comparison uses prefix matching so
    that a request for ``3.14`` matches an installed ``3.14-64``
    (architecture-qualified tag).

    Args:
        package: The package reference
        installed_packages: List of installed packages from the environment
        name_validator: The validator tag declared by the resolved plugin
        kind: The plugin kind (enables prefix matching for RUNTIME)

    Returns:
        Tuple of (is_installed, detail_message, matched_package).
        ``matched_package`` is the ``Package`` object that matched,
        or ``None`` when the package is not installed.
    """
    is_pep440 = name_validator == 'pep440'
    is_runtime = kind == PluginKind.RUNTIME

    for installed in installed_packages:
        if is_runtime:
            # Runtime tags use prefix matching: "3.14" matches "3.14-64"
            if not installed.name.lower().startswith(package.name.lower()):
                continue
        elif is_pep440:
            if canonicalize_name(installed.name) != canonicalize_name(package.name):
                continue
        elif installed.name.lower() != package.name.lower():
            continue

        # Name matched
        if not package.constraint:
            return True, f'{installed.name}=={installed.version} already installed', installed

        if installed.version is not None:
            if is_pep440:
                try:
                    req = Requirement(str(package))
                    if Version(installed.version) in req.specifier:
                        return True, f'{installed.name}=={installed.version} satisfies {package}', installed
                except InvalidVersion, InvalidRequirement:
                    pass
            else:
                # Non-PEP-440: installed means installed; constraint
                # satisfaction is left to the underlying tool.
                return True, f'{installed.name}=={installed.version} already installed', installed

    return False, None, None
