#!/usr/bin/env python3

"""A pactl that answers for a classroom nobody has to be standing in.

This exists because the one thing the room actually does — a television
switched off while the computer boots and switched on afterwards — cannot
be produced by any sound card a lab has. ALSA reports a television through
the HDMI port's availability, and no loopback or dummy driver has one.

So the scenarios that matter are played through this instead, and the real
classroom-audio daemon runs against it unmodified. Everything here is
shaped the way PipeWire 1.4.2 and WirePlumber 0.5.8 really answer, checked
against both running in the lab:

    cards    "ports" is an object keyed by port name, a port carries NO
             "direction", and an unknown availability is the phrase
             "availability unknown"
    sinks    have no top-level "card"; the link to the card is
             properties["device.name"], and "active_port" is a port name
    profile  switching one destroys the old sink and creates a new one,
             and emits a burst of events rather than a single one

It also records every command that changes anything, so a test can say
"and nothing was switched twice", which is the property the room needs
most and the one that is invisible from a single snapshot.
"""

import json
import os
import sys
import time

STATE = os.environ["MOCK_AUDIO_STATE"]
EVENTS = os.environ["MOCK_AUDIO_EVENTS"]
JOURNAL = os.environ["MOCK_AUDIO_JOURNAL"]


def load():
    with open(STATE) as handle:
        return json.load(handle)


def save(state):
    temporary = f"{STATE}.new"

    with open(temporary, "w") as handle:
        json.dump(state, handle, indent=2)

    os.replace(temporary, STATE)


def record(line):
    with open(JOURNAL, "a") as handle:
        handle.write(line + "\n")


def emit(*events):
    with open(EVENTS, "a") as handle:
        for event in events:
            handle.write(event + "\n")


def output_token(profile):
    """hdmi-stereo, from output:hdmi-stereo+input:analog-stereo."""
    head = (profile or "").split("+", 1)[0]

    return head.split(":", 1)[1] if ":" in head else ""


def active_port_of(card):
    profile = card.get("active_profile") or ""

    for name, port in (card.get("ports") or {}).items():
        if profile in (port.get("profiles") or []) and "output" in profile:
            # The port whose own kind matches the output half of the
            # profile, so an analog profile does not light the HDMI port.
            token = output_token(profile)

            if name.startswith("hdmi") or name.startswith("displayport"):
                if "hdmi" in token or "dp" in token:
                    return name, port
            elif "analog" in token or "stereo" in token:
                return name, port

    return None, None


def sinks_of(state):
    """One sink per card, for whatever its active profile turned on."""
    sinks = []
    index = 40

    for card in state["cards"]:
        profile = card.get("active_profile") or ""
        index += 1

        if not profile.startswith("output:"):
            continue

        name, port = active_port_of(card)

        if name is None:
            continue

        suffix = card["name"].split(".", 1)[-1]
        sink_name = f"alsa_output.{suffix}.{output_token(profile)}"
        levels = state.setdefault("levels", {})
        level = levels.get(sink_name, {"volume": 100, "mute": False})

        sinks.append({
            "index": index,
            "state": "SUSPENDED",
            "name": sink_name,
            "description": card.get("description", "Built-in Audio"),
            "mute": level["mute"],
            "volume": {
                "front-left": {"value_percent": f"{level['volume']}%"},
                "front-right": {"value_percent": f"{level['volume']}%"},
            },
            "properties": {
                "device.name": card["name"],
                "node.name": sink_name,
                "alsa.card_name": card.get("description", "Built-in Audio"),
                "device.profile.name": output_token(profile),
            },
            "ports": [{
                "name": name,
                "availability": port.get("availability",
                                         "availability unknown"),
                "priority": port.get("priority", 0),
            }],
            "active_port": name,
        })

    return sinks


def sink_named(state, name):
    for sink in sinks_of(state):
        if sink["name"] == name:
            return sink

    return None


