#!/usr/bin/env python3
"""Turn CoreSystemConfig.json's `mic_follow` block into everything the feature needs.

Writes into mic-follow/generated/ (gitignored — the per-client configs carry the MQTT
password):

  <node_id>/client.json                  the desktop agent's config
  <node_id>/mic-follow-<node_id>.service the systemd --user unit for it
  ha-package/mic_follow.yaml             one status sensor + one automation per client
  dock-bindings.md                       the Stream Dock toggle key, per client
  identity-toggles.json                  the allowlist identity serves to the watch

One client is one desktop machine and the one person it follows. Adding a second
person with a second PC is a second entry in `clients`, and everything below comes out
twice with no other change — which is what "prep it for multiple users/clients" means
in practice.

Usage:
  mic-follow/generate.py [CoreSystemConfig.json] [--out DIR]
"""

from __future__ import annotations

import json
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parent.parent
DESK = "desk"


def room_label(room: str) -> str:
    """`living_room` -> `Living room`. Short and readable, because this ends up as the
    text on a 72-pixel key and on a watch screen, not in a log line."""
    if room == DESK:
        return "Desk"
    return room.replace("_", " ").strip().capitalize() or room


def entity_ids(node_id: str) -> dict[str, str]:
    """The four entity_ids every consumer refers to. They are deterministic because the
    agent sets `object_id` in its discovery payloads — see desktop_agent.py."""
    return {
        "armed": f"switch.mic_follow_{node_id}_armed",
        "input": f"select.mic_follow_{node_id}_input",
        "actual": f"sensor.mic_follow_{node_id}_actual",
        "status": f"sensor.mic_follow_{node_id}_status",
    }


def remote_source_name(source: dict) -> str:
    """A remote microphone's PipeWire source name. Generated rather than configured:
    it is created by remote-mic.sh on this desktop, so nobody has to go and look it up
    on a machine where it does not exist yet."""
    return str(source.get("source") or "").strip() or f"micfollow_{source['room']}"


def build_remote_env(source: dict, repo_root: Path) -> str:
    """One remote microphone's parameters, read by remote-mic.sh."""
    remote = source.get("remote") or {}
    name = remote_source_name(source)
    description = source.get("description") or f"{room_label(source['room'])} microphone"
    return "\n".join([
        f"# Generated by mic-follow/generate.py for the '{source['room']}' microphone.",
        "# Read by mic-follow/remote-mic.sh. Regenerate rather than editing.",
        f"REMOTE_HOST={remote.get('host', '')}",
        f"REMOTE_USER={remote.get('user', 'kiosk')}",
        f"REMOTE_MIC={remote.get('mic_source', '')}",
        f"SOURCE_NAME={name}",
        f"SOURCE_DESCRIPTION={description}",
        f"RATE={remote.get('rate', 48000)}",
        f"CHANNELS={remote.get('channels', 1)}",
        f"LATENCY_MS={remote.get('latency_ms', 40)}",
        f"SSH_KEY={remote.get('ssh_key', '')}",
        "",
    ])


def source_entry(source: dict, repo_root: Path) -> dict:
    """One entry of the agent's `sources` list.

    A remote microphone's hooks are generated, not typed: the whole point of the
    `remote` block is that "start the stream" and — much more importantly — "stop the
    stream" are not things a person should be relying on themselves to have written
    correctly in a config file.
    """
    if source.get("remote"):
        room = source["room"]
        script = repo_root / "mic-follow" / "remote-mic.sh"
        return {
            "room": room,
            "source": remote_source_name(source),
            "start_command": f"{script} start {room}",
            "stop_command": f"{script} stop {room}",
        }
    return {
        "room": source["room"],
        "source": source["source"],
        "start_command": source.get("start_command", ""),
        "stop_command": source.get("stop_command", ""),
    }


def build_client_config(client: dict, cfg: dict) -> dict:
    prefix = cfg["network"]["subnet_prefix"]
    container_ip = f"{prefix}.{cfg['container_host']['ip_last_octet']}"
    secrets = cfg.get("secrets", {}) or {}
    return {
        "node_id": client["node_id"],
        "friendly_name": client.get("friendly_name") or client["node_id"],
        "room": client.get("room", ""),
        "mqtt": {
            "host": container_ip,
            "port": cfg["ports"]["mqtt"],
            "username": secrets.get("mqtt_username", ""),
            "password": secrets.get("mqtt_password", ""),
        },
        "desk_source": client["desk_source"],
        "move_streams": list(client.get("move_streams") or []),
        "reconcile_seconds": client.get("reconcile_seconds", 10),
        "source_timeout_seconds": client.get("source_timeout_seconds", 6),
        "sources": [source_entry(source, REPO) for source in client.get("sources") or []],
    }


def build_unit(client: dict) -> str:
    node_id = client["node_id"]
    return f"""[Unit]
Description=mic-follow agent for {client.get('friendly_name') or node_id}
Documentation=file://{REPO}/mic-follow/README.md
After=network-online.target pipewire.service
Wants=pipewire.service

[Service]
Type=simple
ExecStart={REPO}/mic-follow/desktop_agent.py --config %h/.config/mic-follow/client.json
Restart=always
RestartSec=10

[Install]
WantedBy=default.target
"""


