from __future__ import annotations

import logging
import sys
import textwrap
from collections.abc import Iterable, Sequence
from contextlib import AbstractContextManager as ContextManager
from contextlib import nullcontext
from io import StringIO
from typing import TYPE_CHECKING

from pip._internal.build_env.base import Prefix
from pip._internal.cli.spinners import open_rich_spinner, open_spinner
from pip._internal.exceptions import (
    BuildDependencyInstallError,
    DiagnosticPipError,
    InstallWheelBuildError,
    PipError,
)
from pip._internal.metadata import get_environment
from pip._internal.utils.logging import VERBOSE, capture_logging
from pip._internal.utils.misc import get_runnable_pip
from pip._internal.utils.subprocess import call_subprocess
from pip._internal.utils.temp_dir import TempDirectory

if TYPE_CHECKING:
    from pip._internal.cache import WheelCache
    from pip._internal.index.package_finder import PackageFinder
    from pip._internal.operations.build.build_tracker import BuildTracker
    from pip._internal.req.req_install import InstallRequirement
    from pip._internal.resolution.base import BaseResolver


logger = logging.getLogger(__name__)


class SubprocessBuildEnvironmentInstaller:
    """
    Install build dependencies by calling pip in a subprocess.
    """

    def __init__(
        self,
        finder: PackageFinder,
        build_constraints: list[str] | None = None,
    ) -> None:
        self.finder = finder
        self._build_constraints = build_constraints or []

    def install(
        self,
        requirements: Iterable[str],
        prefix: Prefix,
        *,
        kind: str,
        for_req: InstallRequirement | None,
    ) -> None:
        finder = self.finder
        args: list[str] = [
            get_runnable_pip(),
            "install",
            # HACK: --prefix shouldn't be necessary for venv environments, but
            # we set it anyway so if it's set via an envvar or configuration
            # file, it won't break things, *sigh*.
            "--prefix",
            prefix.path,
            "--no-user",
            "--no-warn-script-location",
            "--disable-pip-version-check",
            # As the build environment is ephemeral, it's wasteful to
            # pre-compile everything, especially as not every Python
            # module will be used/compiled in most cases.
            "--no-compile",
            # The prefix specified two lines above, thus
            # target from config file or env var should be ignored
            "--target",
            "",
        ]
        if prefix.venv_executable:
            args.insert(0, prefix.venv_executable)
        else:
            args.insert(0, sys.executable)
            args.append("--ignore-installed")

        if logger.getEffectiveLevel() <= logging.DEBUG:
            args.append("-vv")
        elif logger.getEffectiveLevel() <= VERBOSE:
            args.append("-v")
        for format_control in ("no_binary", "only_binary"):
            formats = getattr(finder.format_control, format_control)
            args.extend(
                (
                    "--" + format_control.replace("_", "-"),
                    ",".join(sorted(formats or {":none:"})),
                )
            )

        if finder.release_control is not None:
            # Use ordered args to preserve the user's original command-line order
            # This is important because later flags can override earlier ones
            for attr_name, value in finder.release_control.get_ordered_args():
                args.extend(("--" + attr_name.replace("_", "-"), value))

        index_urls = finder.index_urls
        if index_urls:
            args.extend(["-i", index_urls[0]])
            for extra_index in index_urls[1:]:
                args.extend(["--extra-index-url", extra_index])
        else:
            args.append("--no-index")
        for link in finder.find_links:
            args.extend(["--find-links", link])

        # is not None: forward an empty --proxy "" (disable proxying) too.
        if finder.proxy is not None:
            args.extend(["--proxy", finder.proxy])
        if finder.no_proxy_env:
            args.append("--no-proxy-env")
        for host in finder.trusted_hosts:
            args.extend(["--trusted-host", host])
        if finder.custom_cert:
            args.extend(["--cert", finder.custom_cert])
        if finder.client_cert:
            args.extend(["--client-cert", finder.client_cert])
        if finder.prefer_binary:
            args.append("--prefer-binary")
        if finder.refresh_package:
            args.extend(["--refresh-package", ",".join(finder.refresh_package)])

        # Only build constraints apply in the isolated build environment.
        # _PIP_IN_BUILD_IGNORE_CONSTRAINTS tells the subprocess to ignore the
        # regular constraints it inherits (via PIP_CONSTRAINT or config files).
        # Build constraints reach it through --build-constraint, which also
        # constrains any nested builds.
        for constraint_file in self._build_constraints:
            args.extend(["--build-constraint", constraint_file])

        if finder.uploaded_prior_to:
            args.extend(["--uploaded-prior-to", finder.uploaded_prior_to.isoformat()])
        args.append("--")
        args.extend(requirements)

        identify_requirement = (
            f" for {for_req.name}" if for_req and for_req.name else ""
        )
        with open_spinner(f"Installing {kind}") as spinner:
            call_subprocess(
                args,
                command_desc=f"installing {kind}{identify_requirement}",
                spinner=spinner,
                extra_environ={"_PIP_IN_BUILD_IGNORE_CONSTRAINTS": "1"},
            )


