from .base import BaseCommand
import os
from pathlib import Path
from typing import List, Dict, Tuple
import re
import shutil
from collections import namedtuple
from pathlib import Path
import sys
from otree import settings

try:

    import rope.base.codeanalyze
    import rope.refactor.occurrences
    from rope.refactor import rename, move
    from rope.refactor.rename import Rename
    from rope.base.project import Project
    from rope.base.libutils import path_to_resource
    import black
except ModuleNotFoundError:
    import sys

    sys.exit(
        'Before running this command, you need to run "pip3 install -U black rope" '
    )
from rope.refactor.importutils import ImportTools
from collections import namedtuple
from typing import Iterable

print_function = print

MethodInfo = namedtuple('MethodInfo', ['start', 'stop', 'name', 'model'])


class Command(BaseCommand):
    def add_arguments(self, parser):
        parser.add_argument('apps', nargs='*')
        parser.add_argument(
            '--keep', action='store_true', dest='keep_old_files', default=False,
        )

    def handle(self, *args, apps, keep_old_files, **options):
        for app in Path('.').iterdir():
            if app.joinpath('models.py').exists():
                app_name = app.name
                if apps and app_name not in apps:
                    continue
                if not keep_old_files:
                    backup(app_name)
                try:
                    make_noself(app_name)
                except Exception as exc:
                    app.joinpath('__init__.py').write_text('')
                    raise
                if not keep_old_files:
                    rearrange_folder(app_name)
            # convert app.py format
            elif app.joinpath('app.py').exists():
                init = app.joinpath('__init__.py')
                init.unlink(missing_ok=True)
                app.joinpath('app.py').rename(init)

        print_function('Done.')


class CannotConvert(Exception):
    pass


