#! /usr/bin/python3 -sP
#
# -*- coding: utf-8 -*-
# vim: ts=4 sw=4 tw=100 et ai si
#
# Copyright (C) 2019-2021 Intel Corporation
# SPDX-License-Identifier: BSD-3-Clause
#
# Author: Artem Bityutskiy <artem.bityutskiy@linux.intel.com>

"""
This program implements the statistics collection agent (stc-agent) as a service clients can connect
to. Clients can send it commands to collect various kinds of statistics, e.g. turbostat and AC
power.

This program is designed to work in a local network and may require root privileges. Please, do not
use it in production environment, use it only in isolated debugging/research setups.
"""

# pylint: disable=no-member
# pylint: disable=signature-differs

import os
import sys
import time
import socket
import signal
import logging
import tempfile
import argparse
import contextlib
from pathlib import Path
from pepclibs.helperlibs import Logging, ArgParse, LocalProcessManager, Trivial, ClassHelpers
from pepclibs.helperlibs.Exceptions import Error

try:
    from statscollectlibs.helperlibs import ProcHelpers
except ImportError:
    # The project was not installed, and the program was executed from the sources. Insert the
    # project root directory path to the modules search list.
    ownpath = Path(sys.argv[0]).parent.resolve()
    sys.path.append(f"{ownpath}/../../")
    from statscollectlibs.helperlibs import ProcHelpers

VERSION = "1.0"
OWN_NAME = "stc-agent"

# The values for statistics collector properties which mean that the property was not initialized.
# If the property is required to be initialize, the key name starts with "required-".
UNINITIALIZED = {
    # Not required to be initialized.
    "str" : "<not configured>",
    "int" : -1000000000000,
    # Non-optional property, required to be initialized.
    "required-str" : "<must be configured>",
    "required-int" : -9999999999999,
}

# The messages delimiter prefix: every time we see it following a newline - we assume this is the
# end of the message.
DELIMITER = "--"

# Names of the supported statistics.
SUPPORTED_STATS = ("turbostat", "ipmi-oob", "ipmi-inband", "acpower")

LOG = logging.getLogger()
Logging.setup_logger(prefix=OWN_NAME)

# Our own process ID.
PID = Trivial.get_pid()

class ClientDisconnected(Exception):
    """And exception class that we use wen a client disconnects."""

class ExitCommand(Exception):
    """An exception class that we use when we have to exit."""

def parse_arguments():
    """A helper function which parses the input arguments."""

    text = sys.modules[__name__].__doc__
    parser = ArgParse.ArgsParser(description=text, prog=OWN_NAME, ver=VERSION)

    text = f"""The local unix socket path to wait for incoming clients connections on. By default,
               '{OWN_NAME}' creates a socket node with a random name in the temporary directory and
               prints its path to the stanadard output. The socket file name, however, will include
               SUT name, if it was specified with '--sut-name'. E.g., '--sut-name=myhost' would
               result in socket file name like 'stc-agent-myhost-abracadabra', where 'abracadabra'
               is the random part of the name."""
    parser.add_argument("-u", "--unix", help=text)

    text = f"""TCP port number to listen for incoming client connections on. If port value is 0,
               '{OWN_NAME} allocates an available port and prints its value to the standard output.
               WARNING! Using the a TCP port may be dangerous because there is not authentication.
               It is more secure to use a unix socket and let the remote client authenticate and
               donnect via a secure protocol like SSH."""
    parser.add_argument("-p", "--port", type=int, help=text)

    text = """System Under Test (SUT) name. This option affects only the messages and the "
              automatically created Unix socket file name."""
    parser.add_argument("--sut-name", dest="sutname", help=text)

    # This is a hidden option which makes 'stc-agent' print paths to its dependencies and exit.
    parser.add_argument("--print-module-paths", action="store_true", help=argparse.SUPPRESS)
    return parser.parse_args()

def print_module_paths():
    """
    Print paths to all modules other than standard.
    """

    for mobj in sys.modules.values():
        path = getattr(mobj, "__file__", None)
        if not path:
            continue

        if not path.endswith(".py"):
            continue

        if "pepclibs/" in path or "statscollectlibs/" in path:
            print(path)