class InprocessBuildEnvironmentInstaller:
    """
    Install build dependencies via the already running pip process.

    This contains a stripped down version of the install command with
    only the logic necessary for installing build dependencies. The
    finder, session, build tracker, and wheel cache are reused, but new
    instances of everything else are created as needed.

    Options are inherited from the parent install command unless
    they don't make sense for build dependencies (in which case, they
    are hard-coded, see comments below).
    """

    # TODO: this plays poorly with venv-based build environments, but cannot be
    # fixed until pip gains better support for operating within a Python
    # environment that isn't the running environment.

    def __init__(
        self,
        *,
        finder: PackageFinder,
        build_tracker: BuildTracker,
        wheel_cache: WheelCache,
        build_constraints: Sequence[InstallRequirement] = (),
        verbosity: int = 0,
    ) -> None:
        from pip._internal.operations.prepare import RequirementPreparer

        self._finder = finder
        self._build_constraints = build_constraints
        self._wheel_cache = wheel_cache
        self._level = 0

        build_dir = TempDirectory(kind="build-env-install", globally_managed=True)
        self._preparer = RequirementPreparer(
            build_isolation_installer=self,
            # Inherited options or state.
            finder=finder,
            session=finder._link_collector.session,
            build_dir=build_dir.path,
            build_tracker=build_tracker,
            verbosity=verbosity,
            # This is irrelevant as it only applies to editable requirements.
            src_dir="",
            # Hard-coded options (that should NOT be inherited).
            download_dir=None,
            build_isolation="virtual",
            check_build_deps=False,
            progress_bar="off",
            # TODO: hash-checking should be extended to build deps, but that is
            # deferred for later as it'd be a breaking change.
            require_hashes=False,
            use_user_site=False,
            lazy_wheel=False,
            legacy_resolver=False,
            allow_editables=True,
        )

    def install(
        self,
        requirements: Iterable[str],
        prefix: Prefix,
        *,
        kind: str,
        for_req: InstallRequirement | None,
    ) -> None:
        """Install entrypoint. Manages output capturing and error handling."""
        capture_logs = not logger.isEnabledFor(VERBOSE) and self._level == 0
        if capture_logs:
            # Hide the logs from the installation of build dependencies.
            # They will be shown only if an error occurs.
            capture_ctx: ContextManager[StringIO] = capture_logging()
            spinner: ContextManager[None] = open_rich_spinner(f"Installing {kind}")
        else:
            # Otherwise, pass-through all logs (with a header).
            capture_ctx, spinner = nullcontext(StringIO()), nullcontext()
            logger.info("Installing %s ...", kind)

        try:
            self._level += 1
            with spinner, capture_ctx as stream:
                self._install_impl(requirements, prefix)

        except DiagnosticPipError as exc:
            # Format similar to a nested subprocess error, where the
            # causing error is shown first, followed by the build error.
            logger.info(textwrap.dedent(stream.getvalue()))
            logger.error("%s", exc, extra={"rich": True})
            logger.info("")
            raise BuildDependencyInstallError(
                for_req, requirements, cause=exc, log_lines=None
            )

        except Exception as exc:
            logs: list[str] | None = textwrap.dedent(stream.getvalue()).splitlines()
            if not capture_logs:
                # If logs aren't being captured, then display the error inline
                # with the rest of the logs.
                logs = None
                if isinstance(exc, PipError):
                    logger.error("%s", exc)
                else:
                    logger.exception("pip crashed unexpectedly")
            raise BuildDependencyInstallError(
                for_req, requirements, cause=exc, log_lines=logs
            )

        finally:
            self._level -= 1

    def _install_impl(self, requirements: Iterable[str], prefix: Prefix) -> None:
        """Core build dependency install logic."""
        from pip._internal.commands.install import installed_packages_summary
        from pip._internal.req import install_given_reqs
        from pip._internal.req.constructors import install_req_from_line
        from pip._internal.wheel_builder import build

        ireqs = [install_req_from_line(req, user_supplied=True) for req in requirements]
        ireqs.extend(self._build_constraints)

        resolver = self._make_resolver()
        resolved_set = resolver.resolve(ireqs, check_supported_wheels=True)
        self._preparer.prepare_linked_requirements_more(
            resolved_set.requirements.values()
        )

        reqs_to_build = [
            r for r in resolved_set.requirements_to_install if not r.is_wheel
        ]
        _, build_failures = build(
            reqs_to_build, self._wheel_cache, verify=True, allow_editables=True
        )
        if build_failures:
            raise InstallWheelBuildError(build_failures)

        installed = install_given_reqs(
            resolver.get_installation_order(resolved_set),
            prefix=prefix.path,
            # Hard-coded options (that should NOT be inherited).
            root=None,
            home=None,
            warn_script_location=False,
            use_user_site=False,
            # As the build environment is ephemeral, it's wasteful to
            # pre-compile everything since not all modules will be used.
            pycompile=False,
            progress_bar="off",
            # Link console scripts to the build env's interpreter, not pip's.
            script_executable=prefix.venv_executable,
        )

        env = get_environment(list(prefix.lib_dirs))
        if summary := installed_packages_summary(installed, env):
            logger.info(summary)

    def _make_resolver(self) -> BaseResolver:
        """Create a new resolver for one time use."""
        # Legacy installer never used the legacy resolver so create a
        # resolvelib resolver directly. Yuck.
        from pip._internal.req.constructors import install_req_from_req_string
        from pip._internal.resolution.resolvelib.resolver import Resolver

        return Resolver(
            make_install_req=install_req_from_req_string,
            # Inherited state.
            preparer=self._preparer,
            finder=self._finder,
            wheel_cache=self._wheel_cache,
            # Hard-coded options (that should NOT be inherited).
            ignore_requires_python=False,
            use_user_site=False,
            ignore_dependencies=False,
            only_dependencies=False,
            ignore_installed=True,
            force_reinstall=False,
            upgrade_strategy="to-satisfy-only",
            py_version_info=None,
        )
