#!/usr/bin/env python3
"""mic-follow's desktop half: one MQTT device per client machine, exposing "which
microphone is live" to Home Assistant and nothing else.

THE DIVISION OF LABOUR, and why it is this way round:

  Home Assistant  decides WHERE the person is and therefore which mic should be live.
                  It is the only thing that sees the locator sources at all.
  this agent      knows HOW to change the input on this machine, and nothing about
                  presence, people, or rooms beyond the names in its own config.

Same rule as every other agent in this repo: the inbound control surface is MQTT
discovery entities and nothing else — no HTTP listener, no direct path from the LLM,
no presence logic on the desktop. It also means a second client machine is a second
copy of this file with a different config, which is what "prep it for multiple
users/clients" comes down to.

THE ONE RULE THAT MAKES IT PREDICTABLE: **off means the desk mic.** The Follow-me
switch being off is not "ignore me", it is an active guarantee that this machine is on
its own microphone. Being live on the wrong mic is the failure somebody notices in
front of their friends, so the safe state is reachable by one tap on the dock, one
button on a watch, or one MQTT message — and it does not depend on presence being
right, on the remote machine being up, or on this agent having seen a recent update.

Nothing here has run against a real PipeWire or a real Home Assistant. The audio layer
it calls is `audio_sources.py`, whose parsing is fixture-tested; the commands that
change state are not. See mic-follow/README.md.
"""

from __future__ import annotations

import argparse
import json
import logging
import os
import shlex
import signal
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import audio_sources  # noqa: E402

import paho.mqtt.client as mqtt  # noqa: E402

log = logging.getLogger("mic-follow")

DESK = "desk"
DISCOVERY_PREFIX = "homeassistant"