def sighandler(sig, _):
    """
    In case 'satsd' is started in a PID namespace and it is PID1, the default signal handlers are
    not set up, and we use this one to exit on 'SIGTERM' and 'SIGINT' signals.
    """

    LOG.debug("received signal '%d', exiting", sig)
    raise SystemExit(sig)

class _BaseCollector:
    """
    This is the base class for statistics collectors. Here is the methods overview.

    1. '__init__()' - the object constructor, performs basic initialization. The collector is not
       usable yet, because it has not been configured yet (e.g., the output directory was not yet
       set).
    2. 'configure()' - must be called every time a collector property have been changed (e.g., the
       output directory).
    4. 'start()' - start collecting the statistics
    5. 'stop()' - stop collecting the statistics
    6. 'save()' - save the results to the output directory, synchronize all the stats and flush all
                  the buffers.
    7. 'validate()' - validate the collected data to make sure it is sane

    Once the collector is initialized, the usage sequence is as follows.
      * Set various collector properties like 'outdir'
      * 'configure()
      * start()
      * stop()
      * save()
      * validate()
    The sequence can be repeated many times.
    """

    def _error(self, msgformat, *args):
        """The collector error handler."""

        if args:
            msg = msgformat % args
        else:
            msg = str(msgformat)

        raise Error(f"the '{self.name}' statistics collector failed:\n{msg}")

    def _debug(self, msgformat, *args):
        """The collector debug messages."""

        if args:
            msg = msgformat % args
        else:
            msg = str(msgformat)

        LOG.debug("'%s': %s", self.name, msg)

    def _sync(self):
        """Synchronize all the collector files."""

        def _fsync(fobj):
            """Synchronize the 'fobj' file."""

            try:
                fobj.flush()
                os.fsync(fobj.fileno())
            except OSError as err:
                self._error("cannot synchronize '%s':\n%s", self._fobj.name, err)

        _fsync(self._fobj)

    def _handle_dirs(self):
        """Make sure the output directory exists."""

        for key in ("outdir", "logdir"):
            path = self.props[key]
            if not os.path.isabs(path):
                self._error("path '%s' (%s) is not absolute", path, key)
            if os.path.exists(path):
                if not os.path.isdir(path):
                    self._error("path '%s' (%s) already exists and it is not a directory",
                                path, key)
            else:
                self._debug("creating directory '%s' (%s)", path, key)
                try:
                    os.mkdir(path)
                except OSError as err:
                    self._error("cannot create directory '%s' (%s):\n%s", path, key, err)

    def configure(self):
        """Configure the statistics collector."""

        # Validate that all of the mandatory properties have been set.
        for prop, val in self.props.items():
            if val in (UNINITIALIZED["required-str"], UNINITIALIZED["required-int"]):
                self._error("please, configure '%s' first", prop)

        self._handle_dirs()

        self._outpath = os.path.join(self.props["outdir"], self._outfile)

        if self._fobj:
            self._fobj.close()
            self._fobj = None

        try:
            # pylint: disable=consider-using-with
            self._fobj = open(self._outpath, "wb+", buffering=0)
        except OSError as err:
            self._error("failed to open '%s':\n%s", self._outpath, err)

        self._sync() # In case we created the files, make sure they are flushed.
        self._configured = True

    def kill_stale(self):
        """Kill stale collector process that might still be running."""

        if not self._stale_search:
            return

        ProcHelpers.kill_processes(self._stale_search, kill_children=True, log=False)

    def start(self):
        """Start collecting the statistics."""

        if not self._configured:
            self._error("the colletor was not configured")

        self._proc = self._pman.run_async(self._command, stderr=self._fobj, stdout=self._fobj,
                                          newgrp=True)

    def end(self):
        """Stop collecting and get the resulting statistics."""

        exitcode = self._proc.poll()
        if exitcode is not None:
            self._error("the following command exited prematurely with exit code %d:\n%s",
                        exitcode, self._command)
        else:
            try:
                pgid = Trivial.get_pgid(self._proc.pid)
            except Error as err:
                self._error(err)

            # Signal the entire statistics collector process group.
            self._debug("sending signal %s to PGID %d (group of PID %d)",
                        self._signal, pgid, self._proc.pid)
            try:
                os.killpg(pgid, self._signal)
            except OSError as err:
                self._error("failed to kill the process group of PID %d, PGID %d:\n%s",
                            self._proc.pid, pgid, err)

    def save(self):
        """Save the collected statistics."""

        # Make sure the process has exited. The reason it is done in 'save()' is a hacky
        # optimization: we first try signal all process without waiting for them (in 'end()'), and
        # only when all processes are signaled, start checking whether they have exited. This
        # approach helps having the collectors stop "more simultaneously".
        try:
            _, _, exitcode = self._proc.wait(timeout=10, capture_output=False)
            if exitcode is None:
                self._error("PID %d refused to exit", self._proc.pid)
        finally:
            self._proc.close()
            self._proc = None

        self._sync()

    def validate(self):
        """Check that the collected statistics are valid."""

        if self._valid_start:
            self._fobj.seek(0)
            buf = self._fobj.read(len(self._valid_start))
            if buf != self._valid_start:
                self._error("failed to validate the collected statistics:\nthe output file '%s' "
                            "does not start with the required pattern\nExpected '%s', got '%s'",
                            self._outpath, self._valid_start.decode("utf-8"), buf.decode("utf-8"))

        if self._valid_end:
            length = len(self._valid_end)
            self._fobj.seek(-length, 2)
            buf = self._fobj.read(len(self._valid_end))
            if buf != self._valid_end:
                self._error("failed to validate the collected statistics:\nthe output file '%s' "
                            "does not end with the required pattern\nExpected '%s', got '%s'",
                            self._outpath, self._valid_end.decode("utf-8"), buf.decode("utf-8"))

    def __init__(self, name):
        """
        Initialize a class instance. The 'name' parameter is the name of the statistics to collect.
        """

        self.name = name

        # The collector properties that can be changed directly, but any change requires the
        # 'configure()' method to be executed for the changes to take the effect.
        self.props = {}
        # Whether this collector is allowed to fail without causing an error.
        self.props["fallible"] = False
        # The output directory where the statistics will be stored.
        self.props["outdir"] = UNINITIALIZED["required-str"]
        # The log directory where may put their standard error output.
        self.props["logdir"] = UNINITIALIZED["required-str"]
        # The statistics collection interval.
        self.props["interval"] = UNINITIALIZED["required-str"]

        # The local process manager object.
        self._pman = LocalProcessManager.LocalProcessManager()

        #
        # These attributes are internal to this base class.
        #

        self._fobj = None
        # The statistics collector process.
        self._proc = None
        self._outpath = None

        #
        # These attributes can/should be set by child classes.
        #
        self._outfile = f"{name}.raw.txt"
        self._command = None
        self._configured = False
        self._valid_start = None
        self._valid_end = None
        self._signal = signal.SIGTERM
        self._stale_search = None

    def __del__(self):
        """Class destructor."""

        if getattr(self, "_pman", None):
            self._pman.close()
            self._pman = None
        if getattr(self, "_fobj", None):
            self._fobj.close()
            self._fobj = None

