import sys
import json
import traceback
from copy import copy
from collections import OrderedDict

_LOG_LEVELS = {
    level: idx
    for idx, level in enumerate(
        ("critical", "error", "warning", "notice", "info", "verbose", "debug")
    )
}


def _skip_until_empty_line(file):
    while True:
        line = file.readline().strip()
        if not line:
            break


def _get_request(file, record_file=None):
    line = file.readline()
    blank_line = file.readline()
    if record_file is not None:
        record_file.write("< " + line)
        record_file.write("< " + blank_line)

    return json.loads(line.strip())


def _put_response(data, file, record_file=None):
    data = json.dumps(data)
    file.write(data + "\n\n")
    file.flush()

    if record_file is not None:
        record_file.write("> " + data + "\n")
        record_file.write("> \n")


def _would_log(level_set, msg_level):
    if msg_level not in _LOG_LEVELS:
        # uknown level, assume it would be logged
        return True

    return _LOG_LEVELS[msg_level] <= _LOG_LEVELS[level_set]


def _cfengine_type(typing):
    if typing is str:
        return "string"
    if typing is int:
        return "int"
    if typing in (list, tuple):
        return "slist"
    if typing is dict:
        return "data container"
    if typing is bool:
        return "true/false"
    return "Error in promise module"


class AttributeObject(object):
    def __init__(self, d):
        for key, value in d.items():
            setattr(self, key, value)
    def __repr__(self):
        return "{}({})".format(
            self.__class__.__qualname__,
            ", ".join("{}={!r}".format(k, v) for k, v in self.__dict__.items())
        )

class ValidationError(Exception):
    def __init__(self, message):
        self.message = message


class ProtocolError(Exception):
    def __init__(self, message):
        self.message = message


class Result:
    # Promise evaluation outcomes, can reveal "real" problems with system:
    KEPT = "kept"  # Satisfied already, no change
    REPAIRED = "repaired"  # Not satisfied before , but fixed
    NOT_KEPT = "not_kept"  # Not satisfied before , not fixed

    # Validation only, can reveal problems in CFEngine policy:
    VALID = "valid"  # Validation successful
    INVALID = "invalid"  # Validation failed, error in cfengine policy

    # Generic succes / fail for init / terminate requests:
    SUCCESS = "success"
    FAILURE = "failure"

    # Unexpected, can reveal problems in promise module:
    ERROR = "error"  # Something went wrong in module / protocol