class Client:
    """One desktop machine, its microphones, and its Home Assistant entities."""

    def __init__(self, config: dict) -> None:
        self.config = config
        self.node_id = config["node_id"]
        self.friendly_name = config.get("friendly_name") or self.node_id
        self.room = config.get("room", "")
        self.desk_source = config["desk_source"]
        self.move_streams = list(config.get("move_streams") or [])
        self.reconcile_seconds = float(config.get("reconcile_seconds", 10))
        # option name -> {source, start_command, stop_command}
        self.sources: dict[str, dict] = {
            DESK: {"source": self.desk_source, "start_command": "", "stop_command": ""}
        }
        for entry in config.get("sources") or []:
            self.sources[entry["room"]] = {
                "source": entry["source"],
                "start_command": entry.get("start_command", ""),
                "stop_command": entry.get("stop_command", ""),
            }

        self.base = f"smarthome/mic_follow/{self.node_id}"
        self.armed = False
        self.selected = DESK
        self.active = DESK
        self.actual_description = ""

    # --- entity plumbing ------------------------------------------------------------
    @property
    def availability_topic(self) -> str:
        return f"{self.base}/availability"

    def device_block(self) -> dict:
        block = {
            "identifiers": [f"mic_follow_{self.node_id}"],
            "name": self.friendly_name,
            "manufacturer": "SmartestHome",
            "model": "mic-follow desktop agent",
        }
        if self.room:
            block["suggested_area"] = self.room
        return block

    def discovery_payloads(self) -> list[tuple[str, dict]]:
        device = self.device_block()
        common = {
            "device": device,
            "availability_topic": self.availability_topic,
            "payload_available": "online",
            "payload_not_available": "offline",
        }
        return [
            (f"{DISCOVERY_PREFIX}/switch/{self.node_id}/follow_me/config", {
                **common,
                "name": "Follow-me mic",
                "unique_id": f"mic_follow_{self.node_id}_armed",
                # object_id fixes the entity_id instead of letting HA derive one from
                # the device and entity names. Everything downstream — the generated
                # automations, the template sensor, the dock binding, identity's toggle
                # allowlist, the watch — refers to these by name, and "probably
                # switch.amirs_desktop_follow_me_mic" is not a thing to build four
                # consumers on.
                "object_id": f"mic_follow_{self.node_id}_armed",
                "command_topic": f"{self.base}/armed/set",
                "state_topic": f"{self.base}/armed/state",
                "payload_on": "ON",
                "payload_off": "OFF",
                "icon": "mdi:microphone-message",
            }),
            (f"{DISCOVERY_PREFIX}/select/{self.node_id}/mic_input/config", {
                **common,
                "name": "Mic input",
                "unique_id": f"mic_follow_{self.node_id}_input",
                "object_id": f"mic_follow_{self.node_id}_input",
                "command_topic": f"{self.base}/input/set",
                "state_topic": f"{self.base}/input/state",
                "options": list(self.sources.keys()),
                "icon": "mdi:microphone",
            }),
            (f"{DISCOVERY_PREFIX}/sensor/{self.node_id}/mic_actual/config", {
                **common,
                "name": "Live microphone",
                "unique_id": f"mic_follow_{self.node_id}_actual",
                "object_id": f"mic_follow_{self.node_id}_actual",
                "state_topic": f"{self.base}/actual/state",
                "icon": "mdi:microphone-settings",
            }),
        ]

    # --- the actual switching --------------------------------------------------------
    def _run_hook(self, command: str, label: str) -> None:
        """A source's start/stop hook. This is what brings a NETWORK microphone up and
        down — and bringing it down matters more than bringing it up: a room mic that
        keeps streaming after the switch left it is a hot mic in somebody's flat."""
        if not command.strip():
            return
        try:
            result = subprocess.run(shlex.split(command), capture_output=True,
                                    text=True, timeout=20, check=False)
            if result.returncode != 0:
                log.warning("%s hook failed (%s): %s", label, result.returncode,
                            (result.stderr or "").strip()[:200])
        except (OSError, subprocess.SubprocessError) as exc:
            log.warning("%s hook could not run: %s", label, exc)

    def apply(self, option: str) -> str:
        """Make `option` the live input. Returns the option actually applied — which is
        `desk` whenever the requested one cannot be found, because silence is a worse
        answer than the wrong room's microphone only until you remember that the desk
        mic is the one the person is not standing in front of. Falling back loudly and
        predictably beats leaving Discord holding a device that has gone away."""
        wanted = self.sources.get(option)
        if wanted is None:
            log.warning("unknown input %r — falling back to %s", option, DESK)
            option, wanted = DESK, self.sources[DESK]

        sources = audio_sources.list_sources()
        target = audio_sources.select_source(sources, wanted["source"])
        if target is None and option != DESK:
            log.warning("no audio source matching %r on this machine — falling back to %s",
                        wanted["source"], DESK)
            option, wanted = DESK, self.sources[DESK]
            target = audio_sources.select_source(sources, wanted["source"])
        if target is None:
            log.error("no audio source matching %r either — leaving the input alone",
                      wanted["source"])
            return self.active

        if option != self.active:
            previous = self.sources.get(self.active)
            if previous:
                self._run_hook(previous.get("stop_command", ""), f"{self.active} stop")
            self._run_hook(wanted.get("start_command", ""), f"{option} start")

        audio_sources.set_default_source(target)
        # And move what is already recording: an application that picked a specific
        # microphone in its own settings — which anyone with a studio mic has — does
        # not follow the default.
        for stream in audio_sources.streams_to_move(
                audio_sources.list_capture_streams(), self.move_streams, target):
            log.info("moving %s's live stream to %s", stream.application, target.description)
            audio_sources.move_stream(stream, target)

        self.active = option
        self.actual_description = target.description
        log.info("input is now %s (%s)", option, target.description)
        return option