class TurbostatCollector(_BaseCollector):
    """This class represents the turbostat statistics collector."""

    def configure(self):
        """Configure the statistics collector."""

        super().configure()

        self._command = f"{self.props['toolpath']} --enable Time_Of_Day_Seconds " \
                        f"--interval '{self.props['interval']}'"

        toolname = os.path.basename(self.props["toolpath"])
        self._stale_search = f"{toolname} --enable Time_Of_Day_Seconds --interval "

        try:
            self._pman.run_verify("modprobe intel_uncore_frequency")
        except Error as err:
            LOG.debug("unable to load 'intel_uncore_frequency' module which is required "
                      "to collect turbostat uncore frequency measurements: %s", {str(err)})

    def __init__(self):
        """Initialize a class instance."""

        super().__init__("turbostat")
        self.props["toolpath"] = "turbostat"

class _IPMICollector(_BaseCollector):
    """Base class for IPMI statistics collectors."""

    def configure(self):
        """Configure the statistics collector."""

        super().configure()

        self._command = f"{self.props['toolpath']} --interval '{self.props['interval']}'"
        if self.props["retries"] != UNINITIALIZED["int"]:
            self._command += f" --retries '{self.props['retries']}'"
        if self.props["count"] != UNINITIALIZED["int"]:
            self._command += f" --count '{self.props['count']}'"

        self._stale_search = f"{os.path.basename(self.props['toolpath'])} --interval "

    def __init__(self, name):
        """Initialize a class instance."""

        super().__init__(name)
        self.props["toolpath"] = "ipmi-helper"
        self.props["retries"] = UNINITIALIZED["int"]
        self.props["count"] = UNINITIALIZED["int"]
        self._valid_start = b"timestamp | "

