# Valid package specifiers for pipx:
#   PEP508-compliant
#   git+<URL>
#   <URL>
#   <pypi_package_name>
#   <pypi_package_name><version_specifier>
#   <local_path>

import logging
import re
from pathlib import Path
from typing import List, NamedTuple, Optional, Set, Tuple

from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import SpecifierSet
from packaging.utils import canonicalize_name

from pipx.emojis import hazard
from pipx.util import PipxError, pipx_wrap

logger = logging.getLogger(__name__)

ARCHIVE_EXTENSIONS = (".whl", ".tar.gz", ".zip")


class ParsedPackage(NamedTuple):
    valid_pep508: Optional[Requirement]
    valid_url: Optional[str]
    valid_local_path: Optional[str]


def _split_path_extras(package_spec: str) -> Tuple[str, str]:
    """Returns (path, extras_string)"""
    package_spec_extras_re = re.search(r"(.+)(\[.+\])", package_spec)
    if package_spec_extras_re:
        return (package_spec_extras_re.group(1), package_spec_extras_re.group(2))
    else:
        return (package_spec, "")


def _check_package_path(package_path: str) -> Tuple[Path, bool]:
    pkg_path = Path(package_path)
    pkg_path_exists = pkg_path.exists()

    return (pkg_path, pkg_path_exists)


def _parse_specifier(package_spec: str) -> ParsedPackage:
    """Parse package_spec as would be given to pipx"""
    # If package_spec is valid pypi name, pip will always treat it as a
    #       pypi package, not checking for local path.
    #       We replicate pypi precedence here (only non-valid-pypi names
    #       initiate check for local path, e.g. './package-name')
    valid_pep508 = None
    valid_url = None
    valid_local_path = None

    try:
        package_req = Requirement(package_spec)
    except InvalidRequirement:
        # not a valid PEP508 package specification
        pass
    else:
        # valid PEP508 package specification
        valid_pep508 = package_req

    if valid_pep508 and package_req.name.endswith(ARCHIVE_EXTENSIONS):
        # It might be a local archive
        (package_path, package_path_exists) = _check_package_path(package_req.name)

        if package_path_exists:
            valid_local_path = str(package_path.resolve())
        else:
            raise PipxError(f"{package_path} does not exist")

    # packaging currently (2020-07-19) only does basic syntax checks on URL.
    #   Some examples of what it will not catch:
    #       - invalid RCS string (e.g. "gat+https://...")
    #       - non-existent scheme (e.g. "zzzzz://...")
    if not valid_pep508:
        try:
            package_req = Requirement("notapackagename @ " + package_spec)
        except InvalidRequirement:
            # not a valid url
            pass
        else:
            valid_url = package_spec

    if not valid_pep508 and not valid_url:
        (package_path_str, package_extras_str) = _split_path_extras(package_spec)

        (package_path, package_path_exists) = _check_package_path(package_path_str)

        if package_path_exists:
            valid_local_path = str(package_path.resolve()) + package_extras_str

    if not valid_pep508 and not valid_url and not valid_local_path:
        raise PipxError(f"Unable to parse package spec: {package_spec}")

    if valid_pep508 and valid_local_path:
        # It is a valid local path without "./"
        # Use valid_local_path
        valid_pep508 = None

    return ParsedPackage(
        valid_pep508=valid_pep508,
        valid_url=valid_url,
        valid_local_path=valid_local_path,
    )


def package_or_url_from_pep508(
    requirement: Requirement, remove_version_specifiers: bool = False
) -> str:
    requirement.marker = None
    requirement.name = canonicalize_name(requirement.name)
    if remove_version_specifiers:
        requirement.specifier = SpecifierSet("")
    return str(requirement)


