#!/usr/bin/env python3
"""
hub.py — BILLO Universal Wireless Audio Hub (open source)

Capture ONE OR MORE macOS audio devices (or test tones), MIX them into a single
stream, record to disk, and/or serve a live MP3 stream over HTTP so any machine
or phone on the network — including BILLO over Tailscale — can listen.

Everything lives in ~/billo-wireless-audio/. Nothing touches the Desktop.

Usage:
  hub.py list                            # list capture devices
  hub.py record <dev...> [--secs N]      # record one or more devices (mixed) to WAV
  hub.py stream <dev...> [--port P]      # live MP3 stream at http://0.0.0.0:P/
                                          #   /        -> player page
                                          #   /stream  -> raw audio/mpeg
                                          #   /health  -> ok

  Use device "test" for a generated 440 Hz tone (no mic or hardware needed).
  Multiple devices are mixed with ffmpeg amix into ONE stream.
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import tempfile
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path

HOME = Path.home()
BASE = Path(os.environ.get("BILLO_AUDIO_HOME", HOME / "billo-wireless-audio"))
OUT = BASE / "recordings"
OUT.mkdir(parents=True, exist_ok=True)

def _find_ffmpeg():
    # launchd starts with a bare PATH, so also check the Homebrew locations.
    for c in (shutil.which("ffmpeg"), "/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg"):
        if c and os.path.exists(c):
            return c
    sys.exit("ffmpeg not found. Install it once with:  brew install ffmpeg")


FFMPEG = _find_ffmpeg()


def ffmpeg_list():
    r = subprocess.run(
        [FFMPEG, "-hide_banner", "-f", "avfoundation",
         "-list_devices", "true", "-i", ""],
        capture_output=True, text=True,
    )
    txt = (r.stderr or "") + (r.stdout or "")
    devs = []
    in_audio = False
    for line in txt.splitlines():
        if "AVFoundation audio devices:" in line:
            in_audio = True
            continue
        if "AVFoundation video devices:" in line:
            in_audio = False
            continue
        if in_audio:
            m = re.match(r".*\[(\d+)\]\s+(.+)$", line)
            if m:
                devs.append((int(m.group(1)), m.group(2).strip()))
    return devs


def resolve_index(device):
    devs = ffmpeg_list()
    for i, name in devs:  # exact name first, then substring
        if device.lower() == name.lower():
            return i
    for i, name in devs:
        if device.lower() in name.lower():
            return i
    raise SystemExit(f"no capture device matches '{device}'. Run `hub.py list`.")


def build_inputs(devices, realtime=False):
    """Return (flat_input_args, filter_complex_or_None) for one or more devices."""
    flat = []
    for d in devices:
        if d.lower() == "test":
            a = ["-f", "lavfi", "-i", "sine=frequency=440"]
            if realtime:
                a = ["-re"] + a
        elif os.path.exists(d):
            a = []
            if realtime:
                a += ["-stream_loop", "-1", "-re"]
            a += ["-i", d]
        else:
            a = ["-f", "avfoundation", "-i", f":{resolve_index(d)}"]
        flat += a
    if len(devices) == 1:
        return flat, None
    labels = "".join(f"[{i}:a]" for i in range(len(devices)))
    filt = f"{labels}amix=inputs={len(devices)}:duration=longest"
    return flat, filt


def cmd_record(devices, secs):
    name = "mix" if len(devices) > 1 else devices[0]
    out = OUT / f"{_stamp()}_{name.replace(' ', '_')}.wav"
    flat, filt = build_inputs(devices)
    args = [FFMPEG, "-hide_banner", "-y"] + flat
    if filt:
        args += ["-filter_complex", filt]
    args += ["-c:a", "pcm_s16le"]
    if secs > 0:
        args += ["-t", str(secs)]
    args += [str(out)]
    print(f"recording {len(devices)} source(s) -> {out}")
    if secs > 0:
        print(f"  {secs}s  (Ctrl-C to stop early)")
    else:
        print("  no limit — press Ctrl-C to stop")
    subprocess.run(args)
    print(f"saved: {out}")


class Broadcast:
    """Fan-out ring buffer: one producer (ffmpeg via FIFO) -> many readers."""

    def __init__(self, fifo, cap=4 * 1024 * 1024):
        self.cap = cap
        self.buf = bytearray()
        self.base = 0      # absolute offset of buf[0]
        self.avail = 0     # absolute offset one past last byte written
        self.eof = False
        self.cv = threading.Condition()
        threading.Thread(target=self._read, args=(fifo,), daemon=True).start()

    def _read(self, fifo):
        with open(fifo, "rb", buffering=0) as f:
            while True:
                chunk = f.read(65536)
                if not chunk:
                    break
                with self.cv:
                    self.buf.extend(chunk)
                    self.avail += len(chunk)
                    if len(self.buf) > self.cap:
                        drop = len(self.buf) - self.cap // 2
                        del self.buf[:drop]
                        self.base += drop
                    self.cv.notify_all()
        with self.cv:
            self.eof = True
            self.cv.notify_all()

    def read_from(self, pos):
        """Yield bytes from absolute offset `pos` forward; blocks until eof."""
        while True:
            with self.cv:
                if pos < self.base:
                    pos = self.base  # fell behind -> skip to live edge
                if pos < self.avail:
                    data = bytes(self.buf[pos - self.base:])
                    pos += len(data)
                    yield data
                    continue
                if self.eof:
                    return
                self.cv.wait(timeout=0.5)

    def live_edge(self):
        with self.cv:
            return self.avail


_PLAYER = b"""<!doctype html><html><head><meta charset="utf-8">
<title>BILLO Audio Hub</title></head><body style="background:#0a0a0a;color:#fff;
font-family:sans-serif;padding:24px"><h1>BILLO Audio Hub</h1>
<p>Live stream (audio/mpeg):</p>
<audio controls autoplay src="/stream"></audio>
<p><a href="/health" style="color:#FFD24A">health</a></p></body></html>"""


def make_handler(broadcast):
    class Handler(BaseHTTPRequestHandler):
        def do_GET(self):
            if self.path == "/health":
                body = b"ok\n"
                self.send_response(200)
                self.send_header("Content-Type", "text/plain")
                self.send_header("Content-Length", str(len(body)))
                self.end_headers()
                self.wfile.write(body)
            elif self.path == "/stream" or self.path.startswith("/stream?"):
                self.send_response(200)
                self.send_header("Content-Type", "audio/mpeg")
                self.send_header("Cache-Control", "no-cache")
                self.send_header("Connection", "close")
                self.end_headers()
                pos = broadcast.live_edge()
                try:
                    for chunk in broadcast.read_from(pos):
                        self.wfile.write(chunk)
                        self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    pass
            else:
                self.send_response(200)
                self.send_header("Content-Type", "text/html; charset=utf-8")
                self.send_header("Content-Length", str(len(_PLAYER)))
                self.end_headers()
                self.wfile.write(_PLAYER)

        def log_message(self, *a):
            pass

    return Handler


def cmd_stream(devices, port):
    tmp = Path(tempfile.mkdtemp(prefix="billo-hub-"))
    fifo = tmp / "stream.mp3"
    os.mkfifo(fifo)

    flat, filt = build_inputs(devices, realtime=True)
    args = [FFMPEG, "-hide_banner", "-nostdin", "-y"] + flat
    if filt:
        args += ["-filter_complex", filt]
    args += ["-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3", str(fifo)]
    proc = subprocess.Popen(args)

    def _watch():
        # If ffmpeg dies (device unplugged, mic permission denied), exit non-zero
        # so launchd's KeepAlive restarts the hub instead of serving silence forever.
        rc = proc.wait()
        print(f"ffmpeg exited (code {rc}); hub stopping so it can be restarted", flush=True)
        os._exit(1)

    threading.Thread(target=_watch, daemon=True).start()

    broadcast = Broadcast(str(fifo))
    server = ThreadingHTTPServer(("0.0.0.0", port), make_handler(broadcast))
    server.daemon_threads = True
    print(f"streaming {len(devices)} source(s) -> http://0.0.0.0:{port}/  (listener page)")
    print(f"  raw stream: http://0.0.0.0:{port}/stream")
    print("  Ctrl-C to stop")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.shutdown()
        proc.terminate()
        try:
            proc.wait(timeout=3)
        except subprocess.TimeoutExpired:
            proc.kill()
        shutil.rmtree(tmp, ignore_errors=True)
        print("stopped")


def _stamp():
    import datetime
    return datetime.datetime.now().strftime("%Y%m%d-%H%M%S")


def main():
    p = argparse.ArgumentParser(description="BILLO Universal Wireless Audio Hub")
    sub = p.add_subparsers(dest="cmd", required=True)

    sub.add_parser("list", help="list capture devices")

    r = sub.add_parser("record", help="record one or more devices (mixed) to WAV")
    r.add_argument("device", nargs="+")
    r.add_argument("--secs", "-t", type=int, default=0)

    s = sub.add_parser("stream", help="stream one or more devices (mixed) as live MP3")
    s.add_argument("device", nargs="+")
    s.add_argument("--port", "-p", type=int, default=8123)

    a = p.parse_args()

    if a.cmd == "list":
        devs = ffmpeg_list()
        if not devs:
            print("no audio capture devices found")
            return
        for i, name in devs:
            print(f"{i}: {name}")
    elif a.cmd == "record":
        cmd_record(a.device, a.secs)
    elif a.cmd == "stream":
        cmd_stream(a.device, a.port)


if __name__ == "__main__":
    main()