class IPMIInBandCollector(_IPMICollector):
    """This class represents the in-band IPMI statistics collector."""

    def __init__(self):
        """Initialize a class instance."""

        super().__init__("ipmi-inband")

class IPMIOOBCollector(_IPMICollector):
    """This class represents the out-of-band IPMI statistics collector."""

    def configure(self):
        """Configure the statistics collector."""

        super().configure()

        hostopt = f" --host '{self.props['bmchost']}'"
        self._command += hostopt
        if self.props["bmcuser"] != UNINITIALIZED["str"]:
            self._command += f" --user '{self.props['bmcuser']}'"
        if self.props["bmcpwd"] != UNINITIALIZED["str"]:
            self._command += f" --password '{self.props['bmcpwd']}'"

        self._stale_search += f".*{hostopt}"

    def __init__(self):
        """Initialize a class instance."""

        super().__init__("ipmi-oob")
        self.props["bmchost"] = UNINITIALIZED["required-str"]
        self.props["bmcuser"] = UNINITIALIZED["str"]
        self.props["bmcpwd"] = UNINITIALIZED["str"]

class ACPowerCollector(_BaseCollector):
    """This class represents the ACPower statistics collector."""

    def configure(self):
        """Configure the statistics collector."""

        super().configure()

        devnode = self.props['devnode']
        cmd = f"{self.props['toolpath']} {devnode}"
        if self.props["pmtype"] != UNINITIALIZED["str"]:
            cmd += f" --pmtype {self.props['pmtype']}"

        self._stale_search = f"{os.path.basename(self.props['toolpath'])} {devnode}"

        # Note, we assume that the power meter is generally initialized and configured outside of
        # 'stc-agent'. Here we only set the interval.
        self._pman.run_verify(f"{cmd} set interval {self.props['interval']}")

        items = "T,P,I,V,S,Q,Phi,Fv,Vrange,Irange"
        self._command = f"{cmd} read {items}"

    def __init__(self):
        """Initialize a class instance."""

        super().__init__("acpower")
        self.props["toolpath"] = "yokotool"
        self.props["devnode"] = UNINITIALIZED["required-str"]
        self.props["pmtype"] = UNINITIALIZED["str"]
        self._signal = signal.SIGINT