def make_noself(app_name):
    proj = Project(app_name, ropefolder=None)
    approot = Path(app_name)
    app_path = approot / '__init__.py'
    pages_path = approot / 'pages.py'
    models_path = approot / 'models.py'
    if not models_path.exists():
        return
    print_function('Upgrading', app_name)

    def read():
        return app_path.read_text('utf8')

    def write(txt):
        app_path.write_text(txt, encoding='utf8')

    def writelines(lines):
        write('\n'.join(lines))

    END_OF_MODELS = '"""endofmodels"""'
    lines = [
        'from otree.api import Page, WaitPage',
        *models_path.read_text('utf8').splitlines(),
        END_OF_MODELS,
    ]

    pages_txt = pages_path.read_text('utf8')

    m = re.search(
        r'self\.(?!player|group|subsession|participant|session|timeout_happened|round_number)(\w+)',
        pages_txt,
    )
    if m:
        msg = f"""{app_name}/pages.py contains "{m.group(0)}". This is not a recognized page attribute."""
        raise CannotConvert(msg)

    for line in pages_txt.split('\n'):
        if line.startswith('from ._builtin'):
            continue
        if line.startswith('from .models'):
            continue

        lines.append(line)

    writelines(lines)

    # normalize, get rid of empty lines
    lines = app_path.read_text('utf8').splitlines(keepends=False)
    writelines(e.replace('\t', ' ' * 4) for e in lines if e.strip())

    def resource(pth):
        return path_to_resource(proj, app_name + '/' + pth)

    app_res = resource('__init__.py')

    app_txt = read()

    # need it to be reversed so we don't shift everything down
    class_names = list(
        (m.group(1), m.group(2))
        for m in re.finditer(
            r'^class (\w+)\((BasePlayer|BaseGroup|BaseSubsession|Page|WaitPage)',
            app_txt,
            re.MULTILINE,
        )
    )

    model_methods = set()
    for class_name, base_class in reversed(class_names):
        offsets = get_method_offsets(app_txt, class_name)
        for offset, name in reversed(offsets):
            if base_class == 'WaitPage' and name == 'after_all_players_arrive':
                cls_start, cls_end = get_class_bounds(app_txt, class_name)
                is_subsession = (
                    'wait_for_all_groups = True' in app_txt[cls_start:cls_end]
                )
                rename_self_to = 'subsession' if is_subsession else 'group'
            else:
                rename_self_to = dict(
                    Player='player', Group='group', Subsession='subsession'
                ).get(class_name, 'player')
            # it might be error_message or app_after_this_page, which take extra args.
            self_offset = offset + app_txt[offset:].index('(self') + 2
            try:
                changes = Rename(proj, app_res, self_offset).get_changes(rename_self_to)
                proj.do(changes)
            except Exception:
                print_function(app_txt[self_offset : self_offset + 30])
                raise
            if class_name in ['Player', 'Group', 'Subsession']:
                model_methods.add(name)
                template_usage = f'{rename_self_to}.{name}'
                for tpl in approot.joinpath('templates', app_name).glob('*.html'):
                    if template_usage in tpl.read_text('utf8'):
                        print_function(
                            f"""
((((((((((((((((((((((((((((((
"{tpl}" contains the method call {template_usage}, but {name} has been converted to a function.
You have 2 choices:
\t(a) call {name}({rename_self_to}) in vars_for_template
\t(b) manually convert {name} back to a method
))))))))))))))))))))))))))))))"""
                        )

    import_tools = ImportTools(proj)

    rope_module = proj.get_module('__init__')
    module_with_imports = import_tools.module_imports(rope_module)
    module_with_imports.remove_duplicates()
    module_with_imports.sort_imports()
    write(module_with_imports.get_changed_source())

    app_txt = read()
    lines = app_txt.splitlines()

    method_bounds = []
    for class_name, _ in class_names:
        if class_name in ['Player', 'Group', 'Subsession']:
            # print_function(ClassName, list(get_method_bounds(lines, ClassName)))
            method_bounds.extend(get_method_bounds(lines, class_name, start_index=0))
        else:
            for start, end, name, _ in reversed(
                list(get_method_bounds(lines, class_name, start_index=0))
            ):
                lines.insert(start, f'    @staticmethod')

    # return
    function_lines = ['# FUNCTIONS']
    non_function_lines = []

    i = 0
    for bound in method_bounds:
        non_function_lines.extend(lines[i : bound.start])
        function_lines.extend(
            dedent(line) for line in lines[bound.start : bound.stop + 1]
        )
        i = bound.stop + 1
    non_function_lines.extend(lines[i:])

    # not aapa, since we need to resolve it being defined on group vs subsession.
    for i, line in enumerate(non_function_lines):
        non_function_lines[i] = re.sub(
            r"""(live_method) = ["'](\w+)["']""", r'\1 = \2', line,
        )

    function_lines.append('# PAGES')
    function_txt = '\n'.join(function_lines)

    txt = '\n'.join(non_function_lines).replace(END_OF_MODELS, function_txt)

    txt = re.sub(r'\bplayer\.player\b', 'player', txt)
    # for AAPA

    txt = re.sub(r'\bgroup\.group\b', 'group', txt)
    txt = re.sub(r'\bsubsession\.subsession\b', 'subsession', txt)

    txt = txt.replace(
        'def before_next_page(player):',
        'def before_next_page(player, timeout_happened):',
    ).replace('player.timeout_happened', 'timeout_happened')

    # add type annotations
    # some functions have multiple args, like error_message
    txt = re.sub(r'def (\w+)\(player\b', r'def \1(player: Player', txt)
    txt = re.sub(r'def (\w+)\(group\b', r'def \1(group: Group', txt)
    txt = re.sub(r'def (\w+)\(subsession\b', r'def \1(subsession: Subsession', txt)

    txt = fix_method_calls(txt, model_methods)

    lines = txt.splitlines(keepends=False)

    # add missing 'pass' for empty classes
    lines2 = []
    for i in range(len(lines)):
        lines2.append(lines[i])
        if lines[i].startswith('class ') and not lines[i + 1].startswith(' '):
            lines2.append(' ' * 4 + 'pass')

    write(black_format('\n'.join(lines2)))

    tests_path = approot.joinpath('tests.py')
    if tests_path.exists():

        tests_txt = tests_path.read_text('utf8')
        new_txt = (
            tests_txt.replace('from ._builtin import Bot', 'from otree.api import Bot')
            .replace('from . import pages', 'from . import *')
            .replace('from .models import Constants', '')
        )
        new_txt = re.sub(r'\bpages\.(\w)', r'\1', new_txt)
        approot.joinpath('tests_noself.py').write_text(new_txt, encoding='utf8')