def make_mqtt_client(client_id: str) -> mqtt.Client:
    # Same shim as every other agent here: paho 2.x wants an explicit callback API
    # version, bookworm's 1.6.x has no such argument.
    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(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="mic-follow desktop agent")
    parser.add_argument("--config", default=os.environ.get(
        "MIC_FOLLOW_CONFIG", str(Path.home() / ".config/mic-follow/client.json")))
    parser.add_argument("--list-sources", action="store_true",
                        help="print this machine's audio inputs and exit")
    args = parser.parse_args(argv)

    logging.basicConfig(level=os.environ.get("MIC_FOLLOW_LOG_LEVEL", "INFO"),
                        format="%(asctime)s %(levelname)s %(name)s: %(message)s",
                        stream=sys.stdout)

    if args.list_sources:
        return audio_sources.main()

    config_path = Path(args.config).expanduser()
    if not config_path.exists():
        log.error("no config at %s — generate one with mic-follow/generate.py", config_path)
        return 2
    client = Client(json.loads(config_path.read_text()))

    broker = client.config.get("mqtt", {}) or {}
    host = broker.get("host", "")
    if not host:
        log.error("mqtt.host is not set in %s", config_path)
        return 2

    mqtt_client = make_mqtt_client(f"mic-follow-{client.node_id}-{socket.gethostname()}")
    if broker.get("username"):
        mqtt_client.username_pw_set(broker["username"], broker.get("password") or None)
    mqtt_client.will_set(client.availability_topic, "offline", qos=1, retain=True)

    def publish_state() -> None:
        mqtt_client.publish(f"{client.base}/armed/state", "ON" if client.armed else "OFF",
                            qos=1, retain=True)
        mqtt_client.publish(f"{client.base}/input/state", client.active, qos=1, retain=True)
        mqtt_client.publish(f"{client.base}/actual/state",
                            client.actual_description or "unknown", qos=1, retain=True)

    def desired_option() -> str:
        # The whole policy, in one line: armed follows the selection, unarmed is the
        # desk mic. Everything else in this file is mechanism.
        return client.selected if client.armed else DESK

    def reconcile(force: bool = False) -> None:
        wanted = desired_option()
        if force or wanted != client.active:
            client.apply(wanted)
            publish_state()
            return
        # Nothing asked for a change — but something else on the desktop may have moved
        # the default (plugging in a headset does exactly that), so the sensor has to be
        # re-read rather than assumed.
        current = audio_sources.current_default_source_name()
        target = audio_sources.select_source(
            audio_sources.list_sources(), client.sources[client.active]["source"])
        if target is not None and current and current != target.name:
            log.info("something else changed the default input — putting it back")
            client.apply(wanted)
            publish_state()

    def on_connect(_client, _userdata, _flags, rc):
        if rc != 0:
            log.error("MQTT connection refused (rc=%s)", rc)
            return
        log.info("connected to MQTT %s:%s", host, broker.get("port", 1883))
        for topic, payload in client.discovery_payloads():
            mqtt_client.publish(topic, json.dumps(payload), qos=1, retain=True)
        mqtt_client.subscribe([(f"{client.base}/armed/set", 1), (f"{client.base}/input/set", 1)])
        mqtt_client.publish(client.availability_topic, "online", qos=1, retain=True)
        reconcile(force=True)

    def on_message(_client, _userdata, message):
        payload = message.payload.decode("utf-8", "replace").strip()
        if message.topic.endswith("/armed/set"):
            client.armed = payload.upper() == "ON"
            log.info("follow-me %s", "armed" if client.armed else "disarmed")
        elif message.topic.endswith("/input/set"):
            if payload not in client.sources:
                log.warning("ignoring unknown input %r", payload)
                return
            client.selected = payload
            log.info("input selection is now %r", payload)
        reconcile()

    mqtt_client.on_connect = on_connect
    mqtt_client.on_message = on_message
    mqtt_client.on_disconnect = lambda *_: log.warning("disconnected from MQTT; paho will retry")

    stop = threading.Event()
    signal.signal(signal.SIGTERM, lambda *_: stop.set())
    signal.signal(signal.SIGINT, lambda *_: stop.set())

    mqtt_client.connect_async(host, int(broker.get("port", 1883)), keepalive=60)
    mqtt_client.loop_start()
    log.info("mic-follow agent for %s started", client.node_id)

    try:
        while not stop.wait(client.reconcile_seconds):
            reconcile()
    finally:
        log.info("shutting down — returning to the desk microphone")
        # Leaving a machine on a remote microphone because a service stopped is exactly
        # the surprise this component exists to avoid, and it also shuts down any
        # network mic stream through the stop hook.
        client.armed = False
        client.apply(DESK)
        mqtt_client.publish(client.availability_topic, "offline", qos=1, retain=True)
        mqtt_client.loop_stop()
        mqtt_client.disconnect()
    return 0


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