import os
from collections.abc import ItemsView, Mapping, Sequence, Set
from pathlib import Path
from typing import Dict, List, Optional, Tuple, TypeVar, Union
from urllib.parse import urlparse, urlsplit, urlunparse

from pipenv.patched.pip._internal.commands.install import InstallCommand
from pipenv.patched.pip._internal.models.link import Link
from pipenv.patched.pip._internal.models.target_python import TargetPython
from pipenv.patched.pip._internal.network.download import Downloader
from pipenv.patched.pip._internal.operations.prepare import (
    File,
    _check_download_dir,
    get_file_url,
    unpack_vcs_link,
)
from pipenv.patched.pip._internal.utils.filetypes import is_archive_file
from pipenv.patched.pip._internal.utils.hashes import Hashes
from pipenv.patched.pip._internal.utils.misc import is_installable_dir
from pipenv.patched.pip._internal.utils.temp_dir import TempDirectory
from pipenv.patched.pip._internal.utils.unpacking import unpack_file
from pipenv.patched.pip._vendor.packaging import specifiers
from pipenv.utils.fileutils import is_valid_url, normalize_path, url_to_path
from pipenv.vendor import tomlkit

STRING_TYPE = Union[bytes, str, str]
S = TypeVar("S", bytes, str, str)
PipfileEntryType = Union[STRING_TYPE, bool, Tuple[STRING_TYPE], List[STRING_TYPE]]
PipfileType = Union[STRING_TYPE, Dict[STRING_TYPE, PipfileEntryType]]


VCS_LIST = ("git", "svn", "hg", "bzr")
SCHEME_LIST = ("http://", "https://", "ftp://", "ftps://", "file://")


VCS_SCHEMES = {
    "git",
    "git+http",
    "git+https",
    "git+ssh",
    "git+git",
    "git+file",
    "hg",
    "hg+http",
    "hg+https",
    "hg+ssh",
    "hg+static-http",
    "svn",
    "svn+ssh",
    "svn+http",
    "svn+https",
    "svn+svn",
    "bzr",
    "bzr+http",
    "bzr+https",
    "bzr+ssh",
    "bzr+sftp",
    "bzr+ftp",
    "bzr+lp",
}


def strip_ssh_from_git_uri(uri):
    # type: (S) -> S
    """Return git+ssh:// formatted URI to git+git@ format."""
    if isinstance(uri, str) and "git+ssh://" in uri:
        parsed = urlparse(uri)
        # split the path on the first separating / so we can put the first segment
        # into the 'netloc' section with a : separator
        path_part, _, path = parsed.path.lstrip("/").partition("/")
        path = f"/{path}"
        parsed = parsed._replace(netloc=f"{parsed.netloc}:{path_part}", path=path)
        uri = urlunparse(parsed).replace("git+ssh://", "git+", 1)
    return uri


def add_ssh_scheme_to_git_uri(uri):
    # type: (S) -> S
    """Cleans VCS uris from pip format."""
    if isinstance(uri, str):
        # Add scheme for parsing purposes, this is also what pip does
        if uri.startswith("git+") and "://" not in uri:
            uri = uri.replace("git+", "git+ssh://", 1)
            parsed = urlparse(uri)
            if ":" in parsed.netloc:
                netloc, _, path_start = parsed.netloc.rpartition(":")
                path = f"/{path_start}{parsed.path}"
                uri = urlunparse(parsed._replace(netloc=netloc, path=path))
    return uri


def is_vcs(pipfile_entry):
    # type: (PipfileType) -> bool
    """Determine if dictionary entry from Pipfile is for a vcs dependency."""
    if isinstance(pipfile_entry, Mapping):
        return any(key for key in pipfile_entry if key in VCS_LIST)

    elif isinstance(pipfile_entry, str):
        if not is_valid_url(pipfile_entry) and pipfile_entry.startswith("git+"):
            pipfile_entry = add_ssh_scheme_to_git_uri(pipfile_entry)

        parsed_entry = urlsplit(pipfile_entry)
        return parsed_entry.scheme in VCS_SCHEMES
    return False


