#!/usr/bin/env python
r"""a minimalist Xymon client library in Python

.. Note::
   In case of minimalistic needs, this file can be shipped alone
   as ``xymon_client.py``.

"""
# stdlib
from __future__ import annotations
from collections import namedtuple
from concurrent.futures import ThreadPoolExecutor
from functools import partial
from platform import node
from socket import socket, AF_INET, SOCK_STREAM, SHUT_WR
from time import strftime
from typing import Any, AnyStr, Iterable

import logging


__version__ = "1.0.0"
logger = logging.getLogger("xymon")


class Ghost(namedtuple("Ghost", ("hostname", "address", "timestamp"))):
    """describe a ghost client"""

    __slots__ = ()

    def __new__(cls, hostname: str, address: str, timestamp: int | str):
        return super(Ghost, cls).__new__(cls, hostname, address, int(timestamp))

    def __str__(self) -> str:
        return "|".join(map(str, self))


class Xymon(object):
    """thin library over Xymon protocol

    Attempt to reflect :manpage:`xymon(1)` [#]_.

    .. [#] http://xymon.sourceforge.net/xymon/help/manpages/man1/xymon.1.html

    General concepts

    :colors: Valid colors are defined in :data:`~helpers.color_map`.
    :times: Called "duration", "lifetime".
               If given as a number followed by ``s``/``m``/``h``/``d``,
               it is interpreted as being in seconds/minutes/hours/days respectively.
               For "until OK", use -1.
    """

    def __init__(self, server: str = "localhost", port: int = 1984, sender: str | None = None):
        """
        :param str server: xymon server to send the events to
        :param int port: xymon server port
        :param str sender: name of the sender,
                           call :func:`platform.node` when empty
        """
        self.sender: str = sender or node()
        self.target: tuple[str, int] = (server, port)

    def __str__(self) -> str:
        return str(self.target[0])

    def __repr__(self) -> str:
        return f"<Xymon {self.sender!s} to {self.target[0]!s}:{self.target[1]!s}>"

    def __hash__(self) -> int:
        return hash((self.sender, self.target))

    def __call__(self, data: AnyStr, blind: bool = False, timeout: float = 3) -> str:
        """wrapper to send a message to an address (TCP)

        :param data: data to be sent
        :param blind: if True, does not wait an answer from the server
        :param timeout: connection timeout
        :return: the server answer
        """
        result: list[str] = []
        sock = socket(AF_INET, SOCK_STREAM)
        sock.settimeout(timeout)
        sock.connect(self.target)
        _data: str
        if isinstance(data, (bytes, bytearray)):
            try:
                _data = data.decode("utf8")
            except UnicodeDecodeError:
                _data = data.decode(errors="ignore")
        else:
            _data = data
        # xymon does not know utf8
        sock.sendall(_data.encode("ascii", "xmlcharrefreplace"))
        if not blind:
            sock.shutdown(SHUT_WR)
            while True:
                chunk = sock.recv(4096)
                if not chunk:
                    break
                result += [chunk.decode("ascii", "replace")]
        sock.close()
        return "".join(result)

    @property
    def headline(self) -> str:
        """generate a headline for the message

        Used by :meth:`status`.
        """
        return "Message generated by %s at %s" % (
            self.sender,
            strftime("%FT%T"),
        )

    def status(
        self,
        hostname: str,
        testname: str,
        color: str,
        text: str | None = None,
        lifetime: float | str | None = None,
        group: str | None = None,
    ) -> str:
        """sends a status message for a single test on a single host

        :param hostname:
        :param testname:
        :param color:
        :param text:
        :param lifetime: defines how long this status is valid after
                         being received by the Xymon server
        :param group: direct alerts from the status to a specific group
        """
        data = ["status"]
        if lifetime:
            data += [f"+{lifetime!s}"]
        if group:
            data += [f"/group:{group!s}"]
        data += [f" {hostname}.{testname} {color!s} {self.headline!s}"]
        if text:
            data += ["\n", text]
        return self("".join(data))

    def notify(self, hostname: str, testname: str, text: str = "") -> str:
        return self(f"notify {hostname!s}.{testname!s} {text!s}")

    def data(self, hostname: str, dataname: str, text: str = "") -> str:
        return self(f"data {hostname!s}.{dataname!s}\n{text!s}")

    def disable(self, hostname: str, testname: str = "*", duration: float | str = -1, text: str = "") -> str:
        """
        :param hostname:
        :param testname: use an asterisk (``*``) to disable all tests
        :param duration:
        :param text:
        """
        return self(f"disable {hostname!s}.{testname!s} {duration!s} {text!s}")

    def enable(self, hostname: str, testname: str) -> str:
        """re-enables a test that had been disabled

        :param hostname:
        :param testname:
        """
        return self(f"enable {hostname!s}.{testname!s}")

    def query(self, hostname: str, testname: str) -> str:
        """query the Xymon server for the latest status reported for
        this particular test

        :param hostname:
        :param testname:
        """
        return self(f"query {hostname!s}.{testname!s}")

    def config(self, filename: str) -> str:
        return self(f"config {filename!s}")

    def drop(self, hostname: str, testname: str | None = None) -> str:
        """remove all data stored about this status

        When removing an `hostname` as whole, it is assumed that you
        have already deleted the host from the :file:`hosts.cfg`
        configuration file.

        :param hostname:
        :param testname:
        """
        if testname:
            return self(f"drop {hostname!s}")
        return self(f"drop {hostname!s} {testname!s}")

    def rename(self, old: str, new: str, hostname: str | None = None) -> str:
        if hostname:
            return self(f"rename {hostname} {old!s} {new!s}")
        return self(f"rename {old!s} {new!s}")

    def xymondlog(self, hostname: str, testname: str) -> str:
        """
        :param hostname:
        :param testname:
        :return: status as received (example: may contain HTML)
        """
        return self(f"xymondlog {hostname!s}.{testname!s}")

    def xymondxlog(self, hostname: str, testname: str) -> str:
        """
        :param hostname:
        :param testname:
        :return: status in **XML**
        """
        return self(f"xymondxlog {hostname!s}.{testname!s}")

    def xymondboard(self, criteria: dict | list | str | None = None, fields: list | str | None = None) -> list:
        """
        :param criteria: (example: color=red)
        :param fields: (example: hostname,testname,cookie,ackmsg,dismsg)
        """
        query = ["xymondboard"]
        if criteria:
            if isinstance(criteria, dict):
                criteria = [f"{key!s}={val!s}" for key, val in criteria.items()]
            query.append(joiniterable(criteria, " "))

        if fields:
            query.append("fields={}".format(joiniterable(fields, ",")))

        result = self(" ".join(query)).splitlines()
        if fields:
            return [row.split("|") for row in result]

        return result

    def xymondxboard(self, criteria: str = "", fields: str = "") -> str:
        """Same as :meth:`xymondboard`

        :return: the board **XML serialized**
        """
        return self(f"xymondxboard {criteria!s} {fields!s}")

    def hostinfo(self, criteria: str = "") -> list[list[str]]:
        """
        :param criteria:
        """
        return [line.split("|") for line in self(f"hostinfo {criteria!s}").splitlines()]

    def download(self, filename: str) -> str:
        return self(f"download {filename!s}")

    def client(self, hostname, ostype, collectorid=None, hostclass=None):
        raise NotImplementedError

    def clientlog(self, hostname, *sectioname):
        raise NotImplementedError

    def ping(self) -> str:
        """ping the server which should return its version"""
        return self("ping")

    def pullclient(self):
        raise NotImplementedError

    def ghostlist(self) -> list[Ghost]:
        """list of ghost clients seen by the Xymon server

        Ghosts are systems that report data to the Xymon server,
        but are not listed in the :file:`hosts.cfg` file.

        https://www.xymon.com/help/manpages/man1/ghostlist.cgi.1.html
        """
        data = []
        for line in self("ghostlist").splitlines():
            try:
                data.append(Ghost(*line.split("|", 2)))
            except:
                logger.error("invalid ghostlist line: %r", line, exc_info=True)
        return data

    def schedule(self, timestamp: str | None = None, command: str | None = None) -> str:
        """schedule command for execution at a later time

        E.g. used to schedule disabling of a host or service at some time in the future.
        If no parameters are given, the currently scheduled tasks are listed in the response.

        :param command: a complete Xymon command such as the ones listed above or
                        'cancel JOBID' to cancel a previously scheduled command
        :param timestamp: the Unix epoch time when the command will be executed
        """
        if timestamp and command:
            return self(f"schedule {timestamp!s} {command!s}")
        return self("schedule")

    def notes(self, filename: str) -> str:
        return self(f"notes {filename!s}")

    def usermsg(self, identifier: str) -> str:
        return self(f"usermsg {identifier!s}")

    def modify(self, hostname: str, testname: str, color: str, source: str, cause: str) -> str:
        return self(f"modify {hostname!s}.{testname!s} {color!s} {source!s} {cause!s}")

    ######################################################################

    def xymondack(
        self,
        hostname: str,
        service: str,
        xymon_cookies: list | None = None,
        validity: float = 60,
        message: str = "",
        user: str | None = None,
    ) -> None:
        """acknowledge a test in error

        :param hostname:
        :param service:
        :param xymon_cookies:
        :param validity: ack duration in minutes
        :param message: ack message
        :param user: acking user
        """
        acked_by = f" acked by {user!s}" if user else ""

        if not xymon_cookies:
            cookies_result = self.xymondboard(
                f"host=^{hostname!s}$ test=^{service!s}$",
                "cookie",
            )

            for server in cookies_result:
                for ack_cookie in server:
                    self(f"xymondack {int(ack_cookie)} {validity} {message} {acked_by}")

        else:
            for cookie in xymon_cookies:
                self(f"xymondack {int(cookie)} {validity} {message} {acked_by}")