class STCAgent:
    """
    This class represents the statistics collection agent (stc-agent) and it implements all the
    collecting functionality of this program. There should be only one single instance of this
    class.

    Public methods overview.

    1. Create the statistics collector objects for the statistics names in the 'stnames' list.
       * 'create()'
    2. Set a property of the statistics collection agent.
       * 'set_property()'
    3. Set a property of one or multiple statistic collectors. This function handles the
       'set-collector-property' command.
       * 'set_collector_property()'
    4. Configure collectors.
       * 'configure()'
    5. Add a label.
       * 'add_label()'
    6. Start collecting the statistics.
       * 'start()'
    7. Stop collecting the statistics.
       * 'stop()'
    """

    def _execute_collectors_methods(self, methods):
        """Execute collector object methods defined by the 'methods' list of strings."""

        for method in methods:
            for collector in self._collectors.values():
                if collector.name in self.failed_collectors:
                    LOG.debug("skip running the '%s' method of failed '%s' collector",
                              method, collector.name)
                    continue

                LOG.debug("running the '%s' method of the %s collector", method, collector.name)

                try:
                    getattr(collector, method)()
                except Error as err:
                    self.failed_collectors.add(collector.name)
                    msg = f"the '{method}' method of the {collector.name} collector failed:\n" \
                          f"{err.indent(2)}"
                    if collector.props["fallible"]:
                        LOG.debug(msg)
                    else:
                        raise Error(msg) from err
                else:
                    LOG.debug("'%s' method of the %s collector succeeded", method, collector.name)

    def create(self, stnames):
        """
        Create the statistics collector objects for the statistics names in the 'stnames' list.
        """

        if not stnames:
            raise Error("please, specify at least one statistic name")
        if self._started:
            raise Error("statistics collection has been started, create collectors")

        LOG.debug("creating the following collectors: %s", ",".join(stnames))

        # First close the previously initialized collectors.
        for name in list(self._collectors):
            del self._collectors[name]
        self.failed_collectors = set()

        for stname in stnames:
            if stname not in SUPPORTED_STATS:
                raise Error(f"unknown statistics name '{stname}'")

        for name in stnames:
            try:
                LOG.debug("creating the %s collector", name)
                if name == "turbostat":
                    collector = TurbostatCollector()
                elif name == "ipmi-oob":
                    collector = IPMIOOBCollector()
                elif name == "ipmi-inband":
                    collector = IPMIInBandCollector()
                elif name == "acpower":
                    collector = ACPowerCollector()
                else:
                    raise Error(f"unsupported collector '{name}'")

                self._collectors[name] = collector
            except Error as err:
                raise Error(f"failed to create the {name} collector:\n{err.indent(2)}") from err

        LOG.debug("created the collectors")

    @staticmethod
    def _set_obj_property(obj, name, value):
        """Set a property in object 'obj' (e.g., a statistics collector)."""

        the_type = type(obj.props[name])

        try:
            # Since 'bool("False")' is 'True', we have to have a special case for boolean props.
            if the_type == bool:
                if value not in ("True", "False"):
                    raise TypeError
                obj.props[name] = (value == "True")
            else:
                obj.props[name] = the_type(value)
        except TypeError:
            raise Error(f"type conversion error for property '{name}' {obj.name}':\nstring "
                        f"'{value}' cannot be converted to '{the_type}'") from None

    def set_collector_property(self, args):
        """Set a property of a statistic collector."""

        if not self._collectors:
            raise Error("no statistics collectors selected")

        if self._started:
            raise Error("statistics collection has been started, cannot change properties")

        if len(args.split()) != 3:
            raise Error(f"incorrect argument '{args}'\nThe argument must be in the following "
                        f"format:\n<stat_name> <property_name> <property_value>")

        args = args.split()
        if args[0] not in self._collectors:
            all_stats = ", ".join(SUPPORTED_STATS)
            raise Error(f"unknown collector name '{args[0]}', use one of:\n{all_stats}")

        collector = self._collectors[args[0]]

        if collector.name in self.failed_collectors:
            # Ignore failed collectors.
            return

        if args[1] not in collector.props:
            raise Error(f"the '{collector.name}' collector does not support the '{args[1]}' "
                        f"property")

        self._set_obj_property(collector, args[1], args[2])

        LOG.debug("set collector '%s' property '%s' to value '%s'",
                  collector.name, args[1], args[2])

    @staticmethod
    def _set_outdir(path):
        """Set 'stc-agent' output directory."""

        if not os.path.isabs(path):
            raise Error(f"stc-agent output directory path '{path}' is not absolute")
        if os.path.exists(path):
            if not os.path.isdir(path):
                raise Error(f"stc-agent output directory path '{path}' already exists and it is "
                            f"not a directory")
        else:
            LOG.debug("creating sts-agent output directory '%s'", path)
            try:
                os.mkdir(path)
            except OSError as err:
                raise Error(f"cannot create stc-agent output directory '{path}':\n{err}") from None

    def set_property(self, args):
        """Set an stc-agent property."""

        if len(args.split()) != 2:
            raise Error(f"incorrect stc-agent property argument '{args}'\nIt must be in the "
                        f"following format:\n<property_name> <property_value>")

        pname, pval = args.split()
        if pname not in self.props:
            supported = ", ".join(self.props)
            raise Error(f"unsupported stc-agent property '{pname}', supported properties are: "
                        f"{supported}")

        self._set_obj_property(self, pname, pval)
        if pname == "outdir":
            self._set_outdir(pval)

        LOG.debug("set stc-agent property '%s' to value '%s'", pname, pval)

    def configure(self):
        """Configure the statistic collectors."""

        if not self._collectors:
            raise Error("no statistics collectors selected")

        if self._started:
            raise Error("statistics collection has been started, cannot configure")

        self._execute_collectors_methods(("configure", "kill_stale"))
        LOG.debug("configured the collectors")

    def add_label(self, args):
        """
        Add a label. The 'args' argument is expected to be a JSON-serialized dictionary. It must
        include the "name" key for the label name, and any number of other keys.
        """

        if not self._collectors:
            raise Error("no statistics collectors selected")

        if self.props["outdir"] == UNINITIALIZED["str"]:
            raise Error("cannot add stc-agent label: the output directory was not set")

        LOG.debug("adding label '%s'", args)

        import json # pylint: disable=import-outside-toplevel

        try:
            label = json.loads(args)
        except ValueError as err:
            msg = Error(str(err)).indent(2)
            raise Error(f"failed to parse label JSON:\n{msg}") from err

        # Sanity check: make sure the 'name' key is present and make sense.
        name = label.get("name")
        if not name:
            raise Error(f"no label name provided in '{args}'")
        if not name.isalnum():
            raise Error(f"bad label name '{name}': must be alphanumeric")

        # Sanity check: there should be no 'ts' key, stc-agent is supposed to add it.
        if "ts" in label:
            raise Error(f"found reserved key 'ts' in label '{args}")

        if not self._lfobj:
            try:
                path = Path(self.props["outdir"]) / "labels.txt"
                # pylint: disable=consider-using-with
                self._lfobj = open(path, "w", encoding="utf-8")
            except OSError as err:
                msg = Error(str(err)).indent(2)
                raise Error(f"failed to create file '{path}:\n{msg}") from None

            # The first line provides the list of collectors the labels file covers.
            names = ",".join(self._collectors)
            self._lfobj.write(f"# {names}\n")

        label["ts"] = time.time()

        try:
            label = json.dumps(label)
        except ValueError as err:
            msg = Error(str(err)).indent(2)
            raise Error(f"failed to serialize label JSON:\n{msg}") from err

        self._lfobj.write(f"{label}\n")

    def start(self):
        """Start collecting the statistics."""

        if not self._collectors:
            raise Error("no statistics collectors selected")

        if self._started:
            raise Error("statistics collection has already been started")

        self._execute_collectors_methods(("start",))
        self._started = True

        LOG.debug("started the collectors")

    def stop(self):
        """Stop collecting the statistics."""

        if not self._started:
            raise Error("statistics collection has not been started")

        try:
            self._execute_collectors_methods(("end", "save", "validate"))
        finally:
            self._started = False

        LOG.debug("stopped the collectors")

        if self._lfobj:
            try:
                self._lfobj.flush()
                os.fsync(self._lfobj.fileno())
            except OSError as err:
                raise Error(f"failed to synchronize stc-agent labels file:\n{err.indent(2)}") \
                            from err

            self._lfobj.close()
            self._lfobj = None

    def __init__(self):
        """Initialize a class instance."""

        self._started = False
        self._collectors = {}
        self.failed_collectors = set()

        # The labels file object.
        self._lfobj = None

        # Statistics collection agent properties.
        self.props = {}
        # The output directory where data like labels will be stored.
        self.props["outdir"] = UNINITIALIZED["str"]

