"""
Auditable Server Sessions:
Extension of the stock ServerSession that yields objects representing
user inputs and system outputs.

Evennia contribution - Johnny 2017
"""

import os
import re
import socket

from django.conf import settings as ev_settings
from django.utils import timezone

from evennia.server.serversession import ServerSession
from evennia.utils import get_evennia_version, logger, mod_import, utils

# Attributes governing auditing of commands and where to send log objects
AUDIT_CALLBACK = getattr(
    ev_settings, "AUDIT_CALLBACK", "evennia.contrib.utils.auditing.outputs.to_file"
)
AUDIT_IN = getattr(ev_settings, "AUDIT_IN", False)
AUDIT_OUT = getattr(ev_settings, "AUDIT_OUT", False)
AUDIT_ALLOW_SPARSE = getattr(ev_settings, "AUDIT_ALLOW_SPARSE", False)
AUDIT_MASKS = getattr(ev_settings, "AUDIT_MASKS", [])


if AUDIT_CALLBACK:
    try:
        AUDIT_CALLBACK = getattr(
            mod_import(".".join(AUDIT_CALLBACK.split(".")[:-1])), AUDIT_CALLBACK.split(".")[-1]
        )
        logger.log_sec("Auditing module online.")
        logger.log_sec(
            "Audit record User input: {}, output: {}.\n"
            "Audit sparse recording: {}, Log callback: {}".format(
                AUDIT_IN, AUDIT_OUT, AUDIT_ALLOW_SPARSE, AUDIT_CALLBACK
            )
        )
    except Exception as e:
        logger.log_err("Failed to activate Auditing module. %s" % e)


class AuditedServerSession(ServerSession):
    """
    This particular implementation parses all server inputs and/or outputs and
    passes a dict containing the parsed metadata to a callback method of your
    creation. This is useful for recording player activity where necessary for
    security auditing, usage analysis or post-incident forensic discovery.

    *** WARNING ***
    All strings are recorded and stored in plaintext. This includes those strings
    which might contain sensitive data (create, connect, @password). These commands
    have their arguments masked by default, but you must mask or mask any
    custom commands of your own that handle sensitive information.

    See README.md for installation/configuration instructions.
    """

    def audit(self, **kwargs):
        """
        Extracts messages and system data from a Session object upon message
        send or receive.

        Keyword Args:
            src (str): Source of data; 'client' or 'server'. Indicates direction.
            text (str or list): Client sends messages to server in the form of
                lists. Server sends messages to client as string.

        Returns:
            log (dict): Dictionary object containing parsed system and user data
                related to this message.

        """
        # Get time at start of processing
        time_obj = timezone.now()
        time_str = str(time_obj)

        session = self
        src = kwargs.pop("src", "?")
        bytecount = 0

        # Do not log empty lines
        if not kwargs:
            return {}

        # Get current session's IP address
        client_ip = session.address

        # Capture Account name and dbref together
        account = session.get_account()
        account_token = ""
        if account:
            account_token = "%s%s" % (account.key, account.dbref)

        # Capture Character name and dbref together
        char = session.get_puppet()
        char_token = ""
        if char:
            char_token = "%s%s" % (char.key, char.dbref)

        # Capture Room name and dbref together
        room = None
        room_token = ""
        if char:
            room = char.location
            room_token = "%s%s" % (room.key, room.dbref)

        # Try to compile an input/output string
        def drill(obj, bucket):
            if isinstance(obj, dict):
                return bucket
            elif utils.is_iter(obj):
                for sub_obj in obj:
                    bucket.extend(drill(sub_obj, []))
            else:
                bucket.append(obj)
            return bucket

        text = kwargs.pop("text", "")
        if utils.is_iter(text):
            text = "|".join(drill(text, []))

        # Mask any PII in message, where possible
        bytecount = len(text.encode("utf-8"))
        text = self.mask(text)

        # Compile the IP, Account, Character, Room, and the message.
        log = {
            "time": time_str,
            "hostname": socket.getfqdn(),
            "application": "%s" % ev_settings.SERVERNAME,
            "version": get_evennia_version(),
            "pid": os.getpid(),
            "direction": "SND" if src == "server" else "RCV",
            "protocol": self.protocol_key,
            "ip": client_ip,
            "session": "session#%s" % self.sessid,
            "account": account_token,
            "character": char_token,
            "room": room_token,
            "text": text.strip(),
            "bytes": bytecount,
            "data": kwargs,
            "objects": {
                "time": time_obj,
                "session": self,
                "account": account,
                "character": char,
                "room": room,
            },
        }

        # Remove any keys with blank values
        if AUDIT_ALLOW_SPARSE is False:
            log["data"] = {k: v for k, v in log["data"].items() if v}
            log["objects"] = {k: v for k, v in log["objects"].items() if v}
            log = {k: v for k, v in log.items() if v}

        return log

    def mask(self, msg):
        """
        Masks potentially sensitive user information within messages before
        writing to log. Recording cleartext password attempts is bad policy.

        Args:
            msg (str): Raw text string sent from client <-> server

        Returns:
            msg (str): Text string with sensitive information masked out.

        """
        # Check to see if the command is embedded within server output
        _msg = msg
        is_embedded = False
        match = re.match(".*Command.*'(.+)'.*is not available.*", msg, flags=re.IGNORECASE)
        if match:
            msg = match.group(1).replace("\\", "")
            submsg = msg
            is_embedded = True

        for mask in AUDIT_MASKS:
            for command, regex in mask.items():
                try:
                    match = re.match(regex, msg, flags=re.IGNORECASE)
                except Exception as e:
                    logger.log_err(regex)
                    logger.log_err(e)
                    continue

                if match:
                    term = match.group("secret")
                    masked = re.sub(term, "*" * len(term.zfill(8)), msg)

                    if is_embedded:
                        msg = re.sub(
                            submsg, "%s <Masked: %s>" % (masked, command), _msg, flags=re.IGNORECASE
                        )
                    else:
                        msg = masked

                    return msg

        return _msg

    def data_out(self, **kwargs):
        """
        Generic hook for sending data out through the protocol.

        Keyword Args:
            kwargs (any): Other data to the protocol.

        """
        if AUDIT_CALLBACK and AUDIT_OUT:
            try:
                log = self.audit(src="server", **kwargs)
                if log:
                    AUDIT_CALLBACK(log)
            except Exception as e:
                logger.log_err(e)

        super().data_out(**kwargs)

    def data_in(self, **kwargs):
        """
        Hook for protocols to send incoming data to the engine.

        Keyword Args:
            kwargs (any): Other data from the protocol.

        """
        if AUDIT_CALLBACK and AUDIT_IN:
            try:
                log = self.audit(src="client", **kwargs)
                if log:
                    AUDIT_CALLBACK(log)
            except Exception as e:
                logger.log_err(e)

        super().data_in(**kwargs)
