# 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.

import base64
import errno
import json
import logging
import netif
import os
import requests
import signal
import time

from freenasUI.middleware.client import client, ClientException
from lockfile import LockFile
from requests_toolbelt.multipart.encoder import MultipartEncoder

log = logging.getLogger('freenasUI.failover.notifier')

INTERNAL_IFACE_NF = '/tmp/.failover_internal_iface_not_found'


class FailoverNotifier(object):

    def restart_hasyncd(self):
        try:
            with open('/var/run/hasyncd.pid', 'r') as f:
                pid = f.read()
            os.kill(int(pid), signal.SIGTERM)
            time.sleep(0.2)
            os.kill(int(pid), signal.SIGKILL)
        except:
            pass
        self._system_nolog(
            "/usr/local/bin/python /usr/local/www/freenasUI/tools/hasyncd.py"
        )

    def get_carp_states(self):
        from freenasUI.network.models import Interfaces
        masters = []
        backups = []
        inits = []
        internal_interfaces = self.failover_internal_interfaces()
        critical_interfaces = [i.int_interface for i in Interfaces.objects.filter(int_critical=True)]
        for name, iface in list(netif.list_interfaces().items()):
            if name in ('pflog0', 'pfsync0', 'lo0'):
                continue
            # Skip internal CARP
            if name in internal_interfaces:
                continue
            if name not in critical_interfaces:
                continue
            try:
                carp_config = list(iface.carp_config)
            except OSError as e:
                if e.errno not in (45, 2):
                    raise
                continue
            if not carp_config:
                continue
            if carp_config[0].state == netif.CarpState.MASTER:
                masters.append(name)
            elif carp_config[0].state == netif.CarpState.BACKUP:
                backups.append(name)
            elif carp_config[0].state == netif.CarpState.INIT:
                inits.append(name)
            else:
                log.warning("Unknown CARP state %r for interface %s", carp_config[0].state, name)
        return masters, backups, inits

    def check_carp_states(self, local, remote):
        errors = []
        interfaces = set(local[0] + local[1] + remote[0] + remote[1])
        if not interfaces:
            errors.append(f"There are no failover interfaces")
        for name in interfaces:
            if name not in local[0] + local[1]:
                errors.append(f"Interface {name} is not configured for failover on local system")
            if name not in remote[0] + remote[1]:
                errors.append(f"Interface {name} is not configured for failover on remote system")
            if name in local[0] and name in remote[0]:
                errors.append(f"Interface {name} is MASTER on both nodes")
            if name in local[1] and name in remote[1]:
                errors.append(f"Interface {name} is BACKUP on both nodes")
        for name in set(local[2] + remote[2]):
            if name not in local[2]:
                errors.append(f"Interface {name} is in a non-functioning state on local system")
            if name not in remote[2]:
                errors.append(f"Interface {name} is in a non-functioning state on remote system")

        return errors

    def failover_licensed(self):
        from freenasUI.support.utils import get_license
        license, error = get_license()
        if license is None or not license.system_serial_ha:
            return False
        return True

    def failover_status(self):
        """
        Return the current status of this node in the failover

        Returns:
            MASTER
            BACKUP
            ELECTING
            IMPORTING
            ERROR
            SINGLE
        """

        c, conn = self._open_db()
        c.execute(
            'SELECT '
            '   COUNT(*) '
            'FROM network_interfaces '
            'WHERE int_vip != "" AND int_vip IS NOT NULL'
        )
        vipcount = c.fetchone()[0]
        c.execute(
            'SELECT '
            '   COUNT(*) '
            'FROM storage_volume'
        )
        volcount = c.fetchone()[0]
        c.close()
        conn.close()

        if 0 in (vipcount, volcount) or not self.failover_licensed():
            return 'SINGLE'

        try:
            masters, backups, others = self.get_carp_states()
        except:
            return 'ERROR'

        if len(masters) > 0:
            p1 = self._pipeopen("/sbin/zpool list -H | grep -v freenas-boot", logger=None)
            data = p1.communicate()[0].strip()
            if len(data) > 0:
                return 'MASTER'
            else:
                if os.path.exists('/tmp/.failover_electing'):
                    return 'ELECTING'
                elif os.path.exists('/tmp/.failover_importing'):
                    return 'IMPORTING'
                elif os.path.exists('/tmp/.failover_failed'):
                    return 'ERROR'

        try:
            with client as c:
                pools = c.call('failover.call_remote', 'pool.query')
                remote_imported = any([p['status'] != 'OFFLINE' for p in pools])
                # Other node has the pool
                if remote_imported is True or volcount == 0:
                    # check for carp MASTER (any) in remote?
                    return 'BACKUP'
                # Other node has no pool
                elif remote_imported is False:
                    # check for carp MASTER (none) in remote?
                    return 'ERROR'
                # We couldn't contact the other node
                else:
                    return 'UNKNOWN'
        except Exception as e:
            # Anything other than ClientException is unexpected and should be logged
            if not isinstance(e, ClientException):
                log.warn('Failed checking failover status', exc_info=True)
            return 'UNKNOWN'

    def failover_hardware(self):
        from freenasUI.failover.detect import ha_hardware
        return ha_hardware()

    def failover_in_progress(self):
        FAILOVER_EVENT = '/tmp/.failover_event'
        return LockFile(FAILOVER_EVENT).is_locked()

    def failover_internal_interfaces(self):
        # XXX: Use in migrations:
        # failover/migrations/0014
        # XXX: DO NOT REMOVE
        hardware = self.failover_hardware()
        if hardware == 'ECHOSTREAM':
            proc = self._pipeopen('/usr/sbin/pciconf -lv | grep "card=0xa01f8086 chip=0x10d38086"', logger=None)
            output = proc.communicate()[0]
            if not output:
                if not os.path.exists(INTERNAL_IFACE_NF):
                    open(INTERNAL_IFACE_NF, 'w').close()
                return []
            return [output.split('@')[0]]
        elif hardware == 'SBB':
            return ['ix0']
        elif hardware == 'ECHOWARP' or hardware == 'PUMA':
            return ['ntb0']
        elif hardware == 'ULTIMATE':
            return ['igb1']
        return []

    def failover_node(self):
        from freenasUI.failover.detect import ha_node
        # XXX: Use in migrations:
        # failover/migrations/0016
        # XXX: DO NOT REMOVE/CHANGE
        node = ha_node()
        if node is None:
            return 'MANUAL'
        return node

    def failover_pair_ip(self):
        node = self.failover_node()
        if node == 'MANUAL':
            #c, conn = self._open_db()
            #c.execute(
            #    'SELECT '
            #    '   ipaddress '
            #    'FROM system_failover '
            #    'WHERE disabled = 0 '
            #    'ORDER BY -id LIMIT 1'
            #)
            #ipaddress = c.fetchone()
            #c.close()
            #conn.close()
            return None
        elif node == 'A':
            return '169.254.10.2'
        elif node == 'B':
            return '169.254.10.1'
        else:
            return None

    def failover_force_master(self):
        ret = self._system('/usr/local/sbin/fenced force')
        if not ret:
            from freenasUI.network.models import Interfaces
            qs = Interfaces.objects.filter(int_critical=True).exclude(int_vhid=None)
            iface = qs[0]
            self._system(
                '/usr/local/bin/python '
                '/usr/local/libexec/truenas/carp-state-change-hook.py '
                '{0}@{1} forcetakeover'.format(
                    iface.int_vhid, iface.int_interface
                )
            )
        else:
            return False

    def failover_rpc(self, ip=None, timeout=3):
        from freenasUI.failover.utils import TimeoutTransport, ServerProxy
        if ip is None:
            ip = self.failover_pair_ip()
        if ip is None:
            return None
        s = ServerProxy(
            'http://%s:8000' % ip,
            allow_none=True,
            transport=TimeoutTransport(conntimeout=timeout),
        )
        return s

    def sync_file_recv(self, c, path, dst=None):
        data = c.call('failover.call_remote', 'filesystem.file_get_contents', [path])
        if data is None:
            return None
        if dst is None:
            dst = path
        with open(dst, 'wb+') as f:
            f.write(base64.b64decode(data))
        stat = c.call('failover.call_remote', 'filesystem.stat', [path])
        os.chmod(dst, stat['mode'])

    def sync_file_send(self, c, path, dest=None, legacy=False):
        if dest is None:
            dest = path
        if os.path.exists(path):
            mode = os.stat(path).st_mode
            with open(path, 'rb') as f:
                first = True
                while True:
                    read = f.read(1024 * 1024 * 20)
                    if not read:
                        break
                    if legacy:
                        c.file_send(dest, base64.b64encode(read).decode(), mode, not first)
                    else:
                        c.call('failover.call_remote', 'filesystem.file_receive', [dest, base64.b64encode(read).decode(), {'mode': mode, 'append': not first}])
                    first = False

    def sync_file_send_v2(self, c, path, dest=None):
        if dest is None:
            dest = path
        if os.path.exists(path):
            mode = os.stat(path).st_mode
        token = c.call('failover.call_remote', 'auth.generate_token')

        m = MultipartEncoder(fields={
            'data': json.dumps({'method': 'filesystem.put', 'params': [dest, {'mode': mode}]}),
            'file': open(path, 'rb'),
        })
        r = requests.post(
            f'http://{self.failover_pair_ip()}:6000/_upload/?auth_token={token}',
            data=m,
            headers={'Content-Type': m.content_type}
        )
        if r.status_code != 200:
            raise ValueError(f'{r.status_code}: {r.text}')

    def failover_sync_peer(self, fromto='to'):
        '''
        Runs synctopeer script

        Returns:
            False - No failover configured
            Failover object - Sync success

        Raises:
            ValueError
        '''
        from django.db import connection
        from freenasUI.middleware.notifier import (
            GELI_KEYPATH,
            PWENC_FILE_SECRET,
        )
        from freenasUI.support.utils import LICENSE_FILE

        with client as c:
            ssh_rep_path = '/data/ssh'
            if fromto == 'to':
                log.debug("Dump the database to remote side")
                rv = c.call('failover.database_sync')
                log.debug("Send license and pwenc files")
                self.sync_file_send(c, LICENSE_FILE)
                self.sync_file_send(c, PWENC_FILE_SECRET)
                if os.path.exists('/root/.ssh/authorized_keys'):
                    self.sync_file_send(c, '/root/.ssh/authorized_keys')

                if os.path.exists(GELI_KEYPATH) and os.path.isdir(GELI_KEYPATH):
                    for f in os.listdir(GELI_KEYPATH):
                        path = os.path.join(GELI_KEYPATH, f)
                        if not os.path.isfile(path):
                            continue
                        self.sync_file_send(c, path)

                if os.path.exists(ssh_rep_path) and os.path.isdir(ssh_rep_path):
                    for f in os.listdir(ssh_rep_path):
                        path = os.path.join(ssh_rep_path, f)
                        if not os.path.isfile(path):
                            continue
                        self.sync_file_send(c, path)

            else:
                log.debug("Receive the database from remote side")
                rv = c.call('failover.call_remote', 'failover.database_sync')
                log.debug("Receive license and pwenc files")
                self.sync_file_recv(c, LICENSE_FILE)
                self.sync_file_recv(c, PWENC_FILE_SECRET)
                self.sync_file_recv(c, '/root/.ssh/authorized_keys')

                sync_files = []
                for i in (GELI_KEYPATH, ssh_rep_path):
                    try:
                        sync_files += [f['path'] for f in c.call('failover.call_remote', 'filesystem.listdir', [i])]
                    except ClientException as e:
                        if e.errno not in (errno.ENOENT, errno.ENOTDIR):
                            raise

            c.call('failover.call_remote', 'service.start', ['ix-devd'])

        if not rv:
            raise ValueError('Sync %s peer failed' % (fromto, ))
        return True

    def failover_public_ips(self):
        from freenasUI.network.models import Interfaces
        from django.db.models import Q
        node = self.failover_node() or 'A'
        ips = []
        for iface in Interfaces.objects.exclude(Q(int_vip=None) | Q(int_vip='')):
            if node == 'A' and iface.int_ipv4address:
                ips.append(str(iface.int_ipv4address))
            elif node == 'B' and iface.int_ipv4address_b:
                ips.append(str(iface.int_ipv4address_b))
        return ips

    def failover_disabled_reasons(self):
        from django.db.models import Q
        from freenasUI.failover.models import Failover
        from freenasUI.network.models import Interfaces
        from freenasUI.storage.models import Volume
        reasons = []
        if not Volume.objects.all().exists():
            reasons.append('NO_VOLUME')
        if not Interfaces.objects.exclude(Q(int_vip=None) | Q(int_vip='')).exists():
            reasons.append('NO_VIP')
        try:
            with client as c:
                assert c.call('failover.call_remote', 'core.ping') == 'pong'
                # This only matters if it has responded to 'ping', otherwise
                # there is no reason to even try
                if not c.call('failover.call_remote', 'system.ready'):
                    reasons.append('NO_SYSTEM_READY')

                mismatch_disks = c.call('failover.mismatch_disks')
                if mismatch_disks['missing_local'] or mismatch_disks['missing_remote']:
                    reasons.append('MISMATCH_DISKS')

        except ClientException as e:
            if e.errno not in (errno.ECONNREFUSED, ClientException.ENOMETHOD) and e.trace['class'] not in ('KeyError', 'ConnectionRefusedError'):
                reasons.append('NO_PONG')
            else:
                try:
                    assert self.failover_rpc().ping() == 'pong'
                except Exception:
                    reasons.append('NO_PONG')
        except Exception:
            reasons.append('NO_PONG')
        if Failover.objects.filter(disabled=True).exists():
            reasons.append('NO_FAILOVER')
        try:
            with client as c:
                if not c.call('failover.call_remote', 'failover.licensed'):
                    reasons.append('NO_LICENSE')
        except Exception:
            pass
        try:
            with client as c:
                local = c.call('notifier.get_carp_states')
                remote = c.call('failover.call_remote', 'notifier.get_carp_states')
                if self.check_carp_states(local, remote):
                    reasons.append('DISAGREE_CARP')
        except Exception:
            pass
        return reasons

    def geli_passphrase(self, volume, passphrase, **kwargs):
        rv = super(FailoverNotifier, self).geli_passphrase(volume, passphrase, **kwargs)
        if passphrase and self.failover_licensed():
            from freenasUI.failover.enc_helper import LocalEscrowCtl
            with open(passphrase, 'r') as f:
                passphrase = f.read()
            escrowctl = LocalEscrowCtl()
            escrowctl.setkey(passphrase)
            with client as c:
                c.call('failover.call_remote', 'failover.encryption_setkey', [passphrase])
        return rv

    def harepl_get(self):
        p = self._pipeopen('zpool list -H -o name | grep -v freenas-boot')
        if p.wait() == 0:
            pool = p.communicate()[0].split('\n')[0].strip()
            p = self._pipeopen(
                '/sbin/zfs list -H -o com.ixsystems:replremote %s' % pool
            )
            remote = p.communicate()[0].split('\n')[0].strip()
            if remote == '-':
                remote = ''
            p = self._pipeopen(
                '/sbin/zfs list -H -o com.ixsystems:replinit %s' % pool
            )
            wantinit = p.communicate()[0].split('\n')[0].strip()
            wantinit = (wantinit == 'True')
            return (remote, wantinit)
        else:
            log.warn('Can not get pool list')
        return None, None

    def harepl_set(self, remote, wantinit):
        p = self._pipeopen('zpool list -H -o name | grep -v freenas-boot')
        if p.wait() == 0:
            pool = p.communicate()[0].split('\n')[0].strip()
            if remote == '':
                cmd = '/sbin/zfs inherit com.ixsystems:replremote %s' % pool
                self._system(cmd)
                cmd = '/sbin/zfs inherit com.ixsystems:replinit %s' % pool
                self._system(cmd)
            else:
                cmd = '/sbin/zfs set com.ixsystems:replremote=%s %s' % (
                    remote,
                    pool
                )
                self._system(cmd)
                if wantinit:
                    cmd = '/sbin/zfs set com.ixsystems:replinit=True %s' % pool
                else:
                    cmd = '/sbin/zfs inherit com.ixsystems:replinit %s' % pool
                self._system(cmd)
            self._system('/usr/sbin/service ix-crontab quietstart')
        else:
            log.warn('Can not get pool list')
