"""
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.
"""

import json
import logging
import threading
from time import sleep
from dataclasses import dataclass, field
from datetime import datetime, timedelta, UTC
import isodate
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 *
from .  import {{stinger.get_enum_module_name()}} as {{stinger.get_enum_module_alias()}}
{%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%}

T = TypeVar('T')

@dataclass
class PropertyControls(Generic[T]):
    value: T | None = None
    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):
        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%}, {%for arg in prop.arg_list %}{{arg.python_annotation}}{%if not loop.last%}, {%endif%}{%endfor%}] = PropertyControls()
        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._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):
        while self._conn.is_connected() and self._running:
            self._publish_interface_info()
            sleep(self._re_advertise_server_interval_seconds)

    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)

    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]):
        {%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)
            self._send_reply_error_message(MethodReturnCode.DESERIALIZATION_ERROR, properties, 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
        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%}
    {%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}}: {{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}}, {{arg.python_annotation}}), f"The '{{arg.name}}' argument must be of type {{arg.python_annotation}}, but was {type({{arg.name}})}"
        {%endfor%}

        payload = {{sig_name|UpperCamelCase}}SignalPayload({%-for arg in sig.arg_list%}
            {{arg.name}}={{arg.name}}{%if arg.optional%} if {{arg.name}} 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("{{prop.value_topic}}".format(self._instance_id), payload, qos=1, retain=True)
            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("{{prop.value_topic}}".format(self._instance_id), payload, qos=1, retain=True)
            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)}}):
        if self._{{method_name|snake_case}}_method_handler is None and handler is not None:
            self._{{method_name|snake_case}}_method_handler = handler
        else:
            raise Exception("Method handler already set")
    {%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.
        """
        self._{{prop_name|snake_case}}_property_callbacks.append(handler)
    {%endfor%}
    def build(self, connection: IBrokerConnection) -> {{stinger.python.server_class_name}}:
        new_server = {{stinger.python.server_class_name}}(connection)
        {%for method_name, method in stinger.methods.items()%}
        if self._{{method_name|snake_case}}_method_handler is not None:
            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:
            new_server.on_{{prop_name|snake_case}}_updates(callback)
        {%endfor%}
        return new_server

