#!/usr/bin/python3
# pylint: disable=invalid-name  # https://github.com/PyCQA/pylint/issues/516

import signal
import os
import pwd
import contextlib
import argparse
import datetime
import sys
import warnings
import logging
import logging.handlers

import argcomplete

# PEP 3143
import daemon
import daemon.runner  # pylint: disable=import-error

import mini_buildd.misc
import mini_buildd.net
import mini_buildd.config
import mini_buildd.httpd
import mini_buildd.django_settings


# Early global log config (before the actual logging config steps in):
# - preliminary (stderr) handler
# - capture python warnings && setup 'py.warnings' logger
LOG_FORMAT = "[%(threadName)-19s] %(levelname)-8s: %(message)s [%(name)s:%(lineno)d]"
LOG_PRELIMINARY_HANDLER = logging.StreamHandler()
LOG_PRELIMINARY_HANDLER.setFormatter(logging.Formatter("mini-buildd preliminary console logger: " + LOG_FORMAT))
LOG = logging.getLogger("mini_buildd")
LOG.addHandler(LOG_PRELIMINARY_HANDLER)
LOGW = logging.getLogger("py.warnings")
LOGW.addHandler(LOG_PRELIMINARY_HANDLER)
logging.captureWarnings(True)


class PIDFile():
    """
    Pidfile with automatic stale fixup.

    This uses code from the PEP 3143 reference
    implementation.
    """

    @classmethod
    def _is_pidfile_stale(cls, pidfile, name=None):
        """
        Improvement (linux specific) of daemon.runner.is_pidfile_stale: Also checks the name of the process.

        Fixes situations when another unrelated process has reclaimed the pid from the stale pidfile.
        """
        is_stale = False
        pidfile_pid = pidfile.read_pid()
        if pidfile_pid is not None:
            is_stale = daemon.runner.is_pidfile_stale(pidfile)
            if name and not is_stale:
                try:
                    with open("/proc/{}/comm".format(pidfile_pid)) as proc_comm:
                        pidfile_name = proc_comm.read()
                    is_stale = pidfile_name != name
                except BaseException as e:
                    LOG.warning("Error in extra linux /proc-style stale check (ignoring): {e}".format(e=e))

        return is_stale

    def __init__(self, pidfile_path, acquire_timeout=5):
        self.pidfile = daemon.runner.make_pidlockfile(pidfile_path, acquire_timeout)
        if self._is_pidfile_stale(self.pidfile, name="mini-buildd"):
            LOG.warning("Fixing STALE PID file: {p}".format(p=self))
            self.pidfile.break_lock()
        self.pidfile.acquire(timeout=acquire_timeout)

    def __str__(self):
        return "{f} ({p})".format(f=self.pidfile.path, p=self.pidfile.read_pid())

    def close(self):
        self.pidfile.release()


