import importlib
import logging
import os.path
import time
import traceback
from pathlib import Path
from unittest.mock import patch
import signal
import termcolor
from channels.management.commands import runserver
from daphne.endpoints import build_endpoint_description_strings
from django.apps import apps
from django.conf import settings
from django.core.management import call_command
import otree.common
import otree_startup
from otree import __version__ as CURRENT_VERSION
import os
import sys


from otree.common import dump_db, dump_db_and_exit, load_db

TMP_MIGRATIONS_DIR = Path('__temp_migrations')
VERSION_FILE = TMP_MIGRATIONS_DIR.joinpath('otree-version.txt')

ADVICE_DELETE_TMP = (
    "ADVICE: Try deleting the folder {}. If that doesn't work, "
    "look for the error in your models.py."
).format(TMP_MIGRATIONS_DIR)

# this happens when I add a non-nullable field to oTree-core
# (includes renaming a non-nullable field)
ADVICE_FIX_NOT_NULL_FIELD = (
    'You may have added a non-nullable field without a default. '
    'This typically happens when importing model fields from django instead of otree.'
)

PRINT_DETAILS_VERBOSITY_LEVEL = 1

ADVICE_PRINT_DETAILS = (
    '(For technical details about this error, run "otree devserver --verbosity=1")'
).format(PRINT_DETAILS_VERBOSITY_LEVEL)

db_engine = settings.DATABASES['default']['ENGINE'].lower()

if otree.common.is_sqlite():
    ADVICE_DELETE_DB = (
        'ADVICE: Stop the server, '
        'then delete the file db.sqlite3 in your project folder.'
    )
else:
    if 'postgres' in db_engine:
        db_engine = 'PostgreSQL'
    elif 'mysql' in db_engine:
        db_engine = 'MySQL'

    ADVICE_DELETE_DB = (
        'ADVICE: Delete (drop) your {} database, then create a new empty one '
        'with the same name. "otree devserver" cannot be used on a database '
        'that was generated with "otree resetdb". You should either use one '
        'command or the other.'
    ).format(db_engine)

# They should start fresh so that:
# (1) performance refresh
# (2) don't have to worry about old references to things that were removed from otree-core.
MSG_OTREE_UPDATE_DELETE_DB = (
    'oTree has been updated. Please delete your database (usually "db.sqlite3") '
    f'and the folder "{TMP_MIGRATIONS_DIR}".'
)


