"""
This script display or export in JSON format the staus of the Cluster
"""

import os, sys
import json
import eventlet
import socket
import collections
from numpy import std
from urllib.parse import urlparse
from oslo_config import cfg
from swift.cli import recon
from swift.common.ring import Ring as swiftRing

# Initialize Swift connection
timeout = 1
swiftrecon = recon.SwiftRecon()
ring_names = ["object", "container", "account"]
swift_dir = "/etc/swift"
pool_size = 30
pool = eventlet.GreenPool(pool_size)

# Initialize script common oslo_config
CLI_OPTS = [           
    cfg.BoolOpt('json', default=False, short='j',
               help='Format output as json'),
    cfg.BoolOpt('all', default=False, short='a',
               help='Active all options'),
    cfg.BoolOpt('base2', default=False,
               help='Print human-readable result in base 2 unit (kiB, MiB...)'),
]

# Initialize script cluster oslo_config
CLI_OPTS_CLUSTER = [
    cfg.BoolOpt('status', default=False, short='s',
               help='Show cluster state'),
    cfg.BoolOpt('reason', default=False, short='r',
               help='Show cluster state reason'),
    cfg.BoolOpt('ntp_drift', default=False,
               help='Show servers NTP drift'),
    cfg.BoolOpt('validate', default=False,
               help='Validate server in the swift ring'),
]

# Initialize script disks oslo_config
CLI_OPTS_DISK = [
    cfg.BoolOpt('usage', default=False, short='d',
               help='Show disk usage for object ring'),
    cfg.BoolOpt('unmounted', default=False, short='u',
               help='Show unmounted or unknow state drives'),
    cfg.BoolOpt('filling', default=False, short='f',
               help='Show disk filling for object ring'),
    cfg.IntOpt('fmin', default=10,
               help='Display Node/device filling < DISK_FMIN'),
    cfg.IntOpt('fmax', default=90,
               help='Display Node/device filling > DISK_FMAX'),
    cfg.BoolOpt('deviation', default=False,
               help='Show object ring deviation'),
]

def _prepare_config():
    """
    Prepare the oslo_config of scripts by analyse arguments
    return: the oslo_config object
    """
    CONF = cfg.ConfigOpts()
    CONF.register_cli_opts(CLI_OPTS)
    CONF.register_cli_opts(CLI_OPTS_CLUSTER, group='cluster')
    CONF.register_cli_opts(CLI_OPTS_DISK, group='disk')
    return CONF