def build_ha_package(clients: list[dict]) -> str:
    lines: list[str] = [
        "# Generated by mic-follow/generate.py — do not hand-edit. Change the",
        "# `mic_follow` block in CoreSystemConfig.json and regenerate.",
        "#",
        "# Two things per client:",
        "#",
        "#   a status sensor  whose STATE is the human-readable name of the microphone",
        "#                    that is live right now. It exists so that every surface —",
        "#                    the Stream Dock key, the Pebble app, a dashboard — can",
        "#                    show the answer by displaying ONE entity's state, instead",
        "#                    of each one reimplementing the same three-way template.",
        "#   an automation    that moves the microphone when the person moves, and only",
        "#                    while that client's Follow-me switch is on.",
        "#",
        "# The entity_ids below are fixed by the agent's `object_id`, not guessed.",
        "",
        "template:",
        "  - sensor:",
    ]
    for client in clients:
        node_id = client["node_id"]
        ids = entity_ids(node_id)
        labels = {DESK: "Desk"}
        for source in client.get("sources") or []:
            labels[source["room"]] = room_label(source["room"])
        label_map = json.dumps(labels)
        lines += [
            f"      - name: \"{client.get('friendly_name') or node_id} mic\"",
            f"        unique_id: mic_follow_{node_id}_status",
            f"        # object_id keeps this at {ids['status']} whatever the name becomes.",
            f"        object_id: mic_follow_{node_id}_status",
            "        state: >-",
            f"          {{% set labels = {label_map} %}}",
            f"          {{% set live = states('{ids['input']}') %}}",
            "          {{ labels.get(live, live | replace('_', ' ') | capitalize) }}",
            "        icon: >-",
            f"          {{{{ 'mdi:microphone-message' if is_state('{ids['armed']}', 'on')",
            "             else 'mdi:microphone' }}",
            "        attributes:",
            f"          armed: \"{{{{ is_state('{ids['armed']}', 'on') }}}}\"",
            f"          device: \"{{{{ states('{ids['actual']}') }}}}\"",
            f"          person: \"{client.get('person', '')}\"",
            "",
        ]

    lines += ["automation:"]
    for client in clients:
        node_id = client["node_id"]
        ids = entity_ids(node_id)
        presence = client["presence_entity"]
        desk_room = client.get("room", "")
        dwell = client.get("dwell_seconds", 20)
        return_dwell = client.get("return_dwell_seconds", 5)
        policy = client.get("on_unknown_room", "hold")
        rooms = [source["room"] for source in client.get("sources") or []]

        room_map = {room: room for room in rooms}
        if desk_room:
            room_map[desk_room] = DESK

        lines += [
            f"  - id: mic_follow_{node_id}",
            f"    alias: \"Mic follow: {client.get('friendly_name') or node_id}\"",
            "    description: >-",
            f"      Moves {client.get('person') or 'this client'}'s live microphone to match",
            "      where they are, but only while the Follow-me switch is on. The switch",
            "      being off is an active guarantee of the desk mic, so this automation",
            "      never runs then.",
            "    mode: single",
            "    triggers:",
        ]
        for room in rooms:
            lines += [
                "      - trigger: state",
                f"        entity_id: {presence}",
                f"        to: \"{room}\"",
                f"        for: {{ seconds: {dwell} }}",
            ]
        if desk_room:
            lines += [
                "      # Coming back is faster than leaving: sitting down should give you",
                "      # the good microphone back before you say anything into it.",
                "      - trigger: state",
                f"        entity_id: {presence}",
                f"        to: \"{desk_room}\"",
                f"        for: {{ seconds: {return_dwell} }}",
            ]
        if policy == "desk":
            known = json.dumps(sorted(room_map.keys()))
            lines += [
                "      # on_unknown_room: desk — a room with no microphone configured is a",
                "      # room where nothing can hear you, and this says so rather than",
                "      # leaving another room's mic live.",
                "      - trigger: state",
                f"        entity_id: {presence}",
                f"        not_to: {known}",
                f"        for: {{ seconds: {dwell} }}",
            ]
        lines += [
            "      # And whenever it is switched on, catch up with where the person",
            "      # already is rather than waiting for them to move again.",
            "      - trigger: state",
            f"        entity_id: {ids['armed']}",
            "        to: \"on\"",
            "    conditions:",
            "      - condition: state",
            f"        entity_id: {ids['armed']}",
            "        state: \"on\"",
            "    actions:",
            "      - variables:",
            f"          room_map: {json.dumps(room_map)}",
            f"          current: \"{{{{ states('{presence}') }}}}\"",
            f"          wanted: \"{{{{ room_map.get(current, '{'desk' if policy == 'desk' else 'HOLD'}') }}}}\"",
            "      # HOLD is how on_unknown_room: hold is expressed — no service call at",
            "      # all, so the last microphone stays live while somebody walks through",
            "      # a room nothing can hear them in.",
            "      - condition: template",
            "        value_template: \"{{ wanted != 'HOLD' }}\"",
            "      - action: select.select_option",
            "        target:",
            f"          entity_id: {ids['input']}",
            "        data:",
            "          option: \"{{ wanted }}\"",
            "",
        ]
    return "\n".join(lines)


