#
# Copyright (C) 2015-2021 Marko Myllynen <myllynen@redhat.com>
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; either version 2 of the License, or (at your
# option) any later version.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License
# for more details.

# pylint: disable=superfluous-parens, too-many-lines
# pylint: disable=invalid-name, line-too-long, no-self-use
# pylint: disable=too-many-boolean-expressions, too-many-statements
# pylint: disable=too-many-instance-attributes, too-many-locals
# pylint: disable=too-many-branches, too-many-nested-blocks
# pylint: disable=broad-except, too-many-public-methods

""" PCP Python Utils Config Routines """

from copy import deepcopy
from collections import OrderedDict
try:
    import configparser as ConfigParser
except ImportError:
    import ConfigParser
import signal
import time
import math
import csv
import sys
import os
import re

from pcp import pmapi

# Common defaults (for applicable utils)
TRUNC = "xxx"
VERSION = 1
CURR_INSTS = False

class pmConfig(object):
    """ Config reader and validator """
    def __init__(self, util):
        # Common special command line switches
        self.arghelp = ('-?', '--help', '-V', '--version')

        # Supported metricset specifiers - label is txt label of metricspec
        self.metricspec = ('label', 'instances', 'unit', 'type',
                           'width', 'precision', 'limit', 'formula')

        # Main utility reference
        self.util = util

        # Metric details
        self.pmids = []
        self.descs = []
        self.insts = []
        self.texts = []
        self.labels = []                 # PCP labels of initial instances
        self.res_labels = OrderedDict()  # PCP labels of current results

        # Pause helpers
        self._round = 0
        self._init_ts = None

        # Predicate metric references
        self._pred_indom = []

        # Instance regex cache
        self._re_cache = {}

        # Pass data with pmTraversePMNS
        self._tmp = []

        # Store configured metrics to avoid rereading on PMNS updates
        self._conf_metrics = OrderedDict()

        # Update PCP labels on instance changes
        self._prev_insts = []

    def set_signal_handler(self):
        """ Set default signal handler """
        def handler(_signum, _frame):
            """ Default signal handler """
            self.util.finalize()
            sys.exit(0)
        for sig in "SIGHUP", "SIGTERM":
            try:
                signum = getattr(signal, sig)
                signal.signal(signum, handler)
            except Exception:
                pass

    # Deprecated, use set_config_path() below instead
    def set_config_file(self, default_config):
        """ Set default config file """
        return self.set_config_path(default_config)

    def set_config_path(self, default_config):
        """ Set default config path """
        config = None
        usrdir = os.path.expanduser('~')
        sysdir = pmapi.pmContext.pmGetConfig("PCP_SYSCONF_DIR")
        for conf in default_config:
            conf = conf.replace("$HOME", usrdir)
            conf = conf.replace("$PCP_SYSCONF_DIR", sysdir)
            if os.access(conf, os.R_OK) and \
               (os.path.isfile(conf) or os.path.isdir(conf)):
                config = conf
                break

        # Possibly override the default config path before
        # parsing the rest of the command line options
        args = iter(sys.argv[1:])
        for arg in args:
            if arg in self.arghelp:
                return None
            if arg in ('-c', '--config') or arg.startswith("-c"):
                try:
                    if arg in ('-c', '--config'):
                        config = next(args)
                    else:
                        config = arg.replace("-c", "", 1)
                    if not os.access(config, os.R_OK) or \
                       not (os.path.isfile(config) or os.path.isdir(config)):
                        if not os.path.exists(config):
                            err = "No such file or directory"
                        elif not os.access(config, os.R_OK):
                            err = "Permission denied"
                        else:
                            err = "Not a regular file"
                        raise IOError("Failed to read configuration from '%s':\n%s." % (config, err))
                except StopIteration:
                    break

        return config

    def _get_conf_files(self):
        """ Helper to get individual config files """
        conf_files = []
        if self.util.config:
            if os.path.isfile(self.util.config):
                conf_files.append(self.util.config)
            else:
                for f in sorted(os.listdir(self.util.config)):
                    fn = os.path.join(self.util.config, f)
                    if fn.endswith(".conf") and os.access(fn, os.R_OK) and os.path.isfile(fn):
                        conf_files.append(fn)
        return conf_files

    def set_attr(self, name, value):
        """ Set options read from file """
        value = str(value)
        if name == 'colxrow':
            # As a special service for pmrep(1) utility we handle
            # its config colxrow parameter here with minimal impact.
            if value.startswith('"') and value.endswith('"'):
                value = value[1:-1]
            self.util.colxrow = value
            return
        if value in ('true', 'True', 'y', 'yes', 'Yes'):
            value = 1
        if value in ('false', 'False', 'n', 'no', 'No'):
            value = 0
        if name == 'speclocal':
            self.util.speclocal = value
        elif name == 'derived':
            if str(value).find(';') != -1:
                self.util.derived = str(value)
            else:
                self.util.derived = str(value).replace(",", ";")
        elif name == 'samples':
            self.util.opts.pmSetOptionSamples(value)
            self.util.samples = self.util.opts.pmGetOptionSamples()
        elif name == 'interval':
            self.util.opts.pmSetOptionInterval(value)
            self.util.interval = self.util.opts.pmGetOptionInterval()
        elif name == 'type':
            if value == 'raw':
                self.util.type = 1
            else:
                self.util.type = 0
        elif name == 'type_prefer':
            if value == 'raw':
                self.util.type_prefer = 1
            else:
                self.util.type_prefer = 0
        elif name == 'instances':
            self.util.instances = str(value).split(",")
        else:
            try:
                setattr(self.util, name, int(value))
            except ValueError:
                if value.startswith('"') and value.endswith('"'):
                    value = value[1:-1]
                setattr(self.util, name, value)

    def read_section_options(self, config, section):
        """ Read options from a configuration file section """
        if not config.has_section(section):
            return
        for opt in config.options(section):
            if opt in self.util.keys and not config.get(section, opt):
                raise ValueError("No value set for option %s in [%s]" % (opt, section))
            if opt in self.util.keys:
                self.set_attr(opt, config.get(section, opt))
            elif section == 'options':
                raise ValueError("Unknown option %s in [%s]" % (opt, section))

    def read_options(self):
        """ Read options from configuration file """
        # Python < 3.2 compat
        if sys.version_info[0] >= 3 and sys.version_info[1] >= 2:
            config = ConfigParser.ConfigParser()
        else:
            config = ConfigParser.SafeConfigParser()
        config.optionxform = str
        for conf in self._get_conf_files():
            try:
                config.read(conf)
                section = 'options'
                self.read_section_options(config, section)
                for arg in iter(sys.argv[1:]):
                    if arg.startswith(":") and arg[1:] in config.sections():
                        section = arg[1:]
                        self.read_section_options(config, section)
            except ConfigParser.Error as error:
                lineno = str(error.lineno) if hasattr(error, 'lineno') else error.errors[0][0]
                sys.stderr.write("Failed to read configuration file '%s', line %s:\n%s\n"
                                 % (conf, lineno, str(error.message)))
                sys.exit(1)
            except ValueError as error:
                sys.stderr.write("Failed to read configuration file '%s':\n%s.\n" % (conf, error))
                sys.exit(1)

    def read_cmd_line(self):
        """ Read command line options """
        pmapi.c_api.pmSetOptionFlags(pmapi.c_api.PM_OPTFLAG_DONE)
        if pmapi.c_api.pmGetOptionsFromList(sys.argv):
            raise pmapi.pmUsageErr()
        return pmapi.c_api.pmGetOperands()

    def parse_instances(self, instances):
        """ Parse user-supplied instances string """
        insts = []
        reader = csv.reader([instances])
        for inst in list(reader)[0]:
            if inst.startswith('"') or inst.startswith("'"):
                inst = inst[1:]
            if inst.endswith('"') or inst.endswith("'"):
                inst = inst[:-1]
            insts.append(inst)
        return insts

    def parse_metric_spec_instances(self, spec):
        """ Parse instances from metric spec """
        insts = []
        if spec.count(",") < 2:
            return spec + ",,", insts
        # User may supply quoted or unquoted instance specification
        # Conf file preserves outer quotes, command line does not
        # We need to detect which is the case here. What a mess.
        quoted = 0
        s = spec.split(",")[2]
        if s and len(s) > 2 and (s[0] == "'" or s[0] == '"'):
            quoted = 1
        if spec.count('"') or spec.count("'"):
            inststr = spec.partition(",")[2].partition(",")[2]
            q = inststr[0]
            inststr = inststr[:inststr.rfind(q)+1]
            if quoted:
                insts = self.parse_instances(inststr[1:-1])
            else:
                insts = self.parse_instances(inststr)
            spec = spec.replace(inststr, "")
        else:
            if s:
                insts = [s]
        if spec.count(",") < 2:
            spec += ",,"
        return spec, insts

    def parse_new_verbose_metric(self, metrics, key, value):
        """ Parse new verbose metric """
        metrics[key] = [value]
        for index in range(0, len(self.metricspec)):
            if len(metrics[key]) <= index:
                if index == 2:
                    metrics[key].append([])
                else:
                    metrics[key].append(None)

    def parse_verbose_metric_info(self, metrics, key, spec, value):
        """ Parse additional verbose metric info """
        if value.startswith('"') and value.endswith('"'):
            value = value[1:-1]
        if spec == "formula":
            if self.util.derived is None:
                self.util.derived = ";" + metrics[key][0] + "=" + value
            else:
                self.util.derived += ";" + metrics[key][0] + "=" + value
        else:
            if self.metricspec.index(spec) == 1:
                metrics[key][self.metricspec.index(spec)+1] = [value]
            else:
                metrics[key][self.metricspec.index(spec)+1] = value

    def parse_metric_info(self, metrics, key, value):
        """ Parse metric information """
        # NB. Uses the config key, not the metric, as the dict key
        compact = False
        if ',' in value or ('.' in key and key.rsplit(".")[1] not in self.metricspec):
            compact = True
        # NB. Formulas may now contain commas, see pmRegisterDerived(3)
        if ',' in value and ('.' in key and key.rsplit(".")[1] == "formula"):
            compact = False
        if compact:
            # Compact / one-line definition
            spec, insts = self.parse_metric_spec_instances(key + "," + value)
            metrics[key] = spec.split(",")
            metrics[key][2] = insts
        else:
            # Verbose / multi-line definition
            if '.' not in key or key.rsplit(".")[1] not in self.metricspec:
                # New metric
                self.parse_new_verbose_metric(metrics, key, value)
            else:
                # Additional info
                key, spec = key.rsplit(".")
                if key not in metrics:
                    raise ValueError("Undeclared metric key %s" % key)
                self.parse_verbose_metric_info(metrics, key, spec, value)

    def prepare_metrics(self, pmns=False):
        """ Construct and prepare metricset """
        metrics = self.util.opts.pmGetOperands()
        if not metrics and not pmns:
            sys.stderr.write("No metrics specified.\n")
            raise pmapi.pmUsageErr()
        if not metrics and pmns:  # all metrics in the namespace
            metrics = self.util.context.pmGetChildren('')

        def read_cmd_line_items():
            """ Helper to read command line items """
            tempmet = OrderedDict()
            for metric in metrics:
                if metric.startswith(":"):
                    tempmet[metric[1:]] = None
                else:
                    spec, insts = self.parse_metric_spec_instances(metric)
                    m = spec.split(",")
                    m[2] = insts
                    tempmet[m[0]] = m[1:]
            return tempmet

        # Metrics from different sources
        globmet = OrderedDict()
        confmet = OrderedDict()
        cmdlmet = read_cmd_line_items()
        sources = OrderedDict()

        # Read config
        # Python < 3.2 compat
        if sys.version_info[0] >= 3 and sys.version_info[1] >= 2:
            config = ConfigParser.ConfigParser()
            all_sets = ConfigParser.ConfigParser()
        else:
            config = ConfigParser.SafeConfigParser()
            all_sets = ConfigParser.SafeConfigParser()
        all_sets.optionxform = str
        config.optionxform = str
        for conf in self._get_conf_files():
            try:
                config.read(conf)
            except ConfigParser.Error as error:
                lineno = str(error.lineno) if hasattr(error, 'lineno') else error.errors[0][0]
                sys.stderr.write("Failed to read configuration file '%s', line %s:\n%s\n"
                                 % (conf, lineno, str(error.message)))
                sys.exit(1)

            # Read global metrics
            if self.util.globals == 1:
                if config.has_section('global'):
                    parsemet = OrderedDict()
                    for key in config.options('global'):
                        if key in self.util.keys:
                            sys.stderr.write("Failed to read configuration file ")
                            sys.stderr.write("'%s':\nSection [global] contains options.\n" % conf)
                            sys.exit(1)
                        if not config.get('global', key):
                            sys.stderr.write("Failed to read configuration file ")
                            sys.stderr.write("'%s':\nNo value set for %s in [global].\n" % (conf, key))
                            sys.exit(1)
                        try:
                            self.parse_metric_info(parsemet, key, config.get('global', key))
                        except ValueError as error:
                            sys.stderr.write("Failed to read configuration file ")
                            sys.stderr.write("'%s':\n" + str(error) % conf + ".\n")
                            sys.exit(1)
                    for metric in parsemet:
                        name = parsemet[metric][:1][0]
                        globmet[name] = parsemet[metric][1:]

            # Add latest metricsets to full configuration
            for section in config.sections():
                if all_sets.has_section(section):
                    all_sets.remove_section(section)
                all_sets.add_section(section)
                sources[section] = conf
                for key, value in config.items(section):
                    all_sets.set(section, key, value.replace('%', '%%'))
                if section not in ('options', 'global'):
                    config.remove_section(section)

        # Get details for configuration file metricsets
        for spec in cmdlmet:
            if cmdlmet[spec] is None:
                if all_sets.has_section(spec):
                    parsemet = OrderedDict()
                    for key in all_sets.options(spec):
                        if not all_sets.get(spec, key):
                            conf = sources[spec]
                            sys.stderr.write("Failed to read configuration file ")
                            sys.stderr.write("'%s':\nNo value set for %s in [%s].\n" % (conf, key, spec))
                            sys.exit(1)
                        if key in self.util.keys or \
                           (hasattr(self.util, 'keys_ignore') and key in self.util.keys_ignore):
                            continue
                        try:
                            self.parse_metric_info(parsemet, key, all_sets.get(spec, key))
                        except ValueError as error:
                            conf = sources[spec]
                            sys.stderr.write("Failed to read configuration file ")
                            sys.stderr.write("'%s':\n" % conf + str(error) + ".\n")
                            sys.exit(1)
                    for metric in parsemet:
                        name = parsemet[metric][:1][0]
                        confmet[name] = parsemet[metric][1:]
                    cmdlmet[spec] = confmet

        # Check for metricsets not found
        for spec in cmdlmet:
            if cmdlmet[spec] is None:
                sys.stderr.write("Metricset definition ':%s' not found.\n" % spec)
                sys.exit(1)

        # Create combined metricset
        if self.util.globals == 1:
            for metric in globmet:
                self.util.metrics[metric] = globmet[metric]
        for metric in cmdlmet:
            if isinstance(cmdlmet[metric], list):
                self.util.metrics[metric] = cmdlmet[metric]
            else:
                if cmdlmet[metric]:
                    for m in cmdlmet[metric]:
                        self.util.metrics[m] = confmet[m]

        if not self.util.metrics:
            sys.stderr.write("No metrics specified.\n")
            sys.exit(1)

        self._conf_metrics = deepcopy(self.util.metrics)

    def provide_texts(self):
        """ Check if help texts requested """
        if hasattr(self.util, 'include_texts') and self.util.include_texts:
            return True
        return False

    def provide_labels(self):
        """ Check if labels needed """
        if hasattr(self.util, 'include_labels') and self.util.include_labels:
            return True
        return False

    def _dict_to_flat_list(self, d):
        """ Helper to flatten dict to list """
        items = []
        for k, v in d.items():
            if isinstance(v, dict):
                items.extend(self._dict_to_flat_list(v))
            else:
                items.append((k, v))
        return items

    def merge_labels(self, d1, d2):
        """ Helper to merge label dicts """
        d3 = d1.copy()
        d3.update(d2)
        return d3

    def get_labels_str(self, metric, inst=None, curr=True, combine=True):
        """ Return labels as string """
        if curr:
            ref = self.res_labels[metric]
        else:
            ref = self.labels[list(self.util.metrics.keys()).index(metric)]
        if inst in (None, pmapi.c_api.PM_IN_NULL):
            labels = ref[0]
        else:
            if curr:
                inst_labels = {} if inst not in ref[1] else ref[1][inst]
            else:
                inst_labels = {} if not ref[1] else ref[1][inst]
            if not combine:
                labels = inst_labels
                if not labels:
                    return "{}"
            else:
                metric_labels = ref[0]
                labels = self.merge_labels(metric_labels, inst_labels)
        return "{" + ','.join("'%s':'%s'" % (k, v) for (k, v) in self._dict_to_flat_list(labels)) + "}"

    def do_live_filtering(self):
        """ Check if doing live filtering """
        if hasattr(self.util, 'live_filter') and self.util.live_filter:
            return True
        return False

    def do_invert_filtering(self):
        """ Check if doing invert filtering """
        if hasattr(self.util, 'invert_filter') and self.util.invert_filter:
            return True
        return False

    def get_metric_indom(self, desc):
        """ Get instance domain for metric """
        if self.util.context.type == pmapi.c_api.PM_CONTEXT_ARCHIVE:
            return self.util.context.pmGetInDomArchive(desc)
        return self.util.context.pmGetInDom(desc)

    def get_inst_labels(self, indom, curr=True, insts=[]): # pylint: disable=dangerous-default-value
        """ Get instance labels """
        if indom == pmapi.c_api.PM_INDOM_NULL:
            return {} if curr else []
        if curr:
            return self.util.context.pmGetInstancesLabels(indom)
        inst_labels = []
        indom_labels = self.util.context.pmGetInstancesLabels(indom)
        for i in insts:
            inst_labels.append(indom_labels[i] if i in indom_labels else {})
        return inst_labels

    def get_proc_basename(self, proc):
        """ Get process basename """
        if proc.startswith('('):
            # Kernel thread
            proc = proc[1:-1]
        else:
            # User space process
            if proc.startswith('-'):
                proc = proc[1:]
            if proc.endswith(':'):
                proc = proc[:-1]
            proc = os.path.basename(proc)
        return proc

    def check_metric(self, metric):
        """ Validate individual metric and get its details """
        try:
            pmid = self.util.context.pmLookupName(metric)[0]
            if pmid in self.pmids:
                # Always ignore duplicates
                return
            desc = self.util.context.pmLookupDescs(pmid)[0]
            if desc.contents.indom == pmapi.c_api.PM_INDOM_NULL:
                inst = ([pmapi.c_api.PM_IN_NULL], [None])     # mem.util.free
            else:
                inst = self.get_metric_indom(desc)            # disk.dev.read
                if not inst[0]:
                    inst = ([pmapi.c_api.PM_IN_NULL], [None]) # pmcd.pmie.logfile
            # Reject unsupported types
            if desc.contents.type not in (pmapi.c_api.PM_TYPE_32,
                                          pmapi.c_api.PM_TYPE_U32,
                                          pmapi.c_api.PM_TYPE_64,
                                          pmapi.c_api.PM_TYPE_U64,
                                          pmapi.c_api.PM_TYPE_FLOAT,
                                          pmapi.c_api.PM_TYPE_DOUBLE,
                                          pmapi.c_api.PM_TYPE_STRING):
                raise pmapi.pmErr(pmapi.c_api.PM_ERR_TYPE)
            instances = self.util.instances if not self._tmp else self._tmp
            if hasattr(self.util, 'omit_flat') and self.util.omit_flat and not inst[1][0]:
                return
            if instances and inst[1][0] and not self.do_live_filtering():
                found = [[], []]
                for r in instances:
                    hit = False
                    msg = ""
                    try:
                        if r.isdigit():
                            msg = "Invalid instance"
                            if inst[1][0].split()[0].isdigit():
                                for i, s in enumerate(inst[1]):
                                    sp = s.split()[0]
                                    if sp.isdigit() and int(r) == int(sp):
                                        found[0].append(inst[0][i])
                                        found[1].append(inst[1][i])
                                        hit = True
                                        break
                        if r.replace('.', '').replace('_', '').replace('-', '').isalnum():
                            msg = "Invalid process"
                            if ' ' in inst[1][0] and inst[1][0].split()[0].isdigit():
                                for i, s in enumerate(inst[1]):
                                    if r == self.get_proc_basename(s.split()[1]):
                                        found[0].append(inst[0][i])
                                        found[1].append(inst[1][i])
                                        hit = True
                        if not hit:
                            msg = "Invalid regex"
                            cr = re.compile(r'\A' + r + r'\Z')
                            for i, s in enumerate(inst[1]):
                                if re.match(cr, s):
                                    found[0].append(inst[0][i])
                                    found[1].append(inst[1][i])
                            del cr
                    except Exception as error:
                        sys.stderr.write("%s '%s': %s.\n" % (msg, r, error))
                        sys.exit(1)
                if not found[0]:
                    return
                inst = tuple(found)
            self.pmids.append(pmid)
            self.descs.append(desc)
            self.insts.append(inst)
            if self.provide_texts():
                line, full, doml, domh = None, None, None, None
                try:
                    line = self.util.context.pmLookupText(pmid, pmapi.c_api.PM_TEXT_ONELINE)
                    full = self.util.context.pmLookupText(pmid, pmapi.c_api.PM_TEXT_HELP)
                    if desc.contents.indom != pmapi.c_api.PM_INDOM_NULL:
                        doml = self.util.context.pmLookupInDomText(desc, pmapi.c_api.PM_TEXT_ONELINE)
                        domh = self.util.context.pmLookupInDomText(desc, pmapi.c_api.PM_TEXT_HELP)
                except pmapi.pmErr as error:
                    if error.args[0] != pmapi.c_api.PM_ERR_TEXT:
                        raise
                self.texts.append([line, full, doml, domh])
            metric_labels = {}
            inst_labels = []
            ri_labels = {}
            if self.provide_labels():
                try:
                    metric_labels = self.util.context.pmLookupLabels(pmid)
                    inst_labels = self.get_inst_labels(desc.contents.indom, False, inst[0])
                    ri_labels = self.get_inst_labels(desc.contents.indom)
                except Exception:
                    pass
            self.labels.append([metric_labels, inst_labels])
            self.res_labels[metric] = [metric_labels, ri_labels]
        except pmapi.pmErr as error:
            if hasattr(self.util, 'ignore_incompat') and self.util.ignore_incompat:
                return
            sys.stderr.write("Invalid metric %s (%s).\n" % (metric, str(error)))
            sys.exit(1)

    def ignore_unknown_metrics(self):
        """ Check if unknown metrics are ignored """
        if hasattr(self.util, 'ignore_unknown') and self.util.ignore_unknown:
            return True
        return False

    def format_metric_label(self, label):
        """ Format a metric text label """
        # See src/libpcp/src/units.c
        if ' / ' in label:
            label = label.replace("nanosec", "ns").replace("microsec", "us")
            label = label.replace("millisec", "ms").replace("sec", "s")
            label = label.replace("min", "min").replace("hour", "h")
            label = label.replace(" / ", "/")
        return label

    class pmfg_items_to_indom(object): # pylint: disable=too-few-public-methods
        """ Helper to provide consistent interface with pmfg items and indoms """
        def __init__(self, items):
            """ Initialize an instance with items """
            self._items = items

        def __call__(self):
            """ Retrieve the items """
            return self._items

    def integer_roundup(self, value, upper):
        """ Round an integer value up to the nearest upper integer """
        return int(math.ceil(value / float(upper))) * upper

    def validate_common_options(self):
        """ Validate common utility options """
        err = "Integer expected"
        attr = "unknown"
        try:
            if hasattr(self.util, 'rank') and self.util.rank:
                attr = 'rank'
                self.util.rank = int(self.util.rank)
            if hasattr(self.util, 'limit_filter') and self.util.limit_filter:
                attr = 'limit_filter'
                self.util.limit_filter = int(self.util.limit_filter)
            if hasattr(self.util, 'limit_filter_force') and self.util.limit_filter_force:
                attr = 'limit_filter_force'
                self.util.limit_filter_force = int(self.util.limit_filter_force)
            err = "Non-negative integer expected"
            if hasattr(self.util, 'width') and self.util.width:
                attr = 'width'
                self.util.width = int(self.util.width)
                if self.util.width < 0:
                    raise ValueError(err)
            if hasattr(self.util, 'width_force') and self.util.width_force:
                attr = 'width_force'
                self.util.width_force = int(self.util.width_force)
                if self.util.width_force < 0:
                    raise ValueError(err)
            if hasattr(self.util, 'precision') and self.util.precision:
                attr = 'precision'
                self.util.precision = int(self.util.precision)
                if self.util.precision < 0:
                    raise ValueError(err)
            if hasattr(self.util, 'precision_force') and self.util.precision_force:
                attr = 'precision_force'
                self.util.precision_force = int(self.util.precision_force)
                if self.util.precision_force < 0:
                    raise ValueError(err)
            if hasattr(self.util, 'repeat_header') and self.util.repeat_header:
                attr = 'repeat_header'
                if self.util.repeat_header != "auto":
                    self.util.repeat_header = int(self.util.repeat_header)
                    if self.util.repeat_header < 0:
                        raise ValueError(err)
        except ValueError:
            sys.stderr.write("Error while reading option %s: %s.\n" % (attr, err))
            sys.exit(1)

    def validate_metrics(self, curr_insts=CURR_INSTS, max_insts=0):
        """ Validate the metricset """
        # Check the metrics against PMNS, resolve non-leaf metrics

        if not hasattr(self.util, 'leaf_only'):
            self.util.metrics = deepcopy(self._conf_metrics)

        if hasattr(self.util, 'predicate') and self.util.predicate:
            for predicate in self.util.predicate.split(","):
                if predicate not in self.util.metrics:
                    self.util.metrics[predicate] = ['', []]

        if self.util.derived:
            for derived in filter(None, self.util.derived.split(";")):
                if derived.startswith("/") or derived.startswith("."):
                    try:
                        self.util.context.pmLoadDerivedConfig(derived)
                    except pmapi.pmErr as error:
                        sys.stderr.write("Failed to load derived metric definitions ")
                        sys.stderr.write("from file '%s':\n%s.\n" % (derived, str(error)))
                        sys.exit(1)
                else:
                    err = ""
                    expr = ""
                    try:
                        name, expr = derived.split("=", 1)
                        self.util.context.pmLookupName(name.strip())
                    except pmapi.pmErr as error:
                        if error.args[0] != pmapi.c_api.PM_ERR_NAME:
                            err = error.message()
                        else:
                            try:
                                self.util.context.pmRegisterDerived(name.strip(), expr.strip())
                                continue
                            except pmapi.pmErr as error2:
                                err = error2.message()
                    except ValueError as error:
                        err = "Invalid syntax (expected metric=expression)"
                    except Exception as error:
                        err = "Unidentified error"
                    finally:
                        if err:
                            sys.stderr.write("Failed to register derived metric:\n%s.\n" % err)
                            sys.exit(1)

        if not hasattr(self.util, 'leaf_only') or not self.util.leaf_only:
            # Prepare for non-leaf metrics while preserving metric order
            metrics = self.util.metrics
            self.util.metrics = OrderedDict()

            def metric_base_check(metric):
                """ Helper to support non-leaf metricspecs """
                if metric != self._tmp:
                    if metric not in self.util.metrics:
                        self.util.metrics[metric] = deepcopy(metrics[self._tmp])
                else:
                    self.util.metrics[metric] = deepcopy(metrics[metric])

            # Resolve non-leaf metrics to allow metricspecs like disk.dm,,,MB
            for metric in list(metrics):
                self._tmp = metric
                try:
                    self.util.context.pmTraversePMNS(metric, metric_base_check)
                except pmapi.pmErr as error:
                    if error.args[0] != pmapi.c_api.PM_ERR_NAME:
                        raise
                    # Ignore unknown metrics if so requested
                    ignore = False
                    try:
                        self.util.context.pmLookupName(metric)
                    except pmapi.pmErr as error2:
                        if error2.args[0] != pmapi.c_api.PM_ERR_NAME:
                            raise
                        if self.ignore_unknown_metrics() and metric in self._conf_metrics:
                            ignore = True
                    if not ignore:
                        self.util.metrics[metric] = deepcopy(metrics[metric])

        metrics = self.util.metrics
        self.util.metrics = OrderedDict()

        for metric in metrics:
            try:
                l = len(self.pmids)
                self._tmp = metrics[metric][1]
                self.util.context.pmTraversePMNS(metric, self.check_metric)
                if len(self.pmids) == l:
                    # No compatible metrics found
                    continue
                else:
                    self.util.metrics[metric] = metrics[metric]
            except pmapi.pmErr as error:
                sys.stderr.write("Invalid metric %s (%s).\n" % (metric, str(error)))
                sys.exit(1)

        # Exit if no metrics with specified instances found
        if not self.insts and not self.ignore_unknown_metrics():
            sys.stderr.write("No matching instances found.\n")
            # Try to help the user to get the instance specifications right
            if self.util.instances:
                print("\nRequested global instances:")
                print(self.util.instances)
            sys.exit(1)

        # Dynamically set fetchgroup max instances
        # if not specified explicitly by the caller
        dynamic_insts = not max_insts

        # Finalize metricset
        incompat_metrics = OrderedDict()
        for i, metric in enumerate(self.util.metrics):
            # Fill in all fields for easier checking later
            for index in range(0, 8):
                if len(self.util.metrics[metric]) <= index:
                    if index == 1:
                        self.util.metrics[metric].append([])
                    else:
                        self.util.metrics[metric].append(None)

            # Text label
            if not self.util.metrics[metric][0]:
                # mem.util.free -> m.u.free
                name = ""
                for m in metric.split("."):
                    name += m[0] + "."
                self.util.metrics[metric][0] = name[:-2] + metric.split(".")[-1]

            # Instance(s)
            if not self.util.metrics[metric][1] and self.util.instances:
                if self.insts[i][0][0] != pmapi.c_api.PM_IN_NULL:
                    self.util.metrics[metric][1] = self.util.instances
            if self.insts[i][0][0] == pmapi.c_api.PM_IN_NULL:
                self.util.metrics[metric][1] = []

            if dynamic_insts:
                max_insts = self.integer_roundup(len(self.insts[i][0]), 1000)

            # Rawness
            if hasattr(self.util, 'type_prefer') and not self.util.metrics[metric][3]:
                self.util.metrics[metric][3] = self.util.type_prefer
            elif self.util.metrics[metric][3] == 'raw':
                self.util.metrics[metric][3] = 1
            else:
                self.util.metrics[metric][3] = 0
            # Force raw output with archive mode of any tool in order to
            # create pmlogger(1) compatible archives that can be merged.
            if (hasattr(self.util, 'type') and self.util.type == 1) or \
               self.util.metrics[metric][3] == 'raw' or \
               (hasattr(self.util, 'output') and self.util.output == 'archive'):
                self.util.metrics[metric][3] = 1

            # Dimension test helpers
            def is_count(unit):
                """ Test count dimension """
                if unit.dimCount == 1 and ( \
                   unit.dimSpace == 0 and \
                   unit.dimTime == 0):
                    return True
                return False

            def is_space(unit):
                """ Test space dimension """
                if unit.dimSpace == 1 and ( \
                   unit.dimCount == 0 and \
                   unit.dimTime == 0):
                    return True
                return False

            def is_time(unit):
                """ Test time dimension """
                if unit.dimTime == 1 and ( \
                   unit.dimCount == 0 and \
                   unit.dimSpace == 0):
                    return True
                return False

            # Set unit/scale
            unit = self.descs[i].contents.units
            if is_count(unit):
                if hasattr(self.util, 'count_scale_force') and self.util.count_scale_force:
                    self.util.metrics[metric][2] = self.util.count_scale_force
                elif hasattr(self.util, 'count_scale') and self.util.count_scale and \
                   not self.util.metrics[metric][2]:
                    self.util.metrics[metric][2] = self.util.count_scale
                elif not self.util.metrics[metric][2]:
                    self.util.metrics[metric][2] = str(unit)
            if is_space(unit):
                if hasattr(self.util, 'space_scale_force') and self.util.space_scale_force:
                    self.util.metrics[metric][2] = self.util.space_scale_force
                elif hasattr(self.util, 'space_scale') and self.util.space_scale and \
                   not self.util.metrics[metric][2]:
                    self.util.metrics[metric][2] = self.util.space_scale
                elif not self.util.metrics[metric][2]:
                    self.util.metrics[metric][2] = str(unit)
            if is_time(unit):
                if hasattr(self.util, 'time_scale_force') and self.util.time_scale_force:
                    self.util.metrics[metric][2] = self.util.time_scale_force
                elif hasattr(self.util, 'time_scale') and self.util.time_scale and \
                   not self.util.metrics[metric][2]:
                    self.util.metrics[metric][2] = self.util.time_scale
                elif not self.util.metrics[metric][2]:
                    self.util.metrics[metric][2] = str(unit)
            if not self.util.metrics[metric][2]:
                self.util.metrics[metric][2] = str(unit)

            # Force native units with archive mode of any tool in order to
            # create pmlogger(1) compatible archives that can be merged.
            if hasattr(self.util, 'output') and self.util.output == 'archive':
                self.util.metrics[metric][2] = str(unit)

            # Finalize text label and unit/scale
            try:
                label = self.util.metrics[metric][2]
                (pmunit, mult) = self.util.context.pmParseUnitsStr(self.util.metrics[metric][2])
                if self.util.metrics[metric][3] == 0 and \
                   self.descs[i].contents.type != pmapi.c_api.PM_TYPE_STRING and \
                   self.descs[i].sem == pmapi.c_api.PM_SEM_COUNTER and \
                   '/' not in label:
                    label += " / s"
                label = self.format_metric_label(label)
                self.util.metrics[metric][2] = (label, pmunit, mult)
            except pmapi.pmErr as error:
                sys.stderr.write("%s: %s.\n" % (str(error), self.util.metrics[metric][2]))
                sys.exit(1)

            # Set metric type - default to double for precision
            mtype = pmapi.c_api.PM_TYPE_DOUBLE
            # But use native type if nothing else was requested
            if str(pmunit) == str(self.descs[i].contents.units):
                mtype = self.descs[i].contents.type
            # However always use double for non-raw counters
            if self.util.metrics[metric][3] == 0 and \
               self.descs[i].contents.sem == pmapi.c_api.PM_SEM_COUNTER:
                mtype = pmapi.c_api.PM_TYPE_DOUBLE
            # Strings will be strings right till the end
            if self.descs[i].contents.type == pmapi.c_api.PM_TYPE_STRING:
                mtype = self.descs[i].contents.type

            # Set width
            if self.util.metrics[metric][4]:
                try:
                    self.util.metrics[metric][4] = int(self.util.metrics[metric][4])
                    if self.util.metrics[metric][4] < 0:
                        raise ValueError
                except Exception:
                    sys.stderr.write("Non-negative integer expected: %s\n" % metric)
                    sys.exit(1)
            elif hasattr(self.util, 'width'):
                self.util.metrics[metric][4] = self.util.width
            else:
                self.util.metrics[metric][4] = 0 # Auto-adjust
            if hasattr(self.util, 'width_force') and self.util.width_force is not None:
                self.util.metrics[metric][4] = self.util.width_force
            if not self.util.metrics[metric][4]:
                self.util.metrics[metric][4] = len(self.util.metrics[metric][0])
            if self.util.metrics[metric][4] < len(TRUNC):
                self.util.metrics[metric][4] = len(TRUNC) # Forced minimum

            # Set precision
            # NB. We need to take into account that clients expect pmfg item in [5]
            if self.util.metrics[metric][5]:
                try:
                    self.util.metrics[metric][6] = int(self.util.metrics[metric][5])
                except Exception:
                    sys.stderr.write("Non-negative integer expected: %s\n" % metric)
                    sys.exit(1)
            elif hasattr(self.util, 'precision'):
                self.util.metrics[metric][6] = self.util.precision
            else:
                self.util.metrics[metric][6] = 3 # Built-in default
            if hasattr(self.util, 'precision_force') and self.util.precision_force is not None:
                self.util.metrics[metric][6] = self.util.precision_force
            self.util.metrics[metric][5] = None

            # Set value limit filter
            if self.util.metrics[metric][7]:
                try:
                    self.util.metrics[metric][7] = int(self.util.metrics[metric][7])
                except Exception:
                    sys.stderr.write("Integer expected: %s\n" % metric)
                    sys.exit(1)
            elif hasattr(self.util, 'limit_filter'):
                self.util.metrics[metric][7] = self.util.limit_filter
            if hasattr(self.util, 'limit_filter_force') and self.util.limit_filter_force:
                self.util.metrics[metric][7] = self.util.limit_filter_force
            if self.descs[i].contents.type == pmapi.c_api.PM_TYPE_STRING:
                self.util.metrics[metric][7] = None

            # Add fetchgroup items
            try:
                items = []
                max_insts = max(1, max_insts)
                scale = self.util.metrics[metric][2][0]
                if curr_insts and self.util.metrics[metric][1]:
                    mitems = 0
                    vanished = []
                    for j in range(0, len(self.insts[i][1])):
                        try:
                            item = self.util.pmfg.extend_item(metric, mtype, scale, self.insts[i][1][j])
                            items.append((self.insts[i][0][j], self.insts[i][1][j], item))
                            mitems += 1
                        except pmapi.pmErr as error:
                            if error.args[0] == pmapi.c_api.PM_ERR_CONV:
                                raise
                            vanished.append(j)
                    if mitems > 0:
                        for v in reversed(vanished):
                            del self.insts[i][0][v]
                            del self.insts[i][1][v]
                            if self.provide_labels():
                                del self.labels[i][1][v]
                    self.util.metrics[metric][5] = self.pmfg_items_to_indom(items)
                else:
                    self.util.metrics[metric][5] = \
                        self.util.pmfg.extend_indom(metric, mtype, scale, max_insts)

                # Populate per-metric regex cache for live filtering
                if self.do_live_filtering():
                    try:
                        self._re_cache[metric] = []
                        for r in self.util.metrics[metric][1]:
                            self._re_cache[metric].append(re.compile(r'\A' + r + r'\Z'))
                    except Exception as error:
                        sys.stderr.write("Invalid regex '%s': %s.\n" % (r, error))
                        sys.exit(1)
            except Exception:
                if hasattr(self.util, 'ignore_incompat') and self.util.ignore_incompat:
                    # Schedule the metric for removal
                    incompat_metrics[metric] = i
                else:
                    raise

        # Remove all traces of incompatible metrics
        for metric in reversed(incompat_metrics):
            del self.pmids[incompat_metrics[metric]]
            del self.descs[incompat_metrics[metric]]
            del self.insts[incompat_metrics[metric]]
            if self.provide_texts():
                del self.texts[incompat_metrics[metric]]
            if self.provide_labels():
                del self.labels[incompat_metrics[metric]]
            del self.util.metrics[metric]
        del incompat_metrics

        # Verify that we have valid metrics
        if not self.util.metrics:
            if not self.ignore_unknown_metrics():
                sys.stderr.write("No compatible metrics found.\n")
            else:
                sys.stderr.write("Not one known metric found.\n")
            sys.exit(1)

        if hasattr(self.util, 'predicate') and self.util.predicate:
            self.validate_predicate()

    def finalize_options(self):
        """ Finalize util options """
        # Runtime overrides samples/interval
        if self.util.opts.pmGetOptionFinishOptarg():
            if self.util.opts.pmGetOptionOrigin() is None:
                origin = 0
            else:
                origin = float(self.util.opts.pmGetOptionOrigin())
            self.util.runtime = float(self.util.opts.pmGetOptionFinish()) - origin
            if self.util.opts.pmGetOptionSamples():
                self.util.samples = self.util.opts.pmGetOptionSamples()
                self.util.samples = max(2, self.util.samples)
                self.util.interval = float(self.util.runtime) / (self.util.samples - 1)
                self.util.opts.pmSetOptionInterval(str(self.util.interval))
                self.util.interval = self.util.opts.pmGetOptionInterval()
            else:
                self.util.interval = self.util.opts.pmGetOptionInterval()
                if not self.util.interval:
                    self.util.interval = pmapi.timeval(0)
                try:
                    self.util.samples = int(self.util.runtime / float(self.util.interval) + 1)
                except Exception:
                    pass
        else:
            self.util.samples = self.util.opts.pmGetOptionSamples()
            self.util.interval = self.util.opts.pmGetOptionInterval()

        if float(self.util.interval) <= 0:
            sys.stderr.write("Interval must be greater than zero.\n")
            sys.exit(1)

    def clear_metrics(self):
        """ Clear metricset """
        self.util.metrics = OrderedDict()
        self.pmids = []
        self.descs = []
        self.insts = []
        self.texts = []
        self.labels = []
        self.res_labels = OrderedDict()
        self.util.pmfg.clear()
        self.util.pmfg_ts = None

    def update_metrics(self, curr_insts=CURR_INSTS, max_insts=0):
        """ Update metricset """
        self.clear_metrics()
        self.util.pmfg_ts = self.util.pmfg.extend_timestamp()
        self.validate_metrics(curr_insts, max_insts)

    def names_change_action(self):
        """ Action to take when namespace change occurs:
            ignore=0, abort=1, update=2 """
        if hasattr(self.util, 'names_change'):
            return self.util.names_change
        return 0 # By default ignore name change notification from pmcd(1)

    def fetch(self):
        """ Sample using fetchgroup and handle special cases """
        try:
            state = self.util.pmfg.fetch()
        except pmapi.pmErr as error:
            if error.args[0] == pmapi.c_api.PM_ERR_EOL:
                return -1
            if error.args[0] == pmapi.c_api.PM_ERR_TOOSMALL:
                raise pmapi.pmErr(pmapi.c_api.PM_ERR_TOOSMALL,
                                  "\nNo metrics or instances to report present.")
            raise error

        # Watch for end time in uninterpolated mode
        if not self.util.interpol:
            sample = self.util.pmfg_ts().strftime('%s')
            finish = self.util.opts.pmGetOptionFinish()
            if float(sample) > float(finish):
                return -2

        # Handle any PMCD state change notification
        if state & pmapi.c_api.PMCD_NAMES_CHANGE:
            action = self.names_change_action()
            if action == 1:
                return -3
            elif action == 2:
                return 1

        # Successfully completed sampling
        return 0

    def pause(self):
        """ Pause before next sampling """
        self._round += 1

        if not self._init_ts:
            self._init_ts = float(self.util.pmfg_ts().strftime("%s.%f"))

        wakeup = self._init_ts + float(self.util.interval) * self._round

        sleep = wakeup - time.time()

        if sleep > 0:
            time.sleep(sleep)

    def filter_instance(self, metric, name):
        """ Filter instance name against metric instances """
        if not self._re_cache[metric]:
            return True

        for r in self.util.metrics[metric][1]:
            if r.replace('.', '').replace('_', '').replace('-', '').isalnum():
                if ' ' in name and name.split()[0].isdigit():
                    if r == self.get_proc_basename(name.split()[1]):
                        return True

        for cr in self._re_cache[metric]:
            if re.match(cr, name):
                return True

        return False

    def rank(self, instances):
        """ Rank instances """
        if not self.util.rank:
            return instances
        rank = abs(self.util.rank)
        revs = bool(self.util.rank > 0)
        return sorted(instances, key=lambda value: value[2], reverse=revs)[:rank]

    def validate_predicate(self):
        """ Validate predicate filter reference metrics """
        for predicate in self.util.predicate.split(","):
            if predicate not in self.util.metrics:
                sys.stderr.write("Predicate metric %s filtered out.\n" % predicate)
                sys.exit(1)

            i = list(self.util.metrics.keys()).index(predicate)
            self._pred_indom.append(self.descs[i].contents.indom)

            if self.insts[i][0][0] == pmapi.c_api.PM_IN_NULL:
                sys.stderr.write("Predicate metric must have instances.\n")
                sys.exit(1)

            if self.descs[i].contents.type == pmapi.c_api.PM_TYPE_STRING:
                sys.stderr.write("Predicate metric values must be numeric.\n")
                sys.exit(1)

    # Deprecated, use get_ranked_results() below instead
    def get_sorted_results(self, valid_only=False):
        """ Deprecated, use get_ranked_results() instead """
        return self.get_ranked_results(valid_only)

    def get_ranked_results(self, valid_only=False):
        """ Get filtered and ranked results """
        results = OrderedDict()
        if hasattr(self.util, 'predicate') and self.util.predicate:
            predicates = self.util.predicate.split(",")
        else:
            predicates = ()
        early_live_filter = self.do_live_filtering() and not self.do_invert_filtering()
        for i, metric in enumerate(self.util.metrics):
            results[metric] = []
            try:
                for inst, name, val in self.util.metrics[metric][5]():
                    try:
                        # Ignore transient instances
                        if inst != pmapi.c_api.PM_IN_NULL and not name:
                            continue
                        if early_live_filter and inst != pmapi.c_api.PM_IN_NULL and \
                           not self.filter_instance(metric, name):
                            continue
                        value = val()
                        if self.util.metrics[metric][7]:
                            if metric not in predicates:
                                limit = self.util.metrics[metric][7]
                                if limit > 0 and value < limit:
                                    continue
                                elif limit < 0 and value > abs(limit):
                                    continue
                        results[metric].append((inst, name, value))
                    except Exception:
                        pass
            except Exception:
                pass

            if valid_only and not results[metric]:
                del results[metric]

        if self.provide_labels():
            insts = [(metric, list(zip(*results[metric]))[0]) for metric in results if results[metric]]
            if self._prev_insts != insts:
                prev_labels = self.res_labels
                self.res_labels = OrderedDict()
                self._prev_insts = insts
                for metric in results:
                    ri_labels = None
                    if metric in prev_labels:
                        metric_labels = prev_labels[metric][0]
                        prev_insts = prev_labels[metric][1].keys()
                        curr_insts = list(zip(*results[metric]))[0] if results[metric] else {}
                        if all(inst in prev_insts for inst in curr_insts):
                            ri_labels = prev_labels[metric][1]
                    else:
                        i = list(self.util.metrics.keys()).index(metric)
                        metric_labels = self.util.context.pmLookupLabels(self.pmids[i])
                    if ri_labels is None:
                        ri_labels = self.get_inst_labels(self.descs[i].contents.indom)
                    self.res_labels[metric] = [metric_labels, ri_labels]

        if not results:
            return results

        if predicates:
            pred_insts = {}
            for i, predicate in enumerate(predicates):
                results[predicate] = self.rank(results[predicate])
                p = self._pred_indom[i]
                if p not in pred_insts:
                    pred_insts[p] = []
                pred_insts[p].extend(x[0] for x in results[predicate] if x[0] not in pred_insts[p])
            for metric in results:
                if metric in predicates:
                    # Predicate instance values may all get filtered,
                    # but other metrics' instance values may be above
                    # the filter so predicate is filtered after rank.
                    if self.util.metrics[metric][7]:
                        limit = self.util.metrics[metric][7]
                        if limit > 0:
                            results[metric] = [x for x in results[metric] if x[2] >= limit]
                        elif limit < 0:
                            results[metric] = [x for x in results[metric] if x[2] <= abs(limit)]
                    continue
                i = list(self.util.metrics.keys()).index(metric)
                if self.descs[i].contents.indom not in self._pred_indom:
                    results[metric] = self.rank(results[metric])
                    continue
                inst_index = self.descs[i].contents.indom
                results[metric] = [x for x in results[metric] if x[0] in pred_insts[inst_index]]
        else:
            if hasattr(self.util, 'rank') and self.util.rank:
                for metric in results:
                    results[metric] = self.rank(results[metric])

        if self.do_live_filtering() and self.do_invert_filtering():
            for metric in results:
                results[metric] = [x for x in results[metric] if self.filter_instance(metric, x[1])]

        return results
