"""touchpanel-agent entrypoint."""

from __future__ import annotations

import logging
import os
import signal
import socket
import sys
import threading
from dataclasses import dataclass

import paho.mqtt.client as mqtt

from .mpris_bridge import MprisBridge
from .mqtt_discovery import Discovery
from .sway_control import WS_DISCORD, WS_HOME, WS_SPOTIFY, WS_WEB, SwayControl

CONFIG_PATH = os.environ.get("TOUCHPANEL_AGENT_CONFIG", "/etc/touchpanel-agent/config.env")

CONFIG_KEYS = (
    "MQTT_BROKER_HOST",
    "MQTT_BROKER_PORT",
    "MQTT_USERNAME",
    "MQTT_PASSWORD",
    "HA_URL",
    "KIOSK_USERNAME",
    "TOUCHPANEL_NAME",
    "ENABLE_DISCORD",
)

# The workspaces the panel offers over MQTT. Derived from the app table rather than
# fixed, because Discord is per-panel (kiosks[].enable_discord): a select whose
# options include a workspace this image has no app for is a dropdown entry that
# switches to a black screen.
BASE_WORKSPACES = (WS_SPOTIFY, WS_HOME, WS_WEB)

log = logging.getLogger("touchpanel-agent")


@dataclass(frozen=True)
class App:
    name: str
    command: list[str]
    process_pattern: str | None = None
    workspace: str | None = None
    focus_criteria: str | None = None
    icon: str = "mdi:application"


def load_config(path: str = CONFIG_PATH) -> dict[str, str]:
    values: dict[str, str] = {}
    try:
        with open(path, encoding="utf-8") as handle:
            for line in handle:
                line = line.strip()
                if not line or line.startswith("#") or "=" not in line:
                    continue
                key, _, value = line.partition("=")
                values[key.strip()] = value.strip().strip('"').strip("'")
    except OSError as exc:
        log.warning("could not read %s (%s); falling back to the environment", path, exc)

    for key in CONFIG_KEYS:
        if key in os.environ:
            values[key] = os.environ[key]

    return values


def build_apps(config: dict[str, str]) -> dict[str, App]:
    apps = {
        "spotify": App(
            name="Spotify",
            command=["/usr/local/bin/spotify-launch"],
            process_pattern="com.spotify.Client",
            workspace=WS_SPOTIFY,
            focus_criteria='app_id="spotify"',
            icon="mdi:spotify",
        ),
        "home": App(
            name="Home",
            # ha-kiosk is already running (started at session boot, see
            # configs/sway/config) and supervises its own restart loop — this just
            # switches workspace and focuses the existing window rather than
            # relaunching it, since the HA dashboard is stateful. See ha-kiosk's own
            # comment for why this is deliberately not a kill-and-relaunch like the
            # thin client's digest canvas.
            command=["/usr/local/bin/ha-kiosk"],
            process_pattern="ha-kiosk",
            workspace=WS_HOME,
            focus_criteria='app_id="chromium.*"',
            icon="mdi:home-assistant",
        ),
        "web_browser": App(
            name="web browser",
            command=["/usr/local/bin/web-browser"],
            workspace=WS_WEB,
            icon="mdi:web",
        ),
    }

    # Discord, on the panels that asked for it. Launched at session start by the sway
    # config, so this entry — like "home" — is normally a focus-and-switch rather than
    # a cold start: launch_app() sees the process already running and just brings the
    # workspace up, which is what "step outside without dropping the call" needs.
    # Matched on X11 class, not app_id: it is an XWayland window (see discord-launch).
    if str(config.get("ENABLE_DISCORD", "")).strip().lower() == "true":
        apps["discord"] = App(
            name="Discord",
            command=["/usr/local/bin/discord-launch"],
            process_pattern="com.discordapp.Discord",
            workspace=WS_DISCORD,
            focus_criteria='class="discord"',
            icon="mdi:discord",
        )

    return apps


