#!/usr/bin/env python
# -*- coding: utf-8 -*-
""" TcEx Framework Package Module """
import argparse
import ast
import imp
import importlib
import json
import os
import re
import shutil
import sys
import traceback
import zipfile
from collections import deque, OrderedDict

import colorama as c
from jsonschema import SchemaError, ValidationError, validate
from stdlib_list import stdlib_list

# Python 2 unicode
if sys.version_info[0] == 2:
    reload(sys)  # noqa: F821; pylint: disable=E0602
    sys.setdefaultencoding('utf-8')  # pylint: disable=E1101

parser = argparse.ArgumentParser()
parser.add_argument('--bundle', action='store_true', help='Build a bundle file.')
parser.add_argument(
    '--exclude', action='append', default=[], help='File and directories to exclude from build.'
)
parser.add_argument(
    '--config', default='tcex.json', help='Build configuration file. (Default: tcex.json)'
)
parser.add_argument(
    '--ignore_validation', action='store_true', help='Do not exit on validation errors.'
)
parser.add_argument(
    '--install_json', help='The install.json file name for the App that should be built.'
)
parser.add_argument(
    '--outdir', default='target', help='Directory to write the outfile. (Default: target)'
)
args, extra_args = parser.parse_known_args()

# Load Schema
# schema_file = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'tcex_json_schema.json')


