import contextlib
import io
import itertools
import os
from pathlib import Path
from typing import Any, Dict, List, Optional

from pipenv import environments, exceptions
from pipenv.utils import console, err
from pipenv.utils.internet import get_url_name
from pipenv.utils.markers import RequirementError
from pipenv.utils.requirements import import_requirements
from pipenv.utils.requirementslib import is_editable, is_vcs, merge_items
from pipenv.utils.toml import tomlkit_value_to_python
from pipenv.vendor import tomlkit
from pipenv.vendor.plette import pipfiles
from pipenv.vendor.pydantic import BaseModel, Field, validator

DEFAULT_NEWLINES = "\n"


def walk_up(bottom):
    """mimic os.walk, but walk 'up' instead of down the directory tree.
    From: https://gist.github.com/zdavkeos/1098474
    """

    bottom = os.path.realpath(bottom)

    # get files in current dir
    try:
        names = os.listdir(bottom)
    except Exception:
        return

    dirs, nondirs = [], []
    for name in names:
        if os.path.isdir(os.path.join(bottom, name)):
            dirs.append(name)
        else:
            nondirs.append(name)

    yield bottom, dirs, nondirs

    new_path = os.path.realpath(os.path.join(bottom, ".."))

    # see if we are at the top
    if new_path == bottom:
        return

    yield from walk_up(new_path)


def find_pipfile(max_depth=3):
    """Returns the path of a Pipfile in parent directories."""
    i = 0
    for c, _, _ in walk_up(os.getcwd()):
        i += 1

        if i < max_depth and "Pipfile":
            p = os.path.join(c, "Pipfile")
            if os.path.isfile(p):
                return p
    raise RuntimeError("No Pipfile found!")


def ensure_pipfile(
    project, validate=True, skip_requirements=False, system=False, categories=None
):
    """Creates a Pipfile for the project, if it doesn't exist."""

    # Assert Pipfile exists.
    python = (
        project._which("python")
        if not (project.s.USING_DEFAULT_PYTHON or system)
        else None
    )
    if project.pipfile_is_empty:
        # Show an error message and exit if system is passed and no pipfile exists
        if system and not project.s.PIPENV_VIRTUALENV:
            raise exceptions.PipenvOptionsError(
                "--system",
                "--system is intended to be used for pre-existing Pipfile "
                "installation, not installation of specific packages. Aborting.",
            )
        # If there's a requirements file, but no Pipfile...
        if project.requirements_exists and not skip_requirements:
            requirements_dir_path = os.path.dirname(project.requirements_location)
            console.print(
                f"[bold]requirements.txt[/bold] found in [bold yellow]{requirements_dir_path}"
                "[/bold yellow] instead of [bold]Pipfile[/bold]! Converting..."
            )
            # Create a Pipfile...
            project.create_pipfile(python=python)
            with console.status(
                "Importing requirements...", spinner=project.s.PIPENV_SPINNER
            ) as st:
                # Import requirements.txt.
                try:
                    import_requirements(project, categories=categories)
                except Exception:
                    err.print(environments.PIPENV_SPINNER_FAIL_TEXT.format("Failed..."))
                else:
                    st.console.print(
                        environments.PIPENV_SPINNER_OK_TEXT.format("Success!")
                    )
            # Warn the user of side-effects.
            console.print(
                "[bold red]Warning[/bold red]: Your [bold]Pipfile[/bold] now contains pinned versions, "
                "if your [bold]requirements.txt[/bold] did. \n"
                'We recommend updating your [bold]Pipfile[/bold] to specify the [bold]"*"'
                "[/bold] version, instead."
            )
        else:
            err.print("Creating a Pipfile for this project...", style="bold")
            # Create the pipfile if it doesn't exist.
            project.create_pipfile(python=python)
    # Validate the Pipfile's contents.
    if validate and project.virtualenv_exists and not project.s.PIPENV_SKIP_VALIDATION:
        # Ensure that Pipfile is using proper casing.
        p = project.parsed_pipfile
        changed = project.ensure_proper_casing()
        # Write changes out to disk.
        if changed:
            err.print("Fixing package names in Pipfile...", style="bold")
            project.write_toml(p)


def reorder_source_keys(data):
    # type: (tomlkit.toml_document.TOMLDocument) -> tomlkit.toml_document.TOMLDocument
    sources = []  # type: sources_type
    for source_key in ["source", "sources"]:
        sources.extend(data.get(source_key, tomlkit.aot()).value)
    new_source_aot = tomlkit.aot()
    for entry in sources:
        table = tomlkit.table()  # type: tomlkit.items.Table
        source_entry = PipfileLoader.populate_source(entry.copy())
        for key in ["name", "url", "verify_ssl"]:
            table.update({key: source_entry[key]})
        new_source_aot.append(table)
    data["source"] = new_source_aot
    if data.get("sources", None):
        del data["sources"]
    return data


