"""
DO NOT MODIFY THIS FILE. {# Unless you see this comment, in which case you are modifying the template. #} It is automatically generated and changes will be over-written
on the next generation.

This is the Server for the {{stinger.name}} interface.

LICENSE: This generated code is not subject to any license restrictions from the generator itself.
TODO: Get license text from stinger file
"""

import json
import logging
import threading
from time import sleep
from dataclasses import dataclass, field
from datetime import datetime, timedelta, UTC
import isodate
import functools
logging.basicConfig(level=logging.DEBUG)
from pydantic import BaseModel, ValidationError
from typing import Callable, Dict, Any, Optional, List, Generic, TypeVar
from .connection import IBrokerConnection
from .method_codes import *
from .interface_types import *
{%macro method_type_annotation(method) %}Callable[[{%if method.arg_list | length > 0%}{%for arg in method.arg_list%}{{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}{%else%}None{%endif%}], {{method.return_value_python_annotation}}]{%endmacro%}
{%if stinger.properties | length > 0 %}
from .property import {{stinger.name|UpperCamelCase}}InitialPropertyValues
{%endif%}

T = TypeVar('T')

@dataclass
class PropertyControls(Generic[T]):
    value: T
    mutex = threading.Lock()
    version: int = -1
    subscription_id: Optional[int] = None
    callbacks: List[Callable[[T], None]] = field(default_factory=list)

@dataclass
class MethodControls:
    subscription_id: Optional[int] = None
    callback: Optional[Callable] = None



class {{stinger.python.server_class_name}}:

    def __init__(self, connection: IBrokerConnection, instance_id: str{%if stinger.properties | length > 0 %}, initial_property_values: {{stinger.name|UpperCamelCase}}InitialPropertyValues{%endif%}):
        self._logger = logging.getLogger(f'{{stinger.python.server_class_name}}:{instance_id}')
        self._logger.setLevel(logging.DEBUG)
        self._logger.debug("Initializing {{stinger.python.server_class_name}} instance %s", instance_id)
        self._instance_id = instance_id
        self._re_advertise_server_interval_seconds = 120 # every two minutes
        self._conn = connection
        self._running = True
        self._conn.add_message_callback(self._receive_message)
        {%for prop_name, prop in stinger.properties.items()%}
        self._property_{{prop_name|snake_case}}: PropertyControls[{%if prop.arg_list | length == 1%}{{prop.arg_list[0].python_class}}{%else%}{{prop.python_class}}{%endif%}] = PropertyControls(value=initial_property_values.{{prop_name|snake_case}}, version=initial_property_values.{{prop_name|snake_case}}_version)
        self._property_{{prop_name|snake_case}}.subscription_id = self._conn.subscribe("{{prop.update_topic}}".format(self._instance_id), self._receive_{{prop_name|snake_case}}_update_request_message)
        {%endfor-%}
        {%-for method_name, method in stinger.methods.items()%}
        self._method_{{method_name|snake_case}} = MethodControls()
        self._method_{{method_name|snake_case}}.subscription_id = self._conn.subscribe("{{method.topic}}".format(self._instance_id), self._process_{{method_name | snake_case}}_call)
        {%endfor%}
        self._publish_all_properties()
        self._logger.debug("Starting interface advertisement thread")
        self._advertise_thread = threading.Thread(target=self.loop_publishing_interface_info)
        self._advertise_thread.start()

    def __del__(self):
        self._running = False
        self._conn.unpublish_retained(self._conn.online_topic)
        self._advertise_thread.join()

    def loop_publishing_interface_info(self):
        """ We have a discovery topic separate from the MQTT client discovery topic.
        We publish it periodically, but with a Message Expiry interval."""
        self._publish_interface_info()
        while self._running:
            if self._conn.is_connected():
                self._publish_interface_info()
                sleep(self._re_advertise_server_interval_seconds)
            else:
                sleep(2)
            
    def _publish_interface_info(self):
        data = InterfaceInfo(
            instance=self._instance_id,
            connection_topic=self._conn.online_topic,
            timestamp=datetime.now(UTC).isoformat()
        )
        expiry = int(self._re_advertise_server_interval_seconds * 1.2) # slightly longer than the re-advertise interval
        topic = "{{stinger.interface_info_topic}}".format(self._instance_id)
        self._logger.debug("Publishing interface info to %s: %s", topic, data.model_dump_json(by_alias=True))
        self._conn.publish_status(topic, data, expiry)

    {%if stinger.properties|length > 0%}
    def _publish_all_properties(self):
        {%for prop_name, prop in stinger.properties.items()%}
        with self._property_{{prop_name|snake_case}}.mutex:
            {% if prop.arg_list | length == 1 -%}
            prop_obj = {{prop_name|UpperCamelCase}}Property({{prop.arg_list[0].name|snake_case}}=self._property_{{prop_name|snake_case}}.value)
            {%else%}
            prop_obj = self._property_{{prop_name|snake_case}}.value
            {%endif-%}
            self._conn.publish_property_state("{{prop.value_topic}}".format(self._instance_id), prop_obj, self._property_{{prop_name|snake_case}}.version)
        {%endfor%}
    {%endif%}

    def _send_reply_error_message(self, return_code: MethodReturnCode, request_properties: Dict[str, Any], debug_info: Optional[str] = None):
        correlation_id = request_properties.get('CorrelationData') # type: Optional[bytes]
        response_topic = request_properties.get('ResponseTopic') # type: Optional[str]
        if response_topic is not None:
            self._conn.publish_error_response(response_topic, return_code, correlation_id, debug_info=debug_info)

    {%for prop_name, prop in stinger.properties.items()%}
    def _receive_{{prop_name|snake_case}}_update_request_message(self, topic: str, payload: str, properties: Dict[str, Any]):
        user_properties = properties.get('UserProperty', dict()) # type: Optional[Dict[str, str]]
        prop_version = user_properties.get('PropertyVersion', -1) # type: int
        correlation_id = properties.get('CorrelationData', '') # type: Optional[bytes]
        response_topic = properties.get('ResponseTopic') # type: Optional[str]
        {% if prop.arg_list | length == 1%}
        existing_prop_obj = {{prop_name|UpperCamelCase}}Property({{prop.arg_list[0].name|snake_case}}=self._property_{{prop_name|snake_case}}.value)
        {%else%}
        existing_prop_obj = self._property_{{prop_name|snake_case}}.value
        {%endif%}

        try:
            if int(prop_version) != int(self._property_{{prop_name|snake_case}}.version):
                self._logger.warning("Received out-of-date update for %s (version %s, current version %s)", topic, prop_version, self._property_{{prop_name|snake_case}}.version)
                if response_topic is not None:
                    self._conn.publish_property_response(response_topic, existing_prop_obj, str(self._property_{{prop_name|snake_case}}.version), MethodReturnCode.OUT_OF_SYNC, correlation_id, f"Request version {prop_version} does not match current version {self._property_{{prop_name|snake_case}}.version}")
                return

            {%if prop.arg_list | length > 1 -%}
            try:
                prop_value = {{prop.python_class}}.model_validate_json(payload)
            except ValidationError as e:
                self._logger.error("Failed to validate payload for %s: %s", topic, e)
                if response_topic is not None:
                    self._conn.publish_property_response(response_topic, existing_prop_obj, str(self._property_{{prop_name|snake_case}}.version), MethodReturnCode.SERVER_DESERIALIZATION_ERROR, correlation_id, str(e))
                return
            {%else-%}
            payload_obj = json.loads(payload)
            prop_value = {{prop.arg_list[0].python_class}}(payload_obj["{{prop.arg_list[0].name}}"])
            {%endif-%}
            with self._property_{{prop_name|snake_case}}.mutex:
                self._property_{{prop_name|snake_case}}.value = prop_value
                self._property_{{prop_name|snake_case}}.version += 1
                {% if prop.arg_list | length == 1%}
                prop_obj = {{prop_name|UpperCamelCase}}Property({{prop.arg_list[0].name|snake_case}}=self._property_{{prop_name|snake_case}}.value)
                {%else%}
                prop_obj = self._property_{{prop_name|snake_case}}.value
                {%endif%}
                self._conn.publish_property_state("{{prop.value_topic}}".format(self._instance_id), prop_obj, int(self._property_{{prop_name|snake_case}}.version))
            
            if response_topic is not None:
                {% if prop.arg_list | length == 1%}
                prop_obj = {{prop_name|UpperCamelCase}}Property({{prop.arg_list[0].name|snake_case}}=self._property_{{prop_name|snake_case}}.value)
                {%else%}
                prop_obj = self._property_{{prop_name|snake_case}}.value
                {%endif%}
                self._logger.debug("Sending property update response for to %s", response_topic)
                self._conn.publish_property_response(response_topic, prop_obj, str(self._property_{{prop_name|snake_case}}.version), MethodReturnCode.SUCCESS, correlation_id)
            else:
                self._logger.warning("No response topic provided for property update of %s", topic)



            for callback in self._property_{{prop_name|snake_case}}.callbacks:
                {%-if prop.arg_list | length > 1%}
                    callback({%for arg in prop.arg_list %}prop_value.{{arg.name|snake_case}}{%if not loop.last%}, {%endif%}{%endfor%})
                {%-else%}
                callback(prop_value)
                {%-endif%}
        except Exception as e:
            self._logger.exception("Exception while processing property update for %s", topic, exc_info=e)
            if response_topic is not None:
                {% if prop.arg_list | length == 1%}
                prop_obj = {{prop_name|UpperCamelCase}}Property({{prop.arg_list[0].name|snake_case}}=self._property_{{prop_name|snake_case}}.value)
                {%else%}
                prop_obj = self._property_{{prop_name|snake_case}}.value
                {%endif%}
                self._conn.publish_property_response(response_topic, prop_obj, str(self._property_{{prop_name|snake_case}}.version), MethodReturnCode.SERVER_ERROR, correlation_id, str(e))


    {%endfor%}

    def _receive_message(self, topic: str, payload: str, properties: Dict[str, Any]):
        """ This is the callback that is called whenever any message is received on a subscribed topic.
        """
        self._logger.warning("Received unexpected message to %s", topic)

    {%for sig_name, sig in stinger.signals.items()-%}
    def emit_{{sig_name|snake_case}}(self, {%for arg in sig.arg_list%}{{arg.name|snake_case}}: {{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}):
        """ Server application code should call this method to emit the '{{sig_name}}' signal.

        {{sig_name|UpperCamelCase}}SignalPayload is a pydantic BaseModel which will validate the arguments.
        """
        {%for arg in sig.arg_list%}
        assert isinstance({{arg.name|snake_case}}, {{arg.python_type}}){%if arg.optional%} or {{arg.name|snake_case}} is None{%endif%}, f"The '{{arg.name}}' argument must be of type {{arg.python_annotation}}, but was {type({{arg.name|snake_case}})}"
        {%endfor%}

        payload = {{sig_name|UpperCamelCase}}SignalPayload({%-for arg in sig.arg_list%}
            {{arg.name|snake_case}}={{arg.name|snake_case}}{%if arg.optional%} if {{arg.name|snake_case}} is not None else None{%endif%},
        {%endfor%})
        self._conn.publish("{{sig.topic}}".format(self._instance_id), payload.model_dump_json(by_alias=True), qos=1, retain=False)

    {%endfor%}

    {%for method_name, method in stinger.methods.items()%}
    def handle_{{method_name | snake_case}}(self, handler: {{method_type_annotation(method)}}):
        """ This is a decorator to decorate a method that will handle the '{{method_name}}' method calls.
        """
        if self._method_{{method_name|snake_case}}.callback is None and handler is not None:
            self._method_{{method_name|snake_case}}.callback = handler
        else:
            raise Exception("Method handler already set")

    def _process_{{method_name | snake_case}}_call(self, topic: str, payload_str: str, properties: Dict[str, Any]):
        """ This processes a call to the '{{method_name}}' method.  It deserializes the payload to find the method arguments,
        then calls the method handler with those arguments.  It then builds and serializes a response and publishes it to the response topic.
        """
        payload = {{method_name|UpperCamelCase}}MethodRequest.model_validate_json(payload_str)
        correlation_id = properties.get('CorrelationData') # type: Optional[bytes]
        response_topic = properties.get('ResponseTopic') # type: Optional[str]
        self._logger.debug("Correlation data for '{{method_name}}' request: %s", correlation_id)
        if self._method_{{method_name|snake_case}}.callback is not None:
            method_args = [{%for arg in method.arg_list %}payload.{{arg.name|snake_case}}, {%endfor%}]
            
            if response_topic is not None:
                return_json = ""
                debug_msg = None # type: Optional[str]
                try:
                    {%if method.return_arg_list|length > 0 %}return_values = {%endif%}self._method_{{method_name|snake_case}}.callback(*method_args)
                    
                    {%if method.return_arg_list|length == 0%}
                    return_json = "{}"
                    {%elif method.return_arg_list|length == 1 %}
                    if not isinstance(return_values, {{method.return_arg_list[0].python_local_type}}){% if method.return_arg_list[0].optional %} and return_values is not None{% endif %}:
                        raise ServerSerializationErrorStingerMethodException(f"The return value must be of type {{method.return_arg_list[0].python_class}}, but was {type(return_values)}")
                    ret_obj = {{method_name|UpperCamelCase}}MethodResponse({{method.return_arg_list[0].name}}=return_values)
                    return_json = ret_obj.model_dump_json(by_alias=True)
                    {%else%}
                    if not isinstance(return_values, {{method_name|UpperCamelCase}}MethodResponse):
                        raise ServerSerializationErrorStingerMethodException(f"The return value must be of type {{method_name|UpperCamelCase}}MethodResponse, but was {type(return_values)}")
                    return_json = return_values.model_dump_json(by_alias=True)
                    {%endif%}
                except StingerMethodException as sme:
                    self._logger.warning("StingerMethodException while handling {{method_name}}: %s", sme)
                    return_code = sme.return_code
                    debug_msg = str(sme)
                    self._conn.publish_error_response(response_topic, return_code, correlation_id, debug_info=debug_msg)
                except Exception as e:
                    self._logger.exception("Exception while handling {{method_name}}", exc_info=e)
                    return_code = MethodReturnCode.SERVER_ERROR
                    debug_msg = str(e)
                    self._conn.publish_error_response(response_topic, return_code, correlation_id, debug_info=debug_msg)
                else:
                    self._conn.publish(response_topic, return_json, qos=1, retain=False, correlation_id=correlation_id)

    {%endfor%}
    {%for prop_name, prop in stinger.properties.items()%}
    {%if prop.arg_list | length == 1%}
    {%-set arg = prop.arg_list[0] %}
    @property
    def {{prop_name | snake_case}}(self) -> {%if arg.optional%}{{prop.python_annotation}}{%else%}Optional[{{prop.python_annotation}}]{%endif%}:
        """ This property returns the last received value for the '{{prop_name}}' property.
        """
        with self._property_{{prop_name|snake_case}}_mutex:
            return self._property_{{prop_name|snake_case}}

    @{{prop_name | snake_case}}.setter
    def {{prop_name | snake_case}}(self, {{arg.name|snake_case}}: {{prop.python_annotation}}):
        """ This property sets (publishes) a new value for the '{{prop_name}}' property.
        """

        if {%if arg.optional%}({{arg.name|snake_case}} is not None) and {%endif%}(not isinstance({{arg.name|snake_case}}, {{prop.python_local_type}})):
            raise ValueError(f"The value must be {{prop.python_class}} {%if arg.optional%}or None{%endif%}.")

        prop_obj = {{prop_name|UpperCamelCase}}Property({{arg.name|snake_case}}={{arg.name|snake_case}})
        payload = prop_obj.model_dump_json(by_alias=True)

        if self._property_{{prop_name|snake_case}}.value is None or {{arg.name|snake_case}} != self._property_{{prop_name|snake_case}}.value.{{arg.name|snake_case}}:
            with self._property_{{prop_name|snake_case}}.mutex:
                self._property_{{prop_name|snake_case}}.value = prop_obj
                self._property_{{prop_name|snake_case}}.version += 1
                self._conn.publish_property_state("{{prop.value_topic}}".format(self._instance_id), payload, self._property_{{prop_name|snake_case}}.version)
            for callback in self._property_{{prop_name|snake_case}}.callbacks:
                callback(prop_obj.{{arg.name|snake_case}})
    {%else%} {# property with multiple arguments #}
    @property
    def {{prop_name | snake_case}}(self) -> Optional[{{prop.python_annotation}}]:
        """ This property returns the last received value for the '{{prop_name}}' property.
        """
        with self._property_{{prop_name|snake_case}}_mutex:
            return self._property_{{prop_name|snake_case}}

    @{{prop_name | snake_case}}.setter
    def {{prop_name | snake_case}}(self, value: {{prop_name|UpperCamelCase}}Property):
        """ This property sets (publishes) a new value for the '{{prop_name}}' property.
        """

        if not isinstance(value, {{prop_name|UpperCamelCase}}Property):
            raise ValueError(f"The value must be {{prop.python_class}}.")

        payload = value.model_dump_json(by_alias=True)

        if value != self._property_{{prop_name|snake_case}}.value:
            with self._property_{{prop_name|snake_case}}.mutex:
                self._property_{{prop_name|snake_case}}.value = value
                self._property_{{prop_name|snake_case}}.version += 1
                self._conn.publish_property_state("{{prop.value_topic}}".format(self._instance_id), payload, self._property_{{prop_name|snake_case}}.version)
            for callback in self._property_{{prop_name|snake_case}}.callbacks:
                callback({%for arg in prop.arg_list %}value.{{arg.name|snake_case}}{%if not loop.last%}, {%endif%}{%endfor%})
    {%endif%}

    def set_{{prop_name | snake_case}}(self, {%for arg in prop.arg_list %}{{arg.name|snake_case}}: {{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}):
        """ This method sets (publishes) a new value for the '{{prop_name}}' property.
        """
        {%-for arg in prop.arg_list%}
        if not isinstance({{arg.name|snake_case}}, {{arg.python_class}}){%if arg.optional%} and {{arg.name|snake_case}} is not None{%endif%}:
            raise ValueError(f"The '{{arg.name|snake_case}}' value must be {{arg.python_annotation}}.")
        {%-endfor%}

        {%if prop.arg_list | length > 1%}
        obj = interface_types.{{prop.python_local_type}}({%for arg in prop.arg_list%}
            {{arg.name}}={{arg.name|snake_case}},
            {%endfor%}
        )
        {%else%}
        obj = {{prop.arg_list[0].name | snake_case}}
        {%endif%}

        # Use the property.setter to do that actual work.
        self.{{prop_name|snake_case}} = obj

    def on_{{prop_name | snake_case}}_updates(self, handler: Callable[[{%for arg in prop.arg_list %}{{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}], None]):
        """ This method registers a callback to be called whenever a new '{{prop_name}}' property update is received.
        """
        if handler is not None:
            self._property_{{prop_name|snake_case}}.callbacks.append(handler)
    {%endfor%}

class {{stinger.python.server_class_name}}Builder:
    """
    This is a builder for the {{stinger.python.server_class_name}}.  It is used to create a server with the desired parameters.
    """

    def __init__(self):
        {%if (stinger.methods|length + stinger.properties|length) == 0 %}pass{%endif%}
        {%for method_name, method in stinger.methods.items()%}
        self._{{method_name|snake_case}}_method_handler: Optional[{{method_type_annotation(method)}}] = None
        {%-endfor%}
        {%for prop_name, property in stinger.properties.items()%}
        self._{{prop_name|snake_case}}_property_callbacks: List[Callable[[{%for arg in property.arg_list %}{{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}], None]] = []
        {%-endfor%}
    {%for method_name, method in stinger.methods.items()%}
    def handle_{{method_name | snake_case}}(self, handler: {{method_type_annotation(method)}}):
        @functools.wraps(handler)
        def wrapper(*args, **kwargs):
            return handler(*args, **kwargs)
        if self._{{method_name|snake_case}}_method_handler is None and handler is not None:
            self._{{method_name|snake_case}}_method_handler = wrapper
        else:
            raise Exception("Method handler already set")
        return wrapper
    {%endfor%}
    {%for prop_name, prop in stinger.properties.items()%}
    def on_{{prop_name | snake_case}}_updates(self, handler: Callable[[{%for arg in prop.arg_list %}{{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}], None]):
        """ This method registers a callback to be called whenever a new '{{prop_name}}' property update is received.
        """
        @functools.wraps(handler)
        def wrapper(*args, **kwargs):
            return handler(*args, **kwargs)
        self._{{prop_name|snake_case}}_property_callbacks.append(wrapper)
        return wrapper
    {%endfor%}
    def build(self, connection: IBrokerConnection, instance_id: str{%if stinger.properties | length > 0 %}, initial_property_values: {{stinger.name|UpperCamelCase}}InitialPropertyValues{%endif%}, binding: Optional[Any]=None) -> {{stinger.python.server_class_name}}:
        new_server = {{stinger.python.server_class_name}}(connection, instance_id{%if stinger.properties | length > 0 %}, initial_property_values{%endif%})
        {%for method_name, method in stinger.methods.items()%}
        if self._{{method_name|snake_case}}_method_handler is not None:
            if binding:
                binding_cb = self._{{method_name|snake_case}}_method_handler.__get__(binding, binding.__class__)
                new_server.handle_{{method_name|snake_case}}(binding_cb)
            else:
                new_server.handle_{{method_name|snake_case}}(self._{{method_name|snake_case}}_method_handler)
        {%-endfor%}
        {%for prop_name, prop in stinger.properties.items()%}
        for callback in self._{{prop_name|snake_case}}_property_callbacks:
            if binding:
                binding_cb = callback.__get__(binding, binding.__class__)
                new_server.on_{{prop_name|snake_case}}_updates(binding_cb)
            else:
                new_server.on_{{prop_name|snake_case}}_updates(callback)
        {%endfor%}
        return new_server