def is_editable(pipfile_entry):
    # type: (PipfileType) -> bool
    """Check if a Pipfile entry is editable."""
    if isinstance(pipfile_entry, Mapping):
        return pipfile_entry.get("editable", False) is True
    if isinstance(pipfile_entry, str):
        return pipfile_entry.startswith("-e ")
    return False


def is_star(val):
    # type: (PipfileType) -> bool
    """Check if a Pipfile entry has a version specified as '*'."""
    return (isinstance(val, str) and val == "*") or (
        isinstance(val, Mapping) and val.get("version", "") == "*"
    )


def convert_entry_to_path(path):
    # type: (Dict[S, Union[S, bool, Tuple[S], List[S]]]) -> S
    """Convert a pipfile entry to a string."""

    if not isinstance(path, Mapping):
        raise TypeError(f"expecting a mapping, received {path!r}")

    if not any(key in path for key in ["file", "path"]):
        raise ValueError(f"missing path-like entry in supplied mapping {path!r}")

    if "file" in path:
        path = url_to_path(path["file"])

    elif "path" in path:
        path = path["path"]
    return Path(os.fsdecode(path)).as_posix() if os.name == "nt" else os.fsdecode(path)


def is_installable_file(path):
    # type: (PipfileType) -> bool
    """Determine if a path can potentially be installed."""

    if isinstance(path, Mapping):
        path = convert_entry_to_path(path)

    # If the string starts with a valid specifier operator, test if it is a valid
    # specifier set before making a path object (to avoid breaking windows)
    if any(path.startswith(spec) for spec in "!=<>~"):
        try:
            specifiers.SpecifierSet(path)
        # If this is not a valid specifier, just move on and try it as a path
        except specifiers.InvalidSpecifier:
            pass
        else:
            return False

    parsed = urlparse(path)
    is_local = (
        not parsed.scheme
        or parsed.scheme == "file"
        or (len(parsed.scheme) == 1 and os.name == "nt")
    )

    if parsed.scheme and parsed.scheme == "file":
        path = os.fsdecode(url_to_path(path))

    # Normalize the path
    normalized_path = Path(normalize_path(path))

    # Check if the path exists (for local paths)
    if is_local and not normalized_path.exists():
        return False

    # Check if it's an archive file
    is_archive = is_archive_file(str(normalized_path))

    # Check if it's a local installable project directory
    is_local_project = normalized_path.is_dir() and is_installable_dir(normalized_path)

    if is_local and (is_local_project or is_archive):
        return True

    # Check if it's a remote archive
    if not is_local and is_archive_file(parsed.path):
        return True

    return False


def get_setup_paths(base_path, subdirectory=None):
    """Get paths to setup.py, setup.cfg, and pyproject.toml in the given directory."""
    if base_path is None:
        raise TypeError("must provide a path to derive setup paths from")

    # Convert to Path objects
    base_path = Path(base_path)

    # Define setup file paths in the base directory
    setup_py = base_path / "setup.py"
    setup_cfg = base_path / "setup.cfg"
    pyproject_toml = base_path / "pyproject.toml"

    # Handle subdirectory if specified
    if subdirectory is not None:
        subdirectory = Path(subdirectory)

        # If subdirectory is relative, join it with base_path
        if not subdirectory.is_absolute():
            subdir_path = base_path / subdirectory
        else:
            subdir_path = subdirectory

        # Define setup file paths in the subdirectory
        subdir_setup_py = subdir_path / "setup.py"
        subdir_setup_cfg = subdir_path / "setup.cfg"
        subdir_pyproject_toml = subdir_path / "pyproject.toml"

        # Use subdirectory paths if they exist
        if subdir_setup_py.exists():
            setup_py = subdir_setup_py
        if subdir_setup_cfg.exists():
            setup_cfg = subdir_setup_cfg
        if subdir_pyproject_toml.exists():
            pyproject_toml = subdir_pyproject_toml

    # Return the dictionary with string paths (or None if not existing)
    return {
        "setup_py": str(setup_py) if setup_py.exists() else None,
        "setup_cfg": str(setup_cfg) if setup_cfg.exists() else None,
        "pyproject_toml": str(pyproject_toml) if pyproject_toml.exists() else None,
    }