class Xymons(object):
    """same as :class:`Xymon`, but multi-servers able"""

    children: list[Xymon]  #: list of :class:`Xymon` to talk to
    thread: bool  #: whenever to use threading or not

    def __init__(self, servers: list[str], port: int = 1984, sender: str | None = None, thread: bool = False):
        """
        :param servers: list of xymon servers to send the events to
        :param port: xymon server port
        :param sender: name of the sender
        :param thread: parallelize the calls
        """
        self.thread = thread
        self.children = [Xymon(server, port, sender) for server in servers]

    def _apply(self, name: str, *args, **kwargs) -> dict:
        """
        :param name: name of the function to be called on each :attr:`children`
        """
        result = {}
        for child in self.children:
            try:
                result[child] = getattr(child, name)(*args, **kwargs)
            except:
                logger.error(
                    "while calling: %s: %s(*%r, **%r)",
                    child,
                    name,
                    args,
                    kwargs,
                    exc_info=True,
                )
        return result

    def _apply_async(self, name: str, *args, **kwargs):
        """
        Same as :meth:`_apply`, but call each child in a different thread.
        Will be faster on slow networks or when at least one child is expected to fail.
        """
        result = {}
        with ThreadPoolExecutor() as pool:
            tasks = {child: pool.submit(getattr(child, name), *args, **kwargs) for child in self.children}
            for child, task in tasks.items():
                try:
                    result[child] = task.result()
                except:
                    logger.error(
                        "while calling: %s: %s(*%r, **%r)",
                        child,
                        name,
                        args,
                        kwargs,
                        exc_info=True,
                    )
        return result

    def __getattr__(self, name: str):
        if not self.thread:
            return partial(self._apply, name)
        return partial(self._apply_async, name)


def joiniterable(obj: Any, sep: str = ",") -> str:
    """join `obj` with `sep` if it is a non-string iterable

    :param obj: object to be joined
    :param sep: the separator string
    """
    if isinstance(obj, str):
        return obj
    if isinstance(obj, (bytes, bytearray)):
        return obj.decode("utf8", errors="ignore")
    if isinstance(obj, Iterable):
        return sep.join(obj)
    return str(obj)