# TODO: Clean this up when time allows
class TcPackage(object):
    """Package the app for deployment

    This method will package the app for deployment to ThreatConnect. Validation of the
    install.json file or files will be automatically run before packaging the app.
    """

    def __init__(self, _args):
        """Init Class properties"""
        self.args = _args
        self.app_path = os.getcwd()
        self.exit_code = 0
        self.features = ['secureParams', 'aotExecutionEnabled']

        # defaults
        self._app_packages = []
        self.config = {}
        self._schema = None
        self.schema_file = 'tcex_json_schema.json'

        # initialize colorama
        c.init(autoreset=True, strip=False)

        # load config
        self._load_config()

    def _load_config(self):
        """ """
        # load config
        if os.path.isfile(self.args.config):
            with open(self.args.config, 'r') as fh:
                try:
                    self.config = json.load(fh).get('package', {})
                except ValueError as e:
                    print('Invalid JSON File {}{}({})'.format(c.Style.BRIGHT, c.Fore.RED, e))
                    sys.exit(1)

    @staticmethod
    def _load_install_json(file):
        """Load install.json file"""
        install_data = {}
        if os.path.isfile(file):
            with open(file) as fh:
                install_data = json.load(fh, object_pairs_hook=OrderedDict)
        else:
            print('{}{}Could not load {} file.'.format(c.Style.BRIGHT, c.Fore.YELLOW, file))

        # if not install_data.get('apiUserTokenParam'):
        #     print('{}{}The current install.json file is missing param "apiUserTokenParam"'.format(
        #         c.Style.BRIGHT, c.Fore.YELLOW))

        return install_data

    def _update_install_json(self, install_json):
        """Write install.json file"""
        updated = False
        # Update features
        install_json.setdefault('features', [])
        for feature in self.features:
            if feature not in install_json.get('features'):
                install_json['features'].append(feature)
                updated = True
                print(
                    'Updating install.json for feature: {}{}{}'.format(
                        c.Style.BRIGHT, c.Fore.CYAN, feature
                    )
                )

        return install_json, updated

    @staticmethod
    def _write_install_json(filename, install_json):
        """Write install.json file"""
        # TODO: why check if it exists?
        if os.path.isfile(filename):
            with open(filename, 'w') as fh:
                json.dump(install_json, fh, indent=4, sort_keys=True)
        else:
            print('{}{}Could not write {} file.'.format(c.Style.BRIGHT, c.Fore.YELLOW, filename))

    def bundle(self, bundle_name):
        """Bundle App"""
        if self.args.bundle or self.config.get('bundle', False):
            print('{}{}'.format(c.Style.BRIGHT, '-' * 100))
            if self.config.get('bundle_packages') is not None:
                for bundle in self.config.get('bundle_packages') or []:
                    bundle_name = bundle.get('name')
                    bundle_patterns = bundle.get('patterns')

                    bundle_apps = []
                    for app in self._app_packages:
                        for app_pattern in bundle_patterns:
                            p = re.compile(app_pattern, re.IGNORECASE)
                            if p.match(app):
                                bundle_apps.append(app)

                    # bundle app in zip
                    if bundle_apps:
                        self.bundle_apps(bundle_name, bundle_apps)
            else:
                self.bundle_apps(bundle_name, self._app_packages)

    def bundle_apps(self, bundle_name, bundle_apps):
        """Bundle zip (tcx) file"""
        bundle_file = os.path.join(
            self.app_path, self.args.outdir, '{}-bundle.zip'.format(bundle_name)
        )
        print(
            'Creating bundle: {}{}{}'.format(
                c.Style.BRIGHT, c.Fore.CYAN, os.path.basename(bundle_file)
            )
        )
        z = zipfile.ZipFile(bundle_file, 'w')
        for app in bundle_apps:
            print('  Adding: {}{}{}'.format(c.Style.BRIGHT, c.Fore.GREEN, os.path.basename(app)))
            z.write(app, os.path.basename(app))
        z.close()

    def check_ast(self, app_path=None):
        """Run ast on each Python file.

        Args:
            app_path (str, optional): Defaults to None. The path of Python files.
        """
        print('\n{}{}Validating File Syntax:'.format(c.Style.BRIGHT, c.Fore.BLUE))

        app_path = app_path or '.'
        error = False

        print('{}{!s:<60}{!s:<25}'.format(c.Style.BRIGHT, 'File:', 'Status:'))
        for filename in sorted(os.listdir(app_path)):
            errors = []
            status = 'passed'
            status_color = c.Fore.GREEN
            if filename.endswith('.py'):
                try:
                    with open(filename, 'rb') as f:
                        ast.parse(f.read(), filename=filename)
                except SyntaxError:
                    status = 'failed'
                    status_color = c.Fore.RED
                    errors = traceback.format_exc().split('\n')[-5:-2]
                    error = True

            elif filename.endswith('.json'):
                try:
                    with open(filename, 'r') as fh:
                        json.load(fh)
                except Exception:
                    status = 'failed'
                    status_color = c.Fore.RED
                    error = True
            else:
                # skip unsupported file types
                continue

            # display results
            print('{!s:<60}{}{!s:<25}'.format(filename, status_color, status))
            for line in errors:
                print('{}{}{}'.format(c.Style.BRIGHT, c.Fore.RED, line))

        if error:
            if not self.args.ignore_validation:
                print('\n{}Build failed due file(s) with invalid syntax.'.format(c.Fore.RED))
                sys.exit(1)

    def check_imports(self):
        """Check the projects top level directory for missing imports.

        This method will check only files ending in **.py** and does not handle imports validation
        for sub-directories.
        """
        modules = []
        missing_modules = False
        for file in sorted(os.listdir(self.app_path)):
            if not file.endswith('.py'):
                continue

            fq_path = os.path.join(self.app_path, file)
            with open(fq_path, 'rb') as f:
                # TODO: fix this
                code_lines = deque([(f.read(), 1)])

                while code_lines:
                    status = 'missing'
                    status_color = c.Fore.RED

                    code, lineno = code_lines.popleft()  # pylint: disable=W0612
                    try:
                        parsed_code = ast.parse(code)
                        for node in ast.walk(parsed_code):
                            if isinstance(node, ast.Import):
                                for n in node.names:
                                    m = n.name.split('.')[0]
                                    if self.check_import_stdlib(m):
                                        # stdlib module, not need to proceed
                                        continue
                                    m_status = self.check_imported(m)
                                    if not m_status:
                                        missing_modules = True
                                    modules.append({'file': file, 'module': m, 'status': m_status})
                            elif isinstance(node, ast.ImportFrom):
                                m = node.module.split('.')[0]
                                if self.check_import_stdlib(m):
                                    # stdlib module, not need to proceed
                                    continue
                                m_status = self.check_imported(m)
                                if not m_status:
                                    missing_modules = True
                                modules.append({'file': file, 'module': m, 'status': m_status})
                            else:
                                continue
                    except SyntaxError:
                        pass

        print('\n{}{}Validating Imports:'.format(c.Style.BRIGHT, c.Fore.BLUE))
        print('{}{!s:<30}{!s:<30}{!s:<25}'.format(c.Style.BRIGHT, 'File:', 'Module:', 'Status:'))
        for module_data in modules:
            status = 'passed'
            status_color = c.Fore.GREEN
            if not module_data.get('status'):
                status = 'failed'
                status_color = c.Fore.RED
            print(
                '{!s:<30}{}{!s:<30}{}{!s:<25}'.format(
                    module_data.get('file'),
                    c.Fore.WHITE,
                    module_data.get('module'),
                    status_color,
                    status,
                )
            )

        if missing_modules:
            if not self.args.ignore_validation:
                print(
                    '\n{}Build failed due to missing required Python module(s).'.format(c.Fore.RED)
                )
                sys.exit(1)

    @staticmethod
    def check_import_stdlib(module):
        """Check if module is in Python stdlib"""
        if (
            module in stdlib_list('2.7')
            or module in stdlib_list('3.4')
            or module in stdlib_list('3.5')
            or module in stdlib_list('3.6')
            or module in stdlib_list('3.7')
        ):
            return True
        return False

    @staticmethod
    def check_imported(module):
        """Check whether the provide module can be imported (package installed).

        Args:
            module (str): The name of the module to check availability.

        Returns:
            bool: True if the module can be imported, False otherwise.
        """

        imported = True
        module_info = ('', '', '')
        # TODO: update to a cleaner method that doesn't require importing the module and
        # running inline code.
        try:
            # block print statements in imported module
            sys.stdout = open(os.devnull, 'w')
            importlib.import_module(module)
            # reset stdout
            sys.stdout = sys.__stdout__
            module_info = imp.find_module(module)
        except ImportError:
            imported = False

        # get module path
        module_path = module_info[1]
        description = module_info[2]

        if not description:
            # if description is None or empty string the module could not be imported
            imported = False
        elif not description and not module_path:
            # if description/module_path are None or empty string the module could not be imported
            imported = False
        elif module_path is not None and (
            'dist-packages' in module_path or 'site-packages' in module_path
        ):
            # if dist-packages|site-packages in module_path the import doesn't count
            imported = False
        return imported

    def check_install_json(self):
        """Check all install.json files for valid schema.

        """
        # the install.json files can't be validates if the schema file is not present
        if self.schema is None:
            return

        print('\n{}{}Validating install.json Schema:'.format(c.Style.BRIGHT, c.Fore.BLUE))

        if self.args.install_json is not None:
            contents = [self.args.install_json]
        else:
            contents = os.listdir(self.app_path)

        print('{}{!s:<60}{!s:<25}'.format(c.Style.BRIGHT, 'File:', 'Status:'))
        invalid_schema = False
        for install_json in sorted(contents):
            # skip files that are not install.json files
            if 'install.json' not in install_json:
                continue

            error = ''
            status = 'passed'
            status_color = c.Fore.GREEN

            if self.schema is not None:
                try:
                    with open(install_json) as fh:
                        data = json.loads(fh.read())
                    validate(data, self.schema)
                except SchemaError as e:
                    # check_ast performs JSON validation of all JSON files. this exception should
                    # never match.
                    status = 'failed'
                    status_color = c.Fore.RED
                    error = e
                    invalid_schema = True
                except ValidationError as e:
                    status = 'failed'
                    status_color = c.Fore.RED
                    error = e.message
                    invalid_schema = True
            print('{!s:<60}{}{!s:<25}'.format(install_json, status_color, status))
            if error:
                print('  {}{}{}'.format(c.Style.BRIGHT, c.Fore.RED, error))

        if invalid_schema:
            if not self.args.ignore_validation:
                print(
                    '\n{}Build failed due to invalid schema in install.json file(s).'.format(
                        c.Fore.RED
                    )
                )
                sys.exit(1)

    @property
    def commit_hash(self):
        """Return the current commit hash if available.

        This is not a required task so best effort is fine. In other words this is not guaranteed
        to work 100% of the time.
        """
        commit_hash = None
        branch = None
        branch_file = '.git/HEAD'  # ref: refs/heads/develop

        # get current branch
        if os.path.isfile(branch_file):
            with open(branch_file, 'r') as f:
                try:
                    branch = f.read().strip().split('/')[2]
                except IndexError:
                    pass

            # get commit hash
            if branch:
                hash_file = '.git/refs/heads/{}'.format(branch)
                if os.path.isfile(hash_file):
                    with open(hash_file, 'r') as f:
                        commit_hash = f.read().strip()
        return commit_hash

    def package(self):
        """Package the App for deployment in TcEx"""

        #
        # create build directory
        #
        tmp_path = os.path.join(self.app_path, self.args.outdir, 'build')
        if not os.path.isdir(tmp_path):
            os.makedirs(tmp_path)

        #
        # temp path and cleanup
        #
        template_app_path = os.path.join(tmp_path, 'template')
        if os.access(template_app_path, os.W_OK):
            # cleanup any previous failed builds
            shutil.rmtree(template_app_path)
        print('\n{}{}Packaging App:'.format(c.Style.BRIGHT, c.Fore.BLUE))
        print(
            'Building App Template: {}{}{}'.format(c.Style.BRIGHT, c.Fore.CYAN, template_app_path)
        )

        #
        # build exclude file/directory list
        #
        excludes = [
            self.args.config,
            self.args.outdir,
            '__pycache__',
            '.c9',  # C9 IDE
            '.git',  # git directory
            '.gitmodules',  # git modules
            '*.pyc',  # any pyc file
            '.python-version',  # pyenv
            '.vscode',  # Visual Studio Code
            'log',  # log directory
        ]
        excludes.extend(self.args.exclude)
        excludes.extend(self.config.get('excludes', []))
        patterns = ', '.join(excludes)
        print('Excluding: {}{}{}'.format(c.Style.BRIGHT, c.Fore.CYAN, patterns))

        #
        # copy project directory to temp location to use as template for multiple builds
        #
        ignore_patterns = shutil.ignore_patterns(*excludes)
        shutil.copytree(self.app_path, template_app_path, False, ignore_patterns)

        #
        # build list of app json files
        #
        if self.args.install_json is not None:
            contents = [self.args.install_json]
        else:
            contents = os.listdir(self.app_path)

        #
        # package app
        #
        for install_json in sorted(contents):
            # skip files that are not install.json files
            if 'install.json' not in install_json:
                continue

            # divider
            print('{}{}'.format(c.Style.BRIGHT, '-' * 100))

            # get App Name from config, install.json prefix or directory name.
            if install_json == 'install.json':
                app_name = self.config.get('app_name', os.path.basename(self.app_path))
            else:
                app_name = install_json.split('.')[0]

            print('Processing: {}{}{}'.format(c.Style.BRIGHT, c.Fore.CYAN, app_name))

            #
            # load install json
            #
            ij = self._load_install_json(install_json)

            # automatically update install.json for feature sets supported by the SDK
            ij, ij_modified = self._update_install_json(ij)

            #
            # write update install.json
            #
            if ij_modified:
                self._write_install_json(install_json, ij)

            # find a usable app version
            program_version = ij.get('programVersion', '1.0.0').split('.')
            major_version = program_version[0]
            try:
                minor_version = program_version[1]
            except IndexError:
                minor_version = 0
            app_version = '{}'.format(
                self.config.get('app_version', 'v{}.{}'.format(major_version, minor_version))
            )

            # !!! The name of the folder in the zip is the *key* for an App. This value must
            # !!! remain consistent for the App to upgrade successfully.
            app_name_version = '{}_{}'.format(app_name, app_version)

            #
            # build app directory
            #
            tmp_app_path = os.path.join(tmp_path, app_name_version)
            if os.access(tmp_app_path, os.W_OK):
                # cleanup any previous failed builds
                shutil.rmtree(tmp_app_path)
            shutil.copytree(template_app_path, tmp_app_path)

            # Copy install.json
            # TODO: do we need copy if writing the data in the next step?
            shutil.copy(install_json, os.path.join(tmp_app_path, 'install.json'))

            # Update commit hash after install.json has been copied.
            ij.setdefault('commitHash', self.commit_hash)
            self._write_install_json(os.path.join(tmp_app_path, 'install.json'), ij)

            # zip file
            self.zip_file(self.app_path, app_name_version, tmp_path)
            # cleanup build directory
            shutil.rmtree(tmp_app_path)

        # bundle zips (must have more than 1 app)
        if len(self._app_packages) > 1:
            self.bundle(self.config.get('bundle_name', app_name))

    @property
    def schema(self):
        """Load JSON schema file"""
        if self._schema is None:
            if os.path.isfile(self.schema_file):
                with open(self.schema_file) as fh:
                    self._schema = json.load(fh)
        return self._schema

    @staticmethod
    def update_system_path():
        """Update the system path to ensure project modules and dependencies can be found."""
        cwd = os.getcwd()
        lib_dir = os.path.join(os.getcwd(), 'lib_')
        lib_latest = os.path.join(os.getcwd(), 'lib_latest')

        # insert the lib_latest directory into the system Path if no other lib directory found. This
        # entry will be bumped to index 1 after adding the current working directory.
        if not [p for p in sys.path if lib_dir in p]:
            sys.path.insert(0, lib_latest)

        # insert the current working directory into the system Path for the App, ensuring that it is
        # always the first entry in the list.
        try:
            sys.path.remove(cwd)
        except ValueError:
            pass
        sys.path.insert(0, cwd)

    def zip_file(self, app_path, app_name, tmp_path):
        """Zip App"""
        # zip build directory
        zip_file = os.path.join(app_path, self.args.outdir, app_name)
        zip_file_zip = '{}.zip'.format(zip_file)
        zip_file_tcx = '{}.tcx'.format(zip_file)
        print(
            'Creating zip: {}{}{}'.format(
                c.Style.BRIGHT, c.Fore.CYAN, os.path.basename(zip_file_tcx)
            )
        )
        shutil.make_archive(zip_file, 'zip', tmp_path, app_name)
        shutil.move(zip_file_zip, zip_file_tcx)
        self._app_packages.append(zip_file_tcx)


if __name__ == '__main__':
    try:
        tcp = TcPackage(args)
        tcp.update_system_path()
        tcp.check_ast()
        tcp.check_imports()
        tcp.check_install_json()
        tcp.package()
        sys.exit(tcp.exit_code)
    except Exception:
        # TODO: Update this, possibly raise
        print('{}{}{}'.format(c.Style.BRIGHT, c.Fore.RED, traceback.format_exc()))
        sys.exit(1)