def prepare_pip_source_args(sources, pip_args=None):
    # type: (List[Dict[S, Union[S, bool]]], Optional[List[S]]) -> List[S]
    """Prepare pip arguments for source indexes."""
    if pip_args is None:
        pip_args = []
    if sources:
        # Add the source to pip9.
        pip_args.extend(["-i", sources[0]["url"]])  # type: ignore
        # Trust the host if it's not verified.
        hostname = urlparse(sources[0]["url"]).hostname
        if not sources[0].get("verify_ssl", True) and hostname:
            pip_args.extend(["--trusted-host", hostname])  # type: ignore
        # Add additional sources as extra indexes.
        for source in sources[1:]:
            pip_args.extend(["--extra-index-url", source["url"]])  # type: ignore
            hostname = urlparse(source["url"]).hostname
            if not source.get("verify_ssl", True) and hostname:
                pip_args.extend(["--trusted-host", hostname])  # type: ignore
    return pip_args


def get_package_finder(
    install_cmd=None,
    options=None,
    session=None,
    platform=None,
    python_versions=None,
    abi=None,
    implementation=None,
    ignore_requires_python=None,
):
    """Reduced Shim for compatibility to generate package finders."""
    py_version_info = None
    if python_versions:
        py_version_info_python = max(python_versions)
        py_version_info = tuple(int(part) for part in py_version_info_python)
    target_python = TargetPython(
        platforms=[platform] if platform else None,
        py_version_info=py_version_info,
        abis=[abi] if abi else None,
        implementation=implementation,
    )
    return install_cmd._build_package_finder(
        options=options,
        session=session,
        target_python=target_python,
        ignore_requires_python=ignore_requires_python,
    )


_UNSET = object()
_REMAP_EXIT = object()


# The following functionality is either borrowed or modified from the itertools module
# in the boltons library by Mahmoud Hashemi and distributed under the BSD license
# the text of which is included below:

# (original text from https://github.com/mahmoud/boltons/blob/master/LICENSE)
#   Copyright (c) 2013, Mahmoud Hashemi
#
#   Redistribution and use in source and binary forms, with or without
#   modification, are permitted provided that the following conditions are
#   met:
#
#       * Redistributions of source code must retain the above copyright
#         notice, this list of conditions and the following disclaimer.
#
#       * Redistributions in binary form must reproduce the above
#         copyright notice, this list of conditions and the following
#         disclaimer in the documentation and/or other materials provided
#         with the distribution.
#
#       * The names of the contributors may not be used to endorse or
#         promote products derived from this software without specific
#         prior written permission.
#
#
#   THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
#   "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
#   LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
#   A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
#   OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
#   SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
#   LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
#   DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
#   THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
#   (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
#   OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.


class PathAccessError(KeyError, IndexError, TypeError):
    """An amalgamation of KeyError, IndexError, and TypeError, representing
    what can occur when looking up a path in a nested object."""

    def __init__(self, exc, seg, path):
        self.exc = exc
        self.seg = seg
        self.path = path

    def __repr__(self):
        cn = self.__class__.__name__
        return f"{cn}({self.exc!r}, {self.seg!r}, {self.path!r})"

    def __str__(self):
        return f"could not access {self.seg} from path {self.path}, got error: {self.exc}"


def get_path(root, path, default=_UNSET):
    """Retrieve a value from a nested object via a tuple representing the
    lookup path.

    >>> root = {'a': {'b': {'c': [[1], [2], [3]]}}}
    >>> get_path(root, ('a', 'b', 'c', 2, 0))
    3
    The path format is intentionally consistent with that of
    :func:`remap`.
    One of get_path's chief aims is improved error messaging. EAFP is
    great, but the error messages are not.
    For instance, ``root['a']['b']['c'][2][1]`` gives back
    ``IndexError: list index out of range``
    What went out of range where? get_path currently raises
    ``PathAccessError: could not access 2 from path ('a', 'b', 'c', 2,
    1), got error: IndexError('list index out of range',)``, a
    subclass of IndexError and KeyError.
    You can also pass a default that covers the entire operation,
    should the lookup fail at any level.
    Args:
       root: The target nesting of dictionaries, lists, or other
          objects supporting ``__getitem__``.
       path (tuple): A list of strings and integers to be successively
          looked up within *root*.
       default: The value to be returned should any
          ``PathAccessError`` exceptions be raised.
    """
    if isinstance(path, str):
        path = path.split(".")
    cur = root
    try:
        for seg in path:
            try:
                cur = cur[seg]
            except (KeyError, IndexError) as exc:  # noqa: PERF203
                raise PathAccessError(exc, seg, path)
            except TypeError:
                # either string index in a list, or a parent that
                # doesn't support indexing
                try:
                    seg = int(seg)
                    cur = cur[seg]
                except (ValueError, KeyError, IndexError, TypeError):
                    if not hasattr(cur, "__iter__"):
                        exc = TypeError(f"{type(cur).__name__!r} object is not indexable")
                    raise PathAccessError(exc, seg, path)
    except PathAccessError:
        if default is _UNSET:
            raise
        return default
    return cur


