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

import sys
import json
import eventlet
import socket
import collections
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)

def std(array):
    mean = sum(array) / len(array)
    var = sum((l-mean)**2 for l in array) / len(array)
    st_dev = var ** 0.5
    return st_dev


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 = None, None
        self.fmax, self.deviation = 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("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 sorted(self.fmin.items(), key=lambda x:x[1]):
                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 sorted(self.fmax.items(), key=lambda x: x[1], reverse=True):
                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:
                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 Exception:
            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 Exception:
                    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 Exception:
                    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 Exception:
            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())
