import re
from pathlib import Path
from typing import Tuple
from urllib.parse import quote, unquote

from pipenv.patched.pip._internal.network.session import PipSession
from pipenv.patched.pip._internal.req import parse_requirements
from pipenv.patched.pip._internal.req.constructors import (
    install_req_from_parsed_requirement,
)
from pipenv.patched.pip._internal.utils.misc import _transform_url, split_auth_from_netloc
from pipenv.utils.constants import VCS_LIST
from pipenv.utils.indexes import parse_indexes
from pipenv.utils.internet import get_host_and_port
from pipenv.utils.pip import get_trusted_hosts


def redact_netloc(netloc: str) -> str:
    """
    Replace the sensitive data in a netloc with "****", if it exists, unless it's an environment variable.

    For example:
        - "user:pass@example.com" returns "user:****@example.com"
        - "accesstoken@example.com" returns "****@example.com"
        - "${ENV_VAR}:pass@example.com" returns "${ENV_VAR}:****@example.com" if ${ENV_VAR} is an environment variable
        - "git@github.com" returns "git@github.com" (standard SSH username preserved)
    """
    # Standard SSH usernames that should not be redacted
    STANDARD_SSH_USERNAMES = ("git",)

    netloc, (user, password) = split_auth_from_netloc(netloc)
    if user is None:
        return netloc
    if password is None:
        # Check if user is an environment variable or a standard SSH username
        if not re.match(r"\$\{\w+\}", user) and user not in STANDARD_SSH_USERNAMES:
            # If not, redact the user
            user = "****"
        password = ""
    else:
        # Check if password is an environment variable
        if not re.match(r"\$\{\w+\}", password):
            # If not, redact the password
            password = ":****"
        else:
            # If it is, leave it as is
            password = ":" + password
        user = quote(user)
    return f"{user}{password}@{netloc}"


def redact_auth_from_url(url: str) -> str:
    """Replace the password in a given url with ****."""

    def _redact_netloc_wrapper(netloc: str) -> Tuple[str]:
        return (redact_netloc(netloc),)

    return _transform_url(url, _redact_netloc_wrapper)[0]


def normalize_name(pkg) -> str:
    """Given a package name, return its normalized, non-canonicalized form."""
    return pkg.replace("_", "-").lower()


def import_requirements(project, r=None, dev=False, categories=None):
    # Parse requirements.txt file with Pip's parser.
    # Pip requires a `PipSession` which is a subclass of requests.Session.
    # Since we're not making any network calls, it's initialized to nothing.
    if r and not Path(r).is_file():
        raise OSError(f"Requirements file not found: {r}")
    # Default path, if none is provided.
    if r is None:
        r = project.requirements_location
    with open(r) as f:
        contents = f.read()
    if categories is None:
        categories = []

    # Collect indexes and trusted hosts first
    indexes = []
    trusted_hosts = []
    for line in contents.split("\n"):
        index, extra_index, trusted_host, _ = parse_indexes(line.strip(), strict=True)
        if index:
            indexes = [index]
        if extra_index:
            indexes.append(extra_index)
        if trusted_host:
            trusted_hosts.append(get_host_and_port(trusted_host))

    # Collect all packages for batch processing
    packages_to_add = []
    req_path = str(r) if isinstance(r, Path) else r

    for f in parse_requirements(req_path, session=PipSession()):
        package = install_req_from_parsed_requirement(f)
        if package.name not in BAD_PACKAGES:
            if package.link is not None:
                if package.editable:
                    package_string = f"-e {package.link}"
                else:
                    package_string = unquote(
                        redact_auth_from_url(package.original_link.url)
                    )
            else:
                package_string = str(package.req)
                if package.markers:
                    package_string += f" ; {package.markers}"

            packages_to_add.append((package, package_string))

    # Batch add all packages to Pipfile
    if packages_to_add:
        project.add_packages_to_pipfile_batch(
            packages_to_add, dev=dev, categories=categories
        )

    # Add indexes after packages
    indexes = sorted(set(indexes))
    trusted_hosts = sorted(set(trusted_hosts))
    for index in indexes:
        add_index_to_pipfile(project, index, trusted_hosts)

    # Recase pipfile once at the end
    project.recase_pipfile()


