#!/usr/bin/env python3

"""The classroom's link to the church broker.

Runs as the classroom user so it can put messages on the room's screen
through classroom-notify, and holds no opinion about anything else: it
subscribes, validates, and hands work to the classroom commands that
already exist.

Nothing here is allowed to matter to the room. A broker that is down, a
network that is missing, a machine with no broker configured at all —
each of those is a service that sits quietly, not a classroom that
fails. The dock, the browser and the DVD player never ask this whether
it is connected.
"""

import json
import os
import subprocess
import sys
import time

sys.path.insert(0, "/usr/local/lib/ekklesia-classroom")
sys.path.insert(
    0,
    os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "lib"),
)

import classroom_mqtt as transport  # noqa: E402
from classroom_config import read_device_config  # noqa: E402

NOTIFY = "/usr/local/bin/classroom-notify"

# Long enough that a broker down for the morning does not fill the
# journal, short enough that a blip is invisible.
RECONNECT_MIN_SECONDS = 2
RECONNECT_MAX_SECONDS = 60

VERSION_FILE = "/usr/local/lib/ekklesia-classroom/VERSION"


def log(message):
    print(f"[classroom-mqtt] {message}", flush=True)


def version():
    try:
        with open(VERSION_FILE, encoding="utf-8") as handle:
            return handle.read().strip()
    except OSError:
        return "unknown"


class Classroom:
    def __init__(self, room, settings):
        self.room = room
        self.settings = settings
        self.notifications = transport.Notifications()
        self.connected = False

    # -- delivery ---------------------------------------------------

    def show(self, notification):
        try:
            subprocess.Popen(
                [
                    NOTIFY,
                    f"--urgency={notification['urgency']}",
                    "--",
                    notification["title"],
                    notification["message"],
                ],
                start_new_session=True,
            )
        except OSError as error:
            log(f"could not show a notification: {error}")

    # -- broker callbacks -------------------------------------------

    def on_connect(self, client, userdata, flags, reason_code, properties=None):
        if reason_code != 0:
            log(f"broker refused the connection: {reason_code}")
            return

        self.connected = True

        topics = transport.notify_topics(self.room)

        for topic in topics:
            client.subscribe(topic, qos=1)

        log(f"connected as {self.room}, listening on {', '.join(topics)}")

        # Retained, because this is state rather than an event: whoever
        # asks later should be told the room is up without waiting for
        # it to say so again.
        client.publish(
            transport.status_topic(self.room),
            json.dumps(
                {
                    "room": self.room,
                    "online": True,
                    "version": version(),
                    "since": int(time.time()),
                }
            ),
            qos=1,
            retain=True,
        )

    def on_disconnect(self, client, userdata, flags, reason_code=None, properties=None):
        # Only worth a line the first time: paho retries on its own, and
        # a broker that is down for an hour should not write an hour of
        # log lines.
        if self.connected:
            self.connected = False
            log("lost the broker; retrying quietly until it returns")

    def on_message(self, client, userdata, message):
        try:
            self.dispatch(message)
        except Exception as error:  # noqa: BLE001
            # A bad message is never worth taking the service down for.
            log(f"dropped a message on {message.topic}: {error}")

    def dispatch(self, message):
        topic = message.topic

        if topic in transport.notify_topics(self.room):
            notification, reason = self.notifications.accept(
                message.payload,
                retained=bool(message.retain),
            )

            if notification is None:
                log(f"ignored a notification on {topic}: {reason}")
                return

            self.show(notification)
            return

        log(f"nothing listens to {topic} yet")


def main():
    config = read_device_config()
    room = transport.room_name(config)
    settings = transport.broker(config)

    if settings is None:
        log("no broker configured for this classroom; nothing to do")
        return 0

    try:
        import paho.mqtt.client as mqtt
    except ImportError:
        log("python3-paho-mqtt is not installed; nothing to do")
        return 0

    classroom = Classroom(room, settings)

    # A stable client id keeps one room from appearing twice, and a clean
    # session means a broker never hands over a backlog of notifications
    # from while the room was off. Yesterday's message is not news.
    client = mqtt.Client(
        callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
        client_id=f"classroom-{room}",
        protocol=mqtt.MQTTv311,
        clean_session=True,
    )

    if settings["username"]:
        client.username_pw_set(settings["username"], settings["password"])

    client.will_set(
        transport.status_topic(room),
        json.dumps({"room": room, "online": False}),
        qos=1,
        retain=True,
    )

    client.on_connect = classroom.on_connect
    client.on_disconnect = classroom.on_disconnect
    client.on_message = classroom.on_message

    client.reconnect_delay_set(
        min_delay=RECONNECT_MIN_SECONDS,
        max_delay=RECONNECT_MAX_SECONDS,
    )

    log(f"room {room}, broker {settings['host']}:{settings['port']}")

    # connect_async plus loop_forever means a broker that is not there at
    # boot is simply a broker that is not there yet.
    client.connect_async(settings["host"], settings["port"], keepalive=45)

    try:
        client.loop_forever(retry_first_connection=True)
    except KeyboardInterrupt:
        pass
    finally:
        client.disconnect()

    return 0


if __name__ == "__main__":
    sys.exit(main())
