#!/usr/bin/env python3
import filecmp
import json
import os
import sys
import shutil
from collections import OrderedDict
from fnmatch import fnmatchcase
import sh
from jinja2 import Environment, TemplateNotFound
from qbuild.color_print import Color, cprint
from qbuild.helpers import ls_recursive, load_statement_templates, uncomment, get_comment_style
from qbuild.tree import tree


class Builder:
    SOLUTION_COMMENT_BEGIN = ['_q_hide_from_users_begin', '_q_solution_begin']
    TEST_COMMENT_BEGIN = ['_q_test_begin']
    COMMENT_REPLACE = ['_q_replace']
    COMMENT_END = ['_q_hide_from_users_end', '_q_end']

    QIGNORE_FILES = ['.qignore']
    QSOLUTION_FILES = ['.qhide', '.qsolution']
    QTEST_FILES = ['.qtest']
    QSAMPLETEST_FILES = ['.qsampletest']
    QRUN_FILES = ['.qrun.py']

    DOTQ_FILES = QIGNORE_FILES + QSOLUTION_FILES + QTEST_FILES + QSAMPLETEST_FILES + QRUN_FILES

    NOSOLUTION_EXTENSIONS = ['.initial', '.nosolution']
    NOTEST_EXTENSIONS = ['.notest']

    EXPORT_INITIAL = {
        'code': 'initial',
        'export_name': '{slug}',
        'hide_solution': True,
        'hide_test': True,
        'hide_sampletest': False,
        'hide_dotq_files': True,
    }

    EXPORT_MODEL_SOLUTION = {
        'code': 'model_solution',
        'export_name': 'model_solution',
        'hide_solution': False,
        'hide_test': True,
        'hide_sampletest': True,
        'hide_dotq_files': True,
    }

    EXPORT_TEST = {
        'code': 'test',
        'export_name': 'test',
        'hide_solution': True,
        'hide_test': False,
        'hide_sampletest': False,
        'hide_dotq_files': False,
    }

    EXPORTS = [
        EXPORT_INITIAL,
        EXPORT_MODEL_SOLUTION,
        EXPORT_TEST
    ]

    def __init__(self, base_dir):
        # Creating directory paths...
        self.BASE_DIR = base_dir
        self.SLUG = os.path.basename(self.BASE_DIR)
        self.SRC_DIR = os.path.join(self.BASE_DIR, 'src')

        self.DIST = os.path.join(self.BASE_DIR, 'dist')
        self.SRC_DIR_2 = os.path.join(self.DIST, '_copy__src')

        self.QBUILD_DIR = os.path.join(self.BASE_DIR, '.qbuild')
        self.STATEMENT_DIR = os.path.join(self.BASE_DIR, 'statement')

        if not os.path.isdir(self.QBUILD_DIR):
            os.mkdir(self.QBUILD_DIR)

    @staticmethod
    def process_comments(path, comment_begin, show=None):
        for item in ls_recursive(path, only_files=True):
            with open(item) as fp:
                # FIXME it reads file at once. what happens for large files?
                try:
                    lines = fp.readlines()
                except UnicodeDecodeError:
                    # file is not text
                    continue
            new_content = ''
            state = 'normal'  # states: normal, main, replacement
            comment_style = None
            for line in lines:
                if state == 'main':
                    if any(i in line for i in Builder.COMMENT_END):
                        state = 'normal'
                    elif any(i in line for i in Builder.COMMENT_REPLACE):
                        state = 'replacement'
                    elif show == 'main':
                        new_content += line
                elif state == 'replacement':
                    if any(i in line for i in Builder.COMMENT_END):
                        state = 'normal'
                    elif show == 'replacement':
                        new_content += uncomment(line, comment_style)
                elif state == 'normal':
                    for scb in comment_begin:
                        if scb in line:
                            state = 'main'
                            comment_style = get_comment_style(line, scb)
                            break
                    else:
                        new_content += line
            with open(item, 'w') as fp:
                fp.write(new_content)

    @staticmethod
    def process_initial(path, ext_list, action):
        for ext in ext_list:
            if ext not in ['.initial', '.nosolution', '.notest']:
                raise Exception
        if action not in ['replace', 'delete']:
            raise Exception
        items = ls_recursive(path)
        items_to_hide = []
        for item in items:
            a, b = os.path.splitext(item)
            for ext in ext_list:
                if item + ext in items:
                    items_to_hide.append((item, ext, item + ext))
                elif a + ext + b in items:
                    items_to_hide.append((item, ext, a + ext + b))
        for item, ext, repl in items_to_hide:
            if action == 'replace':
                if os.path.isdir(item):
                    shutil.rmtree(item)
                else:
                    os.remove(item)
                os.rename(repl, item)
            elif action == 'delete':
                if os.path.isdir(repl):
                    shutil.rmtree(repl)
                else:
                    os.remove(repl)

    @staticmethod
    def hide_ignorefile(path, ignorefile_list):
        for ignorefile in ignorefile_list:
            if not os.path.exists(os.path.join(path, ignorefile)):
                continue
            try:
                os.remove(os.path.join(path, '.gitignore'))
            except FileNotFoundError:
                pass
            os.rename(os.path.join(path, ignorefile), os.path.join(path, '.gitignore'))
            sh.git.init(_cwd=path)
            sh.git.add('.', _cwd=path)
            sh.git.clean('-xdf', _cwd=path)
            shutil.rmtree(os.path.join(path, '.git'))
            os.remove(os.path.join(path, '.gitignore'))

    @staticmethod
    def run_qrun(path, export):
        if not os.path.exists(os.path.join(path, '.qrun.py')):
            return
        args = []
        if export['hide_solution']:
            args.append('--hide-solution')
        if export['hide_test']:
            args.append('--hide-test')
        if export['hide_sampletest']:
            args.append('--hide-sampletest')
        sh.python('.qrun.py', *args, _cwd=path)

    def get_export_name(self, export):
        return export['export_name'].format(slug=self.SLUG)

    def get_export_path(self, export):
        return os.path.join(self.DIST, self.get_export_name(export))

    @staticmethod
    def delete_gitignore_files(path):
        for item in ls_recursive(path, only_files=True):
            if os.path.basename(item) in ['.gitignore', '.gitkeep']:
                os.remove(item)

    @staticmethod
    def process_package_json(path, export):

        def action_remove(dd, keys):
            if type(dd) is not OrderedDict:
                return
            if type(keys) is str:
                for k in list(dd.keys()):
                    if fnmatchcase(k, keys):
                        dd.pop(k)
            elif type(keys) is list:
                for i in keys:
                    action_remove(dd, i)
            elif type(keys) is OrderedDict:
                for k, v in keys.items():
                    if k in dd:
                        action_remove(dd[k], v)

        def action_add(dd, keys):
            if type(dd) is not OrderedDict:
                return
            if type(keys) is not OrderedDict:
                return
            for k, v in keys.items():
                if type(v) is not OrderedDict or k not in dd:
                    dd[k] = v
                else:
                    action_add(dd[k], v)

        for item in ls_recursive(path, only_files=True):
            if os.path.basename(item) != 'package.json':
                continue
            with open(item) as fp:
                d = json.load(fp, object_pairs_hook=OrderedDict)
            if '//' not in d:
                continue
            if export['code'] in d['//']:
                actions = d['//'][export['code']]
                if 'remove' in actions:
                    action_remove(d, actions['remove'])
                if 'add' in actions:
                    action_add(d, actions['add'])
            d.pop('//')
            with open(item, 'w') as fp:
                json.dump(d, fp, indent=2)

    def is_built(self):
        for export in Builder.EXPORTS:
            if not os.path.isdir(self.get_export_path(export)):
                return False
        return True

    def render_statement(self):
        env = Environment(
            loader=load_statement_templates(self.STATEMENT_DIR),
            trim_blocks=True, lstrip_blocks=True
        )
        try:
            template = env.get_template('statement.md')
        except TemplateNotFound:
            cprint('[WARNING] "statement/statement.md" not found! Did you forget it?', color=Color.YELLOW)
            return

        outputs = [
            {
                'file_name': 'README.md',
                'extra_context': {
                    'is_readme': True
                }
            },
            # {
            #     'file_name': 'statement.md',
            #     'extra_context': {
            #         'is_readme': False
            #     }
            # },
        ]
        context = {
            'has_initial': len(os.listdir(self.get_export_path(self.EXPORT_INITIAL))) > 0,
            'initial_structure': tree(self.get_export_path(self.EXPORT_INITIAL), self.SLUG),
            'solution_structure': tree(self.get_export_path(self.EXPORT_MODEL_SOLUTION), '[your-zip-file-name].zip'),
        }

        for output in outputs:
            destination_file = os.path.join(self.BASE_DIR, output['file_name'])
            destination_file_prev = os.path.join(self.QBUILD_DIR, '.{}.prev'.format(output['file_name']))
            if os.path.isfile(destination_file) and os.path.isfile(destination_file_prev) \
                    and not filecmp.cmp(destination_file, destination_file_prev):
                shutil.copyfile(destination_file, os.path.join(self.BASE_DIR, '{}.backup'.format(output['file_name'])))
                cprint(
                    '\n[WARNING] {file_name} was modified manually. '
                    'It will be auto-generated and your changes will be overwritten. '
                    'However we created a copy before overwriting: {file_name}.backup'.format(
                        file_name=output['file_name']), color=Color.YELLOW
                )

            statement = template.render(
                **context,
                **output['extra_context']
            )

            attachments_dir = os.path.join(self.STATEMENT_DIR, 'attachments')
            if os.path.isdir(attachments_dir):
                for item in os.listdir(attachments_dir):
                    path = os.path.join(attachments_dir, item)
                    rel_path = os.path.relpath(path, self.BASE_DIR)
                    if not os.path.isfile(path):
                        continue
                    file_size = os.path.getsize(path) / 1024
                    if path.endswith('.png') and file_size > 10:
                        cprint('[WARNING] Large PNG file detected (>10KB). '
                               'Consider optimizing with https://compressor.io',
                               color=Color.YELLOW)
                        cprint('  {} ({:.1f}KB)'.format(rel_path, file_size), color=Color.YELLOW)
                    if path.endswith('.gif') and file_size > 100:
                        cprint('[WARNING] Large GIF file detected (>100KB). '
                               'Consider optimizing with https://ezgif.com/optimize',
                               color=Color.YELLOW)
                        cprint('  {} ({:.1f}KB)'.format(rel_path, file_size), color=Color.YELLOW)
                    statement = statement.replace(os.path.join('attachments', item), rel_path)
            with open(destination_file, 'w') as fp:
                fp.write(statement)
            shutil.copy2(destination_file, destination_file_prev)

    def validate_jupyter(self):
        required_files = [
            'solution.ipynb',
            'dumper.py',
        ]
        is_valid, message = True, 'OK'

        for rf in required_files:
            rf_path = os.path.join(self.SRC_DIR, rf)
            if not os.path.exists(rf_path):
                is_valid = False
                message = f'jupyter problems require a file that not found: "{rf}"'
                break

        return is_valid, message

    def build(self, is_jupyter_problem=False):
        # initial checks
        cprint('Performing initial checks...', color=Color.CYAN)
        if not os.path.isdir(self.SRC_DIR):
            cprint('Directory "src" not found!', color=Color.RED)
            return
        if not os.path.isfile(os.path.join(self.BASE_DIR, 'tester_config.json')):
            cprint('"tester_config.json" not found!', color=Color.RED)
            return
        if not os.path.isfile(os.path.join(self.BASE_DIR, 'valid_files')):
            cprint('"valid_files" not found!', color=Color.RED)
            return
        if not os.path.isdir(os.path.join(self.BASE_DIR, '.git')):
            cprint('This is not a git repo!', color=Color.RED)
            return
        if is_jupyter_problem:
            is_valid, message = self.validate_jupyter()
            if not is_valid:
                cprint(message, color=Color.RED)
                return
        if not os.path.isfile(os.path.join(self.SRC_DIR, '.qtest')):
            cprint('[WARNING] "src/.qtest" not found! Did you forget it?', color=Color.YELLOW)
        else:
            with open(os.path.join(self.SRC_DIR, '.qtest')) as fp:
                if not fp.read().strip():
                    cprint('[WARNING] "src/.qtest" is empty! Did you forget it?', color=Color.YELLOW)
        with open(os.path.join(self.BASE_DIR, 'tester_config.json')) as fp:
            if 'solution_signature' not in fp.read():
                cprint('[WARNING] "solution_signature" is missing from tester_config.json! Did you forget it?',
                       color=Color.YELLOW)

        # copy src (except ignored files) to self.SRC_DIR_2 (dist/_copy__src)
        if os.path.exists(self.DIST):
            shutil.rmtree(self.DIST)
        os.mkdir(self.DIST)
        if os.path.exists(self.SRC_DIR_2):
            shutil.rmtree(self.SRC_DIR_2)
        os.mkdir(self.SRC_DIR_2)
        for item in ls_recursive(self.SRC_DIR, relative=True, exclude_gitignore=True, only_files=True):
            d = os.path.dirname(os.path.join(self.SRC_DIR_2, item))
            if not os.path.exists(d):
                os.makedirs(d)
            shutil.copy2(os.path.join(self.SRC_DIR, item), d)

        for export in self.EXPORTS:
            name = self.get_export_name(export)
            cprint('\nBuilding {}'.format(name), color=Color.CYAN)
            path = self.get_export_path(export)
            shutil.copytree(self.SRC_DIR_2, path)
            if export['hide_solution']:
                self.process_initial(path, self.NOSOLUTION_EXTENSIONS, action='replace')  # must be first
                self.hide_ignorefile(path, self.QSOLUTION_FILES)
            else:
                self.process_initial(path, self.NOSOLUTION_EXTENSIONS, action='delete')  # must be first
            if export['hide_test']:
                self.process_initial(path, self.NOTEST_EXTENSIONS, action='replace')  # must be first
                self.hide_ignorefile(path, self.QTEST_FILES)
            else:
                self.process_initial(path, self.NOTEST_EXTENSIONS, action='delete')  # must be first
                shutil.copy2(os.path.join(self.BASE_DIR, 'tester_config.json'), path)
                shutil.copy2(os.path.join(self.BASE_DIR, 'valid_files'), path)
            if export['hide_solution']:
                self.process_comments(path, self.SOLUTION_COMMENT_BEGIN, show='replacement')
            else:
                self.process_comments(path, self.SOLUTION_COMMENT_BEGIN, show='main')
            if export['hide_test']:
                self.process_comments(path, self.TEST_COMMENT_BEGIN, show='replacement')
            else:
                self.process_comments(path, self.TEST_COMMENT_BEGIN, show='main')
            if export['hide_sampletest']:
                self.hide_ignorefile(path, self.QSAMPLETEST_FILES)
            self.run_qrun(path, export)
            if export['hide_dotq_files']:
                for i in self.DOTQ_FILES:
                    try:
                        os.remove(os.path.join(path, i))
                    except FileNotFoundError:
                        pass
            self.delete_gitignore_files(path)
            self.process_package_json(path, export)
            cprint('Creating {}.zip'.format(name), color=Color.CYAN)
            shutil.make_archive(path, 'zip', path)

            # export jupyter nonquera
            if is_jupyter_problem and export.get('code') == 'initial':
                from qbuild_jupyter import converter as jupyter_converter
                cprint(f'\nBuilding jupyter nonquera initial for {name}', color=Color.CYAN)
                nonquera_extract_dir_path, nonquera_extract_zip_path = jupyter_converter.convert_initial_to_nonquera_in_path(
                    path=path,
                    mode='dir',
                    nonquera_dir=self.DIST
                )
                nonquera_zip_name = os.path.basename(nonquera_extract_zip_path)
                cprint(f'Jupyter nonquera initial was successful: {nonquera_zip_name}', color=Color.CYAN)

        shutil.rmtree(self.SRC_DIR_2)

        cprint('\nRendering statement', color=Color.CYAN)
        self.render_statement()

        cprint('\nBuild was successful!\n', color=Color.GREEN)

    def diff(self):
        os.system(
            'git diff --color --no-index  --src-prefix=INITIAL/ --dst-prefix=MODEL_SOLUTION/ '
            'dist/{initial_name} dist/{model_solution_name} | '
            'sed --expression="s/INITIAL\/dist\/{initial_name}\///g" | '
            'sed --expression="s/MODEL_SOLUTION\/dist\/{model_solution_name}\///g" | '
            'sed --expression="s/dist\/{initial_name}\///g" | '
            'sed --expression="s/dist\/{model_solution_name}\///g" | '
            'qbuild_diff-so-fancy | '
            'less --tabs=4 -RFX'.format(initial_name=self.get_export_name(self.EXPORT_INITIAL),
                                        model_solution_name=self.get_export_name(self.EXPORT_MODEL_SOLUTION))
        )

    def main(self):
        if len(sys.argv) == 2 and sys.argv[1] == '--version':
            from qbuild import version
            print(version.__version__)
        elif len(sys.argv) == 2 and sys.argv[1] == 'diff':
            if not self.is_built():
                cprint('\nChallenge is not built. Run `qbuild` first.\n', color=Color.YELLOW)
            else:
                self.diff()
        elif len(sys.argv) == 3 and sys.argv[1] == 'tree':
            print(tree(sys.argv[2]))
        elif len(sys.argv) == 2 and sys.argv[1] == '--jupyter':
            try:
                from qbuild_jupyter import converter as jupyter_converter
                self.build(is_jupyter_problem=True)
            except ModuleNotFoundError:
                cprint(
                    '\n`qbuild-jupyter` is not installed yet. please install it with this command:\n'
                    '        pip3 install git+ssh://git@gitlab.com/codamooz/challenges/qbuild-jupyter\n',
                    color=Color.RED
                )
        else:
            self.build(is_jupyter_problem=False)


if __name__ == "__main__":
    Builder(os.getcwd()).main()
