#! /usr/bin/python3 -s
#
# -*- 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 as a service clients can connect to. Clients
can send it commands it 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 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 not "helperlibs/" in path:
            continue

        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 all the single statistics collectors. Here is the short description
    of the methods.

    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 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 collection 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."""

        _, _, exitcode = self._proc.wait(timeout=10, capture_output=False)
        self._sync()
        if exitcode is None:
            self._error("PID %d refused to exit", self._proc.pid)

    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 collection 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 and it implements all the collecting
    functionality of this program. There is 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 one or multiple statistic collectores. This function handles the
       'set-collector-property' command.
       * 'set_collector_property()'
    3. Configure collectors.
       * 'configure()'
    4. Start collecting the statistics.
       * 'start()'
    5. 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")

    def set_collector_property(self, args):
        """
        Set a property of one or multiple statistic collectores. This function handles the
        'set-collector-property' command.
        """

        def do_set_collector_property(collector, args):
            """Set the requested property of a single statistics collector."""

            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")

            # Set the property and convert it to the default type.
            the_type = type(collector.props[args[1]])
            try:
                # Since 'bool("False")' is 'True', we have to have a special case for boolean props.
                if the_type == bool:
                    if args[2] not in ("True", "False"):
                        raise TypeError
                    collector.props[args[1]] = (args[2] == "True")
                else:
                    collector.props[args[1]] = the_type(args[2])
            except TypeError as err:
                raise Error(f"type conversion error for property '{args[1]}' of collector "
                            f"'{collector.name}':\nstring '{args[2]}' cannot be converted to "
                            f"'{the_type}'") from err

        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 of the following "
                        f"format:\n<stat_name> <property_name> <property_value>")

        args = args.split()
        if args[0] != '*':
            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}")
            do_set_collector_property(self._collectors[args[0]], args)
        else:
            for collector in self._collectors.values():
                do_set_collector_property(collector, args)

        LOG.debug("set the property: %s", args)

    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 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")

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

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

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-collector-property":
            # Set a property of one or multiple collectors.
            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 == "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(Exception)}':\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)