class Command(runserver.Command):
    def add_arguments(self, parser):
        super().add_arguments(parser)
        # see log_action below; we only show logs of each request
        # if verbosity >= 1.
        # this still allows logger.info and logger.warning to be shown.
        # NOTE: if we change this back to 1, then need to update devserver
        # not to show traceback of errors.
        parser.set_defaults(verbosity=0)

        parser.add_argument(
            '--inside-zipserver',
            action='store_true',
            dest='inside_zipserver',
            default=False,
        )

    def handle(self, *args, **options):
        self.verbosity = options.get("verbosity", 1)

        if not settings.DEBUG:
            # this tends to cause confusion. people don't know why they get server 500 error,
            # and it's worse with zipserver, where it is not possible to run collectstatic.
            sys.exit(
                'Error: devserver & zipserver cannot be used in production mode. '
                'Ensure that your settings.py does not contain a DEBUG setting, '
                'and that the OTREE_PRODUCTION env var is not set.'
            )

        # for performance,
        # only run checks when the server starts, not when it reloads
        # (RUN_MAIN is set by Django autoreloader).
        if not os.environ.get('RUN_MAIN'):

            try:
                # don't suppress output. it's good to know that check is
                # not failing silently or not being run.
                # also, intercepting stdout doesn't even seem to work here.
                self.check(display_num_errors=True)

            except Exception as exc:
                otree_startup.print_colored_traceback_and_exit(exc)

            # better to do this here, because:
            # (1) it's redundant to do it on every reload
            # (2) we can exit if we run this before the autoreloader is started
            if TMP_MIGRATIONS_DIR.exists() and (
                not VERSION_FILE.exists() or VERSION_FILE.read_text() != CURRENT_VERSION
            ):
                # - Don't delete the DB, because it might have important data
                # - Don't delete __temp_migrations, because then we erase the knowledge that
                # oTree was updated. If the user starts the server at a later time, we can't remind them
                # that they needed to delete the DB. So, the two things must be deleted together.
                self.stdout.write(MSG_OTREE_UPDATE_DELETE_DB)
                sys.exit(0)
            TMP_MIGRATIONS_DIR.mkdir(exist_ok=True)
            VERSION_FILE.write_text(CURRENT_VERSION)
            TMP_MIGRATIONS_DIR.joinpath('__init__.py').touch(exist_ok=True)
            # python docs say: invalidate_cache must be called after creating new modules
            # i suspect invalidate_cache is needed to avoid this issue:
            # https://groups.google.com/d/msg/otree/d3fOpKCgLWo/-TSiZHy5CAAJ
            # because it only happened on mac and was sporadic like a race condition
            # seems to have no impact on perf
            importlib.invalidate_caches()

        is_reloader_process = options['use_reloader'] and not os.environ.get('RUN_MAIN')
        if not is_reloader_process:
            # this handles restarts due Ctrl+C

            # can't handle this signal in the autoreloader process,
            # since the DB is stored in the main django process's memory.

            # i guess the child process receives the SIGINT
            # from the parent autoreloader process.
            signal.signal(signal.SIGINT, dump_db_and_exit)

        try:
            super().handle(*args, **options)
        except SystemExit as exc:
            # this is designed to handle the sys.exit(3) that is fired
            # by the child process file-watcher thread, when a file is changed.
            # it needs to be here rather than inner_run because that is only
            # run by the child process main_func thread.

            # this also gets run if using zipserver and a sys.exit occurs.

            # if an exception occurs inside the main_func thread that executes inner_run,
            # it will just print the TB and hang, as usual. Then the user presses Ctrl+C,
            # which triggers the SIGINT handler above.
            # if the main_func thread calls sys.exit that is not a concern here
            # because that will just exit that thread and not bubble up here.
            if not is_reloader_process:
                dump_db()
            raise

    def inner_run(self, *args, inside_zipserver, **options):
        '''
        inner_run does not get run twice with runserver, unlike .handle()
        '''

        self.inside_zipserver = inside_zipserver
        self.makemigrations_and_migrate()

        # silence the lines like:
        # 2018-01-10 18:51:18,092 - INFO - worker - Listening on channels
        # http.request, otree.create_session, websocket.connect,
        # websocket.disconnect, websocket.receive
        daphne_logger = logging.getLogger('django.channels')
        original_log_level = daphne_logger.level
        daphne_logger.level = logging.WARNING

        endpoints = build_endpoint_description_strings(host=self.addr, port=self.port)
        application = self.get_application(options)

        # silence the lines like:
        # INFO HTTP/2 support not enabled (install the http2 and tls Twisted extras)
        # INFO Configuring endpoint tcp:port=8000:interface=127.0.0.1
        # INFO Listening on TCP address 127.0.0.1:8000
        logging.getLogger('daphne.server').level = logging.WARNING

        # I removed the IPV6 stuff here because its not commonly used yet
        addr = self.addr
        # 0.0.0.0 is not a regular IP address, so we can't tell the user
        # to open their browser to that address
        if addr == '127.0.0.1':
            addr = 'localhost'
        elif addr == '0.0.0.0':
            addr = '<ip_address>'
        self.stdout.write(
            (
                f"Open your browser to http://{addr}:{self.port}/\n"
                "To quit the server, press Control+C.\n"
            )
        )

        try:
            self.server_cls(
                application=application,
                endpoints=endpoints,
                signal_handlers=not options["use_reloader"],
                action_logger=self.log_action,
                http_timeout=self.http_timeout,
                root_path=getattr(settings, "FORCE_SCRIPT_NAME", "") or "",
                websocket_handshake_timeout=self.websocket_handshake_timeout,
            ).run()
        except KeyboardInterrupt:
            shutdown_message = options.get("shutdown_message", "")
            if shutdown_message:
                self.stdout.write(shutdown_message)
            return

    def makemigrations_and_migrate(self):

        # only get apps with models, otherwise migrate will raise an error
        # when it tries to migrate that app but no migrations dir was created
        app_labels = set(model._meta.app_config.label for model in apps.get_models())

        migrations_modules = {
            app_label: '{}.{}'.format(TMP_MIGRATIONS_DIR, app_label)
            for app_label in app_labels
        }
        settings.MIGRATION_MODULES = migrations_modules
        start = time.time()

        try:
            # makemigrations rarely sends any interesting info to stdout.
            # if there is an error, it will go to stderr,
            # or raise CommandError.
            # if someone needs to see the details of makemigrations,
            # they can do "otree makemigrations".
            with patch('sys.stdout.write'):
                call_command('makemigrations', '--noinput', *migrations_modules.keys())
        except SystemExit as exc:
            # SystemExit will be raised if NonInteractiveMigrationQuestioner
            # cannot decide what to do automatically.
            # SystemExit does not inherit from Exception,
            # so we need to catch it explicitly.
            # without this, the process will just exit and the autoreloader
            # will hang.
            self.print_error_and_exit(ADVICE_FIX_NOT_NULL_FIELD)
        except Exception as exc:
            self.print_error_and_exit(ADVICE_DELETE_TMP)

        # migrate imports some modules that were created on the fly,
        # so according to the docs for import_module, we need to call
        # invalidate_cache.
        # the following line is necessary to avoid a crash I experienced
        # on Mac, because makemigrations tries some imports which cause ImportErrors,
        # messes up the cache on some systems.
        importlib.invalidate_caches()

        # should I instead connect to connection_created signal?
        # but this seems better because we know exactly where it happens,
        # and can guarantee we won't subscribe after the signal was already sent.
        load_db()

        try:
            # see above comment about makemigrations and capturing stdout.
            # it applies to migrate command also.
            with patch('sys.stdout.write'):
                # call_command does not add much overhead (0.1 seconds typical)
                call_command('migrate', '--noinput')
        except Exception as exc:
            # it seems there are different exceptions all named
            # OperationalError (django.db.OperationalError,
            # sqlite.OperationalError, mysql....)
            # so, simplest to use the string name

            if type(exc).__name__ in (
                'OperationalError',
                'ProgrammingError',
                'InconsistentMigrationHistory',
            ):
                self.print_error_and_exit(ADVICE_DELETE_DB)
            else:
                raise

        total_time = round(time.time() - start, 1)
        if total_time > 5:
            self.stdout.write('makemigrations & migrate ran in {}s'.format(total_time))

    def print_error_and_exit(self, advice):
        '''this won't actually exit because we can't kill the autoreload process'''
        self.stdout.write('\n')
        is_verbose = self.verbosity >= PRINT_DETAILS_VERBOSITY_LEVEL
        show_error_details = is_verbose or self.inside_zipserver
        if show_error_details:
            traceback.print_exc()
        else:
            self.stdout.write('An error occurred.')
        if self.inside_zipserver:
            self.stdout.write('Please report to chris@otree.org.')
        else:
            termcolor.cprint(advice, 'white', 'on_red')
        if not show_error_details:
            self.stdout.write(ADVICE_PRINT_DETAILS)
        sys.exit(0)

    def log_action(self, protocol, action, details):
        '''
        Override log_action method.
        Need this until https://github.com/django/channels/issues/612
        is fixed.
        maybe for some minimal output use this?
            self.stderr.write('.', ending='')
        so that you can see that the server is running
        (useful if you are accidentally running multiple servers)

        idea: maybe only show details if it's a 4xx or 5xx.

        '''
        if self.verbosity >= 1:
            super().log_action(protocol, action, details)

    inside_zipserver = False