def preferred_newlines(f):
    if isinstance(f.newlines, str):
        return f.newlines
    return DEFAULT_NEWLINES


class ProjectFile(BaseModel):
    location: str
    line_ending: str
    model: Optional[Any] = Field(default_factory=lambda: {})

    @classmethod
    def read(cls, location: str, model_cls, invalid_ok: bool = False) -> "ProjectFile":
        if not os.path.exists(location) and not invalid_ok:
            raise FileNotFoundError(location)
        try:
            with open(location, encoding="utf-8") as f:
                model = model_cls.load(f)
                line_ending = preferred_newlines(f)
        except Exception:
            if not invalid_ok:
                raise
            model = {}
            line_ending = DEFAULT_NEWLINES
        return cls(location=location, line_ending=line_ending, model=model)

    def write(self) -> None:
        kwargs = {"encoding": "utf-8", "newline": self.line_ending}
        with open(self.location, "w", **kwargs) as f:
            if self.model:
                self.model.dump(f)

    def dumps(self) -> str:
        if self.model:
            strio = io.StringIO()
            self.model.dump(strio)
            return strio.getvalue()
        return ""


class PipfileLoader(pipfiles.Pipfile):
    @classmethod
    def validate(cls, data):
        # type: (tomlkit.toml_document.TOMLDocument) -> None
        for key, klass in pipfiles.PIPFILE_SECTIONS.items():
            if key not in data or key == "sources":
                continue
            with contextlib.suppress(Exception):
                klass.validate(data[key])

    @classmethod
    def ensure_package_sections(cls, data):
        # type: (tomlkit.toml_document.TOMLDocument[Text, Any]) -> tomlkit.toml_document.TOMLDocument[Text, Any]
        """Ensure that all pipfile package sections are present in the given
        toml document.

        :param :class:`~tomlkit.toml_document.TOMLDocument` data: The toml document to
            ensure package sections are present on
        :return: The updated toml document, ensuring ``packages`` and ``dev-packages``
            sections are present
        :rtype: :class:`~tomlkit.toml_document.TOMLDocument`
        """
        package_keys = (k for k in pipfiles.PIPFILE_SECTIONS if k.endswith("packages"))
        for key in package_keys:
            if key not in data:
                data.update({key: tomlkit.table()})
        return data

    @classmethod
    def populate_source(cls, source):
        """Derive missing values of source from the existing fields."""
        # Only URL pararemter is mandatory, let the KeyError be thrown.
        if "name" not in source:
            source["name"] = get_url_name(source["url"])
        if "verify_ssl" not in source:
            source["verify_ssl"] = "https://" in source["url"]
        if not isinstance(source["verify_ssl"], bool):
            source["verify_ssl"] = str(source["verify_ssl"]).lower() == "true"
        return source

    @classmethod
    def load(cls, f, encoding=None):
        # type: (Any, Text) -> PipfileLoader
        content = f.read()
        if encoding is not None:
            content = content.decode(encoding)
        _data = tomlkit.loads(content)
        should_reload = "source" not in _data
        _data = reorder_source_keys(_data)
        if should_reload:
            if "sources" in _data:
                content = tomlkit.dumps(_data)
            else:
                # HACK: There is no good way to prepend a section to an existing
                # TOML document, but there's no good way to copy non-structural
                # content from one TOML document to another either. Modify the
                # TOML content directly, and load the new in-memory document.
                sep = "" if content.startswith("\n") else "\n"
                content = pipfiles.DEFAULT_SOURCE_TOML + sep + content
        data = tomlkit.loads(content)
        data = cls.ensure_package_sections(data)
        instance = cls(data)
        instance._data = dict(instance._data)
        return instance

    def __contains__(self, key):
        # type: (Text) -> bool
        if key not in self._data:
            package_keys = self._data.get("packages", {}).keys()
            dev_package_keys = self._data.get("dev-packages", {}).keys()
            return any(key in pkg_list for pkg_list in (package_keys, dev_package_keys))
        return True

    def __getattribute__(self, key):
        # type: (Text) -> Any
        if key == "source":
            return self._data[key]
        return super().__getattribute__(key)