def make_client(client_id: str) -> mqtt.Client:
    # paho-mqtt 2.x requires an explicit callback API version; bookworm's
    # python3-paho-mqtt is 1.6.x and has no such argument. VERSION1 is requested when
    # available so the callback signatures below are identical under both.
    callback_api = getattr(mqtt, "CallbackAPIVersion", None)
    if callback_api is not None:
        return mqtt.Client(callback_api.VERSION1, client_id=client_id)
    return mqtt.Client(client_id=client_id)


def main() -> int:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
        stream=sys.stdout,
    )

    config = load_config()
    hostname = socket.gethostname()
    node_id = "".join(c if c.isalnum() else "_" for c in hostname).strip("_") or "touchpanel"
    friendly_name = config.get("TOUCHPANEL_NAME") or f"Touch panel ({hostname})"
    # The HA area this device sits in, published as suggested_area — see
    # docs/rooms-and-endpoints.md. Blank is fine and means "no suggestion".
    room = config.get("TOUCHPANEL_ROOM", "")

    broker_host = config.get("MQTT_BROKER_HOST", "")
    broker_port = int(config.get("MQTT_BROKER_PORT") or 1883)

    sway = SwayControl()
    apps = build_apps(config)
    workspaces = tuple(dict.fromkeys(
        list(BASE_WORKSPACES) + [app.workspace for app in apps.values() if app.workspace]))

    client = make_client(f"touchpanel-agent-{node_id}")
    if config.get("MQTT_USERNAME"):
        client.username_pw_set(config["MQTT_USERNAME"], config.get("MQTT_PASSWORD") or None)

    discovery = Discovery(client, node_id, friendly_name, room)
    mpris = MprisBridge(discovery.publish_media_state, sway.session_env)

    def on_launch(key: str) -> None:
        app = apps[key]
        sway.launch_app(
            app.command,
            process_pattern=app.process_pattern,
            workspace=app.workspace,
            focus_criteria=app.focus_criteria,
        )
        if app.workspace:
            discovery.publish_workspace(app.workspace)

    def on_workspace(payload: str) -> None:
        name = payload.strip()
        # Enumerated, never passed through: see the security note in mqtt_discovery.py.
        if name not in workspaces:
            log.warning("ignoring unknown workspace %r", name)
            return
        sway.switch_workspace(name)
        discovery.publish_workspace(name)

    def on_connect(_client, _userdata, _flags, rc):
        if rc != 0:
            log.error("MQTT connection refused (rc=%s)", rc)
            return
        log.info("connected to MQTT broker %s:%s", broker_host, broker_port)
        discovery.register_media_player(mpris.handle_command, mpris.set_volume)
        discovery.register_app_launchers(apps, on_launch)
        discovery.register_workspace_select(workspaces, on_workspace, WS_HOME)
        discovery.subscribe_all()
        discovery.publish_available(True)

    def on_disconnect(_client, _userdata, rc):
        log.warning("disconnected from MQTT broker (rc=%s); paho will retry", rc)

    def on_message(_client, _userdata, message):
        discovery.dispatch(message.topic, message.payload.decode("utf-8", "replace"))

    client.on_connect = on_connect
    client.on_disconnect = on_disconnect
    client.on_message = on_message
    client.will_set(discovery.availability_topic, "offline", qos=1, retain=True)

    stop_event = threading.Event()

    def handle_signal(_signum, _frame):
        stop_event.set()

    signal.signal(signal.SIGTERM, handle_signal)
    signal.signal(signal.SIGINT, handle_signal)

    if not broker_host:
        log.error("MQTT_BROKER_HOST is not set in %s — running without HA control", CONFIG_PATH)
    else:
        # connect_async + loop_start, never a blocking connect(): the panel must come
        # up and show Spotify/HA/the browser with the container host powered off,
        # same "reactive path never depends on a remote service" rule as the thin
        # client's Phase 11.10.
        client.connect_async(broker_host, broker_port, keepalive=60)
        client.loop_start()

    log.info("touchpanel-agent %s started (node_id=%s)", node_id, node_id)
    if not config.get("HA_URL"):
        log.warning("HA_URL is not set — the Home workspace will have nothing to show")

    try:
        mpris.run_forever(stop_event)
    finally:
        log.info("shutting down")
        if broker_host:
            discovery.publish_available(False)
            client.loop_stop()
            client.disconnect()

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
