import logging
import threading
from collections.abc import Callable
from typing import Any

import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion

from frigate.comms.base_communicator import Communicator
from frigate.config import FrigateConfig

logger = logging.getLogger(__name__)


class MqttClient(Communicator):
    """Frigate wrapper for mqtt client."""

    def __init__(self, config: FrigateConfig) -> None:
        self.config = config
        self.mqtt_config = config.mqtt
        self.connected = False

    def subscribe(self, receiver: Callable) -> None:
        """Wrapper for allowing dispatcher to subscribe."""
        self._dispatcher = receiver
        self._start()

    def publish(self, topic: str, payload: Any, retain: bool = False) -> None:
        """Wrapper for publishing when client is in valid state."""
        if not self.connected:
            logger.debug(f"Unable to publish to {topic}: client is not connected")
            return

        self.client.publish(
            f"{self.mqtt_config.topic_prefix}/{topic}",
            payload,
            qos=self.config.mqtt.qos,
            retain=retain,
        )

    def stop(self) -> None:
        self.publish("available", "stopped", retain=True)
        self.client.disconnect()

    def _notifications_enabled_in_config(self) -> bool:
        """Whether notifications are configured globally or on any camera.

        Notifications can be enabled per camera with the global config left
        disabled, so the global topics must consider both (matching how
        app.py decides to create the WebPushClient).
        """
        return self.config.notifications.enabled_in_config or any(
            cam.enabled and cam.notifications.enabled_in_config
            for cam in self.config.cameras.values()
        )

    def _set_initial_topics(self) -> None:
        """Set initial state topics."""
        for camera_name, camera in self.config.cameras.items():
            self.publish(
                f"{camera_name}/enabled/state",
                "ON" if camera.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/recordings/state",
                "ON" if camera.record.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/snapshots/state",
                "ON" if camera.snapshots.enabled else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/audio/state",
                "ON" if camera.audio.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/detect/state",
                "ON" if camera.detect.enabled else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/motion/state",
                "ON",
                retain=True,
            )
            self.publish(
                f"{camera_name}/improve_contrast/state",
                "ON" if camera.motion.improve_contrast else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/ptz_autotracker/state",
                "ON" if camera.onvif.autotracking.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/motion_threshold/state",
                camera.motion.threshold,
                retain=True,
            )
            self.publish(
                f"{camera_name}/motion_contour_area/state",
                camera.motion.contour_area,
                retain=True,
            )
            self.publish(
                f"{camera_name}/motion",
                "OFF",
                retain=False,
            )
            self.publish(
                f"{camera_name}/birdseye/state",
                "ON" if camera.birdseye.enabled else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/birdseye_mode/state",
                (
                    camera.birdseye.mode.value.upper()
                    if camera.birdseye.enabled
                    else "OFF"
                ),
                retain=True,
            )
            self.publish(
                f"{camera_name}/review_alerts/state",
                "ON" if camera.review.alerts.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/review_detections/state",
                "ON" if camera.review.detections.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/object_descriptions/state",
                "ON" if camera.objects.genai.enabled_in_config else "OFF",
                retain=True,
            )
            self.publish(
                f"{camera_name}/review_descriptions/state",
                "ON" if camera.review.genai.enabled_in_config else "OFF",
                retain=True,
            )

            for mask_name, motion_mask in camera.motion.mask.items():
                if motion_mask:
                    self.publish(
                        f"{camera_name}/motion_mask/{mask_name}/state",
                        "ON" if motion_mask.enabled else "OFF",
                        retain=True,
                    )

            for mask_name, object_mask in camera.objects.mask.items():
                if object_mask:
                    self.publish(
                        f"{camera_name}/object_mask/{mask_name}/state",
                        "ON" if object_mask.enabled else "OFF",
                        retain=True,
                    )

            for zone_name, zone in camera.zones.items():
                self.publish(
                    f"{camera_name}/zone/{zone_name}/state",
                    "ON" if zone.enabled else "OFF",
                    retain=True,
                )

        if self._notifications_enabled_in_config():
            self.publish(
                "notifications/state",
                "ON" if self.config.notifications.enabled else "OFF",
                retain=True,
            )

        self.publish(
            "profile/state",
            self.config.active_profile or "none",
            retain=True,
        )
        self.publish("available", "online", retain=True)

    def on_mqtt_command(
        self, client: mqtt.Client, userdata: Any, message: mqtt.MQTTMessage
    ) -> None:
        self._dispatcher(
            message.topic.replace(f"{self.mqtt_config.topic_prefix}/", "", 1),
            message.payload.decode(),
        )

    def _on_connect(
        self,
        client: mqtt.Client,
        userdata: Any,
        flags: Any,
        reason_code: mqtt.ReasonCode,  # type: ignore[name-defined]
        properties: Any,
    ) -> None:
        """Mqtt connection callback."""
        threading.current_thread().name = "mqtt"
        if reason_code != 0:
            if reason_code == "Server unavailable":
                logger.error(
                    "Unable to connect to MQTT server: MQTT Server unavailable"
                )
            elif reason_code == "Bad user name or password":
                logger.error(
                    "Unable to connect to MQTT server: MQTT Bad username or password"
                )
            elif reason_code == "Not authorized":
                logger.error("Unable to connect to MQTT server: MQTT Not authorized")
            else:
                logger.error(
                    "Unable to connect to MQTT server: Connection refused. Error code: %s",
                    reason_code.getName(),
                )

        self.connected = True
        logger.debug("MQTT connected")
        client.subscribe(f"{self.mqtt_config.topic_prefix}/#", qos=self.config.mqtt.qos)
        self._set_initial_topics()

    def _on_disconnect(
        self,
        client: mqtt.Client,
        userdata: Any,
        flags: Any,
        reason_code: mqtt.ReasonCode,  # type: ignore[name-defined]
        properties: Any,
    ) -> None:
        """Mqtt disconnection callback."""
        self.connected = False
        logger.error("MQTT disconnected")

    def _start(self) -> None:
        """Start mqtt client."""
        self.client = mqtt.Client(
            callback_api_version=CallbackAPIVersion.VERSION2,
            client_id=self.mqtt_config.client_id,
        )
        self.client.on_connect = self._on_connect
        self.client.on_disconnect = self._on_disconnect
        self.client.will_set(
            self.mqtt_config.topic_prefix + "/available",
            payload="offline",
            qos=1,
            retain=True,
        )

        # register callbacks
        callback_types = [
            "enabled",
            "recordings",
            "snapshots",
            "detect",
            "audio",
            "motion",
            "improve_contrast",
            "ptz_autotracker",
            "motion_threshold",
            "motion_contour_area",
            "birdseye",
            "birdseye_mode",
            "review_alerts",
            "review_detections",
            "object_descriptions",
            "review_descriptions",
            "notifications",
        ]

        for name in self.config.cameras.keys():
            for callback in callback_types:
                self.client.message_callback_add(
                    f"{self.mqtt_config.topic_prefix}/{name}/{callback}/set",
                    self.on_mqtt_command,
                )

            # notifications suspend doesn't follow the /set topic pattern
            self.client.message_callback_add(
                f"{self.mqtt_config.topic_prefix}/{name}/notifications/suspend",
                self.on_mqtt_command,
            )

            if self.config.cameras[name].onvif.host:
                self.client.message_callback_add(
                    f"{self.mqtt_config.topic_prefix}/{name}/ptz",
                    self.on_mqtt_command,
                )

            for mask_name in self.config.cameras[name].motion.mask.keys():
                self.client.message_callback_add(
                    f"{self.mqtt_config.topic_prefix}/{name}/motion_mask/{mask_name}/set",
                    self.on_mqtt_command,
                )

            for mask_name in self.config.cameras[name].objects.mask.keys():
                self.client.message_callback_add(
                    f"{self.mqtt_config.topic_prefix}/{name}/object_mask/{mask_name}/set",
                    self.on_mqtt_command,
                )

            for zone_name in self.config.cameras[name].zones.keys():
                self.client.message_callback_add(
                    f"{self.mqtt_config.topic_prefix}/{name}/zone/{zone_name}/set",
                    self.on_mqtt_command,
                )

        if self._notifications_enabled_in_config():
            self.client.message_callback_add(
                f"{self.mqtt_config.topic_prefix}/notifications/set",
                self.on_mqtt_command,
            )

        self.client.message_callback_add(
            f"{self.mqtt_config.topic_prefix}/profile/set",
            self.on_mqtt_command,
        )

        self.client.message_callback_add(
            f"{self.mqtt_config.topic_prefix}/onConnect", self.on_mqtt_command
        )

        self.client.message_callback_add(
            f"{self.mqtt_config.topic_prefix}/restart", self.on_mqtt_command
        )

        if self.mqtt_config.tls_ca_certs is not None:
            if (
                self.mqtt_config.tls_client_cert is not None
                and self.mqtt_config.tls_client_key is not None
            ):
                self.client.tls_set(
                    self.mqtt_config.tls_ca_certs,
                    self.mqtt_config.tls_client_cert,
                    self.mqtt_config.tls_client_key,
                )
            else:
                self.client.tls_set(self.mqtt_config.tls_ca_certs)
        if self.mqtt_config.tls_insecure is not None:
            self.client.tls_insecure_set(self.mqtt_config.tls_insecure)
        if self.mqtt_config.user is not None:
            self.client.username_pw_set(
                self.mqtt_config.user, password=self.mqtt_config.password
            )
        try:
            # https://stackoverflow.com/a/55390477
            # with connect_async, retries are handled automatically
            self.client.connect_async(self.mqtt_config.host, self.mqtt_config.port, 60)
            self.client.loop_start()
        except Exception as e:
            logger.error(f"Unable to connect to MQTT server: {e}")
            return
