"""
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 dataclasses import dataclass, field
logging.basicConfig(level=logging.DEBUG)

from typing import Callable, Dict, Any, Optional, List, Generic, TypeVar
from connection import BrokerConnection
from method_codes import *
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_type}}{%if not loop.last%}, {%endif%}{%endfor%}{%else%}None{%endif%}], {{method.return_value_python_type}}]{%endmacro%}

T = TypeVar('T')

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

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

class {{stinger.python.server_class_name}}:

    def __init__(self, connection: BrokerConnection):
        self._logger = logging.getLogger('{{stinger.python.server_class_name}}')
        self._logger.setLevel(logging.DEBUG)
        self._logger.debug("Initializing {{stinger.python.server_class_name}}")
        self._conn = connection
        self._conn.set_message_callback(self._receive_message)
        self._conn.set_last_will(topic="{{stinger.interface_info.0}}", payload=None, qos=1, retain=True)
        {%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_type}}{%if not loop.last%}, {%endif%}{%endfor%}] = PropertyControls()
        self._property_{{prop_name|snake_case}}.subscription_id = self._conn.subscribe("{{prop.update_topic}}")
        {%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}}")
        {%endfor%}
        self._publish_interface_info()
    
    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.debug("Received message to %s", topic)
        {%if stinger.methods | length > 0 -%}
        {%for method_name, method in stinger.methods.items()-%}
        {%if not loop.first%}el{%endif%}if (properties.get('SubscriptionId', -1) == self._method_{{method_name|snake_case}}.subscription_id) or self._conn.is_topic_sub(topic, "{{method.topic}}"):
            try:
                payload_obj = json.loads(payload)
            except json.decoder.JSONDecodeError:
                self._logger.warning("Invalid JSON payload received at topic '%s'", topic)
            else:
                self._process_{{method_name | snake_case}}_call(topic, payload_obj, properties)
        {%endfor%}
        {%-endif%}
        {%for prop_name, prop in stinger.properties.items()%}
        {%if not loop.first%}el{%endif%}if (properties.get('SubscriptionId', -1) == self._property_{{prop_name|snake_case}}.subscription_id) or self._conn.is_topic_sub(topic, "{{prop.update_topic}}"):
            {%if prop.arg_list | length > 1 -%}
            prop_value = {{prop.python_class}}.model_validate_json(payload)
            {%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 _publish_interface_info(self):
        self._conn.publish("{{stinger.interface_info.0}}", '''{{stinger.interface_info.1 | tojson}}''', qos=1, retain=True)

    {%for sig_name, sig in stinger.signals.items()-%}
    def emit_{{sig_name}}(self, {%for arg in sig.arg_list%}{{arg.name}}: {{arg.python_type}}{%if not loop.last%}, {%endif%}{%endfor%}):
        """ Server application code should call this method to emit the '{{sig_name}}' signal.
        """
        {%-for arg in sig.arg_list%}
        if not isinstance({{arg.name}}, {{arg.python_class}}){%if arg.optional%} and {{arg.name}} is not None{%endif%}:
            raise ValueError(f"The '{{arg.name}}' value must be {{arg.python_type}}.")
        {%-endfor%}
        
        payload = { {%-for arg in sig.arg_list%}
            {%if arg.arg_type.name.lower() == 'primitive'%}"{{arg.name}}": {{arg.python_type}}({{arg.name}}){%if arg.optional%} if {{arg.name}} is not None else None{%endif%},{%endif-%}
            {%if arg.arg_type.name.lower() == 'enum'%}"{{arg.name}}": {{arg.python_class}}({{arg.name}}).value{%if arg.optional%} if {{arg.name}} is not None else None{%endif%},{%endif%}{%endfor%}
        }
        self._conn.publish("{{sig.topic}}", json.dumps(payload), 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: Dict[str, Any], 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.
        """
        correlation_id = properties.get('CorrelationData') # type: Optional[bytes]
        response_topic = properties.get('ResponseTopic') # type: Optional[str]
        self._logger.info("Correlation Data %s", correlation_id)
        if self._method_{{method_name|snake_case}}.callback is not None:
            method_args = [] # type: List[Any]
            {%for arg in method.arg_list -%}
            if "{{arg.name}}" in payload:
                if not isinstance(payload["{{arg.name}}"], {{arg.python_class}}){%if arg.optional%} or {{arg.name}} is None{%endif%}:
                    self._logger.warning("The '{{arg.name}}' property in the payload to '%s' wasn't the correct type.  It should have been {{arg.python_type}}.", topic)
                    # TODO: return an error via MQTT
                    return
                else:
                    method_args.append(payload["{{arg.name}}"])
            else:
                {%if arg.optional%}
                method_args.append(None)
                {%else%}
                self._logger.warning("The '{{arg.name}}' property in the payload to '%s' wasn't present", topic)
                # TODO: return an error via MQTT
                return
                {%endif%}
            {%endfor%}
            
            if response_topic is not None:
                return_json = ""
                debug_msg = None # type: Optional[str]
                try:
                    return_struct = self._method_{{method_name|snake_case}}.callback(*method_args)
                    self._logger.debug("Return value is %s", return_struct)
                    {%if method.return_value is false%}
                        return_json = "{}"
                    {%else%}
                    if return_struct is not None:
                        {%-if method.return_value_type == "primitive" %}
                        return_json = json.dumps({
                            "{{method.return_value_property_name}}": return_struct
                        })
                        {%-elif method.return_value_type == "enum" %}
                        return_json = json.dumps({
                            "{{method.return_value_property_name}}": return_struct.value
                        })
                        {%-elif method.return_value_type == "struct" %}
                        return_json = json.dumps({
                            "{{method.return_value_property_name}}": return_struct.model_dump_json()
                        })
                        {%else%}                        
                        return_json = return_struct.model_dump_json()
                        {%endif%}
                    {%-endif%}
                except Exception as e:
                    self._logger.exception("Exception while handling {{method_name}}", exc_info=e)
                    return_code = MethodReturnCode.SERVER_ERROR
                    debug_msg = str(e)
                else:
                    return_code = MethodReturnCode.SUCCESS
                    debug_msg = None

                self._conn.publish(response_topic, return_json, qos=1, retain=False, 
                    correlation_id=correlation_id, return_value=return_code, debug_info=debug_msg)
    {%endfor%}
    {%for prop_name, prop in stinger.properties.items()%}
    @property
    def {{prop_name | snake_case}}(self) -> {{prop.python_class}}|None:
        """ 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, {%if prop.arg_list | length > 1%}value{%else%}{{prop.arg_list[0].name|snake_case}}{%endif%}: {{prop.python_class}}):
        """ This property sets (publishes) a new value for the '{{prop_name}}' property.
        """
        if not isinstance({%if prop.arg_list | length > 1%}value{%else%}{{prop.arg_list[0].name|snake_case}}{%endif%}, {{prop.python_class}}):
            raise ValueError(f"The value must be {{prop.python_class}}.")

        {%if prop.arg_list | length > 1%}
        payload = value.model_dump_json()
        {%else%}
        payload = json.dumps({ "{{prop.arg_list[0].name}}": {{prop.arg_list[0].name|snake_case}} })
        {%endif%}

        if {%if prop.arg_list | length > 1%}value{%else%}{{prop.arg_list[0].name|snake_case}}{%endif%} != self._property_{{prop_name|snake_case}}.value:
            with self._property_{{prop_name|snake_case}}.mutex:
                self._property_{{prop_name|snake_case}}.value = {%if prop.arg_list | length > 1%}value{%else%}{{prop.arg_list[0].name|snake_case}}{%endif%}
                self._property_{{prop_name|snake_case}}.version += 1
            self._conn.publish("{{prop.value_topic}}", payload, qos=1, retain=True)
            for callback in self._property_{{prop_name|snake_case}}.callbacks:
                {%-if prop.arg_list | length > 1%}
                    callback({%for arg in prop.arg_list %}value.{{arg.name|snake_case}}{%if not loop.last%}, {%endif%}{%endfor%})
                {%-else%}
                callback({{prop.arg_list[0].name|snake_case}})
                {%-endif%}

    def set_{{prop_name | snake_case}}(self, {%for arg in prop.arg_list %}{{arg.name|snake_case}}: {{arg.python_type}}{%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_type}}.")
        {%-endfor%}

        {%if prop.arg_list | length > 1%}
        obj = stinger_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_type}}{%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, connection: BrokerConnection):
        self._conn = connection
        {%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_type}}{%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_type}}{%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) -> {{stinger.python.server_class_name}}:
        new_server = {{stinger.python.server_class_name}}(self._conn)
        {%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

if __name__ == '__main__':
    """
    This shows an example on how to run the code.  Ideally, your app should do something similar, but use the methods in
    a more meaningful way.
    """
    from time import sleep
    import signal
    {%set broker = stinger.get_example_broker()%}
    from connection import {{broker.class_name}}

    conn = {{broker.class_name}}({%if broker.hostname is none%}'localhost', 1883{%endif%})
    server = {{stinger.python.server_class_name}}(conn)

    {%for prop_name, prop in stinger.properties.items()-%}
    {%if prop.arg_list | length > 1%}
    server.{{prop_name | snake_case}} = {{prop.python_class}}(
        {%for arg in prop.arg_list%}
        {{arg.name}}={{arg.get_random_example_value()}},
        {%endfor%}
    )
    {%else%}
    server.{{prop_name | snake_case}} = {{prop.arg_list[0].get_random_example_value()}}
    {%endif%}
    {%endfor%}

    {%for method_name, method in stinger.methods.items()%}
    @server.handle_{{method_name | snake_case}} 
    def {{method_name | snake_case}}({%for arg in method.arg_list%}{{arg.name}}: {{arg.python_type}}{%if not loop.last%}, {%endif%}{%endfor%}) -> {{method.return_value_python_type}}:
        """ This is an example handler for the '{{method_name}}' method.  """
        print(f"Running {{method_name | snake_case}}'({%for arg in method.arg_list %}{ {{-arg.name-}} }{%if not loop.last%}, {%endif%}{%endfor%})'")
        return {{method.get_return_value_random_example_value('python')}}
    {%endfor%}

    {%for prop_name, prop in stinger.properties.items()%}
    @server.on_{{prop_name | snake_case}}_updates
    def on_{{prop_name | snake_case}}_update({%for arg in prop.arg_list %}{{arg.name}}: {{arg.python_type}}{%if not loop.last%}, {%endif%}{%endfor%}):
        print(f"Received update for '{{prop_name}}' property: {%for arg in prop.arg_list %}{ {{arg.name}}= }{%if not loop.last%}, {%endif%}{%endfor%}")
    {%endfor%}

    print("Ctrl-C will stop the program.")

    while True:
        try:
            {%for sig_name, sig in stinger.signals.items()-%}
            server.emit_{{sig_name}}({%for arg in sig.arg_list%}{{arg.get_random_example_value()}}{%if not loop.last%}, {%endif%}{%endfor%})
            {%endfor%}
            sleep(4)
            {%for sig_name, sig in stinger.signals.items()-%}
            server.emit_{{sig_name}}({%for arg in sig.arg_list%}{{arg.name}}={{arg.get_random_example_value()}}{%if not loop.last%}, {%endif%}{%endfor%})
            {%endfor%}
            sleep(6)
        except KeyboardInterrupt:
            break


    signal.pause()
