import logging
import os
import os.path
import os.path
import shutil
import subprocess
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
from time import sleep
from typing import Optional

from . import unzip
from otree.main import send_termination_notice
from .base import BaseCommand
from otree.update import check_update_needed

logger = logging.getLogger(__name__)

stdout_write = print

PORT = '8000'


class Command(BaseCommand):
    def add_arguments(self, parser):
        parser.add_argument('zipfile', nargs='?')

    def handle(self, **options):
        zipfile = options.get('zipfile')
        try:
            if zipfile:
                exit_code = run_single_zipfile(zipfile)
            else:
                exit_code = autoreload_for_new_zipfiles()
            # the rest is based on django autoreload, not sure why it's done
            # this way
            if exit_code < 0:
                os.kill(os.getpid(), -exit_code)
            else:
                sys.exit(exit_code)
        except KeyboardInterrupt:
            pass


def run_single_zipfile(fn: str) -> int:
    project = Project(Path(fn))
    project.unzip_to_tempdir()
    project.start()
    # from experimenting, this responds to Ctrl+C,
    # and there is no zombie subprocess
    return project.wait()


MSG_NO_OTREEZIP_YET = 'No *.otreezip file found in this folder yet, waiting...'
MSG_FOUND_NEWER_OTREEZIP = 'Newer project found'
MSG_RUNNING_OTREEZIP_NAME = "Running {}"


def autoreload_for_new_zipfiles() -> int:
    exit_code = None
    project = get_newest_project()
    newer_project = None
    if not project:
        stdout_write(MSG_NO_OTREEZIP_YET)
        while True:
            project = get_newest_project()
            if project:
                break
            sleep(1)

    tempdirs = []
    try:
        while True:
            if newer_project:
                project = newer_project
            stdout_write(MSG_RUNNING_OTREEZIP_NAME.format(project.zipname()))
            project.unzip_to_tempdir()
            if tempdirs:
                project.take_db_from_previous(tempdirs[-1].name)

            tempdirs.append(project.tmpdir)
            project.start()
            # I used to have a try block that executed 'terminate_through_http' inside 'finally'
            # added on 2019-03-09. not sure why that was necessary
            # maybe it was just for thoroughness but now it interferes with terminating through HTTP.
            while True:
                # if process is still running, poll() returns None
                exit_code = project.poll()
                if exit_code != None:
                    return exit_code
                sleep(1)
                latest_project = get_newest_project()
                # it's possible that zipfile was deleted while the program
                # was running
                if latest_project and latest_project != project:
                    newer_project = latest_project
                    # use stdout.write because logger is not configured
                    # (django setup has not even been run)
                    stdout_write(MSG_FOUND_NEWER_OTREEZIP)
                    project.terminate()
                    break
    finally:
        # e.g. KeyboardInterrupt
        project.wait()
        for td in tempdirs:
            td.cleanup()


class Project:
    tmpdir: TemporaryDirectory = None
    _proc: subprocess.Popen

    def __init__(self, otreezip: Path):
        self._otreezip = otreezip

    def zipname(self):
        return self._otreezip.name

    def mtime(self):
        return self._otreezip.stat().st_mtime

    def __eq__(self, other):
        return self._otreezip == other._otreezip

    def unzip_to_tempdir(self):
        self.tmpdir = TemporaryDirectory()
        unzip.unzip(str(self._otreezip), self.tmpdir.name)

    def start(self):
        self.check_update_needed()
        self._proc = subprocess.Popen(
            [
                'otree',
                'devserver_inner',
                PORT,
            ],
            cwd=self.tmpdir.name,
            env=os.environ.copy(),
        )

    def delete_otreezip(self):
        self._otreezip.unlink()

    def poll(self):
        return self._proc.poll()

    def wait(self) -> int:
        return self._proc.wait()

    def terminate(self):
        child_pid = send_termination_notice(PORT)
        self._proc.terminate()
        # see the explanation in devserver about this
        os.kill(child_pid, 9)

    def take_db_from_previous(self, other_tmpdir: str):
        for item in ['db.sqlite3']:
            item_path = Path(other_tmpdir) / item
            if item_path.exists():
                shutil.move(str(item_path), self.tmpdir.name)

    def check_update_needed(self):
        """
        The main need to check if requirements.txt matches the current version
        is for oTree Studio users, since they have no way to control what version
        is installed on the server. we instead need the otreezip file to tell
        their local installation what version to use.

        We used to check if an update was needed for any otree command (devserver etc),
        but i think putting it here is more targeted with a clearer scenario.
        other cases are not really essential and there are already other ways
        to handle those.
        """
        warning = check_update_needed(
            Path(self.tmpdir.name).joinpath('requirements.txt')
        )
        if warning:
            logger.warning(warning)


MAX_OTREEZIP_FILES = 10
MSG_DELETING_OLD_OTREEZIP = 'Deleting old file: {}'

# returning the time together with object makes it easier to test
def get_newest_project() -> Optional[Project]:

    projects = [Project(path) for path in Path('.').glob('*.otreezip')]
    if not projects:
        return None

    sorted_projects = sorted(projects, key=lambda proj: proj.mtime(), reverse=True)
    newest_project = sorted_projects[0]

    # cleanup so they don't end up with hundreds of zipfiles
    for old_proj in sorted_projects[MAX_OTREEZIP_FILES:]:
        stdout_write(MSG_DELETING_OLD_OTREEZIP.format(old_proj.zipname()))
        old_proj.delete_otreezip()

    return newest_project