def build_dock_bindings(clients: list[dict]) -> str:
    out = ["# Stream Dock — the follow-me toggle", "",
           "Generated by `mic-follow/generate.py`. One key per client. Put it on whichever",
           "OpenDeck layer you like — it does not have to share the lighting layer.", ""]
    for client in clients:
        node_id = client["node_id"]
        ids = entity_ids(node_id)
        out += [
            f"## {client.get('friendly_name') or node_id}",
            "",
            "| Field | Value |",
            "|---|---|",
            f"| Entity | `{ids['status']}` |",
            f"| Service | `switch.toggle` |",
            "| Service data JSON | see below |",
            "",
            "```json",
            json.dumps({"entity_id": ids["armed"]}, indent=2),
            "```",
            "",
            "**Point the key's displayed entity at the status sensor, not at the switch.**",
            f"The switch's state is `on`/`off`, which tells you nothing useful; "
            f"`{ids['status']}`'s state is the *name of the microphone that is live right now*",
            "— `Desk`, `Loggia` — which is what you actually want to read at a glance. Its",
            "icon changes with the switch (`mdi:microphone-message` armed, `mdi:microphone`",
            "off), so one key shows both facts: which mic, and whether it will follow you.",
            "",
            "The plugin subscribes to Home Assistant's websocket, so the key updates when",
            "the state changes rather than on a timer — nothing here polls, and the key is",
            "correct within a moment of the microphone actually moving, including when it",
            "was the watch or an automation that moved it.",
            "",
            "Suggested title: leave it EMPTY and let the state be the whole label. A key",
            "reading `Loggia` in large type is readable across a room; the same key reading",
            "`Follow-me mic` over a small `Loggia` is not.",
            "",
        ]
    return "\n".join(out)


def main(argv: list[str]) -> int:
    args = [a for a in argv[1:] if not a.startswith("--")]
    out_dir = Path(__file__).resolve().parent / "generated"
    if "--out" in argv:
        out_dir = Path(argv[argv.index("--out") + 1])
    config_path = Path(args[0]) if args else REPO / "CoreSystemConfig.json"

    if not config_path.exists():
        print(f"error: {config_path} not found", file=sys.stderr)
        return 2
    cfg = json.loads(config_path.read_text())
    section = cfg.get("mic_follow") or {}
    if not section.get("enabled"):
        print("error: mic_follow.enabled is false — nothing to generate", file=sys.stderr)
        return 2
    clients = [c for c in (section.get("clients") or []) if isinstance(c, dict) and c.get("node_id")]
    if not clients:
        print("error: mic_follow.clients is empty", file=sys.stderr)
        return 2

    out_dir.mkdir(parents=True, exist_ok=True)
    for client in clients:
        node_id = client["node_id"]
        client_dir = out_dir / node_id
        client_dir.mkdir(parents=True, exist_ok=True)
        config_file = client_dir / "client.json"
        config_file.write_text(json.dumps(build_client_config(client, cfg), indent=2) + "\n")
        config_file.chmod(0o600)
        (client_dir / f"mic-follow-{node_id}.service").write_text(build_unit(client))
        for source in client.get("sources") or []:
            if source.get("remote"):
                (client_dir / f"remote-{source['room']}.env").write_text(
                    build_remote_env(source, REPO))

    package_dir = out_dir / "ha-package"
    package_dir.mkdir(parents=True, exist_ok=True)
    (package_dir / "mic_follow.yaml").write_text(build_ha_package(clients))

    (out_dir / "dock-bindings.md").write_text(build_dock_bindings(clients))

    allowlist = [
        {
            "id": client["node_id"],
            "name": client.get("friendly_name") or client["node_id"],
            "switch_entity": entity_ids(client["node_id"])["armed"],
            "detail_entity": entity_ids(client["node_id"])["status"],
        }
        for client in clients
    ]
    (out_dir / "identity-toggles.json").write_text(json.dumps(allowlist, indent=2) + "\n")
    # The same thing as one line, ready to paste into identity.env on the container
    # host — which is where it has to end up for the watch to see any toggles at all.
    (out_dir / "identity-toggles.env").write_text(
        "# Paste into /opt/smart-home/identity/identity.env on the container host,\n"
        "# then: docker compose restart identity\n"
        f"TOGGLE_ALLOWLIST_JSON={json.dumps(allowlist, separators=(',', ':'))}\n")

    print(f"wrote {out_dir}/ for {len(clients)} client(s):")
    for client in clients:
        remotes = [s["room"] for s in client.get("sources") or [] if s.get("remote")]
        extra = f", remote-*.env for {', '.join(remotes)}" if remotes else ""
        print(f"  {client['node_id']}/client.json, {client['node_id']}/mic-follow-{client['node_id']}.service{extra}")
    print("  ha-package/mic_follow.yaml")
    print("  dock-bindings.md")
    print("  identity-toggles.json + identity-toggles.env (paste into identity.env)")
    return 0


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