# -*- coding=utf-8 -*-
from __future__ import absolute_import, unicode_literals

import abc
import operator
from collections import defaultdict

import pipenv.vendor.attr as attr
import pipenv.vendor.six as six

from ..compat import fs_str
from ..environment import MYPY_RUNNING
from ..exceptions import InvalidPythonVersion
from ..utils import (
    KNOWN_EXTS,
    Sequence,
    expand_paths,
    looks_like_python,
    path_is_known_executable,
)

if MYPY_RUNNING:
    from typing import (
        Any,
        DefaultDict,
        Dict,
        Generator,
        Iterator,
        List,
        Optional,
        Tuple,
        Type,
        TypeVar,
        Union,
    )

    from ..compat import Path  # noqa
    from .path import PathEntry
    from .python import PythonVersion

    BaseFinderType = TypeVar("BaseFinderType")


@attr.s(slots=True)
class BasePath(object):
    path = attr.ib(default=None)  # type: Path
    _children = attr.ib(
        default=attr.Factory(dict), order=False
    )  # type: Dict[str, PathEntry]
    only_python = attr.ib(default=False)  # type: bool
    name = attr.ib(type=str)
    _py_version = attr.ib(default=None, order=False)  # type: Optional[PythonVersion]
    _pythons = attr.ib(
        default=attr.Factory(defaultdict), order=False
    )  # type: DefaultDict[str, PathEntry]
    _is_dir = attr.ib(default=None, order=False)  # type: Optional[bool]
    _is_executable = attr.ib(default=None, order=False)  # type: Optional[bool]
    _is_python = attr.ib(default=None, order=False)  # type: Optional[bool]

    def __str__(self):
        # type: () -> str
        return fs_str("{0}".format(self.path.as_posix()))

    def __lt__(self, other):
        # type: ("BasePath") -> bool
        return self.path.as_posix() < other.path.as_posix()

    def __lte__(self, other):
        # type: ("BasePath") -> bool
        return self.path.as_posix() <= other.path.as_posix()

    def __gt__(self, other):
        # type: ("BasePath") -> bool
        return self.path.as_posix() > other.path.as_posix()

    def __gte__(self, other):
        # type: ("BasePath") -> bool
        return self.path.as_posix() >= other.path.as_posix()

    def which(self, name):
        # type: (str) -> Optional[PathEntry]
        """Search in this path for an executable.

        :param executable: The name of an executable to search for.
        :type executable: str
        :returns: :class:`~pythonfinder.models.PathEntry` instance.
        """

        valid_names = [name] + [
            "{0}.{1}".format(name, ext).lower() if ext else "{0}".format(name).lower()
            for ext in KNOWN_EXTS
        ]
        children = self.children
        found = None
        if self.path is not None:
            found = next(
                (
                    children[(self.path / child).as_posix()]
                    for child in valid_names
                    if (self.path / child).as_posix() in children
                ),
                None,
            )
        return found

    def __del__(self):
        for key in ["_is_dir", "_is_python", "_is_executable", "_py_version"]:
            if getattr(self, key, None):
                try:
                    delattr(self, key)
                except Exception:
                    print("failed deleting key: {0}".format(key))
        self._children = {}
        for key in list(self._pythons.keys()):
            del self._pythons[key]
        self._pythons = None
        self._py_version = None
        self.path = None

    @property
    def children(self):
        # type: () -> Dict[str, PathEntry]
        if not self.is_dir:
            return {}
        return self._children

    @property
    def as_python(self):
        # type: () -> PythonVersion
        py_version = None
        if self.py_version:
            return self.py_version
        if not self.is_dir and self.is_python:
            try:
                from .python import PythonVersion

                py_version = PythonVersion.from_path(  # type: ignore
                    path=self, name=self.name
                )
            except (ValueError, InvalidPythonVersion):
                pass
        if py_version is None:
            pass
        self.py_version = py_version
        return py_version  # type: ignore

    @name.default
    def get_name(self):
        # type: () -> Optional[str]
        if self.path:
            return self.path.name
        return None

    @property
    def is_dir(self):
        # type: () -> bool
        if self._is_dir is None:
            if not self.path:
                ret_val = False
            try:
                ret_val = self.path.is_dir()
            except OSError:
                ret_val = False
            self._is_dir = ret_val
        return self._is_dir

    @is_dir.setter
    def is_dir(self, val):
        # type: (bool) -> None
        self._is_dir = val

    @is_dir.deleter
    def is_dir(self):
        # type: () -> None
        self._is_dir = None

    @property
    def is_executable(self):
        # type: () -> bool
        if self._is_executable is None:
            if not self.path:
                self._is_executable = False
            else:
                self._is_executable = path_is_known_executable(self.path)
        return self._is_executable

    @is_executable.setter
    def is_executable(self, val):
        # type: (bool) -> None
        self._is_executable = val

    @is_executable.deleter
    def is_executable(self):
        # type: () -> None
        self._is_executable = None

    @property
    def is_python(self):
        # type: () -> bool
        if self._is_python is None:
            if not self.path:
                self._is_python = False
            else:
                self._is_python = self.is_executable and (
                    looks_like_python(self.path.name)
                )
        return self._is_python

    @is_python.setter
    def is_python(self, val):
        # type: (bool) -> None
        self._is_python = val

    @is_python.deleter
    def is_python(self):
        # type: () -> None
        self._is_python = None

    def get_py_version(self):
        # type: () -> Optional[PythonVersion]
        from ..environment import IGNORE_UNSUPPORTED

        if self.is_dir:
            return None
        if self.is_python:
            py_version = None
            from .python import PythonVersion

            try:
                py_version = PythonVersion.from_path(  # type: ignore
                    path=self, name=self.name
                )
            except (InvalidPythonVersion, ValueError):
                py_version = None
            except Exception:
                if not IGNORE_UNSUPPORTED:
                    raise
            return py_version
        return None

    @property
    def py_version(self):
        # type: () -> Optional[PythonVersion]
        if not self._py_version:
            py_version = self.get_py_version()
            self._py_version = py_version
        else:
            py_version = self._py_version
        return py_version

    @py_version.setter
    def py_version(self, val):
        # type: (Optional[PythonVersion]) -> None
        self._py_version = val

    @py_version.deleter
    def py_version(self):
        # type: () -> None
        self._py_version = None

    def _iter_pythons(self):
        # type: () -> Iterator
        if self.is_dir:
            for entry in self.children.values():
                if entry is None:
                    continue
                elif entry.is_dir:
                    for python in entry._iter_pythons():
                        yield python
                elif entry.is_python and entry.as_python is not None:
                    yield entry
        elif self.is_python and self.as_python is not None:
            yield self  # type: ignore

    @property
    def pythons(self):
        # type: () -> DefaultDict[Union[str, Path], PathEntry]
        if not self._pythons:
            from .path import PathEntry

            self._pythons = defaultdict(PathEntry)
            for python in self._iter_pythons():
                python_path = python.path.as_posix()  # type: ignore
                self._pythons[python_path] = python
        return self._pythons

    def __iter__(self):
        # type: () -> Iterator
        for entry in self.children.values():
            yield entry

    def __next__(self):
        # type: () -> Generator
        return next(iter(self))

    def next(self):
        # type: () -> Generator
        return self.__next__()

    def find_all_python_versions(
        self,
        major=None,  # type: Optional[Union[str, int]]
        minor=None,  # type: Optional[int]
        patch=None,  # type: Optional[int]
        pre=None,  # type: Optional[bool]
        dev=None,  # type: Optional[bool]
        arch=None,  # type: Optional[str]
        name=None,  # type: Optional[str]
    ):
        # type: (...) -> List[PathEntry]
        """Search for a specific python version on the path. Return all copies

        :param major: Major python version to search for.
        :type major: int
        :param int minor: Minor python version to search for, defaults to None
        :param int patch: Patch python version to search for, defaults to None
        :param bool pre: Search for prereleases (default None) - prioritize releases if None
        :param bool dev: Search for devreleases (default None) - prioritize releases if None
        :param str arch: Architecture to include, e.g. '64bit', defaults to None
        :param str name: The name of a python version, e.g. ``anaconda3-5.3.0``
        :return: A list of :class:`~pythonfinder.models.PathEntry` instances matching the version requested.
        :rtype: List[:class:`~pythonfinder.models.PathEntry`]
        """

        call_method = "find_all_python_versions" if self.is_dir else "find_python_version"
        sub_finder = operator.methodcaller(
            call_method, major, minor, patch, pre, dev, arch, name
        )
        if not self.is_dir:
            return sub_finder(self)
        unnested = [sub_finder(path) for path in expand_paths(self)]
        version_sort = operator.attrgetter("as_python.version_sort")
        unnested = [p for p in unnested if p is not None and p.as_python is not None]
        paths = sorted(unnested, key=version_sort, reverse=True)
        return list(paths)

    def find_python_version(
        self,
        major=None,  # type: Optional[Union[str, int]]
        minor=None,  # type: Optional[int]
        patch=None,  # type: Optional[int]
        pre=None,  # type: Optional[bool]
        dev=None,  # type: Optional[bool]
        arch=None,  # type: Optional[str]
        name=None,  # type: Optional[str]
    ):
        # type: (...) -> Optional[PathEntry]
        """Search or self for the specified Python version and return the first match.

        :param major: Major version number.
        :type major: int
        :param int minor: Minor python version to search for, defaults to None
        :param int patch: Patch python version to search for, defaults to None
        :param bool pre: Search for prereleases (default None) - prioritize releases if None
        :param bool dev: Search for devreleases (default None) - prioritize releases if None
        :param str arch: Architecture to include, e.g. '64bit', defaults to None
        :param str name: The name of a python version, e.g. ``anaconda3-5.3.0``
        :returns: A :class:`~pythonfinder.models.PathEntry` instance matching the version requested.
        """

        version_matcher = operator.methodcaller(
            "matches", major, minor, patch, pre, dev, arch, python_name=name
        )
        if not self.is_dir:
            if self.is_python and self.as_python and version_matcher(self.py_version):
                return self  # type: ignore

        matching_pythons = [
            [entry, entry.as_python.version_sort]
            for entry in self._iter_pythons()
            if (
                entry is not None
                and entry.as_python is not None
                and version_matcher(entry.py_version)
            )
        ]
        results = sorted(matching_pythons, key=operator.itemgetter(1, 0), reverse=True)
        return next(iter(r[0] for r in results if r is not None), None)


@six.add_metaclass(abc.ABCMeta)
class BaseFinder(object):
    def __init__(self):
        #: Maps executable paths to PathEntries
        from .path import PathEntry

        self._pythons = defaultdict(PathEntry)  # type: DefaultDict[str, PathEntry]
        self._versions = defaultdict(PathEntry)  # type: Dict[Tuple, PathEntry]

    def get_versions(self):
        # type: () -> DefaultDict[Tuple, PathEntry]
        """Return the available versions from the finder"""
        raise NotImplementedError

    @classmethod
    def create(cls, *args, **kwargs):
        # type: (Any, Any) -> BaseFinderType
        raise NotImplementedError

    @property
    def version_paths(self):
        # type: () -> Any
        return self._versions.values()

    @property
    def expanded_paths(self):
        # type: () -> Any
        return (p.paths.values() for p in self.version_paths)

    @property
    def pythons(self):
        # type: () -> DefaultDict[str, PathEntry]
        return self._pythons

    @pythons.setter
    def pythons(self, value):
        # type: (DefaultDict[str, PathEntry]) -> None
        self._pythons = value