def _parsed_package_to_package_or_url(
    parsed_package: ParsedPackage, remove_version_specifiers: bool
) -> str:
    if parsed_package.valid_pep508 is not None:
        if parsed_package.valid_pep508.marker is not None:
            logger.warning(
                pipx_wrap(
                    f"""
                    {hazard}  Ignoring environment markers
                    ({parsed_package.valid_pep508.marker}) in package
                    specification. Use pipx options to specify this type of
                    information.
                    """,
                    subsequent_indent=" " * 4,
                )
            )
        package_or_url = package_or_url_from_pep508(
            parsed_package.valid_pep508,
            remove_version_specifiers=remove_version_specifiers,
        )
    elif parsed_package.valid_url is not None:
        package_or_url = parsed_package.valid_url
    elif parsed_package.valid_local_path is not None:
        package_or_url = parsed_package.valid_local_path

    logger.info(f"cleaned package spec: {package_or_url}")
    return package_or_url


def parse_specifier_for_install(
    package_spec: str, pip_args: List[str]
) -> Tuple[str, List[str]]:
    """Return package_or_url and pip_args suitable for pip install

    Specifically:
    * Strip any markers (e.g. python_version > "3.4")
    * Ensure --editable is removed for any package_spec not a local path
    * Convert local paths to absolute paths
    """
    parsed_package = _parse_specifier(package_spec)
    package_or_url = _parsed_package_to_package_or_url(
        parsed_package, remove_version_specifiers=False
    )
    if "--editable" in pip_args and not parsed_package.valid_local_path:
        logger.warning(
            pipx_wrap(
                f"""
                {hazard}  Ignoring --editable install option. pipx disallows it
                for anything but a local path, to avoid having to create a new
                src/ directory.
                """,
                subsequent_indent=" " * 4,
            )
        )
        pip_args.remove("--editable")

    return (package_or_url, pip_args)


def parse_specifier_for_metadata(package_spec: str) -> str:
    """Return package_or_url suitable for pipx metadata

    Specifically:
    * Strip any markers (e.g. python_version > 3.4)
    * Convert local paths to absolute paths
    """
    parsed_package = _parse_specifier(package_spec)
    package_or_url = _parsed_package_to_package_or_url(
        parsed_package, remove_version_specifiers=False
    )
    return package_or_url


def parse_specifier_for_upgrade(package_spec: str) -> str:
    """Return package_or_url suitable for pip upgrade

    Specifically:
    * Strip any version specifiers (e.g. package == 1.5.4)
    * Strip any markers (e.g. python_version > 3.4)
    * Convert local paths to absolute paths
    """
    parsed_package = _parse_specifier(package_spec)
    package_or_url = _parsed_package_to_package_or_url(
        parsed_package, remove_version_specifiers=True
    )
    return package_or_url


def get_extras(package_spec: str) -> Set[str]:
    parsed_package = _parse_specifier(package_spec)
    if parsed_package.valid_pep508 and parsed_package.valid_pep508.extras is not None:
        return parsed_package.valid_pep508.extras
    elif parsed_package.valid_local_path:
        (_, package_extras_str) = _split_path_extras(parsed_package.valid_local_path)
        return Requirement("notapackage" + package_extras_str).extras

    return set()


def valid_pypi_name(package_spec: str) -> Optional[str]:
    try:
        package_req = Requirement(package_spec)
    except InvalidRequirement:
        # not a valid PEP508 package specification
        return None

    if package_req.url or package_req.name.endswith(ARCHIVE_EXTENSIONS):
        # package name supplied by user might not match package found in URL,
        # also if package name ends with archive extension, it might be a local archive file,
        #   so force package name determination the long way
        return None

    return canonicalize_name(package_req.name)


def fix_package_name(package_or_url: str, package_name: str) -> str:
    try:
        package_req = Requirement(package_or_url)
    except InvalidRequirement:
        # not a valid PEP508 package specification
        return package_or_url

    if package_req.name.endswith(ARCHIVE_EXTENSIONS):
        return str(package_req)

    if canonicalize_name(package_req.name) != canonicalize_name(package_name):
        logger.warning(
            pipx_wrap(
                f"""
                {hazard}  Name supplied in package specifier was
                {package_req.name!r} but package found has name {package_name!r}.
                Using {package_name!r}.
                """,
                subsequent_indent=" " * 4,
            )
        )
    package_req.name = package_name

    return str(package_req)