def fix_method_calls(txt, model_methods):
    """this doesn't work for functions that take args. too complicated."""
    # change player.group.my_method() to my_method(player.group)
    def repl(m):
        if m.group(3) in model_methods:
            return m.group(3) + '(' + m.group(1) + m.group(2) + ')'
        return m.group()

    return re.sub(r'([\.\w]*)\b(player|group|subsession)\.(\w+)\(\)', repl, txt)


def dedent(line):
    if line.startswith(' ' * 4):
        return line[4:]
    return line


def black_format(txt):
    return black.format_str(
        txt, mode=black.Mode(line_length=100, string_normalization=False)
    )


def is_within_a_bound(bounds, lineno):
    for bound in bounds:
        if bound.start <= lineno <= bound.stop:
            return True


def get_method_bounds(lines, ModelName, start_index=1) -> Iterable[MethodInfo]:
    """1 based"""
    in_model = False

    start = None
    name = None
    model = None
    # use start=1 to match line numbers in text editor
    for lineno, line in enumerate(lines, start=start_index):
        if line.startswith(f'class {ModelName}('):
            in_model = True
            continue

        if in_model:

            if is_class_or_module_level_statement(line):
                if start:
                    yield MethodInfo(start, lineno - 1, name, model)
                    start = None
                if line.startswith('    def '):
                    start = lineno
                    m = re.search(r'def (\w+)\((\w+)', line)
                    name = m.group(1)
                    model = m.group(2)

            if is_module_level_statement(line):
                return


def is_class_or_module_level_statement(line):
    return line[:5].strip() and not line[:5].strip().startswith('#')


def is_module_level_statement(line):
    return line[:1].strip() and not line[:1].strip().startswith('#')


def get_method_offsets(txt, ClassName) -> List[Tuple[int, str]]:
    class_start, class_end = get_class_bounds(txt, ClassName)
    return [
        (m.start(), m.group(1))
        for m in re.finditer(r'^\s{4}def (\w+)\(self\b', txt, re.MULTILINE)
        if class_start < m.start() < class_end
    ]


def get_class_bounds(txt, ClassName):
    class_start = txt.index(f'\nclass {ClassName}(')
    m = list(re.finditer(r'^\w', txt[class_start:], re.MULTILINE))[1]
    class_end = class_start + m.start()
    return class_start, class_end


BACKUP_FOLDER = '_REMOVE_SELF_BACKUP'


def backup(app_name):
    approot = Path(app_name)
    old_folder = Path(BACKUP_FOLDER)
    if not old_folder.exists():
        old_folder.mkdir()
    app_backup_dest = old_folder.joinpath(app_name)
    if not app_backup_dest.exists():
        shutil.copytree(approot, app_backup_dest)
    print_function(f'Your old files were saved to {BACKUP_FOLDER}/.')


def rearrange_folder(app_name):
    approot = Path(app_name)
    app_path = approot / '__init__.py'
    if not 'from otree.api' in app_path.read_text('utf8'):
        return
    print_function('Removing old files from', app_name)
    pages_path = approot / 'pages.py'
    models_path = approot / 'models.py'
    app_py_path = approot / 'app.py'
    if pages_path.exists():
        pages_path.unlink()
    if models_path.exists():
        models_path.unlink()
    if app_py_path.exists():
        app_py_path.unlink()
    _builtin = approot.joinpath('_builtin')
    if _builtin.exists():
        shutil.rmtree(_builtin)
    templates = approot.joinpath('templates', app_name)
    if templates.exists():
        copytree_py37_compat(templates, approot)
        shutil.rmtree(approot.joinpath('templates'))
    tests_noself = approot.joinpath('tests_noself.py')
    tests_path = approot.joinpath('tests.py')
    if tests_noself.exists():
        if tests_path.exists():
            tests_path.unlink()
        tests_noself.rename(tests_path)


def copytree_py37_compat(src, dst, symlinks=False, ignore=None):
    """replacement for shutil.copytree(templates, approot, dirs_exist_ok=True)"""
    for item in os.listdir(src):
        s = os.path.join(src, item)
        d = os.path.join(dst, item)
        if os.path.isdir(s):
            shutil.copytree(s, d, symlinks, ignore)
        else:
            shutil.copy2(s, d)