def default_visit(path, key, value):
    return key, value


_orig_default_visit = default_visit


# Modified from https://github.com/mahmoud/boltons/blob/master/boltons/iterutils.py
def dict_path_enter(path, key, value):
    if isinstance(value, str):
        return value, False
    elif isinstance(value, (tomlkit.items.Table, tomlkit.items.InlineTable)):
        return value.__class__(
            tomlkit.container.Container(), value.trivia, False
        ), ItemsView(value)
    elif isinstance(value, (Mapping, dict)):
        return value.__class__(), ItemsView(value)
    elif isinstance(value, tomlkit.items.Array):
        return value.__class__([], value.trivia), enumerate(value)
    elif isinstance(value, (Sequence, list)):
        return value.__class__(), enumerate(value)
    elif isinstance(value, (Set, set)):
        return value.__class__(), enumerate(value)
    else:
        return value, False


def dict_path_exit(path, key, old_parent, new_parent, new_items):
    ret = new_parent
    if isinstance(new_parent, (Mapping, dict)):
        vals = dict(new_items)
        try:
            new_parent.update(new_items)
        except AttributeError:
            # Handle toml containers specifically
            try:
                new_parent.update(vals)
            # Now use default fallback if needed
            except AttributeError:
                ret = new_parent.__class__(vals)
    elif isinstance(new_parent, tomlkit.items.Array):
        vals = tomlkit.items.item([v for i, v in new_items])
        try:
            new_parent._value.extend(vals._value)
        except AttributeError:
            ret = tomlkit.items.item(vals)
    elif isinstance(new_parent, (Sequence, list)):
        vals = [v for i, v in new_items]
        try:
            new_parent.extend(vals)
        except AttributeError:
            ret = new_parent.__class__(vals)  # tuples
    elif isinstance(new_parent, (Set, set)):
        vals = [v for i, v in new_items]
        try:
            new_parent.update(vals)
        except AttributeError:
            ret = new_parent.__class__(vals)  # frozensets
    else:
        raise RuntimeError(f"Unexpected iterable type: {type(new_parent)!r}")
    return ret