def size_suffix(size, base2 = False):
    """
    Format the disk size in human readable
    param size: the size in octet
    param base2: a boolean for return result in base2, else return in base10
    return: the formatted string
    """
    units = 'iB' if base2 else 'B'
    suffixes = ['','k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
    kilo = 1024.0 if base2 else 1000.0
    for suffix in suffixes:
        if size < kilo:
            return "%s %s%s" % (size, suffix, units)
        size = round(size / kilo, 2)
    return "%s %s%s" % (size, suffix, units)

class Cluster():
    """Class representing the entire Cluster"""

    def __init__(self,ring_names,args):
        """
        Instance method to initialize Cluster
        param ring_names: a list with the différent ring to initialize
        param args: an oslo_config object
        """
        self.rings = {}
        self.args = args
        for ring_name in ring_names:
            self.rings[ring_name] = Ring(ring_name)
        self.status = 'OK'
        self.reason = ''
        self.disks_state = {}
        self.usage, self.usage_human = {}, {}
        self.ntpdrift, self.validate  = {}, {}
        self.filling, self.fmin, self.fmax, self.deviation = None, None, None, None
        self.hosts_in_error = set()
        self.region_with_state = {'unmounted':0,'unknown':0}
        self.disks_state_by_region = {}
        self.analyse()

    def analyse(self):
        """
        Instance method to analyse Cluster
        """
        # True if one options is True
        all_analyze = any( 
                (
                    self.args.all,
                    self.args.json,
                    self.args.cluster.status,
                    self.args.cluster.reason
                )
        )
        # True if one options is True
        disks_analyze = any(
            (
                self.args.all,
                self.args.disk.usage,
                self.args.disk.unmounted,
                self.args.disk.filling,
                self.args.disk.fmin,
                self.args.disk.fmax,
                self.args.disk.deviation
            )
        )
        if disks_analyze or all_analyze:
            for name,ring in self.rings.items():
                # Get properties of all rings
                self.disks_state[name] = {}
                self.disks_state[name]['unmounted'] = ring.disks_unmounted()
                self.disks_state[name]['unknown'] = ring.disks_unknown()
                self.usage[name] = ring.disks_usage()
                self.usage_human[name] = ring.disks_usage_human(self.args.base2)
                self.ntpdrift[name] = ring.ntpdrift()
                self.validate[name]= ring.validate()
                for name_st,ds in self.disks_state[name].items():
                    # Aggregate disks staus by region
                    for region,value in ds.items():
                        self.disks_state_by_region.setdefault(region,{})
                        for host,devices in value.items():
                            self.disks_state_by_region[region].setdefault(host,{})
                            self.disks_state_by_region[region][host] = {name_st: devices}
                self.hosts_in_error.update(ring.hosts_in_error)
            for region,hosts in self.disks_state_by_region.items():
                # Increment region with unmounted or unknown disk state
                for st_name in self.region_with_state.keys():
                    if len({k:v for (k,v) in hosts.items() if st_name in v}):
                        self.region_with_state[st_name] += 1
            for state,value in self.region_with_state.items():
                if value > 1:
                    self.status = "Critical"
                    self.reason = f"{self.reason}{value} regions with {state} state drives\n"
            self.filling = self.rings['object'].disks_filling()
            self.fmin = self.rings['object'].disks_fmin(self.args.disk.fmin)
            self.fmax = self.rings['object'].disks_fmax(self.args.disk.fmax)
            self.deviation = self.rings['object'].disks_deviation()
            if self.deviation > 5 and self.status == "OK":
                self.status = "Warn"
                self.reason = "Deviation higher than 5."
        if self.status == "OK" and (all_analyze or self.args.cluster.ntp_drift):
            if [n for n in self.ntpdrift.values() if n]:
                self.status = "Warn"
                self.reason = "Some servers have ntpdrift"
        if self.status == "OK" and (all_analyze or self.args.cluster.validate): 
            if [n for n in self.ntpdrift.values() if n]:
                self.status = "Warn"
                self.reason = "Some servers cannot be validate"

    def display(self):
        """
        Instance method to display status of Cluster
        """
        if self.args.all:
            print(f"Cluster status: {self.status}")
            if self.reason:
                print(f"Cluster state reason: {self.reason}\n")
            if self.hosts_in_error:
                print("WARNING, hosts in error:")
                for h in sorted(self.hosts_in_error):
                    print(f"\t{h}")
                print('')
            self.display_unmounted()
            self.display_ntpdrift()
            self.display_validate()
            self.display_usage()
            self.display_filling()
            self.display_deviation()
        else:
            if self.args.cluster.status:
                print(f"Cluster status: {self.status}")
            if self.args.cluster.reason and self.reason:
                print(f"Cluster state reason: {self.reason}")
            if len(self.hosts_in_error) > 0:
                print("WARNING, hosts in error:")
                for h in sorted(self.hosts_in_error):
                    print(f"\t{h}")
                print('')
            if self.args.disk.unmounted:
                self.display_unmounted()
            if self.args.cluster.ntp_drift:
                self.display_ntpdrift()
            if self.args.cluster.validate:
                self.display_validate()
            if self.args.disk.usage:
                self.display_usage()
            if self.args.disk.filling:
                self.display_filling()
            if self.args.disk.deviation:
                self.display_deviation()

    def display_unmounted(self):
        """
        Instance method to display disks in unmounted or unknown state
        """
        rs = self.region_with_state
        print(f"Region with umounted/unknown state drives: {rs['unmounted']}/{rs['unknown']}")
        if self.region_with_state['unmounted'] or self.region_with_state['unknown']:
            print("Unmounted/unknown state disks output per region:")
            for region,nodes in self.disks_state_by_region.items():
                txt = ''
                for node,v in sorted(nodes.items()):
                    unmounted = ','.join(sorted(v['unmounted'] if 'unmounted' in v else []))
                    unknown =  ','.join(sorted(v['unknown'] if 'unknown' in v else []))
                    txt = f"{txt}\t{node}: ({unmounted})/({unknown})\n"
                if txt:
                    print(f"    {region}:{txt}")
        print('')

    def display_ntpdrift(self):
        """
        Instance method to display the servers with Ntp drift in the cluster
        """
        print("Ntp drift:")
        for name,ntp in self.ntpdrift.items():
            print(f"  {name}: {ntp}")
        print('')

    def display_validate(self):
        """
        Instance method to display the invalidate servers in the cluster
        """
        print("Validate server output:")
        for name,val in self.validate.items():
            print(f"  {name}: {val}")
        print('')

    def display_usage(self):
        """
        Instance method to display the disks usage in human readable
        """
        print("Disk usage human readable output:")
        for ring,usage in self.usage_human.items():
            print(f"  {ring}: {usage['used']} used on {usage['size']} {usage['percent']}%")
        print('')

    def display_filling(self):
        """
        Instance method to display the disks filling in pseudo graph for object ring
        """
        print(f"Disk filling for object ring:")
        tpercent,tvalue = 0, 0
        # Calcul number of disks by representing by #
        div = int(max( self.filling.values()) / 100) + 1
        for k, v in self.filling.items():
            # Shows disk fill distribution
            nb = round(v / div)
            print(f"\t{k:2}% -> {v:<5d}{nb * '#'}")
            tpercent += (k * v)
            tvalue += v
        print("Average disk filling for object ring:")
        print(f"\t{tpercent/tvalue:2.2f}% -> {tvalue}")
        # Show disks with less than fmin padding
        if self.fmin and self.fmin.items():
            print(f"\nList of Disk filling < {self.args.disk.fmin} for object ring:")
            for k, v in self.fmin.items():
                print(f"\t {k} -> {round(v,2)}%")
        # Show disks with more than fmax padding
        if self.fmax and self.fmax.items():
            print(f"\nList of Disk filling > {self.args.disk.fmax} for object ring:")
            for k, v in self.fmax.items():
                print(f"\t {k} -> {round(v,2)}%")

    def display_deviation(self):
        """
        Instance method to display the deviation filling for object ring
        """
        print(f"\nDeviation for object ring: {round(self.deviation,2)}")

    def toJson(self):
        """
        Instance method to export all status in json format
        """
        formated_output = {
            "status": self.status,
            "hosts_in_error": self.hosts_in_error,
            "reason": self.reason,
            "unmounted_per_region": self.unmounted,
            "unknowstate_per_region": self.unknown,
            "ntp_drift": self.ntpdrift,
            "validation": self.validate,
            "disk_usage": self.usage,
            "disk_usage_human": self.usage_human,
            "disk_filling": self.filling,
            "df_min":self.fmin,
            "df_max": self.fmax,
            "deviation": self.deviation,
        }
        print(json.dumps(formated_output))
class Ring():
    """Class representing a ring of  Cluster"""

    def __init__(self, ring_name):
        """
        Instance method to initialize Cluster
        param ring_name: the ring name
        """
        self.ring = swiftRing(swift_dir, ring_name=ring_name)
        self.ring_name = ring_name
        self.hosts = {}
        self.hosts_in_error = set()
        self.host_port = set()
        self.load_hosts()
        self.load_devices()

    def load_hosts(self):
        """
        Instance method to load information for host
        """
        d = {}
        devs = [d for d in self.ring.devs if d]
        for dev in devs:
            self.hosts[dev['ip']] = Host.create(dev)
            d[dev['ip']] = dev['port']
        self.host_port = set(d.items())
    
    def load_devices(self):
        """
        Instance method to load informations for all devices and assign to hosts
        """
        scout = recon.Scout(recon_type="diskusage", timeout=timeout, suppress_errors=True)
        for url, response, status, _ts_start, _ts_end in pool.imap(scout.scout, self.host_port):
            ip = urlparse(url).hostname
            if status == 200:
                port = urlparse(url).port
                for res in response:
                    self.hosts[ip].devices_by_name[res['device']].load_datas(res)
            else:
                self.hosts[ip].error = True

    def add_host_in_error(self, url):
        """
        Instance method to add host in error to the list
        param url: the host url
        """
        ip = urlparse(url).hostname
        try:
            hostname = socket.gethostbyaddr(ip)[0]
        except:
            hostname = ip
        self.hosts_in_error.add(hostname)

    def ntpdrift(self):
        """
        Instance method search host with ntp drift
        return: list of hosts with ntp drift
        """
        jitter = abs(0.0)
        scout = recon.Scout(recon_type="time", timeout=timeout, suppress_errors=True)
        ntpdrift_data = []
        swiftrecon._ptime
        for url, ts_remote, status, ts_start, ts_end in pool.imap(scout.scout, self.host_port):
            if status != 200:
                self.add_host_in_error(url)
                continue
            # Must round as request take some ms
            ts_remote = round(ts_remote, 1)
            ts_start = round(ts_start, 1)
            ts_end = round(ts_end, 1)
            if ts_remote + jitter < ts_start or ts_remote - jitter > ts_end:
                hostname = self.hosts[urlparse(url).hostname].hostname
                if hostname not in ntpdrift_data:
                    ntpdrift_data.append(hostname)
                continue
        return ntpdrift_data

    def validate(self):
        """
        Instance method to load invalid hosts
        return: list of invalid hosts
        """
        errors = []
        validate_data = []
        scout = recon.Scout(recon_type="server_type_check", timeout=timeout, suppress_errors=True)
        for url, response, status in pool.imap(scout.scout_server_type, self.host_port):
            if status == 200:
                if response != self.ring_name + "-server":
                        errors.append(url)
            else:
                self.add_host_in_error(url)
        for url in errors:
            hostname = self.hosts[urlparse(url).hostname].hostname
            if hostname not in validate_data:
                validate_data.append(hostname)
        return validate_data

    def disks_usage(self):
        """
        Instance method to load disk usage of all hosts
        """
        used = 0
        avail = 0
        size = 0
        for h in self.hosts.values():
            for d in h.devices_by_port.values():
                used += d.used if isinstance(d.used,int) else 0
                avail += d.avail if isinstance(d.avail,int) else 0
                size += d.size if isinstance(d.size,int) else 0
        percent = round(100 * used / size, 2)
        return {'used':used, 'avail':avail, 'size':size, 'percent':percent}

    def disks_usage_human(self,base2):
        """
        Instance method to load disk usage of all hosts
        return: dict with used, avail, total size and percent filling
        """
        du = self.disks_usage()
        return {'used':size_suffix(du['used'],base2),
                'avail':size_suffix(du['avail'],base2),
                'size':size_suffix(du['size'],base2),
                'percent':du['percent']}

    def disks_filling(self):
        """
        Instance method to load filling of all hosts
        return: dict with distribution of filling
        """
        freq = {}
        for h in self.hosts.values():
            for d in h.devices_by_port.values():
                if d.filling in freq:
                    freq[d.filling] += 1
                else:
                    freq[d.filling] = 1
        freq = collections.OrderedDict(sorted(freq.items()))
        return freq
    
    def disks_deviation(self):
        """
        Instance method to load deviation of filling
        return: the deviation
        """
        filling = []
        for h in self.hosts.values():
            filling += [ d.filling for d in h.devices_by_port.values() ]
        return (std(filling))

    def disks_fmin(self,dfmin):
        """
        Instance method to load devices filling less than dfmin
        param dfmin: the minimum filling to detect
        return: dict of host/device with filling less than dfmin
        """
        stats = {}
        for h in self.hosts.values():
            for d in h.devices_by_name.values():
                try:
                    usage = 100 * d.used / d.size
                    if usage < dfmin:
                        stats[f"{h.hostname}/{d.name}"] = usage
                except:
                    pass
        return stats

    def disks_fmax(self,dfmax):
        """
        Instance method to load devices filling more than dfmax
        param dfmax: the maximum filling to detect
        return: dict of host/device with filling more than dfmax
        """
        stats = {}
        for h in self.hosts.values():
            for d in h.devices_by_name.values():
                try:
                    usage = 100 * d.used / d.size
                    if usage > dfmax:
                        stats[f"{h.hostname}/{d.name}"] = usage
                except:
                    pass
        return stats

    def disks_unmounted(self):
        """
        Instance method to load devices unmoiunted or unknow state
        return: dict with unmounted or unknow state devices
        """
        unmounted = {}
        for h in self.hosts.values():
            unmounted.setdefault(h.region, {})
            for d in h.devices_by_port.values():
                if not d.mounted:
                    unmounted[h.region].setdefault(h.hostname, [])
                    unmounted[h.region][h.hostname].append(d.name)
        return unmounted

    def disks_unknown(self):
        unknown = {}
        for h in self.hosts.values():
            unknown.setdefault(h.region, {})
            for d in h.devices_by_port.values():
                if d.unknown:
                    unknown[h.region].setdefault(h.hostname, [])
                    unknown[h.region][h.hostname].append(d.name)
        return unknown

class Host():
    """Class representing an host"""
    List = {}
    def create(device):
        """
        Class method to create a device and attach it to an host instance
        return: instance of device
        """
        if device['ip'] not in Host.List:
            Host.List[device['ip']] = Host(device)
        else:
            Host.List[device['ip']].add_device(device)
        return Host.List[device['ip']]

    def __init__(self, device):
        """
        Instance method to initialize Host with a device
        param device: a device to attach to the new host
        """
        self.ip = device['ip']
        self.hostname = socket.gethostbyaddr(self.ip)[0]
        self.zone = device['zone']
        self.region = device['region']
        self.devices_by_name = {}
        self.devices_by_port = {}
        self.add_device(device)
        self.error = False

    def add_device(self, device):
        """
        Instance method to add device to the host
        param device: device to attach to the Host instance
        """
        d = Device(device)
        self.devices_by_name[d.name] = d
        self.devices_by_port[d.port] = d


class Device():
    """Class representing a device"""

    def __init__(self, device):
        """
        Instance method to initialize Device
        param device: device to initialize
        """
        self.port = device['port']
        self.name = device['device']
        self.weight = device['weight']
        self.used = 0
        self.avail = 0
        self.size = 0
        self.filling = 0
        self.mounted = False
        self.unknown = False

    def load_datas(self, datas):
        """
        Instance method to load data off device
        param device: device to initialize
        """
        self.used = datas['used']
        self.avail = datas['avail']
        self.size = datas['size']
        if isinstance(datas['mounted'], bool) and datas['mounted']:
            self.mounted = datas['mounted']
        elif datas['mounted'] is None:
            self.unknown = True
        try:
            self.filling = round(100 * self.used / self.size)
        except:
            pass

def main():
    """
    Entry point for the script
    """
    args = _prepare_config()
    args(sys.argv[1:])
    cluster = Cluster(ring_names,args)
    cluster.display()

if __name__ == "__main__":
    sys.exit(main())