class Client(ClassHelpers.SimpleCloseContext):
    """This class represents a network client."""

    def get_command(self):
        """Receive and return client command."""

        LOG.debug("waiting for a command from client '%s", self.clientid)
        bufs = []
        cmd = None
        msg = bytes()

        while cmd is None:
            buf = self._sock.recv(1)
            if not buf:
                raise ClientDisconnected(f"client '{self.clientid}' disconnected, failed to "
                                         f"receive a command from it")
            bufs.append(buf)
            if buf != "\n".encode("utf-8"):
                continue

            msg += b"".join(bufs)
            bufs = []
            # Make sure we handle both Linux and Windows newlines.
            for delim in (DELIMITER + "\n", DELIMITER + "\r\n"):
                delim = delim.encode("utf-8")
                if msg.endswith(delim):
                    cmd = msg[:-len(delim)]
                    break

        try:
            cmd = cmd.decode("utf-8").strip()
        except UnicodeError as err:
            self.respond("failed to decode the command from UTF-8")
            msg = Error(err).indent(2)
            raise ClientDisconnected(f"failed to decode the command from UTF-8 from client "
                                     f"'{self.clientid}:\n{msg}") from err

        LOG.debug("received command from client '%s':\n%s", self.clientid, cmd)
        return cmd

    def respond(self, msg):
        """Respond to the client by sending it a message."""

        LOG.debug("sending the following response to client '%s': %s", self.clientid, msg)

        buf = (msg + DELIMITER + "\n").encode("utf-8")
        total = 0

        while total < len(buf):
            sent = self._sock.send(buf[total:])
            if sent == 0:
                raise ClientDisconnected(f"client '{self.clientid}' disconnected, cannot send it "
                                         f"the following message: {msg}")
            total += sent

    def __init__(self, sock, clientid):
        """
        The class constructor. The 'sock' argument is the client connection socket and 'clientid' is
        a printable client ID string (used in messages).
        """

        self._sock = sock
        self.clientid = clientid

    def close(self):
        """Close the client connection."""

        if getattr(self, "_sock", None):
            with contextlib.suppress(socket.error):
                self._sock.shutdown(socket.SHUT_RDWR)
                self._sock.close()