def add_index_to_pipfile(project, index, trusted_hosts=None):
    # don't require HTTPS for trusted hosts (see: https://pip.pypa.io/en/stable/cli/pip/#cmdoption-trusted-host)
    if trusted_hosts is None:
        trusted_hosts = get_trusted_hosts()

    host_and_port = get_host_and_port(index)
    require_valid_https = not any(
        v in trusted_hosts
        for v in (
            host_and_port,
            host_and_port.partition(":")[
                0
            ],  # also check if hostname without port is in trusted_hosts
        )
    )
    index_name = project.add_index_to_pipfile(index, verify_ssl=require_valid_https)
    return index_name


BAD_PACKAGES = (
    "distribute",
    "pip",
    "pkg-resources",
    "setuptools",
    "wheel",
)


def requirement_from_lockfile(
    package_name, package_info, include_hashes=True, include_markers=True
):
    from pipenv.utils.dependencies import is_editable_path, is_star, normalize_vcs_url

    # Handle string requirements
    if isinstance(package_info, str):
        if package_info and not is_star(package_info):
            return f"{package_name}=={package_info}"
        else:
            return package_name

    markers = (
        "; {}".format(package_info["markers"])
        if include_markers and "markers" in package_info and package_info["markers"]
        else ""
    )
    os_markers = (
        "; {}".format(package_info["os_markers"])
        if include_markers and "os_markers" in package_info and package_info["os_markers"]
        else ""
    )

    # Handling vcs repositories
    for vcs in VCS_LIST:
        if vcs in package_info:
            vcs_url = package_info[vcs]
            ref = package_info.get("ref", "")
            # We have to handle the fact that some vcs urls have a ref in them
            # and some have a netloc with a username and password in them, and some have both
            vcs_url, fallback_ref = normalize_vcs_url(vcs_url)
            if not ref:
                ref = fallback_ref
            extras = (
                "[{}]".format(",".join(package_info.get("extras", [])))
                if "extras" in package_info
                else ""
            )
            subdirectory = package_info.get("subdirectory", "")
            include_vcs = "" if f"{vcs}+" in vcs_url else f"{vcs}+"
            egg_fragment = "" if "#egg=" in vcs_url else f"#egg={package_name}"
            ref_str = "" if not ref or f"@{ref}" in vcs_url else f"@{ref}"
            if (
                is_editable_path(vcs_url)
                or "file://" in vcs_url
                or package_info.get("editable", False)
            ):
                pip_line = f"-e {include_vcs}{vcs_url}{ref_str}{egg_fragment}{extras}"
                pip_line += f"&subdirectory={subdirectory}" if subdirectory else ""
            else:
                pip_line = f"{package_name}{extras} @ {include_vcs}{vcs_url}{ref_str}"
                pip_line += f"#subdirectory={subdirectory}" if subdirectory else ""
            return pip_line
    # Handling file-sourced packages
    for k in ["file", "path"]:
        line = []
        if k in package_info:
            path = package_info[k]
            if package_info.get("editable") and is_editable_path(path):
                line.append("-e")
            line.append(f"{package_info[k]}")
            if os_markers:
                line.append(os_markers)
            if markers:
                line.append(markers)
            pip_line = " ".join(line)
            return pip_line

    # Handling packages from standard pypi like indexes
    version = package_info.get("version", "")
    # Skip wildcard versions - they mean "any version" and should not be included
    if is_star(version):
        version = ""
    hashes = (
        f" --hash={' --hash='.join(package_info['hashes'])}"
        if include_hashes and "hashes" in package_info
        else ""
    )
    extras = (
        "[{}]".format(",".join(package_info.get("extras", [])))
        if "extras" in package_info
        else ""
    )
    pip_line = f"{package_name}{extras}{version}{os_markers}{markers}{hashes}"
    return pip_line


def requirements_from_lockfile(deps, include_hashes=True, include_markers=True):
    pip_packages = []

    for package_name, package_info in deps.items():
        pip_package = requirement_from_lockfile(
            package_name, package_info, include_hashes, include_markers
        )

        # Append to the list
        pip_packages.append(pip_package)

    # pip_packages contains the pip-installable lines
    return pip_packages


