# -*- coding: utf-8 -*-

"""
statfiles.py - class for digesting and plotting NTP logfiles

Requires gnuplot and liberation fonts installed.

"""
# SPDX-License-Identifier: BSD-2-Clause
from __future__ import print_function, division

import calendar
import glob
import gzip
import os
import socket
import sys
import time


class NTPStats:
    "Gather statistics for a specified NTP site"
    SecondsInDay = 24*60*60
    DefaultPeriod = 7*24*60*60  # default 7 days, 604800 secs
    peermap = {}    # cached result of peersplit()
    period = None
    starttime = None
    endtime = None
    sitename = ''

    @staticmethod
    def unixize(lines, starttime, endtime):
        """Extract first two fields, MJD and seconds past midnight.
        convert timestamp (MJD & seconds past midnight) to Unix time
        Replace MJD+second with Unix time."""
        # HOT LOOP!  Do not change w/o profiling before and after
        lines1 = []
        for line in lines:
            try:
                split = line.split()
                mjd = int(split[0])
                second = float(split[1])
            except ValueError:
                # unparseable, skip this line
                continue

            # warning: 32 bit overflows
            time = NTPStats.SecondsInDay * mjd + second - 3506716800
            if starttime <= time <= endtime:
                # time as integer number milli seconds
                split[0] = int(time * 1000)
                # time as string
                split[1] = str(time)
                lines1.append(split)
        return lines1

    @staticmethod
    def timestamp(line):
        "get Unix time from converted line."
        return float(line.split()[0])

    @staticmethod
    def percentiles(percents, values):
        """Return given percentiles of a given row in a given set of entries.
        assuming values are already split and sorted"""
        ret = {}
        length = len(values)
        if 1 >= length:
            # uh, oh...
            if 1 == length:
                # just one data value, set all to that one value
                value = values[0]
            else:
                # no data, set all to zero
                value = 0
            for perc in percents:
                ret["p" + str(perc)] = value
        else:
            for perc in percents:
                if perc == 100:
                    ret["p100"] = values[length - 1]
                else:
                    ret["p" + str(perc)] = values[int(length * (perc/100))]
        return ret

    @staticmethod
    def ip_label(key):
        "Produce appropriate label for an IP address."
        # If it's a new-style NTPsep clock label, pass it through,
        # Otherwise we expect it to be an IP address and the next guard fires
        if key[0].isdigit():
            # TO BE REMOVED SOMEDAY
            # Clock address - only possible if we're looking at a logfile made
            # by NTP Classic or an NTPsec version configured with
            # --enable-classic-mode.  Nasty that we have to emit a numeric
            # driver type here.
            if key.startswith("127.127."):
                (_, _, clock_type, unit) = key.split(".")
                return "REFCLOCK(type=%s,unit=%s)" % (clock_type, unit)
            # Ordinary IP address - replace with primary hostname.
            # Punt if the lookup fails.
            try:
                (hostname, _, _) = socket.gethostbyaddr(key)
                return hostname
            except socket.herror:
                pass
        return key      # Someday, be smarter than this.

    def __init__(self, statsdir, sitename=None,
                 period=None, starttime=None, endtime=None):
        "Grab content of logfiles, sorted by timestamp."
        if period is None:
            period = NTPStats.DefaultPeriod
        self.period = period

        # Default to one week before the latest date
        if endtime is None and starttime is None:
            endtime = int(time.time())
            starttime = endtime - period
        elif starttime is None and endtime is not None:
            starttime = endtime - period
        elif starttime is not None and endtime is None:
            endtime = starttime + period
        self.starttime = starttime
        self.endtime = endtime

        self.sitename = sitename or os.path.basename(statsdir)
        if 'ntpstats' == self.sitename:
            # boring, use hostname
            self.sitename = socket.getfqdn()

        if not os.path.isdir(statsdir):  # pragma: no cover
            sys.stderr.write("ntpviz: ERROR: %s is not a directory\n"
                             % statsdir)
            raise SystemExit(1)

        self.clockstats = []
        self.peerstats = []
        self.loopstats = []
        self.rawstats = []
        self.temps = []
        self.gpsd = []

        for stem in ("clockstats", "peerstats", "loopstats",
                     "rawstats", "temps", "gpsd"):
            lines = self.__load_stem(statsdir, stem)
            processed = self.__process_stem(stem, lines)
            setattr(self, stem, processed)

    def __load_stem(self, statsdir, stem):
        lines = []
        try:
            pattern = os.path.join(statsdir, stem)
            if stem != "temps" and stem != "gpsd":
                pattern += "."
            for logpart in glob.glob(pattern + "*"):
                # skip files older than starttime
                if self.starttime > os.path.getmtime(logpart):
                    continue
                if logpart.endswith("gz"):
                    lines += gzip.open(logpart, 'rt').readlines()
                else:
                    lines += open(logpart, 'r').readlines()
        except IOError:  # pragma: no cover
            sys.stderr.write("ntpviz: WARNING: could not read %s\n"
                             % logpart)

        return lines

    def __process_stem(self, stem, lines):
        lines1 = []
        if stem == "temps" or stem == "gpsd":
            # temps and gpsd are already in UNIX time
            for line in lines:
                split = line.split()
                if 3 > len(split):
                    # skip short lines
                    continue

                try:
                    time_float = float(split[0])
                except ValueError:
                    # ignore comment lines, lines with no time
                    continue

                if self.starttime <= time_float <= self.endtime:
                    # prefix with int milli sec.
                    split.insert(0, int(time_float * 1000))
                    lines1.append(split)
        else:
            # Morph first fields into Unix time with fractional seconds
            # ut into nice dictionary of dictionary rows
            lines1 = NTPStats.unixize(lines, self.starttime, self.endtime)

        # Sort by datestamp
        # by default, a tuple sort()s on the 1st item, which is a nice
        # integer of milli seconds.  This is faster than using
        # cmp= or key=
        lines1.sort()
        return lines1

    def peersplit(self):
        """Return a dictionary mapping peerstats IPs to entry subsets.
        This is very expensive, so cache the result"""
        if self.peermap:
            return self.peermap

        for row in self.peerstats:
            try:
                ip_address = row[2]     # peerstats field 2, refclock id
                if ip_address not in self.peermap:
                    self.peermap[ip_address] = []
                self.peermap[ip_address].append(row)
            except IndexError:  # pragma: no cover
                # ignore corrupted rows
                pass
        return self.peermap

    def gpssplit(self):
        "Return a dictionary mapping gps sources to entry subsets."
        gpsmap = {}
        for row in self.gpsd:
            try:
                source = row[2]
                if source not in gpsmap:
                    gpsmap[source] = []
                gpsmap[source].append(row)
            except IndexError:  # pragma: no cover
                # ignore corrupted rows
                pass
        return gpsmap

    def tempssplit(self):
        "Return a dictionary mapping temperature sources to entry subsets."
        tempsmap = {}
        for row in self.temps:
            try:
                source = row[2]
                if source not in tempsmap:
                    tempsmap[source] = []
                tempsmap[source].append(row)
            except IndexError:  # pragma: no cover
                # ignore corrupted rows
                pass
        return tempsmap


def iso_to_posix(time_string):
    "Accept timestamps in ISO 8661 format or numeric POSIX time. UTC only."
    if str(time_string).isdigit():
        return int(time_string)
    time_struct = time.strptime(time_string, "%Y-%m-%dT%H:%M:%S")
    # don't use time.mktime() as that is local tz
    return calendar.timegm(time_struct)


def posix_to_iso(unix_time):
    "ISO 8601 string in UTC from Unix time."
    return time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(unix_time))

# end
