from __future__ import unicode_literals

from packaging.version import parse as parse_version
from packaging.specifiers import SpecifierSet
import requests

from safety import safety
from safety.errors import InvalidKeyError
from collections import OrderedDict

from .updates import InitialUpdate, SequentialUpdate, ScheduledUpdate
from .pullrequest import PullRequest
import logging
from .package import Package, fetch_package
from pyup import settings
from datetime import datetime
from dparse import parse, parser, updater, filetypes
from dparse.dependencies import Dependency
from dparse.parser import setuptools_parse_requirements_backport as parse_requirements

logger = logging.getLogger(__name__)


class RequirementsBundle(list):

    def __init__(self, *args, **kwargs):
        super(RequirementsBundle, self).__init__(*args, **kwargs)
        self.pull_requests = []

    def resolve_pipfiles(self):
        for req_file in self:
            if req_file.is_pipfile:
                corresponding_path = req_file.get_pipfile_lock_path()
                for _req_file in self:
                    if _req_file.path == corresponding_path:
                        req_file.corresponding_pipfile = _req_file

    def has_file_in_path(self, path):
        return path in [req_file.path for req_file in self]

    def get_updates(self, initial, scheduled, config):
        return self.get_update_class(
            initial=initial,
            scheduled=scheduled,
            config=config
        )(self, config).get_updates()

    def get_update_class(self, initial, scheduled, config):
        if initial:
            return self.get_initial_update_class()
        elif scheduled and config.is_valid_schedule():
            return self.get_scheduled_update_class()
        return self.get_sequential_update_class()

    @property
    def requirements(self):
        for req_file in self:
            for req in req_file.requirements:
                yield req

    def get_pull_request_class(self):  # pragma: no cover
        return PullRequest

    def get_requirement_class(self):  # pragma: no cover
        return Requirement

    def get_requirement_file_class(self):  # pragma: no cover
        return RequirementFile

    def get_initial_update_class(self):  # pragma: no cover
        return InitialUpdate

    def get_scheduled_update_class(self):  # pragma: no cover
        return ScheduledUpdate

    def get_sequential_update_class(self):  # pragma: no cover
        return SequentialUpdate


class RequirementFile(object):
    def __init__(self, path, content, sha=None):
        self.path = path
        self.content = content
        self.sha = sha
        self._requirements = None
        self._other_files = None
        self._is_valid = None
        self.is_pipfile = False
        self.is_pipfile_lock = False
        self.is_setup_cfg = False
        self.corresponding_pipfile = None

    def get_pipfile_lock_path(self):
        return "{}.lock".format(self.path)

    def get_pipfile_path(self):
        return self.path.replace(".lock", "")

    def __str__(self):
        return "RequirementFile(path='{path}', sha='{sha}', content='{content}')".format(
            path=self.path,
            content=self.content[:30] + "[truncated]" if len(self.content) > 30 else self.content,
            sha=self.sha
        )

    @property
    def is_valid(self):
        if self._is_valid is None:
            self._parse()
        return self._is_valid

    @property
    def requirements(self):
        if not self._requirements:
            self._parse()
        return self._requirements

    @property
    def other_files(self):
        if not self._other_files:
            self._parse()
        return self._other_files

    @staticmethod
    def parse_index_server(line):
        return parser.Parser.parse_index_server(line)

    def _hash_parser(self, line):
        return parser.Parser.parse_hashes(line)

    def _parse_requirements_txt(self):
        self.parse_dependencies(filetypes.requirements_txt)

    def _parse_conda_yml(self):
        self.parse_dependencies(filetypes.conda_yml)

    def _parse_tox_ini(self):
        self.parse_dependencies(filetypes.tox_ini)

    def _parse_pipfile(self):
        self.parse_dependencies(filetypes.pipfile)
        self.is_pipfile = True

    def _parse_pipfile_lock(self):
        self.parse_dependencies(filetypes.pipfile_lock)
        self.is_pipfile_lock = True

    def _parse_setup_cfg(self):
        self.parse_dependencies(filetypes.setup_cfg)
        self.is_setup_cfg = True

    def _parse(self):
        self._requirements, self._other_files = [], []
        if self.path.endswith('.yml') or self.path.endswith(".yaml"):
            self._parse_conda_yml()
        elif self.path.endswith('.ini'):
            self._parse_tox_ini()
        elif self.path.endswith("Pipfile"):
            self._parse_pipfile()
        elif self.path.endswith("Pipfile.lock"):
            self._parse_pipfile_lock()
        elif self.path.endswith('setup.cfg'):
            self._parse_setup_cfg()
        else:
            self._parse_requirements_txt()
        self._is_valid = len(self._requirements) > 0 or len(self._other_files) > 0

    def parse_dependencies(self, file_type):

        klass = self.get_requirement_class()
        result = parse(
            self.content,
            path=self.path,
            sha=self.sha,
            file_type=file_type,
            marker=(
                ("pyup: ignore file", "pyup:ignore file"),  # file marker
                ("pyup: ignore", "pyup:ignore"),  # line marker
            )
        )
        for dep in result.dependencies:
            req = klass(
                name=dep.name,
                specs=dep.specs,
                line=dep.line,
                lineno=dep.line_numbers[0] if dep.line_numbers else 0,
                extras=dep.extras,
                file_type=file_type,
            )
            req.index_server = dep.index_server
            if self.is_pipfile:
                req.pipfile = self.path
            if req.package:
                req.hashes = dep.hashes
                self._requirements.append(req)
        self._other_files = result.resolved_files

    def iter_lines(self, lineno=0):
        for line in self.content.splitlines()[lineno:]:
            yield line

    @classmethod
    def resolve_file(cls, file_path, line):
        return parser.Parser.resolve_file(file_path, line)

    def get_requirement_class(self):  # pragma: no cover
        return Requirement