class Pipfile(BaseModel):
    path: Path
    projectfile: ProjectFile
    pipfile: Optional[PipfileLoader]
    _pyproject: Optional[tomlkit.TOMLDocument] = tomlkit.document()
    build_system: Optional[Dict] = {}
    _requirements: Optional[List] = []
    _dev_requirements: Optional[List] = []

    class Config:
        validate_assignment = True
        arbitrary_types_allowed = True
        allow_mutation = True
        include_private_attributes = True
        # keep_untouched = (cached_property,)

    @validator("path", pre=True, always=True)
    def _get_path(cls, v):
        return v or Path(os.curdir).absolute()

    @validator("projectfile", pre=True, always=True)
    def _get_projectfile(cls, v, values):
        return v or cls.load_projectfile(os.curdir, create=False)

    @validator("pipfile", pre=True, always=True)
    def _get_pipfile(cls, v, values):
        return v or values["projectfile"].model

    @property
    def root(self):
        return self.path.parent

    @property
    def extended_keys(self):
        return list(
            itertools.product(("packages", "dev-packages"), ("", "vcs", "editable"))
        )

    def get_deps(self, dev=False, only=True):
        deps = {}  # type: Dict[Text, Dict[Text, Union[List[Text], Text]]]
        if dev:
            deps.update(dict(self.pipfile._data.get("dev-packages", {})))
            if only:
                return deps
        return tomlkit_value_to_python(
            merge_items([deps, dict(self.pipfile._data.get("packages", {}))])
        )

    def get(self, k):
        return self.__getitem__(k)

    def __contains__(self, k):
        check_pipfile = k in self.extended_keys or self.pipfile.__contains__(k)
        if check_pipfile:
            return True
        return False

    def __getitem__(self, k, *args, **kwargs):
        retval = None
        pipfile = self.pipfile
        section = None
        pkg_type = None
        try:
            retval = pipfile[k]
        except KeyError:
            if "-" in k:
                section, _, pkg_type = k.rpartition("-")
                vals = getattr(pipfile.get(section, {}), "_data", {})
                vals = tomlkit_value_to_python(vals)
                if pkg_type == "vcs":
                    retval = {k: v for k, v in vals.items() if is_vcs(v)}
                elif pkg_type == "editable":
                    retval = {k: v for k, v in vals.items() if is_editable(v)}
            if retval is None:
                raise
        else:
            retval = getattr(retval, "_data", retval)
        return retval

    def __getattr__(self, k, *args, **kwargs):
        pipfile = self.pipfile
        try:
            retval = super(Pipfile).__getattribute__(k)
        except AttributeError:
            retval = getattr(pipfile, k, None)
        return retval

    @property
    def requires_python(self):
        # type: () -> bool
        return getattr(
            self.pipfile.requires,
            "python_version",
            getattr(self.pipfile.requires, "python_full_version", None),
        )

    @property
    def allow_prereleases(self):
        # type: () -> bool
        return self.pipfile.get("pipenv", {}).get("allow_prereleases", False)

    @classmethod
    def read_projectfile(cls, path):
        # type: (Text) -> ProjectFile
        """Read the specified project file and provide an interface for
        writing/updating.

        :param Text path: Path to the target file.
        :return: A project file with the model and location for interaction
        :rtype: :class:`~project.ProjectFile`
        """
        pf = ProjectFile.read(path, PipfileLoader, invalid_ok=True)
        return pf

    @classmethod
    def load_projectfile(cls, path, create=False):
        # type: (Text, bool) -> ProjectFile
        """Given a path, load or create the necessary pipfile.

        :param Text path: Path to the project root or pipfile
        :param bool create: Whether to create the pipfile if not found, defaults to True
        :raises OSError: Thrown if the project root directory doesn't exist
        :raises FileNotFoundError: Thrown if the pipfile doesn't exist and ``create=False``
        :return: A project file instance for the supplied project
        :rtype: :class:`~project.ProjectFile`
        """
        if not path:
            raise RuntimeError("Must pass a path to classmethod 'Pipfile.load'")
        if not isinstance(path, Path):
            path = Path(path).absolute()
        pipfile_path = path if path.is_file() else path.joinpath("Pipfile")
        project_path = pipfile_path.parent
        if not project_path.exists():
            raise FileNotFoundError("%s is not a valid project path!" % path)
        elif (not pipfile_path.exists() or not pipfile_path.is_file()) and not create:
            raise RequirementError("%s is not a valid Pipfile" % pipfile_path)
        return cls.read_projectfile(pipfile_path.as_posix())

    @classmethod
    def load(cls, path, create=False):
        # type: (Text, bool) -> Pipfile
        """Given a path, load or create the necessary pipfile.

        :param Text path: Path to the project root or pipfile
        :param bool create: Whether to create the pipfile if not found, defaults to True
        :raises OSError: Thrown if the project root directory doesn't exist
        :raises FileNotFoundError: Thrown if the pipfile doesn't exist and ``create=False``
        :return: A pipfile instance pointing at the supplied project
        :rtype:: class:`~pipfile.Pipfile`
        """

        projectfile = cls.load_projectfile(path, create=create)
        pipfile = projectfile.model
        creation_args = {
            "projectfile": projectfile,
            "pipfile": pipfile,
            "path": Path(projectfile.location),
        }
        return cls(**creation_args)