class Main():
    class ArgumentDefaultsRawTextHelpFormatter(argparse.RawTextHelpFormatter, argparse.ArgumentDefaultsHelpFormatter):
        """Custom argparse help formatter (mixin): We like to use raw text, but also have default values shown."""

    @classmethod
    def _parse_args(cls):
        parser = argparse.ArgumentParser(prog="mini-buildd",
                                         description="Minimal Debian build daemon.",
                                         formatter_class=cls.ArgumentDefaultsRawTextHelpFormatter)

        parser.add_argument("--version", action="version", version=mini_buildd.__version__)
        group_conf = parser.add_argument_group("daemon arguments")

        # https://twistedmatrix.com/documents/current/core/howto/endpoints.html#servers
        # pylint: disable=unused-variable
        default_httpd_endpoint_ssl = "ssl:port=8066:privateKey=/etc/ssl/private/ssl-cert-snakeoil.key:certKey=/etc/ssl/certs/ssl-cert-snakeoil.pem"
        default_httpd_endpoint_tcp6 = "tcp6:port=8066"
        default_httpd_endpoint_unix = "unix:address=/var/lib/mini-buildd/var/httpd.sock"
        default_httpd_endpoints = [default_httpd_endpoint_tcp6]

        group_conf.add_argument("-E", "--httpd-endpoint", action="append",
                                help="""Network endpoint for the http daemon (twisted-style). May be given multiple times. Examples:

  tcp6:port=8066

  ssl:port=8068:privateKey=<path>:certKey=<path>

  unix:address=/tmp/mini-buildd.sock

See also: https://twistedmatrix.com/documents/current/core/howto/endpoints.html#servers

If none given, the default used is:

{}

""".format(default_httpd_endpoints))
        group_conf.add_argument("-W", "--httpd-bind", action="store", default=":::8066",
                                help="DEPRECATED (use '--httpd-endpoint' instead): Web Server IP/Hostname and port to bind to.")
        group_conf.add_argument("-S", "--smtp", action="store", default=":@smtp://localhost:25",
                                help="SMTP credentials in format '[USER]:[PASSWORD]@smtp|ssmtp://HOST:PORT'.")
        group_conf.add_argument("-U", "--dedicated-user", action="store", default="mini-buildd",
                                help="Force a custom dedicated user name (to run as a different user than 'mini-buildd').")
        group_conf.add_argument("-H", "--home", action="store", default="~",
                                help="Run with this home dir (you may use '~' for user expansion). The only use case to change this for debugging, really.")
        group_conf.add_argument("-F", "--pidfile", action="store", default="~/.mini-buildd.pid",
                                help="Set pidfile path (you may use '~' for user expansion).")
        group_conf.add_argument("-f", "--foreground", action="store_true",
                                help="Don't daemonize, log to console.")

        group_log = parser.add_argument_group("logging and debugging arguments")
        group_log.add_argument("-v", "--verbose", dest="verbosity", action="count", default=0,
                               help="Lower log level. Give twice for max logs.")
        group_log.add_argument("-q", "--quiet", dest="terseness", action="count", default=0,
                               help="Tighten log level. Give twice for min logs.")
        group_log.add_argument("-l", "--loggers", action="store", default="file,syslog",
                               help="Comma-separated list of loggers (file,syslog,console) to use.")
        group_log.add_argument("-d", "--debug", action="store", default="", metavar="OPTION,..",
                               help="""\
Comma-separated list of special debugging options:
'warnings' (show all warnings from python's warnings module in log),
'exception' (log tracebacks in exception handlers),
'http' (put http server in debug mode),
'webapp' (put web application [django] in debug mode),
'sbuild' (run sbuild in debug mode),
'keep' (keep spool and temporary directories),
'profile' (produce cProfile dump in log directory).""")

        group_db = parser.add_argument_group("database arguments")
        group_db.add_argument("-P", "--set-admin-password", action="store", metavar="PASSWORD",
                              help="Update password for django superuser named 'admin'; user is created if non-existent yet.")
        group_db.add_argument("-D", "--dumpdata", action="store", metavar="APP[.MODEL]",
                              help="Dump database contents for app[.MODEL] as JSON file (see 'django-admin dumpdata').")
        group_db.add_argument("-L", "--loaddata", action="store", metavar="FILE",
                              help="INTERNAL USE ONLY, use with care! Load JSON file into database (see 'django-admin loaddata').")
        group_db.add_argument("-R", "--remove-system-artifacts", action="store_true",
                              help="INTERNAL USE ONLY, use with care! Bulk-remove associated data of all objects that might have produced artifacts on the system.")

        argcomplete.autocomplete(parser)
        args = parser.parse_args()

        # Arguments that imply foreground mode
        if args.set_admin_password or args.loaddata or args.dumpdata:
            args.foreground = True

        # reproducible builds: Expand these for run, leave static for usage (as this is used to build man pages) (thx to Chris Lamb, see Debian Bug #833340)
        args.home = os.path.expanduser(args.home)
        args.pidfile = os.path.expanduser(args.pidfile)

        # Warn when deprecated --httpd-bind is still used (unless you use the default)
        if parser.get_default("httpd_bind") != args.httpd_bind:
            args.httpd_endpoint = [mini_buildd.net.ServerEndpoint.hopo2desc(args.httpd_bind)]
            LOG.warning("Option '--httpd-bind' has been deprecated, please use '--httpd-endpoint' instead (see 'mini-buildd --help').")
            LOG.warning("Compat --httpd-bind': Computed '--httpd-endpoint': {}".format(args.httpd_endpoint[0]))

        # Manually set default for --httpd-endpoint ('default' option in add_argument() above can't be used, see https://bugs.python.org/issue16399).
        if args.httpd_endpoint is None:
            args.httpd_endpoint = default_httpd_endpoints

        return args

    def _setup(self):
        """Set global variables that really make no sense to propagate through."""
        mini_buildd.config.DEBUG = self._args.debug.split(",")
        mini_buildd.config.FOREGROUND = self._args.foreground

        mini_buildd.config.HTTPD_ENDPOINTS = [mini_buildd.net.ServerEndpoint(ep_desc, mini_buildd.net.Protocol.HTTP) for ep_desc in self._args.httpd_endpoint]

        mini_buildd.config.HOME_DIR = self._args.home

        mini_buildd.config.INCOMING_DIR = os.path.join(self._args.home, "incoming")
        mini_buildd.config.REPOSITORIES_DIR = os.path.join(self._args.home, "repositories")

        vardir = os.path.join(self._args.home, "var")
        mini_buildd.config.LOG_DIR = os.path.join(vardir, "log")
        mini_buildd.config.LOG_FILE = os.path.join(mini_buildd.config.LOG_DIR, "daemon.log")
        mini_buildd.config.ACCESS_LOG_FILE = os.path.join(mini_buildd.config.LOG_DIR, "access.log")
        mini_buildd.config.CHROOTS_DIR = os.path.join(vardir, "chroots")
        mini_buildd.config.CHROOTS_LIBDIR = os.path.join(vardir, "chroots-libdir")
        mini_buildd.config.SPOOL_DIR = os.path.join(vardir, "spool")
        mini_buildd.config.TMP_DIR = os.path.join(vardir, "tmp")

        # Hardcoded to the Debian path atm
        mini_buildd.config.MANUAL_DIR = os.path.realpath("/usr/share/doc/mini-buildd/html")

        # Create base directories
        for d in [mini_buildd.config.INCOMING_DIR,
                  mini_buildd.config.REPOSITORIES_DIR,
                  mini_buildd.config.LOG_DIR,
                  mini_buildd.config.TMP_DIR,
                  mini_buildd.config.SPOOL_DIR,
                  mini_buildd.config.CHROOTS_LIBDIR]:
            os.makedirs(d, exist_ok=True)

    @staticmethod
    def _log_handler_file():
        handler = logging.handlers.RotatingFileHandler(
            mini_buildd.config.LOG_FILE,
            maxBytes=5000000,
            backupCount=9,
            encoding="UTF-8")
        handler.setFormatter(logging.Formatter("%(asctime)s " + LOG_FORMAT))
        return handler

    @staticmethod
    def _log_handler_syslog():
        handler = logging.handlers.SysLogHandler(
            address="/dev/log",
            facility=logging.handlers.SysLogHandler.LOG_USER)
        handler.setFormatter(logging.Formatter(LOG_FORMAT))
        return handler

    @staticmethod
    def _log_handler_console():
        handler = logging.StreamHandler()
        handler.setFormatter(logging.Formatter("%(asctime)s " + LOG_FORMAT))
        return handler

    def _loglevel(self):
        return logging.WARNING - (10 * (min(2, self._args.verbosity) - min(2, self._args.terseness)))

    def _setup_logging(self):
        loggers = self._args.loggers.split(",")
        if self._args.foreground:
            loggers.append("console")

        # Clear all loggers now; this will remove the
        # preliminary console logger
        LOG.handlers = []
        LOGW.handlers = []

        # Try to add all loggers; collect exceptions to be able
        # to do error reporting later, when hopefully one valid
        # handler is set up.
        loggers_failed = {}
        for typ in loggers:
            try:
                handler_func = getattr(self, "_log_handler_" + typ)
                LOG.addHandler(handler_func())
                LOGW.addHandler(handler_func())
            except BaseException as e:
                loggers_failed[typ] = e

        # Set log level
        LOG.setLevel(self._loglevel())
        LOGW.setLevel(self._loglevel())

        # Properly set raiseException based "exception" debug value given (see https://docs.python.org/3.5/howto/logging.html#exceptions-raised-during-logging)
        logging.raiseExceptions = "exception" in mini_buildd.config.DEBUG

        if "warnings" in mini_buildd.config.DEBUG:
            warnings.simplefilter("default")

        # Finally, log all errors now that occurred while setting up loggers
        for typ, err in list(loggers_failed.items()):
            LOG.critical("Logger {t} failed: {e}".format(t=typ, e=err))

    def _setup_environment(self):
        os.environ.clear()
        os.environ["HOME"] = self._args.home
        os.environ["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"
        os.environ["LANG"] = "C.UTF-8"
        for name in ["USER", "LOGNAME"]:
            os.environ[name] = self._user

    def is_extra_run(self):
        """Return True if in a non-daemon, non-HTTP server extra run."""
        return self._args.set_admin_password or self._args.remove_system_artifacts or self._args.loaddata or self._args.dumpdata

    def __init__(self):
        self._user = pwd.getpwuid(os.getuid())[0]

        self._args = self._parse_args()

        # User sanity check
        if self._args.dedicated_user != self._user:
            raise Exception("Run as dedicated user only (use '--dedicated-user={u}' if you really want this, will write to that user's $HOME!)".format(u=self._user))

        if not self.is_extra_run():
            # Pre-daemonize check if shm is usable on the system (else pyftpdlib fails strangely later)
            mini_buildd.misc.check_multiprocessing()

        # Daemonize early
        if not self._args.foreground:
            daemon.DaemonContext(working_directory=self._args.home, umask=0o022).open()
        self._setup()
        self._setup_environment()

        # Configure django
        mini_buildd.django_settings.configure(self._args.smtp, self._loglevel())

        # Setup logging *after* django config, as the latter might overwrite global logging/warning setup.
        self._setup_logging()

    class Signals():
        SHUTDOWN = [signal.SIGTERM, signal.SIGINT]
        RESTART = [signal.SIGHUP]
        SIGNALS = SHUTDOWN + RESTART

        def __init__(self):
            for s in self.SIGNALS:
                signal.signal(s, lambda x, y: None)  # We need overwrite default handlers with no-ops for signals we handle below

        @classmethod
        def seconds_until_next_sunday_2am(cls):
            now = datetime.datetime.now()
            next_sunday = now + datetime.timedelta(days=7 - (now.isoweekday() % 7))
            next_sunday_2am = datetime.datetime(year=next_sunday.year, month=next_sunday.month, day=next_sunday.day, hour=2)
            return (next_sunday_2am - now).total_seconds()

        def wait(self):
            secs = self.seconds_until_next_sunday_2am()
            LOG.info("Scheduling internal restart for: {}".format(datetime.datetime.now() + datetime.timedelta(seconds=secs)))
            sig = signal.sigtimedwait(self.SIGNALS, self.seconds_until_next_sunday_2am())
            LOG.info("Got signal: {}".format(sig))
            if not sig or sig.si_signo in self.RESTART:
                return self.RESTART  # On SIGHUP or timeout
            return self.SHUTDOWN

    def run_daemon(self, webapp):
        # Start httpd w/ webapp
        from mini_buildd.httpd_twisted import HttpD  # Note: Backend import must be here: daemonize() might close things created by backend module's initialization.
        mini_buildd.misc.run_as_thread(HttpD(wsgi_app=webapp).run, name="httpd", daemon=True)

        # Get the daemon manager instance
        from mini_buildd.daemon import Daemon  # Note: Import here: We cannot import anything 'django' prior to django's configuration.
        mini_buildd_daemon = Daemon()
        mini_buildd_daemon.start()

        # Main loop
        signals = self.Signals()
        while signals.wait() == signals.RESTART:
            mini_buildd_daemon.restart()
        mini_buildd_daemon.stop()

    def run(self):
        # Get the django project instance (import here: We cannot import anything 'django' prior to django's configuration)
        from mini_buildd.webapp import WebApp
        webapp = WebApp()

        # Extra options that exit without running as daemon
        if self._args.set_admin_password:
            webapp.set_admin_password(self._args.set_admin_password)
        elif self._args.remove_system_artifacts:
            webapp.remove_system_artifacts()
        elif self._args.loaddata:
            webapp.loaddata(self._args.loaddata)
        elif self._args.dumpdata:
            webapp.dumpdata(self._args.dumpdata)
        else:
            with contextlib.closing(PIDFile(self._args.pidfile)) as pidfile:
                LOG.info("Starting daemon with pidfile: {p}".format(p=pidfile))
                self.run_daemon(webapp)


try:
    MAIN = Main()
    if "profile" in mini_buildd.config.DEBUG:
        PROFILE = os.path.join(mini_buildd.config.LOG_DIR, "daemon.profile")
        LOG.warning("PROFILE DEBUG MODE: Profiling to '{p}'".format(p=PROFILE))
        import cProfile
        cProfile.run("MAIN.run()", PROFILE)
    else:
        MAIN.run()
except SystemExit as e:
    sys.exit(e.code)
except BaseException as e:
    mini_buildd.config.log_exception(LOG, "mini-buildd FAILED", e)
    sys.exit(1)