def main():
    argv = sys.argv[1:]

    if argv[:2] == ["-f", "json"]:
        argv = argv[2:]
        as_json = True
    else:
        as_json = False

    if not argv:
        return 1

    state = load()

    # The server not being up yet is the whole of the boot race, so it is
    # a state this has to be able to be in.
    if not state.get("server_up", True) and argv[0] != "subscribe":
        sys.stderr.write("Connection failure: Connection refused\n")
        return 1

    command = argv[0]

    if command == "info":
        if not state.get("server_up", True):
            sys.stderr.write("Connection failure: Connection refused\n")
            return 1
        print("Server Name: PulseAudio (on PipeWire 1.4.2)")
        return 0

    if command == "list":
        what = argv[1] if len(argv) > 1 else ""

        if what == "cards":
            payload = state["cards"]
        elif what == "sinks":
            payload = sinks_of(state)
        elif what == "sink-inputs":
            payload = state.get("streams", [])
        else:
            payload = []

        print(json.dumps(payload) if as_json else "")
        return 0

    if command == "get-default-sink":
        print(state.get("default_sink") or "")
        return 0

    if command == "set-default-sink":
        name = argv[1]

        if sink_named(state, name) is None:
            sys.stderr.write(f"Failure: No such entity {name}\n")
            return 1

        if state.get("default_sink") != name:
            record(f"set-default-sink {name}")
            state["default_sink"] = name
            save(state)
            # A no-op set-default-sink emits nothing on the real server;
            # measured. Only a real change is announced.
            emit("Event 'change' on server #4294967295")

        return 0

    if command == "set-card-profile":
        card_name, profile = argv[1], argv[2]

        for card in state["cards"]:
            if card["name"] != card_name:
                continue

            if profile not in (card.get("profiles") or []):
                sys.stderr.write(f"Failure: No such profile {profile}\n")
                return 1

            record(f"set-card-profile {card_name} {profile}")
            old = sinks_of(state)
            card["active_profile"] = profile
            save(state)
            new = sinks_of(state)

            # Switching a profile tears the old sink down and builds a new
            # one, and the server says all of it.
            events = []
            for sink in old:
                if sink["name"] not in [item["name"] for item in new]:
                    events.append(f"Event 'remove' on sink #{sink['index']}")
            events.append(f"Event 'change' on card #{card.get('index', 0)}")
            for sink in new:
                if sink["name"] not in [item["name"] for item in old]:
                    events.append(f"Event 'new' on sink #{sink['index']}")
            emit(*events)

            # A stream on a sink that has gone is re-landed by the server
            # on whatever is default, exactly as WirePlumber does.
            survivors = [item["name"] for item in new]
            for stream in state.get("streams", []):
                if stream.get("sink_name") not in survivors:
                    fallback = state.get("default_sink") or (
                        survivors[0] if survivors else None)
                    stream["sink_name"] = fallback
                    target = sink_named(state, fallback) if fallback else None
                    stream["sink"] = target["index"] if target else -1
            save(state)
            return 0

        sys.stderr.write(f"Failure: No such card {card_name}\n")
        return 1

    if command == "set-sink-volume":
        name, value = argv[1], argv[2]
        record(f"set-sink-volume {name} {value}")
        state.setdefault("levels", {}).setdefault(
            name, {"volume": 100, "mute": False})
        state["levels"][name]["volume"] = int(value.rstrip("%"))
        save(state)
        emit(f"Event 'change' on sink #{sink_named(state, name)['index']}")
        return 0

    if command == "set-sink-mute":
        name, value = argv[1], argv[2]
        record(f"set-sink-mute {name} {value}")
        state.setdefault("levels", {}).setdefault(
            name, {"volume": 100, "mute": False})
        state["levels"][name]["mute"] = value not in ("0", "false", "no")
        save(state)
        emit(f"Event 'change' on sink #{sink_named(state, name)['index']}")
        return 0

    if command == "move-sink-input":
        index, name = int(argv[1]), argv[2]
        target = sink_named(state, name)

        if target is None:
            sys.stderr.write(f"Failure: No such entity {name}\n")
            return 1

        for stream in state.get("streams", []):
            if stream["index"] == index:
                if stream.get("sink_name") != name:
                    record(f"move-sink-input {index} {name}")
                    stream["sink_name"] = name
                    stream["sink"] = target["index"]
                    save(state)
                    emit(f"Event 'change' on sink-input #{index}")
                return 0

        sys.stderr.write(f"Failure: No such entity {index}\n")
        return 1

    if command == "subscribe":
        # Blocks and prints events as they happen, like the real one. Only
        # events written from now on: a subscription never replays what it
        # missed, which is precisely how the rooms came to be silent.
        with open(EVENTS) as handle:
            handle.seek(0, os.SEEK_END)

            while True:
                line = handle.readline()

                if not line:
                    time.sleep(0.05)
                    continue

                sys.stdout.write(line)
                sys.stdout.flush()

    sys.stderr.write(f"mock-pactl: unhandled {' '.join(argv)}\n")
    return 1


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