class Requirement(object):
    def __init__(self, name, specs, line, lineno, extras, file_type):
        self.name = name
        self.key = name.lower()
        self.specs = specs
        self.line = line
        self.lineno = lineno
        self.index_server = None
        self.extras = extras
        self.pull_request = None
        self.hashes = []
        self._fetched_package = False
        self._package = None
        self.file_type = file_type
        self.pipfile = None

        self.hashCmp = (
            self.key,
            self.specs,
            frozenset(self.extras),
        )

        self._is_insecure = None
        self._changelog = None

    def __eq__(self, other):
        return (
            isinstance(other, Requirement) and
            self.hashCmp == other.hashCmp
        )

    def __ne__(self, other):
        return not self == other

    def __str__(self):
        return "Requirement.parse({line}, {lineno})".format(line=self.line, lineno=self.lineno)

    def __repr__(self):
        return self.__str__()

    @property
    def is_pinned(self):
        if len(self.specs._specs) == 1 and next(iter(self.specs._specs))._spec[0] == "==":
            return True
        return False

    @property
    def is_compatible(self):
        if len(self.specs._specs) == 1 and next(iter(self.specs._specs))._spec[0] == "~=":
            return True
        return False

    @property
    def is_open_ranged(self):
        if len(self.specs._specs) == 1 and next(iter(self.specs._specs))._spec[0] == ">=":
            return True
        return False

    @property
    def is_ranged(self):
        return len(self.specs._specs) >= 1 and not self.is_pinned

    @property
    def is_loose(self):
        return len(self.specs._specs) == 0

    @staticmethod
    def convert_semver(version):
        semver = {'major': 0, "minor": 0, "patch": 0}
        version = version.split(".")
        # don't be overly clever here. repitition makes it more readable and works exactly how
        # it is supposed to
        try:
            semver['major'] = int(version[0])
            semver['minor'] = int(version[1])
            semver['patch'] = int(version[2])
        except (IndexError, ValueError):
            pass
        return semver

    @property
    def can_update_semver(self):
        # return early if there's no update filter set
        if "pyup: update" not in self.line:
            return True
        update = self.line.split("pyup: update")[1].strip().split("#")[0]
        current_version = Requirement.convert_semver(next(iter(self.specs._specs))._spec[1])
        next_version = Requirement.convert_semver(self.latest_version)
        if update == "major":
            if current_version['major'] < next_version['major']:
                return True
        elif update == 'minor':
            if current_version['major'] < next_version['major'] \
                    or current_version['minor'] < next_version['minor']:
                return True
        return False

    @property
    def filter(self):
        rqfilter = False
        if "rq.filter:" in self.line:
            rqfilter = self.line.split("rq.filter:")[1].strip().split("#")[0]
        elif "pyup:" in self.line:
            if "pyup: update" not in self.line:
                rqfilter = self.line.split("pyup:")[1].strip().split("#")[0]
                # unset the filter once the date set in 'until' is reached
                if "until" in rqfilter:
                    rqfilter, until = [l.strip() for l in rqfilter.split("until")]
                    try:
                        until = datetime.strptime(until, "%Y-%m-%d")
                        if until < datetime.now():
                            rqfilter = False
                    except ValueError:
                        # wrong date formatting
                        pass
        if rqfilter:
            try:
                rqfilter, = parse_requirements("filter " + rqfilter)
                if len(rqfilter.specifier._specs) > 0:
                    return rqfilter.specifier
            except ValueError:
                pass
        return False

    @property
    def version(self):
        if self.is_pinned or self.is_compatible:
            return next(iter(self.specs._specs))._spec[1]

        specs = self.specs
        if self.filter:
            specs = SpecifierSet(
                ",".join(["".join(s._spec) for s in list(specs._specs) + list(self.filter._specs)])
            )
        return self.get_latest_version_within_specs(
            specs,
            versions=self.package.versions,
            prereleases=self.prereleases
        )

    @property
    def latest_version_within_specs(self):
        if self.filter:
            return self.get_latest_version_within_specs(
                self.filter,
                versions=self.package.versions,
                prereleases=self.prereleases
            )
        return self.latest_version

    @property
    def latest_version(self):
        return self.package.latest_version(self.prereleases)

    @property
    def prereleases(self):
        return self.is_pinned and parse_version(
            next(iter(self.specs._specs))._spec[1]).is_prerelease

    @staticmethod
    def get_latest_version_within_specs(specs, versions, prereleases=None):
        # build up a spec set and convert compatible specs to pinned ones
        spec_set = SpecifierSet(
            ",".join(["".join(s._spec).replace("~=", "==") for s in specs])
        )
        candidates = []
        for version in versions:
            if spec_set.contains(version, prereleases=prereleases):
                candidates.append(version)
        candidates = sorted(candidates, key=lambda v: parse_version(v), reverse=True)
        if len(candidates) > 0:
            return candidates[0]
        return None

    @property
    def package(self):
        if not self._fetched_package:
            self._package = fetch_package(self.name, self.index_server)
            self._fetched_package = True
        return self._package

    @property
    def needs_update(self):
        if self.is_pinned or self.is_ranged or self.is_compatible:
            return self.can_update_semver and self.is_outdated
        return self.can_update_semver

    @property
    def is_insecure(self):
        if self._is_insecure is None:
            if not settings.api_key:
                self._is_insecure = False
            else:
                self._is_insecure = len(safety.check(
                    packages=(self,),
                    cached=True,
                    key=settings.api_key,
                    db_mirror="",
                    ignore_ids=()
                )) != 0

        return self._is_insecure

    @property
    def changelog(self):
        if self._changelog is None:
            self._changelog = OrderedDict()
            if settings.api_key:
                r = requests.get(
                    "https://pyup.io/api/v1/changelogs/{}/".format(self.key),
                    headers={"X-Api-Key": settings.api_key}
                )
                if r.status_code == 403:
                    raise InvalidKeyError
                if r.status_code == 200:
                    data = r.json()
                    if data:
                        # sort the changelog by release
                        sorted_log = sorted(
                            data.items(), key=lambda v: parse_version(v[0]), reverse=True)
                        # go over each release and add it to the log if it's within the "upgrade
                        # range" e.g. update from 1.2 to 1.3 includes a changelog for 1.2.1 but
                        # not for 0.4.
                        for version, log in sorted_log:
                            parsed_version = parse_version(version)
                            if self.is_pinned and parsed_version > parse_version(
                                self.version) and parsed_version <= parse_version(
                                    self.latest_version_within_specs):
                                self._changelog[version] = log
                            elif not self.is_pinned and parsed_version <= parse_version(
                                    self.latest_version_within_specs):
                                self._changelog[version] = log
        return self._changelog

    @property
    def is_outdated(self):
        if self.version and self.latest_version_within_specs:
            return parse_version(self.version) < parse_version(self.latest_version_within_specs)
        return False

    @property
    def full_name(self):
        if self.extras:
            return "{}[{}]".format(self.name, ",".join(self.extras))
        return self.name

    def get_hashes(self, version):
        r = requests.get('https://pypi.org/pypi/{name}/{version}/json'.format(
            name=self.key,
            version=version
        ))
        hashes = []
        data = r.json()
        for item in data.get("releases", {}).get(version, []):
            sha256 = item.get("digests", {}).get("sha256", False)
            if sha256:
                hashes.append({"hash": sha256})
        return data["hashes"]

    def update_content(self, content, update_hashes=True):
        if self.file_type == filetypes.tox_ini:
            updater_class = updater.ToxINIUpdater
        elif self.file_type == filetypes.conda_yml:
            updater_class = updater.CondaYMLUpdater
        elif self.file_type == filetypes.requirements_txt:
            updater_class = updater.RequirementsTXTUpdater
        elif self.file_type == filetypes.pipfile:
            updater_class = updater.PipfileUpdater
        elif self.file_type == filetypes.pipfile_lock:
            updater_class = updater.PipfileLockUpdater
        elif self.file_type == filetypes.setup_cfg:
            updater_class = updater.SetupCFGUpdater
        else:
            raise NotImplementedError

        dep = Dependency(
            name=self.name,
            specs=self.specs,
            line=self.line,
            line_numbers=[self.lineno, ] if self.lineno != 0 else None,
            dependency_type=self.file_type,
            hashes=self.hashes,
            extras=self.extras
        )
        hashes = []
        if self.hashes and update_hashes:
            for item in self.get_hashes(self.latest_version_within_specs):
                hashes.append({"method": "sha256", "hash": item["hash"]})

        return updater_class.update(
            content=content,
            dependency=dep,
            version=self.latest_version_within_specs,
            hashes=hashes
        )

    @classmethod
    def parse(cls, s, lineno, file_type=filetypes.requirements_txt):
        # setuptools requires a space before the comment. If this isn't the case, add it.
        if "\t#" in s:
            parsed, = parse_requirements(s.replace("\t#", "\t #"))
        else:
            parsed, = parse_requirements(s)
        return cls(
            name=parsed.name,
            specs=parsed.specifier,
            line=s,
            lineno=lineno,
            extras=parsed.extras,
            file_type=file_type
        )

    def get_package_class(self):  # pragma: no cover
        return Package
