<gh_stars>0
# -*- coding: utf-8 -*-

"""
    dependencies
    ~~~~~~~~~~~~

    Implements Dependencies Check

    :author:    BlBana <<EMAIL>>
    :homepage:  https://github.com/WhaleShark-Team/cobra
    :license:   MIT, see LICENSE for more details.
    :copyright: Copyright (c) 2018 Feei. All rights reserved
"""
import json
import os
import re
import xml.etree.cElementTree as eT
from distutils.version import LooseVersion

try:  # for pip >= 10
    from pip._internal.req import parse_requirements
except ImportError:  # for pip <= 9.0.3
    from pip.req import parse_requirements

try:
    from xml.etree.cElementTree import ParseError
except ImportError:
    from xml.parsers.expat import ExpatError

from .log import logger


class Version(LooseVersion):
    """
    处理 version 中的一些特殊情况
    """

    def __init__(self, vstring=None):
        self.vstring = vstring
        self.version = []
        LooseVersion.__init__(self, vstring=vstring)

    def parse(self, vstring):
        """
        version 字符串处理为列表
        1.2.x 处理为 1.2.0
        * 处理为 0
        :param vstring:
        :return:
        """
        components = [x for x in self.component_re.split(vstring)
                      if x and x != '.']
        for i, obj in enumerate(components):
            try:
                if obj == 'x' or obj == '*':
                    components[i] = 0
                else:
                    components[i] = int(obj)
            except ValueError:
                pass

        self.version = components


class Comparator:
    """
    只比较版本号部分，返回 True 为命中规则，False 为未命中
    """

    def __init__(self, rule_version, dep_version, fmt='python'):
        """
        初始化
        :param rule_version: 规则内的版本号
        :param dep_version: 依赖文件内的版本号
        :param fmt: 版本号的格式 支持 python nodejs java
        """
        # 版本号区间的分隔符
        self.types = {
            'python': ',',
            'nodejs': '||',
            'java': ',',
        }
        self.rule_version = rule_version.strip()
        self.dep_version = dep_version.strip()
        self.fmt = fmt if fmt in self.types else 'python'

    def parse_version(self, version_str, fmt='python'):
        """
        处理 version 字符串中的比较符号，如 >= > <= < ^ ~ * - 等
        :param version_str: version 字符串
        :param fmt: 格式
        :return: [(operator, version)]
        """
        splitter = self.types.get(fmt) if self.types.get(fmt) else ','

        if 'http' in version_str or 'file' in version_str or 'git' in version_str:
            # 引用外部地址的全部报为 0 版本
            return [('>=', Version('0.0.0'))]
        elif version_str == 'latest' or version_str == '*' or version_str == '':
            # 安装时为当时的最新版本，需要再次确认
            return [('>=', Version('0.0.0'))]
        elif splitter in version_str:
            # 不以数字开头，但有多个并列的关系需要处理
            versions = []
            for ver in version_str.split(splitter):
                if ' ' in ver.strip():
                    versions += ver.strip().split(' ')
                else:
                    versions.append(ver.strip())

            parsed = []
            for v in versions:
                parsed.append(self.simple_parse(v))
            return parsed
        elif ' ' in version_str:
            version_str = version_str.strip()
            if ' ' in version_str and splitter == '||':
                versions = []
                for ver in version_str.split(' '):
                    versions.append(ver.strip())
                parsed = []
                for v in versions:
                    parsed.append(self.simple_parse(v))
                return parsed
            else:
                version_str = version_str.replace(' ', '')
                return [self.simple_parse(version_str)]
        else:
            # 不以数字开头，匹配开头的运算符，之后的为版本号
            return [self.simple_parse(version_str)]

    @staticmethod
    def simple_parse(simple_version_str):
        """
        只处理形如 >=1.2.3 和 1.2.3 的
        :param simple_version_str: 需要处理的版本号字符串
        :return: (operator, version)
        """
        operator_re = re.compile(r'^(>|<|>=|<=|==|\^|~+?)\d')
        # 先去掉两边的空格
        simple_version_str = simple_version_str.strip()
        match = operator_re.match(simple_version_str)
        if match:
            operator = match.group(1)
            version = simple_version_str.replace(operator, '')
            return operator, Version(version)
        elif simple_version_str[0].isdigit():
            # 没匹配出运算符
            return '==', Version(simple_version_str)
        else:
            # 开头不是数字，位置运算符
            raise Exception('Unknown operator {}'.format(simple_version_str))

    def compare(self):
        """

        :return:
        """
        max_rule_version = max(self.parse_version(self.rule_version), key=lambda v: v[1])
        min_dep_version = min(self.parse_version(self.dep_version, fmt=self.fmt), key=lambda v: v[1])

        if min_dep_version[1] <= max_rule_version[1] and max_rule_version[0] != '==':
            # 依赖中的最小版本号比规则中的版本号小
            return True
        elif min_dep_version[1] == max_rule_version[1] and max_rule_version[0] == '==':
            return True
        elif min_dep_version[0] in ('<', '<='):
            # 最小依赖版本号比规则版本号大，但运算符是小于，应该没人这么干。。
            return True
        else:
            return False


