#!/usr/local/bin/python
#-
# Copyright (c) 2015 iXsystems, Inc.
# All rights reserved.
# This file is a part of TrueNAS
# and may not be copied and/or distributed
# without the express permission of iXsystems.

from socketserver import ThreadingMixIn
from xmlrpc.server import (
    SimpleXMLRPCRequestHandler,
    SimpleXMLRPCServer,
    resolve_dotted_attribute,
)
import base64
import fcntl
import logging
import logging.config
import os
import resource
import socket
import sys
import threading
import xmlrpc.client

from collections import defaultdict
from setproctitle import setproctitle
import daemon

LOG_FILE = '/var/log/hasyncd.log'
SYNC_FILE = '/var/tmp/sync_failed'


class PidFile(object):

    def __init__(self, path):
        self.path = path
        self.pidfile = None

    def __enter__(self):
        self.pidfile = open(self.path, 'a+')
        try:
            fcntl.flock(self.pidfile.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
        except IOError:
            raise SystemExit('Already running according to ' + self.path)
        self.pidfile.seek(0)
        self.pidfile.truncate()
        self.pidfile.write(str(os.getpid()))
        self.pidfile.flush()
        self.pidfile.seek(0)
        return self.pidfile

    def __exit__(self, *args, **kwargs):
        try:
            if os.path.exists(self.path):
                os.unlink(self.path)
            self.pidfile.close()
        except IOError:
            pass


class JournalAlive(threading.Thread):

    def __init__(self, *args, **kwargs):
        self.sleep = threading.Event()
        logger = kwargs.pop('logger', None)
        if not logger:
            self.logger = logging.getLogger('hasyncd.journal')
        else:
            self.logger = log
        super(JournalAlive, self).__init__(*args, **kwargs)

    def run(self):
        from freenasUI.freeadmin.sqlite3_ha.base import Journal
        from freenasUI.middleware.notifier import notifier

        retries = defaultdict(int)

        while True:
            self.sleep.wait(5)
            if Journal.is_empty():
                continue

            s = notifier().failover_rpc()

            with Journal() as j:
                for q in list(j.queries):
                    query, params = q
                    try:
                        s.run_sql(query, params)
                        j.queries.remove(q)
                    except xmlrpc.client.Fault as e:
                        retries[q] += 1
                        if retries[q] >= 2:
                            continue
                        self.logger.exception('Failed to run sql: %s', e)
                        try:
                            if not os.path.exists(SYNC_FILE):
                                open(SYNC_FILE, 'w').close()
                        except:
                            pass
                        break
                    except socket.error:
                        # Network issue, skip it for now
                        break
                    except Exception as e:
                        self.logger.exception('Query %s has failed for unknown reasons', query)

                if len(list(j.queries)) == 0 and os.path.exists(SYNC_FILE):
                    try:
                        os.unlink(SYNC_FILE)
                    except:
                        pass


class Request(object):

    def __init__(self, **kwargs):
        self.client_address = kwargs.pop('client_address')
        self.connection = kwargs.pop('connection')
        self.headers = kwargs.pop('headers')


class HASyncRequestHandler(SimpleXMLRPCRequestHandler):

    protocol_version = 'HTTP/1.1'

    def log_message(self, format, *args):
        return None

    def _dispatch(self, method, params):
        """
        Method based on SimpleXMLRPCDispatcher to pass the request object
        as argument.
        """
        func = None
        try:
            # check to see if a matching function has been registered
            func = self.server.funcs[method]
        except KeyError:
            if self.server.instance is not None:
                # check for a _dispatch method
                if hasattr(self.server.instance, '_dispatch'):
                    return self.server.instance._dispatch(method, params)
                else:
                    # call instance method directly
                    try:
                        func = resolve_dotted_attribute(
                            self.server.instance,
                            method,
                            self.server.allow_dotted_names
                            )
                    except AttributeError:
                        pass

        request = Request(
            client_address=self.client_address,
            connection=self.connection,
            headers=self.headers,
        )
        if func is not None:
            try:
                return func(request, *params)
            except xmlrpc.client.Fault:
                raise
            except:
                log.exception('Failed to run method "%s"', method)
                raise
        else:
            raise Exception('method "%s" is not supported' % method)


class HASyncServer(ThreadingMixIn, SimpleXMLRPCServer):

    _shutdown = False

    def shutdown(self):
        self._shutdown = True

    def serve_forever(self):
        while not self._shutdown: self.handle_request()


class Funcs:

    def __init__(self, server, *args, **kwargs):
        from django.db import connection
        from freenasUI.common.system import get_sw_version
        self._conn = connection
        self._server = server
        self._version = get_sw_version()

    def _ebRender(self, failure):
        return xmlrpc.client.Fault(self.FAILURE, str(failure))

    def _authenticated(self, request):
        from freenasUI.failover.models import Failover
        client_addr, client_port = request.connection.getpeername()
        # Connection is only allowed from privileged port and IPs
        if client_port > 1024 or client_addr not in (
            '169.254.10.1',
            '169.254.10.2',
            '169.254.10.20',
            '169.254.10.80',
        ):
            raise xmlrpc.client.Fault(5, 'Access Denied')
        try:
            return Failover.objects.order_by('-id')[0]
        except IndexError:
            return Failover.objects.create()

    def _check_version(self, request):
        rversion = request.headers.get('Version')
        if rversion != self._version:
            raise xmlrpc.client.Fault(55, 'Versions mismatch (%s != %s' % (
                self._version, rversion
            ))

    def debug(self, request):
        from freenasUI.common.locks import mntlock
        from freenasUI.system.utils import debug_get_settings, debug_run

        self._authenticated(request)

        mntpt, direc, dump = debug_get_settings()
        with mntlock(mntpt=mntpt):
            debug_run(direc)

        return dump

    def enc_getkey(self, request):
        from freenasUI.failover.enc_helper import LocalEscrowCtl
        self._authenticated(request)
        escrowctl = LocalEscrowCtl()
        return escrowctl.getkey()

    def enc_setkey(self, request, passphrase):
        from freenasUI.failover.enc_helper import LocalEscrowCtl
        self._authenticated(request)
        escrowctl = LocalEscrowCtl()
        return escrowctl.setkey(passphrase)

    def listdir(self, request, path):
        self._authenticated(request)
        if not os.path.exists(path):
            return []
        if not os.path.isdir(path):
            return []

        files = []
        for f in os.listdir(path):
            fullpath = os.path.join(path, f)
            if not os.path.isfile(fullpath):
                continue
            files.append(fullpath)
        return files

    def ping(self, request):
        return 'pong'

    def boot_ready(self, request):
        return os.path.exists('/tmp/.bootready')

    def pool_available(self, request):
        from freenasUI.middleware.notifier import notifier
        self._authenticated(request)
        p1 = notifier()._pipeopen('zpool list -H | grep -v freenas-boot')
        data = p1.communicate()[0].strip()
        return len(data) > 0

    def file_recv(self, request, path):
        self._authenticated(request)
        if not os.path.exists(path):
            return None
        with open(path, 'rb') as f:
            data = base64.b64encode(f.read()).decode()
        mode = os.stat(path).st_mode
        return data, mode

    def file_send(self, request, path, content, mode, chunk=False):
        self._authenticated(request)
        dirname = os.path.dirname(path)
        if not os.path.exists(dirname):
            os.makedirs(dirname)
        if chunk:
            openmode = 'ab'
        else:
            openmode = 'wb+'
        with open(path, openmode) as f:
            f.write(base64.b64decode(content))
        os.chmod(path, mode)
        return True

    def file_delete(self, request, path):
        self._authenticated(request)
        if not os.path.exists(path):
            return False
        os.unlink(path)
        return True

    def run_sql(self, request, query, params):
        self._authenticated(request)
        self._check_version(request)
        cursor = self._conn.cursor()
        if params is None:
            cursor.executelocal(query)
        else:
            cursor.executelocal(query, params)
        cursor.close()

    def service(self, request, verbs):
        from freenasUI.middleware.notifier import notifier
        self._authenticated(request)
        rvs = []
        _n = notifier()
        for verb, service, timeout, onetime in verbs:
            if verb not in (
                'start', 'stop', 'restart', 'reload'
            ):
                continue
            rvs.append(getattr(_n, verb)(service, timeout=timeout, onetime=onetime))
        return rvs

    def sync_to(self, request, query):
        self._check_version(request)
        self._authenticated(request)
        return self._conn.dump_recv(query)

    def sync_from(self, request):
        failover = self._authenticated(request)
        self._check_version(request)
        return self._conn.dump_send()

    def node_specific_config(self, request):
        from collections import defaultdict
        from freenasUI.middleware.notifier import notifier
        from freenasUI.network.models import (
            Alias, GlobalConfiguration, Interfaces
        )
        from freenasUI.services.models import CIFS
        from freenasUI.system.models import SystemDataset

        self._check_version(request)
        self._authenticated(request)

        data = {
            'cifs': {},
            'alias': defaultdict(dict),
            'interfaces': {},
            'globalconfiguration': {},
            'systemdataset': {},
        }

        node = notifier().failover_node()
        if node not in ('A', 'B'):
            return data

        if node == 'A':
            aliasv4 = 'alias_v4address'
            aliasv6 = 'alias_v6address'
            netbios = 'cifs_srv_netbiosname'
            ipv4 = 'int_ipv4address'
            uuid = 'sys_uuid'
            hostname = 'gc_hostname'
        else:
            aliasv4 = 'alias_v4address_b'
            aliasv6 = 'alias_v6address_b'
            netbios = 'cifs_srv_netbiosname_b'
            ipv4 = 'int_ipv4address_b'
            uuid = 'sys_uuid_b'
            hostname = 'gc_hostname_b'

        cifs = CIFS.objects.order_by('-id')
        if cifs.exists():
            cifs = cifs[0]
            data['cifs'][netbios] = getattr(cifs, netbios)

        gc = GlobalConfiguration.objects.order_by('-id')
        if gc.exists():
            gc = gc[0]
            data['globalconfiguration'][hostname] = getattr(gc, hostname)

        for iface in Interfaces.objects.all():
            data['interfaces'][str(iface.id)] = {
                ipv4: str(getattr(iface, ipv4)),
            }

        for alias in Alias.objects.all():
            key = str(alias.id)
            ipv4 = getattr(alias, aliasv4)
            if ipv4:
                data['alias'][key][aliasv4] = str(ipv4)
            ipv6 = getattr(alias, aliasv6)
            if ipv6:
                data['alias'][key][aliasv6] = str(ipv6)

        # RPC can't marshall defaultdict object
        data['alias'] = dict(data['alias'])

        sys = SystemDataset.objects.order_by('-id')
        if sys.exists():
            sys = sys[0]
            data['systemdataset'][uuid] = getattr(sys, uuid)

        return data

    def version(self, request):
        self._authenticated(request)
        return self._version

    def reboot(self, request):
        from freenasUI.middleware.notifier import notifier
        self._authenticated(request)
        self._server.shutdown()
        notifier().restart("system")

    def notifier(self, request, method, args, kwargs):
        if not args:
            args = []
        if not kwargs:
            kwargs = {}
        from freenasUI.middleware.notifier import notifier
        self._authenticated(request)
        return getattr(notifier(), method)(*args, **kwargs)

    def hactl(self, request, action, args=None):
        from freenasUI.middleware.notifier import notifier
        self._check_version(request)
        failover = self._authenticated(request)

        if args is None:
            args = []

        if action == 'enable':
            if failover.disabled is False:
                return False
            failover.disabled = False
            failover.master = False
            failover.save()
            notifier().start("ix-devd")
        elif action == 'disable':
            if failover.disabled is True:
                return False
            failover.disabled = True
            if 'active' in args:
                failover.master = True
            elif 'passive' in args:
                failover.master = False
            failover.save()
            notifier().start("ix-devd")

    def updated(self, request, download=True, apply=False):
        from freenasUI.middleware.notifier import notifier
        from freenasUI.system.models import Update as mUpdate
        from freenasUI.system.utils import run_updated

        self._authenticated(request)

        # Create a memory device filesystem
        # Since standby node has no disks
        notifier().create_upload_location()

        try:
            updateobj = mUpdate.objects.order_by('-id')[0]
        except IndexError:
            updateobj = mUpdate.objects.create()

        returncode, uuid = run_updated(
            str(updateobj.get_train()),
            '/var/tmp/firmware/update',
            download=download,
            apply=apply,
        )
        if returncode != 0:
            return False
        return uuid

    def updated_handler(self, request, uuid):
        from freenasUI.system.utils import (
            UpdateHandler,
        )
        self._authenticated(request)
        handler = UpdateHandler(uuid=uuid)
        if handler.finished and not handler.error:
            handler.exit()
        return {
            'uuid': handler.uuid,
            'apply': handler.apply,
            'data': handler.load(),
            'error': handler.error,
            'finished': handler.finished,
            'reboot': handler.reboot,
        }

    def update_check(self, request):
        from freenasOS import Configuration
        from freenasOS.Exceptions import UpdateManifestNotFound
        from freenasOS.Update import CheckForUpdates
        from freenasUI.system.models import Update as mUpdate
        from middlewared.plugins.update import (
            CheckUpdateHandler, get_changelog
        )

        self._authenticated(request)

        try:
            updateobj = mUpdate.objects.order_by('-id')[0]
        except IndexError:
            updateobj = mUpdate.objects.create()

        handler = CheckUpdateHandler()
        try:
            update = CheckForUpdates(
                diff_handler=handler.diff_call,
                handler=handler.call,
                train=updateobj.get_train(),
            )
            network = True
        except UpdateManifestNotFound:
            network = False
            update = False
        if update:
            conf = Configuration.Configuration()
            sys_mani = conf.SystemManifest()
            if sys_mani:
                sequence = sys_mani.Sequence()
            else:
                sequence = ''
            changelog = get_changelog(
                updateobj.get_train(),
                start=sequence,
                end=update.Sequence(),
            )
        else:
            changelog = None

        return {
            'update': {
                'Notice': update.Notice,
                'Notes': update.Notes,
            } if update else update,
            'network': network,
            'handler': {
                'output': handler.output,
                'reboot': handler.reboot,
            },
            'changelog': changelog,
        }

    def update_pending(self, request):
        from freenasUI.middleware.client import client
        self._authenticated(request)
        with client as c:
            return c.call('update.get_pending', '/var/tmp/firmware/update')

    def update_manual(self, request, path):
        from freenasUI.system.utils import manual_update
        self._authenticated(request)
        return manual_update(path)


if __name__ == '__main__':

    """
    python-daemon uses rlimit to determine how many file descriptors to close
    before forking.
    This limit might be really high and take a long time in TrueNAS, so lets set it
    to a small value.
    """
    resource.setrlimit(resource.RLIMIT_NOFILE, (4096, 4096))

    pidfile = PidFile('/var/run/hasyncd.pid')

    logging.config.dictConfig({
        'version': 1,
        'disable_existing_loggers': False,
        'formatters': {
            'simple': {
                'format': '%(levelname)s %(asctime)s: %(message)s'
            },
        },
        'handlers': {
            'file': {
                'level': 'DEBUG',
                'filters': [],
                'class': 'logging.handlers.RotatingFileHandler',
                'maxBytes': 1024 * 1024 * 10,
                'backupCount': 5,
                'filename': LOG_FILE,
                'formatter': 'simple',
            },
        },
        'loggers': {
            'hasyncd': {
                'handlers': ['file'],
                'level': 'DEBUG',
                'propagate': True,
            },
        }
    })

    context = daemon.DaemonContext(
        working_directory='/root',
        umask=0o002,
        pidfile=pidfile,
        stdout=logging._handlers['file'].stream,
        stderr=logging._handlers['file'].stream,
        detach_process=True,
        files_preserve=[logging._handlers['file'].stream, sys.stdin.fileno()],
    )

    with context:


        if os.path.exists(LOG_FILE):
            os.chmod(LOG_FILE, 0o660)

        log = logging.getLogger('hasyncd')

        setproctitle('hasyncd')
        sys.path.extend([
            '/usr/local/lib',
            '/usr/local/www',
            '/usr/local/www/freenasUI'
        ])

        os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'freenasUI.settings')

        import django
        django.setup()

        from freenasUI.freeadmin.utils import set_language
        set_language()

        log.debug('Starting Journal')

        ja = JournalAlive(logger=log)
        ja.daemon = True
        ja.start()

        server = HASyncServer(
            ('0.0.0.0', 8000),
            HASyncRequestHandler,
            allow_none=True,
        )
        server.register_instance(Funcs(server))
        server.serve_forever()