class Server(ClassHelpers.SimpleCloseContext):
    """This class represents 'stc-agent' as a network server."""

    def wait_for_client(self):
        """Wait for a client to connect. Returns a 'Client' object."""

        try:
            client_sock, _ = self._sock.accept()
        except (OSError, socket.error) as err:
            msg = Error(err).indent(2)
            raise Error(f"error while accepting a client connection:\n{msg}") from err

        if self._is_unix:
            clientid = "local_client"
        else:
            client_host, client_port = client_sock.getpeername()
            clientid = f"{client_host}:{client_port}"
        LOG.debug("client connected: %s", clientid)

        return Client(client_sock, clientid)

    def start_listening(self):
        """Start listening for incoming client connections."""

        if self._is_unix:
            try:
                self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
                self._sock.bind(self._uspath)
                self._sock.listen(1)
            except socket.error as err:
                raise Error(f"failed to start listening on Unix socket '{self._uspath}: "
                            f"{err}") from err

            msg = f"Listening on Unix socket {self._uspath}"
        else:
            try:
                self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
                self._sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                self._sock.bind(("", self._port))
                _, self._port = self._sock.getsockname()
                self._sock.listen(1)
            except socket.error as err:
                msg = Error(err).indent(2)
                raise Error(f"failed to start listening on TCP port {self._port}:\n{msg}") from err

            msg = f"Listening on TCP port {self._port}"

        LOG.debug(msg)
        LOG.info(msg)

    def _unix_socket_prepare(self):
        """
        This is a helper function for '__init__()' which prepares the SUT for using Unix sockets.
        """

        if self._uspath is None:
            # Select random name for the unix socket file.
            try:
                pfx = f"{OWN_NAME}-"
                if self._sutname:
                    pfx = f"{pfx}{self._sutname}-"
                with tempfile.NamedTemporaryFile("wb+", prefix=pfx, delete=True) as fobj:
                    self._uspath = fobj.name
            except OSError as err:
                msg = Error(err).indent(2)
                raise Error(f"failed to create a temporary file:\n{msg}") from err
        elif os.path.exists(self._uspath):
            try:
                with contextlib.suppress(FileNotFoundError):
                    os.remove(self._uspath)
            except OSError as err:
                msg = Error(err).indent(2)
                raise Error(f"failed to remove '{self._uspath}':\n{msg}") from err

    def __init__(self, unix=None, port=None, sutname=None):
        """
        The class constructor. The arguments are as follows.
          * unix - path to the Unix socket to listen for incomming connections on.
          * port - path to the the TCP port number to listen for incoming connetions on.
          * sutname - optional name of the SUT we run on. Currently only used in the unix socket
                      file name prefix.

        If neither 'unix' nor 'port' are specified, this constructor creates a Unix socket file with
        random name in the temporary directory.
        """

        self._port = port
        self._uspath = unix
        self._sutname = sutname
        self._is_unix = port is None
        self._sock = None

        if self._port is not None and self._uspath is not None:
            raise Error("please, specify either TCP port or Unix socket path, not both of them")

        if self._is_unix:
            self._unix_socket_prepare()

    def close(self):
        """Close the server."""

        if getattr(self, "_sock", None):
            with contextlib.suppress(socket.error):
                self._sock.shutdown(socket.SHUT_RDWR)
                self._sock.close()

        if getattr(self, "_uspath", None):
            with contextlib.suppress(OSError):
                os.remove(self._uspath)