def remap(
    root, visit=default_visit, enter=dict_path_enter, exit=dict_path_exit, **kwargs
):
    """The remap ("recursive map") function is used to traverse and transform
    nested structures. Lists, tuples, sets, and dictionaries are just a few of
    the data structures nested into heterogeneous tree-like structures that are
    so common in programming. Unfortunately, Python's built-in ways to
    manipulate collections are almost all flat. List comprehensions may be fast
    and succinct, but they do not recurse, making it tedious to apply quick
    changes or complex transforms to real-world data. remap goes where list
    comprehensions cannot. Here's an example of removing all Nones from some
    data:

    >>> from pprint import pprint
    >>> reviews = {'Star Trek': {'TNG': 10, 'DS9': 8.5, 'ENT': None},
    ...            'Babylon 5': 6, 'Dr. Who': None}
    >>> pprint(remap(reviews, lambda p, k, v: v is not None))
    {'Babylon 5': 6, 'Star Trek': {'DS9': 8.5, 'TNG': 10}}
    Notice how both Nones have been removed despite the nesting in the
    dictionary. Not bad for a one-liner, and that's just the beginning.
    See `this remap cookbook`_ for more delicious recipes.
    .. _this remap cookbook: http://sedimental.org/remap.html
    remap takes four main arguments: the object to traverse and three
    optional callables which determine how the remapped object will be
    created.
    Args:
        root: The target object to traverse. By default, remap
            supports iterables like :class:`list`, :class:`tuple`,
            :class:`dict`, and :class:`set`, but any object traversable by
            *enter* will work.
        visit (callable): This function is called on every item in
            *root*. It must accept three positional arguments, *path*,
            *key*, and *value*. *path* is simply a tuple of parents'
            keys. *visit* should return the new key-value pair. It may
            also return ``True`` as shorthand to keep the old item
            unmodified, or ``False`` to drop the item from the new
            structure. *visit* is called after *enter*, on the new parent.
            The *visit* function is called for every item in root,
            including duplicate items. For traversable values, it is
            called on the new parent object, after all its children
            have been visited. The default visit behavior simply
            returns the key-value pair unmodified.
        enter (callable): This function controls which items in *root*
            are traversed. It accepts the same arguments as *visit*: the
            path, the key, and the value of the current item. It returns a
            pair of the blank new parent, and an iterator over the items
            which should be visited. If ``False`` is returned instead of
            an iterator, the value will not be traversed.
            The *enter* function is only called once per unique value. The
            default enter behavior support mappings, sequences, and
            sets. Strings and all other iterables will not be traversed.
        exit (callable): This function determines how to handle items
            once they have been visited. It gets the same three
            arguments as the other functions -- *path*, *key*, *value*
            -- plus two more: the blank new parent object returned
            from *enter*, and a list of the new items, as remapped by
            *visit*.
            Like *enter*, the *exit* function is only called once per
            unique value. The default exit behavior is to simply add
            all new items to the new parent, e.g., using
            :meth:`list.extend` and :meth:`dict.update` to add to the
            new parent. Immutable objects, such as a :class:`tuple` or
            :class:`namedtuple`, must be recreated from scratch, but
            use the same type as the new parent passed back from the
            *enter* function.
        reraise_visit (bool): A pragmatic convenience for the *visit*
            callable. When set to ``False``, remap ignores any errors
            raised by the *visit* callback. Items causing exceptions
            are kept. See examples for more details.
    remap is designed to cover the majority of cases with just the
    *visit* callable. While passing in multiple callables is very
    empowering, remap is designed so very few cases should require
    passing more than one function.
    When passing *enter* and *exit*, it's common and easiest to build
    on the default behavior. Simply add ``from boltons.iterutils import
    default_enter`` (or ``default_exit``), and have your enter/exit
    function call the default behavior before or after your custom
    logic. See `this example`_.
    Duplicate and self-referential objects (aka reference loops) are
    automatically handled internally, `as shown here`_.
    .. _this example: http://sedimental.org/remap.html#sort_all_lists
    .. _as shown here: http://sedimental.org/remap.html#corner_cases
    """
    # TODO: improve argument formatting in sphinx doc
    # TODO: enter() return (False, items) to continue traverse but cancel copy?
    if not callable(visit):
        raise TypeError(f"visit expected callable, not: {visit!r}")
    if not callable(enter):
        raise TypeError(f"enter expected callable, not: {enter!r}")
    if not callable(exit):
        raise TypeError(f"exit expected callable, not: {exit!r}")
    reraise_visit = kwargs.pop("reraise_visit", True)
    if kwargs:
        raise TypeError(f"Unexpected keyword arguments: {kwargs.keys()!r}")

    path, registry, stack = (), {}, [(None, root)]
    new_items_stack = []
    while stack:
        key, value = stack.pop()
        id_value = id(value)
        if key is _REMAP_EXIT:
            key, new_parent, old_parent = value
            id_value = id(old_parent)
            path, new_items = new_items_stack.pop()
            value = exit(path, key, old_parent, new_parent, new_items)
            registry[id_value] = value
            if not new_items_stack:
                continue
        elif id_value in registry:
            value = registry[id_value]
        else:
            res = enter(path, key, value)
            try:
                new_parent, new_items = res
            except TypeError:
                # TODO: handle False?
                raise TypeError(
                    f"enter should return a tuple of (new_parent, items_iterator), not: {res!r}"
                )
            if new_items is not False:
                # traverse unless False is explicitly passed
                registry[id_value] = new_parent
                new_items_stack.append((path, []))
                if value is not root:
                    path += (key,)
                stack.append((_REMAP_EXIT, (key, new_parent, value)))
                if new_items:
                    stack.extend(reversed(list(new_items)))
                continue
        if visit is _orig_default_visit:
            # avoid function call overhead by inlining identity operation
            visited_item = (key, value)
        else:
            try:
                visited_item = visit(path, key, value)
            except Exception:
                if reraise_visit:
                    raise
                visited_item = True
            if visited_item is False:
                continue  # drop
            elif visited_item is True:
                visited_item = (key, value)
            # TODO: typecheck?
            #    raise TypeError('expected (key, value) from visit(),'
            #                    ' not: %r' % visited_item)
        try:
            new_items_stack[-1][1].append(visited_item)
        except IndexError:
            raise TypeError(f"Expected remappable root, not: {root!r}")
    return value