def requirement_from_pipfile(package_name, package_spec, include_markers=True):
    """Convert a Pipfile package entry to a pip requirements line.

    Uses the version specifiers from the Pipfile (like "*", ">=1.0") rather than
    locked versions. This is useful for generating requirements for libraries
    that need more flexible version constraints.

    Args:
        package_name: The package name
        package_spec: The package specification from Pipfile (str or dict)
        include_markers: Whether to include environment markers

    Returns:
        A pip-installable requirement line
    """
    from pipenv.utils.dependencies import is_editable_path, is_star

    # Handle simple string specifications like "*", ">=1.0", "==1.0"
    if isinstance(package_spec, str):
        if is_star(package_spec):
            return package_name
        elif package_spec.startswith(("==", ">=", "<=", ">", "<", "~=", "!=")):
            return f"{package_name}{package_spec}"
        else:
            # Assume it's a version without operator, default to ==
            return f"{package_name}=={package_spec}"

    # Handle dict specifications
    if not isinstance(package_spec, dict):
        return package_name

    # Handle VCS dependencies
    for vcs_type in ["git", "hg", "svn", "bzr"]:
        if vcs_type in package_spec:
            vcs_url = package_spec[vcs_type]
            ref = package_spec.get("ref", "")
            subdirectory = package_spec.get("subdirectory", "")
            extras = (
                "[{}]".format(",".join(package_spec.get("extras", [])))
                if "extras" in package_spec
                else ""
            )

            # Build the VCS URL
            ref_str = f"@{ref}" if ref and f"@{ref}" not in vcs_url else ""
            egg_fragment = f"#egg={package_name}" if "#egg=" not in vcs_url else ""

            if (
                is_editable_path(vcs_url)
                or "file://" in vcs_url
                or package_spec.get("editable", False)
            ):
                line = f"-e {vcs_type}+{vcs_url}{ref_str}{egg_fragment}{extras}"
                if subdirectory:
                    line += f"&subdirectory={subdirectory}"
            else:
                line = f"{package_name}{extras} @ {vcs_type}+{vcs_url}{ref_str}"
                if subdirectory:
                    line += f"#subdirectory={subdirectory}"
            return line

    # Handle file/path dependencies
    for key in ["file", "path"]:
        if key in package_spec:
            path = package_spec[key]
            parts = []
            if package_spec.get("editable") and is_editable_path(path):
                parts.append("-e")
            parts.append(path)

            if include_markers:
                if "markers" in package_spec and package_spec["markers"]:
                    parts.append(f"; {package_spec['markers']}")
            return " ".join(parts)

    # Handle standard version specifications
    version = package_spec.get("version", "")
    extras = (
        "[{}]".format(",".join(package_spec.get("extras", [])))
        if "extras" in package_spec
        else ""
    )

    # Process version
    if is_star(version):
        version_str = ""
    elif version.startswith(("==", ">=", "<=", ">", "<", "~=", "!=")):
        version_str = version
    elif version:
        version_str = f"=={version}"
    else:
        version_str = ""

    # Process markers
    markers = ""
    if include_markers:
        # Handle sys_platform and other common markers
        marker_parts = []
        if "markers" in package_spec and package_spec["markers"]:
            marker_parts.append(package_spec["markers"])
        if "sys_platform" in package_spec and package_spec["sys_platform"]:
            marker_parts.append(f"sys_platform {package_spec['sys_platform']}")

        if marker_parts:
            markers = "; " + " and ".join(marker_parts)

    return f"{package_name}{extras}{version_str}{markers}"


def requirements_from_pipfile(deps, include_markers=True):
    """Convert Pipfile dependencies to pip requirements lines.

    Uses the version specifiers from the Pipfile rather than locked versions.

    Args:
        deps: Dictionary of package names to specifications from Pipfile
        include_markers: Whether to include environment markers

    Returns:
        List of pip-installable requirement lines
    """
    pip_packages = []

    for package_name, package_spec in deps.items():
        pip_package = requirement_from_pipfile(
            package_name, package_spec, include_markers
        )
        if pip_package:
            pip_packages.append(pip_package)

    return pip_packages
