
import subprocess
from typing import Optional, List
import itertools
import re
import dateutil.parser
import enum


class ELOG:
    """
    Connection to an ELOG (https://elog.psi.ch/elog) electronic logbook

    """
    class Encoding(enum.Enum):
        """
        The encoding of the ELOG messages
        """
        ELcode = 0
        plain = 1
        HTML = 2

    def __init__(self, host, port, username, password, authorname, logbookname, elog_cmd = "elog"):
        """Represent an ELOG electronic logbook (https://elog.psi.ch/elog)
        
        Connection is not maintained in Python, the logbook server is accessed using the "elog"
        commandline tool.
        
        
        :param host: the hostname of the ELOG server
        :type host: str
        :param port: the port number of the ELOG server
        :type port: int
        :param username: the username to use for authentication
        :type username: str
        :param password: the password to use for authentication
        :type password: str
        :param authorname: the author name for the ELOG messages
        :type authorname: str
        :param logbookname: the name of the logbook to use
        :type logbookname: str
        :param elog_cmd: path to the ELOG commandline tool
        :type elog_cmd: str
        """
        self._host = host
        self._port = port
        self._username = username
        self._password = password
        self._authorname = authorname
        self._logbookname = logbookname
        self._elog_cmd = elog_cmd

    def addEntry(self, subject: str, text: str, files_to_attach: Optional[List[str]] = None, in_reply_to_id = None, encoding=Encoding.ELcode, **kwargs):
        """
        Create a new ELOG entry
        
        :param text: The body text of the message, in ELCode format (https://elog.psi.ch/elog/elcode_en.html)
        :type text: str
        :param subject: The subject of the message
        :type subject: str
        :param files_to_attach: A list of files to attach to the message (default: None)
        :type files_to_attach: Optional[List[str]]
        :param in_reply_to_id: The ID of the message in reply to (None for a new message thread)
        :type in_reply_to_id: Optional[str]
        :param encoding: The encoding of the message: ELcode, plain or HTML (default: ELcode)
        :type encoding: ELOG.Encoding
        :param **kwargs: Additional attributes to add to the message
        :type **kwargs: Dict[str, Any]
        :return: the numeric ID of the created message
        :rtype: int
        :raise RuntimeError: if the entry could not be created
        :raise ConnectionRefusedError: if the connection to the ELOG server could not be established
        """
        files_args = list(itertools.chain(*[("-f", f) for f in files_to_attach])) if files_to_attach is not None else []
        attribute_args = list(itertools.chain(*[("-a", f"{kw}={value}") for kw,value in kwargs.items()]))
        print(attribute_args)
        result = subprocess.run(
            [self._elog_cmd, 
             "-h", self._host,
             "-p", str(self._port),
             "-u", self._username, self._password,
             "-l", self._logbookname,
             "-a", f"Author={self._authorname}",
             "-a", f"Subject={subject}",
             "-n", str(encoding.value),
             "-s"] + files_args + (["-r", str(in_reply_to_id)] if in_reply_to_id is not None else []) + attribute_args,
             input=text, encoding="utf-8", capture_output=True, check=False)
        
        msgid = None
        for line in result.stdout.split('\n'):
            if line == "Possibly invalid certificate, continue on your own risk!":
                pass
            elif line.startswith("Cannot connect to host"):
                raise ConnectionRefusedError(line)
            elif m:=re.match(r"^Message successfully transmitted, ID=(?P<msgid>\d+)$", line):
                msgid = int(m["msgid"])
            elif not line.strip():
                pass
            elif m := re.match(r"^Error: (?P<errormessage>.*)$", line):
                raise RuntimeError(m["errormessage"])
            else:
                raise RuntimeError(f"Unknown reply line: {line}")
        if msgid is None:
            raise RuntimeError("Could not determine message id: probably no reply")
        return msgid
    
    def getEntry(self, message_id: Optional[int]=None):
        """Get the ELOG message with the given ID

        :param message_id: the ID of the message to get (None to read the latest)
        :type message_id: Optional[int]
        :return: the attributes and text of the message
        :rtype: Tuple[Dict[str, str], str]
        :raise RuntimeError: if the entry could not be retrieved
        :raise ConnectionRefusedError: if the connection to the ELOG server could not be established
        :raise ValueError: if the message ID is invalid
        :raise KeyError: if the message ID could not be found
        """
        result = subprocess.run(
            [self._elog_cmd, 
             "-h", self._host,
             "-p", str(self._port),
             "-u", self._username, self._password,
             "-l", self._logbookname,
             "-w", (str(message_id) if message_id is not None else "last"), "-s"],
             encoding="utf-8", capture_output=True, check=False)
        attributes = {}
        separator_seen = False
        text = []
        for line in result.stdout.split("\n"):
            print(line)
            if not line:
                continue
            elif line.startswith("Cannot connect to host"):
                raise ConnectionRefusedError(line)
            elif line == "Possibly invalid certificate, continue on your own risk!":
                pass
            elif set(line) == {"="}:
                separator_seen = True
            elif not separator_seen:
                attribute, value = line.split(":", 1)
                value = value.strip()
                attribute=attribute.strip()
                attributes[attribute] = value
                if (attribute == "$@MID@$") and (message_id is not None) and (int(value) != message_id):
                    raise RuntimeError(f"Inconsistent message id received: expected {message_id}, got {m['mid']}")
            else:
                text.append(line)
        try:
            attributes["$@MID@$"] = int(attributes["$@MID@$"])
        except KeyError:
            raise KeyError("Could not determine message ID from the reply")
        try:
            attributes["Date"] = dateutil.parser.parse(attributes["Date"])
        except KeyError:
            pass
        return attributes, '\n'.join(text)

    @classmethod
    def fromTangoDefaults(cls, authorname: Optional[str] = None, elog_cmd: str = "elog"):
        """Create an ELOG object from the Tango defaults (using the CREDO.elogurl free object property)

        The Tango property is expected to be of the form "username!password@host:port/logbookname".

        The ":port" part can be omitted, in this case the port is assumed to be 8080.

        :param authorname: the author name for the ELOG messages (None to use the username from the ELOG URL)
        :type authorname: Optional[str]
        :param elog_cmd: path to the ELOG commandline tool (default: "elog")
        :type elog_cmd: str
        :return: an instance of ELOG
        :rtype: ELOG
        :raise ValueError: if the ELOG URL is invalid
        """
        import tango
        db = tango.Database()
        elogurl = db.get_property("CREDO", "elogurl")["elogurl"][0]
        if m:= re.match(r"^(?P<username>.*?)!(?P<password>.*?)@(?P<host>.*?)(\:(?P<port>\d+))?/(?P<logbookname>.*)$", elogurl.strip()):
            return cls(m["host"], 8080 if m["port"] is None else int(m["port"]), m["username"], m["password"], m["username"] if authorname is None else authorname, m["logbookname"], elog_cmd)
        else:
            raise ValueError(f"Invalid ELOG URL: {elogurl}")