def handle_command(cmd, stc_agent):
    """Handle a single command from the client."""

    cmd, args = cmd.partition(" ")[::2]
    cmd = cmd.strip()
    args = args.strip()

    response = "OK"

    try:
        if cmd == "set-stats":
            # Create the statistics collectors without initializing them.
            stc_agent.create([stname.strip() for stname in args.split(",")])
        elif cmd == "set-agent-property":
            # Set a property of 'stc-agent'.
            stc_agent.set_property(args)
        elif cmd == "set-collector-property":
            # Set a property of a statistics collector.
            stc_agent.set_collector_property(args)
        elif cmd == "configure":
            # Once all the properties had been set, configure the collectors.
            stc_agent.configure()
        elif cmd == "start":
            # Start collecting the statistics.
            stc_agent.start()
        elif cmd == "stop":
            # Stop collecting the statistics.
            stc_agent.stop()
        elif cmd == "add_label":
            stc_agent.add_label(args)
        elif cmd == "get-failed-collectors":
            # Get the list of failed collectors.
            response += f" {','.join(stc_agent.failed_collectors)}"
        elif cmd == "exit":
            pass
        else:
            response = f"bad command: {cmd}"
    except Error as err:
        response = f"error: {err}"
    except Exception as err: # pylint: disable=broad-except
        response = f"Unknown exception of type '{type(err).__name__}':\n{err}"

    return response

def handle_client(client, stc_agent):
    """Handle a client connection. Only one client can be handled at a time."""

    cmd = None
    while cmd != "exit":
        cmd = client.get_command()
        response = handle_command(cmd, stc_agent)
        client.respond(response)

    raise ExitCommand()

def main():
    """Script entry point."""

    if PID == 1:
        # We are the 'init' process, set up signal handlers.
        signal.signal(signal.SIGINT, sighandler)
        signal.signal(signal.SIGTERM, sighandler)

    args = parse_arguments()
    if args.port is not None and args.unix is not None:
        raise Error("'--port' and '--unix' options are mutually exclusive")

    if args.print_module_paths:
        print_module_paths()
        return 0

    stc_agent = STCAgent()

    with Server(unix=args.unix, port=args.port, sutname=args.sutname) as server:
        server.start_listening()
        LOG.debug("commands delimiter is '\\n%s'", DELIMITER)

        while True:
            with server.wait_for_client() as client:
                try:
                    handle_client(client, stc_agent)
                except ClientDisconnected as err:
                    LOG.debug(err)
                except ExitCommand:
                    break

    LOG.debug("exiting")
    return 0

# The very first script entry point.
if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        LOG.error_out("interrupted, exiting")
    except Error as error:
        LOG.error_out(error, print_tb=True)