def merge_items(target_list, sourced=False):
    if not sourced:
        target_list = [(id(t), t) for t in target_list]

    ret = None
    source_map = {}

    def remerge_enter(path, key, value):
        new_parent, new_items = dict_path_enter(path, key, value)
        if ret and not path and key is None:
            new_parent = ret

        try:
            cur_val = get_path(ret, path + (key,))
        except KeyError:
            pass
        else:
            new_parent = cur_val

        return new_parent, new_items

    def remerge_exit(path, key, old_parent, new_parent, new_items):
        return dict_path_exit(path, key, old_parent, new_parent, new_items)

    for t_name, target in target_list:
        if sourced:

            def remerge_visit(path, key, value):
                source_map[path + (key,)] = t_name  # noqa: B023
                return True

        else:
            remerge_visit = default_visit

        ret = remap(target, enter=remerge_enter, visit=remerge_visit, exit=remerge_exit)

    if not sourced:
        return ret
    return ret, source_map


def get_pip_command() -> InstallCommand:
    """Get pip's InstallCommand for configuration management and defaults."""
    # Use pip's parser for pip.conf management and defaults.
    # General options (find_links, index_url, extra_index_url, trusted_host,
    # and pre) are deferred to pip.
    pip_command = InstallCommand(
        name="InstallCommand", summary="pipenv pip Install command."
    )
    return pip_command


def unpack_url(
    link: Link,
    location: str,
    download: Downloader,
    verbosity: int,
    download_dir: Optional[str] = None,
    hashes: Optional[Hashes] = None,
) -> Optional[File]:
    """Unpack link into location, downloading if required.

    :param hashes: A Hashes object, one of whose embedded hashes must match,
        or HashMismatch will be raised. If the Hashes is empty, no matches are
        required, and unhashable types of requirements (like VCS ones, which
        would ordinarily raise HashUnsupported) are allowed.
    """
    # non-editable vcs urls
    if link.scheme in VCS_SCHEMES:
        unpack_vcs_link(link, location, verbosity=verbosity)
        return File(location, content_type=None)

    assert not link.is_existing_dir()

    # file urls
    if link.is_file:
        file = get_file_url(link, download_dir, hashes=hashes)

    # http urls
    else:
        file = get_http_url(
            link,
            download,
            download_dir,
            hashes=hashes,
        )

    # unpack the archive to the build dir location. even when only downloading
    # archives, they have to be unpacked to parse dependencies, except wheels
    if not link.is_wheel:
        unpack_file(file.path, location, file.content_type)

    return file


def get_http_url(
    link: Link,
    download: Downloader,
    download_dir: Optional[str] = None,
    hashes: Optional[Hashes] = None,
) -> File:
    """Download a file from an HTTP URL."""
    temp_dir = TempDirectory(kind="unpack", globally_managed=False)
    # If a download dir is specified, is the file already downloaded there?
    already_downloaded_path = None
    if download_dir:
        already_downloaded_path = _check_download_dir(link, download_dir, hashes)

    if already_downloaded_path:
        from_path = already_downloaded_path
        content_type = None
    else:
        # let's download to a tmp dir
        from_path, content_type = download(link, temp_dir.path)
        if hashes:
            hashes.check_against_path(from_path)

    return File(from_path, content_type)