class Dependencies(object):
    def __init__(self, target_directory):
        """
        :param target_directory: The project's path
        """
        self.directory = os.path.abspath(target_directory)
        # _result  {'flask': {'version': '==0.10.1', 'format': 'python'}, ...}
        self._result = {}
        self._framework = []
        self.dependencies()

    def dependencies(self):
        file_path, flag = self.find_file()
        if flag == 0:  # flag == 0
            logger.debug('Dependency analysis cannot be done without finding dependency files')
            return False
        if flag == 1:
            self.find_python_pip(file_path)
            return True
        if flag == 2:
            self.find_java_mvn(file_path)
            return True
        if flag == 3:
            self.find_nodejs_npm(file_path)
            return True

    def find_file(self):
        """
        :return:flag:{1:'python', 2:'java', 3:'node.js'}
        """
        flag = 0
        file_path = []
        if os.path.isdir(self.directory):
            for root, dirs, filenames in os.walk(self.directory):
                for filename in filenames:
                    if filename == 'requirements.txt' and flag != 2:
                        file_path.append(self.get_path(root, filename))
                        flag = 1
                    if filename == 'pom.xml' and flag != 1:
                        file_path.append(self.get_path(root, filename))
                        flag = 2
                    if filename == 'package.json':
                        file_path.append(self.get_path(root, filename))
            return file_path, flag
        else:
            filename = os.path.basename(self.directory)
            if filename == 'requirements.txt':
                flag = 1
                file_path.append(self.directory)
                return file_path, flag
            if filename == 'pom.xml':
                flag = 2
                file_path.append(self.directory)
                return file_path, flag
            if filename == 'package.json':
                flag = 3
                file_path.append(self.directory)
                return file_path, flag
            return file_path, flag

    @staticmethod
    def get_path(root, filename):
        """
        :param root:
        :param filename:
        :return:
        """
        return os.path.join(root, filename)

    def find_python_pip(self, file_path):
        for requirement in file_path:
            if 'requirements.txt' in requirement:
                reqs = parse_requirements(filename=requirement, session=False)
                for r in reqs:
                    module_ = r.name
                    version_ = r.specifier
                    self._framework.append(module_)
                    self._result.update(
                        {
                            module_: {
                                'version': str(version_),
                                'format': 'python',
                            }
                        }
                    )
            elif 'package.json' in requirement:
                self.find_nodejs_npm([requirement])

    def find_java_mvn(self, file_path):
        pom_ns = "{http://maven.apache.org/POM/4.0.0}"
        for pom in file_path:
            if 'pom.xml' in pom:
                try:
                    tree = self.parse_xml(pom)
                    root = tree.getroot()
                    childs = root.findall('.//%sdependency' % pom_ns)
                    for child in childs:
                        group_id = child.getchildren()[0].text
                        artifact_id = child.getchildren()[1].text
                        if len(child.getchildren()) > 2:
                            version = child.getchildren()[2].text
                            version_match = re.match(r'\$\{(.*)\}', version)
                            if version_match:
                                version_var = version_match.group(1)
                                ver_num = root.findall('.//{pom}{ver}'.format(pom=pom_ns, ver=version_var))[0].text
                                version = ver_num
                        else:
                            # 不确定版本，需要自查
                            version = '0.0.0'
                        module_ = artifact_id
                        self._framework.append(group_id)
                        self._framework.append(artifact_id)
                        self._result.update(
                            {
                                module_: {
                                    'version': str(version),
                                    'format': 'java',
                                }
                            }
                        )
                except ParseError:
                    logger.warning('[DEP] The {} have invalid token'.format(pom))
            elif 'package.json' in pom:
                logger.critical(pom)
                self.find_nodejs_npm([pom])

    def find_nodejs_npm(self, file_path):
        """
        处理 package.json 依赖
        :param file_path: 接收文件名 list
        :return:
        """
        for npm in file_path:
            if 'package.json' in npm:
                with open(npm, 'r') as fi:
                    try:
                        package = json.load(fi)
                        deps = package.get('dependencies')
                        for dep in deps:
                            module_ = dep
                            version = deps.get(dep)
                            self._framework.append(module_)
                            self._result.update(
                                {
                                    module_: {
                                        'version': str(version),
                                        'format': 'nodejs',
                                    }
                                }
                            )
                    except json.JSONDecodeError:
                        logger.warning('[DEP] {} is not a valid json file'.format(npm))

    @staticmethod
    def parse_xml(file_path):
        return eT.parse(file_path)

    def get_version(self, module_):
        return self._result[module_].get('version')

    @property
    def get_result(self):
        return self._result

    @property
    def get_framework(self):
        return self._framework