class PromiseModule:
    def __init__(
        self, name="default_module_name", version="0.0.1", record_file_path=None
    ):
        self.name = name
        self.version = version
        # Note: The class doesn't expose any way to set protocol version
        # or flags, because that should be abstracted away from the
        # user (module author).
        self._validator_attributes = OrderedDict()
        self._result_classes = None

        # File to record all the incoming and outgoing communication
        self._record_file = open(record_file_path, "a") if record_file_path else None

    def start(self, in_file=None, out_file=None):
        self._in = in_file or sys.stdin
        self._out = out_file or sys.stdout

        first_line = self._in.readline()
        if self._record_file is not None:
            self._record_file.write("< " + first_line)

        header = first_line.strip().split(" ")
        name = header[0]
        version = header[1]
        protocol_version = header[2]
        # flags = header[3:] -- unused for now

        assert len(name) > 0  # cf-agent
        assert version.startswith("3.")  # 3.18.0
        assert protocol_version[0] == "v"  # v1

        _skip_until_empty_line(self._in)

        header_reply = "{name} {version} v1 json_based\n\n".format(
            name=self.name, version=self.version
        )
        self._out.write(header_reply)
        self._out.flush()

        if self._record_file is not None:
            self._record_file.write("> " + header_reply.strip() + "\n")
            self._record_file.write(">\n")

        while True:
            self._response = {}
            self._result = None
            request = _get_request(self._in, self._record_file)
            self._handle_request(request)

    def _convert_types(self, promiser, attributes):
        # Will only convert types if module has typing information:
        if not self._has_validation_attributes:
            return promiser, attributes

        replacements = {}
        for name, value in attributes.items():
            if type(value) is not str:
                # If something is not string, assume it is correct type
                continue
            if name not in self._validator_attributes:
                # Unknown attribute, this will cause a validation error later
                continue
            # "true"/"false" -> True/False
            if self._validator_attributes[name]["typing"] is bool:
                if value == "true":
                    replacements[name] = True
                elif value == "false":
                    replacements[name] = False
            # "int" -> int()
            elif self._validator_attributes[name]["typing"] is int:
                try:
                    replacements[name] = int(value)
                except ValueError:
                    pass

        # Don't edit dict while iterating over it, after instead:
        attributes.update(replacements)

        return (promiser, attributes)

    def _handle_request(self, request):
        if not request:
            sys.exit("Error: Empty/invalid request or EOF reached")

        operation = request["operation"]
        self._log_level = request.get("log_level", "info")
        self._response["operation"] = operation

        # Agent will never request log level critical
        assert self._log_level in [
            "error",
            "warning",
            "notice",
            "info",
            "verbose",
            "debug",
        ]

        if operation in ["validate_promise", "evaluate_promise"]:
            promiser = request["promiser"]
            attributes = request.get("attributes", {})
            promiser, attributes = self._convert_types(promiser, attributes)
            promiser, attributes = self.prepare_promiser_and_attributes(
                promiser, attributes
            )
            self._response["promiser"] = promiser
            self._response["attributes"] = attributes

        if operation == "init":
            self._handle_init()
        elif operation == "validate_promise":
            self._handle_validate(promiser, attributes, request)
        elif operation == "evaluate_promise":
            self._handle_evaluate(promiser, attributes, request)
        elif operation == "terminate":
            self._handle_terminate()
        else:
            self._log_level = None
            raise ProtocolError(
                "Unknown operation: '{operation}'".format(operation=operation)
            )

        self._log_level = None

    def _add_result(self):
        self._response["result"] = self._result

    def _add_result_classes(self):
        if self._result_classes:
            self._response["result_classes"] = self._result_classes

    def _add_traceback_to_response(self):
        if self._log_level != "debug":
            return

        trace = traceback.format_exc()
        logs = self._response.get("log", [])
        logs.append({"level": "debug", "message": trace})
        self._response["log"] = logs

    def add_attribute(
        self,
        name,
        typing,
        default=None,
        required=False,
        default_to_promiser=False,
        validator=None,
    ):
        attribute = OrderedDict()
        attribute["name"] = name
        attribute["typing"] = typing
        attribute["default"] = default
        attribute["required"] = required
        attribute["default_to_promiser"] = default_to_promiser
        attribute["validator"] = validator
        self._validator_attributes[name] = attribute

    @property
    def _has_validation_attributes(self):
        return bool(self._validator_attributes)

    def create_attribute_dict(self, promiser, attributes):

        # Check for missing required attributes:
        for name, attribute in self._validator_attributes.items():
            if attribute["required"] and name not in attributes:
                raise ValidationError(
                    "Missing required attribute '{name}'".format(name=name)
                )

        # Check for unknown attributes:
        for name in attributes:
            if name not in self._validator_attributes:
                raise ValidationError("Unknown attribute '{name}'".format(name=name))

        # Check typings and run custom validator callbacks:
        for name, value in attributes.items():
            expected = _cfengine_type(self._validator_attributes[name]["typing"])
            found = _cfengine_type(type(value))
            if found != expected:
                raise ValidationError(
                    "Wrong type for attribute '{name}', requires '{expected}', not '{value}'({found})".format(
                        name=name, expected=expected, value=value, found=found
                    )
                )
            if self._validator_attributes[name]["validator"]:
                # Can raise ValidationError:
                self._validator_attributes[name]["validator"](value)

        attribute_dict = OrderedDict()

        # Copy attributes specified by user policy:
        for key, value in attributes.items():
            attribute_dict[key] = value

        # Set defaults based on promise module validation hints:
        for name, value in self._validator_attributes.items():
            if value.get("default_to_promiser", False):
                attribute_dict.setdefault(name, promiser)
            elif value.get("default", None) is not None:
                attribute_dict.setdefault(name, copy(value["default"]))
            else:
                attribute_dict.setdefault(name, None)

        return attribute_dict

    def create_attribute_object(self, promiser, attributes):
        attribute_dict = self.create_attribute_dict(promiser, attributes)
        return AttributeObject(attribute_dict)

    def _validate_attributes(self, promiser, attributes):
        if not self._has_validation_attributes:
            # Can only validate attributes if module
            # provided typings for attributes
            return
        self.create_attribute_object(promiser, attributes)
        return  # Only interested in exceptions, return None

    def _handle_init(self):
        self._result = self.protocol_init(None)
        self._add_result()
        _put_response(self._response, self._out, self._record_file)

    def _handle_validate(self, promiser, attributes, request):
        meta = {"promise_type": request.get("promise_type")}
        try:
            self.validate_attributes(promiser, attributes, meta)
            returned = self.validate_promise(promiser, attributes, meta)
            if returned is None:
                # Good, expected
                self._result = Result.VALID
            else:
                # Bad, validate method shouldn't return anything else
                self.log_critical(
                    "Bug in promise module {name} - validate_promise() should not return anything".format(
                        name=self.name
                    )
                )
                self._result = Result.ERROR
        except ValidationError as e:
            message = str(e)
            if "promise_type" in request:
                message += " for {request_promise_type} promise with promiser '{promiser}'".format(
                    request_promise_type=request["promise_type"], promiser=promiser
                )
            else:
                message += " for promise with promiser '{promiser}'".format(
                    promiser=promiser
                )
            if "filename" in request and "line_number" in request:
                message += " ({request_filename}:{request_line_number})".format(
                    request_filename=request["filename"],
                    request_line_number=request["line_number"],
                )

            self.log_error(message)
            self._result = Result.INVALID
        except Exception as e:
            self.log_critical(
                "{error_type}: {error}".format(error_type=type(e).__name__, error=e)
            )
            self._add_traceback_to_response()
            self._result = Result.ERROR
        self._add_result()
        _put_response(self._response, self._out, self._record_file)

    def _handle_evaluate(self, promiser, attributes, request):
        self._result_classes = None
        meta = {"promise_type": request.get("promise_type")}
        try:
            results = self.evaluate_promise(promiser, attributes, meta)

            # evaluate_promise should return either a result or a (result, result_classes) pair
            if type(results) == str:
                self._result = results
            else:
                assert len(results) == 2
                self._result = results[0]
                self._result_classes = results[1]
        except Exception as e:
            self.log_critical(
                "{error_type}: {error}".format(error_type=type(e).__name__, error=e)
            )
            self._add_traceback_to_response()
            self._result = Result.ERROR
        self._add_result()
        self._add_result_classes()
        _put_response(self._response, self._out, self._record_file)

    def _handle_terminate(self):
        self._result = self.protocol_terminate()
        self._add_result()
        _put_response(self._response, self._out, self._record_file)
        sys.exit(0)

    def _log(self, level, message):
        if self._log_level is not None and not _would_log(self._log_level, level):
            return

        # Message can be str or an object which implements __str__()
        # for example an exception:
        message = str(message).replace("\n", r"\n")
        assert "\n" not in message
        self._out.write("log_{level}={message}\n".format(level=level, message=message))
        self._out.flush()

        if self._record_file is not None:
            self._record_file.write(
                "log_{level}={message}\n".format(level=level, message=message)
            )

    def log_critical(self, message):
        self._log("critical", message)

    def log_error(self, message):
        self._log("error", message)

    def log_warning(self, message):
        self._log("warning", message)

    def log_notice(self, message):
        self._log("notice", message)

    def log_info(self, message):
        self._log("info", message)

    def log_verbose(self, message):
        self._log("verbose", message)

    def log_debug(self, message):
        self._log("debug", message)

    def _log_traceback(self):
        trace = traceback.format_exc().split("\n")
        for line in trace:
            self.log_debug(line)

    # Functions to override in subclass:

    def protocol_init(self, version):
        return Result.SUCCESS

    def prepare_promiser_and_attributes(self, promiser, attributes):
        """Override if you want to modify promiser or attributes before validate or evaluate"""
        return (promiser, attributes)

    def validate_attributes(self, promiser, attributes, meta):
        """Override this if you want to prevent automatic validation"""
        return self._validate_attributes(promiser, attributes)

    def validate_promise(self, promiser, attributes, meta):
        """Must override this or use validation through self.add_attribute()"""
        if not self._has_validation_attributes:
            raise NotImplementedError("Promise module must implement validate_promise")

    def evaluate_promise(self, promiser, attributes, meta):
        raise NotImplementedError("Promise module must implement evaluate_promise")

    def protocol_terminate(self):
        return Result.SUCCESS
