#!/usr/bin/env python3
"""
KAUST Azan Receiver — Raspberry Pi listener appliance.

Fullscreen GUI (matched to the web listener) that plays the live azan out the
3.5 mm jack. Idle until live: asks LiveKit directly whether anyone is publishing,
connects ONLY while live, disconnects + returns to idle when it ends.

Talks DIRECTLY to LiveKit (mints its own token, queries the API) — does NOT go
through mosque.alraee.group, so SiteGround's anti-bot WAF never interferes.
"""

import sys, os, json, time, base64, hmac, hashlib, asyncio, threading, subprocess, urllib.request, traceback, socket

from PyQt5.QtCore import Qt, QObject, pyqtSignal, QPropertyAnimation, QEasingCurve
from PyQt5.QtGui import QPixmap, QFont, QFontDatabase
from PyQt5.QtWidgets import (QApplication, QWidget, QLabel, QVBoxLayout, QHBoxLayout,
                             QFrame, QGraphicsDropShadowEffect, QGraphicsOpacityEffect)
from PyQt5.QtGui import QColor

from livekit import rtc


def _load_env_file():
    """Load ~/azan/azan.env (creds + config) if present. run.sh also sources it;
    this is a belt-and-braces fallback so the app works no matter how it's launched."""
    p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "azan.env")
    try:
        with open(p, encoding="utf-8") as fh:
            for line in fh:
                line = line.strip()
                if line and not line.startswith("#") and "=" in line:
                    k, v = line.split("=", 1)
                    k, v = k.strip(), v.strip()
                    if len(v) >= 2 and v[0] == v[-1] and v[0] in ("'", '"'):
                        v = v[1:-1]   # strip surrounding quotes (values may be quoted for run.sh)
                    os.environ.setdefault(k, v)
    except FileNotFoundError:
        pass


_load_env_file()

# ---- config -----------------------------------------------------------------
# Secrets come from the environment (run.sh sources ~/azan/azan.env). This file
# is intentionally SECRET-FREE so it can be served by the update API and committed
# to git without leaking the LiveKit key/secret.
LIVEKIT_URL = os.environ.get("AZAN_LK_URL", "wss://lk.alraee.group")
API_KEY     = os.environ.get("AZAN_LK_KEY", "")
API_SECRET  = os.environ.get("AZAN_LK_SECRET", "")
ROOM        = os.environ.get("AZAN_ROOM", "azan")
MOSQUE_NAME = os.environ.get("AZAN_MOSQUE", "King Abdullah Mosque")
ALSA_DEVICE = os.environ.get("AZAN_ALSA", "plughw:2,0")
APP_VERSION = "0.10"
SAMPLE_RATE = 48000
CHANNELS    = 1
POLL_SEC    = 2   # how often to check LiveKit for "is the azan live?" (lower = faster start)
UA          = "Mozilla/5.0 (X11; Linux aarch64) KAUST-Azan-Receiver/1.0"
HERE        = os.path.dirname(os.path.abspath(__file__))
ASSETS      = os.path.join(HERE, "assets")

# KAUST palette
TEAL = "#00A6AA"; TEAL_DARK = "#004C59"; WARM = "#80715D"; FAINT = "#B5ABA1"; TEXT = "#4a4a4a"


def _b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()


def lan_ip() -> str:
    """This Pi's LAN IP (the address the mosque network sees it as)."""
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.connect(("8.8.8.8", 80))   # no packets sent — just picks the egress interface
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return ""


def mint_token(identity: str, room_list: bool = False, metadata: str = "") -> str:
    now = int(time.time())
    grant = {"roomList": True} if room_list else \
            {"room": ROOM, "roomJoin": True, "canPublish": False, "canSubscribe": True}
    claims = {"iss": API_KEY, "sub": identity, "nbf": now, "exp": now + 3600, "video": grant}
    if metadata:
        claims["metadata"] = metadata   # so the broadcaster lists this Pi separately
    header  = _b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
    payload = _b64url(json.dumps(claims, separators=(",", ":")).encode())
    sig = _b64url(hmac.new(API_SECRET.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest())
    return f"{header}.{payload}.{sig}"


def is_live() -> bool:
    host = LIVEKIT_URL.replace("wss://", "https://").replace("ws://", "http://")
    req = urllib.request.Request(
        host + "/twirp/livekit.RoomService/ListRooms", data=b"{}",
        headers={"Authorization": "Bearer " + mint_token("status", room_list=True),
                 "Content-Type": "application/json", "User-Agent": UA}, method="POST")
    with urllib.request.urlopen(req, timeout=8) as r:
        data = json.loads(r.read().decode())
    for rm in (data.get("rooms") or []):
        if rm.get("name") == ROOM and int(rm.get("num_publishers", 0)) > 0:
            return True
    return False


class Worker(QObject):
    state = pyqtSignal(str)   # "idle" | "connecting" | "live"

    def __init__(self):
        super().__init__()
        self.aplay = None
        self.room = None

    def start(self):
        threading.Thread(target=lambda: asyncio.run(self._main()), daemon=True).start()

    def _ensure_aplay(self):
        if self.aplay is None or self.aplay.poll() is not None:
            self.aplay = subprocess.Popen(
                ["aplay", "-q", "-D", ALSA_DEVICE, "-f", "S16_LE",
                 "-r", str(SAMPLE_RATE), "-c", str(CHANNELS)], stdin=subprocess.PIPE)

    def _stop_aplay(self):
        if self.aplay:
            try: self.aplay.stdin.close()
            except Exception: pass
            try:
                self.aplay.terminate(); self.aplay.wait(timeout=1)
            except Exception:
                try: self.aplay.kill()
                except Exception: pass
            self.aplay = None

    async def _cleanup(self):
        if self.room is not None:
            r, self.room = self.room, None
            try: await r.disconnect()
            except Exception: pass
        self._stop_aplay()
        self.state.emit("idle")

    async def _play(self, track):
        self._ensure_aplay()
        self.state.emit("live")
        stream = rtc.AudioStream(track, sample_rate=SAMPLE_RATE, num_channels=CHANNELS)
        try:
            async for ev in stream:
                frame = getattr(ev, "frame", ev)
                if self.aplay and self.aplay.stdin:
                    try: self.aplay.stdin.write(bytes(frame.data))
                    except (BrokenPipeError, ValueError): break
        except Exception:
            traceback.print_exc()
        finally:
            try: await stream.aclose()
            except Exception: pass
            self._stop_aplay()

    async def _main(self):
        loop = asyncio.get_event_loop()
        self.state.emit("idle")
        while True:
            try:
                live = await loop.run_in_executor(None, is_live)
            except Exception:
                live = None
            if live is True and self.room is None:
                self.state.emit("connecting")
                try:
                    self.room = rtc.Room()

                    @self.room.on("track_subscribed")
                    def _on_sub(track, pub, participant):
                        if track.kind == rtc.TrackKind.KIND_AUDIO:
                            asyncio.create_task(self._play(track))

                    @self.room.on("disconnected")
                    def _on_disc(*a):
                        asyncio.create_task(self._cleanup())

                    md = json.dumps({"kind": "pi", "name": socket.gethostname(),
                                     "ip": lan_ip(), "ver": APP_VERSION},
                                    separators=(",", ":"))
                    await self.room.connect(
                        LIVEKIT_URL,
                        mint_token("pi-" + socket.gethostname() + "-" + os.urandom(2).hex(), metadata=md))
                    print("connected to", LIVEKIT_URL, flush=True)
                except Exception:
                    traceback.print_exc(); await self._cleanup()
            elif live is False and self.room is not None:
                print("azan ended -> idle", flush=True)
                await self._cleanup()
            await asyncio.sleep(POLL_SEC)


class Window(QWidget):
    def __init__(self):
        super().__init__()
        self.setStyleSheet(
            "QWidget#root { background: qlineargradient(x1:0,y1:0,x2:0,y2:1,"
            " stop:0 #eef9f9, stop:0.45 #ffffff, stop:1 #ffffff); }")
        self.setObjectName("root")

        # ---- card ----
        card = QFrame(); card.setObjectName("card")
        card.setStyleSheet("QFrame#card { background:#ffffff; border:1px solid #e5e9ec;"
                           " border-radius:26px; }")
        card.setFixedWidth(640)
        sh = QGraphicsDropShadowEffect(); sh.setBlurRadius(50); sh.setOffset(0, 16)
        sh.setColor(QColor(0, 76, 89, 36)); card.setGraphicsEffect(sh)

        cl = QVBoxLayout(card); cl.setAlignment(Qt.AlignCenter)
        cl.setContentsMargins(48, 44, 48, 40); cl.setSpacing(14)

        logo = QLabel(); logo.setAlignment(Qt.AlignCenter)
        logo.setPixmap(QPixmap(os.path.join(ASSETS, "kaust-logo.png"))
                       .scaledToWidth(300, Qt.SmoothTransformation))

        title = QLabel("Live Azan"); title.setAlignment(Qt.AlignCenter)
        title.setStyleSheet(f"color:{TEAL}; font-size:52px; font-weight:800;")

        sub = QLabel(MOSQUE_NAME); sub.setAlignment(Qt.AlignCenter)
        sub.setStyleSheet(f"color:{WARM}; font-size:26px; font-weight:600;")

        # status pill
        self.pill = QFrame(); self.pill.setObjectName("pill")
        pl = QHBoxLayout(self.pill); pl.setContentsMargins(18, 9, 20, 9); pl.setSpacing(9)
        self.dot = QLabel("●"); self.dot.setStyleSheet(f"color:{FAINT}; font-size:14px;")
        self.pill_txt = QLabel("No live azan right now")
        self.pill_txt.setStyleSheet(f"color:{WARM}; font-size:19px; font-weight:700;")
        pl.addWidget(self.dot); pl.addWidget(self.pill_txt)
        self._pill_bg(FAINT)
        pill_row = QHBoxLayout(); pill_row.setAlignment(Qt.AlignCenter); pill_row.addWidget(self.pill)

        # icon
        self.px_off = QPixmap(os.path.join(ASSETS, "loud-off.png")).scaled(
            240, 240, Qt.KeepAspectRatio, Qt.SmoothTransformation)
        self.px_air = QPixmap(os.path.join(ASSETS, "loud-air.png")).scaled(
            240, 240, Qt.KeepAspectRatio, Qt.SmoothTransformation)
        self.icon = QLabel(); self.icon.setAlignment(Qt.AlignCenter)
        self.icon.setFixedHeight(248); self.icon.setPixmap(self.px_off)
        ish = QGraphicsDropShadowEffect(); ish.setBlurRadius(26); ish.setOffset(0, 10)
        ish.setColor(QColor(0, 76, 89, 50)); self.icon.setGraphicsEffect(ish)

        self.meta = QLabel("Keep this page open — it will play automatically when the azan is broadcast.")
        self.meta.setAlignment(Qt.AlignCenter); self.meta.setWordWrap(True)
        self.meta.setStyleSheet(f"color:{WARM}; font-size:18px;")

        for w in (logo, title, sub):
            cl.addWidget(w)
        cl.addSpacing(6); cl.addLayout(pill_row); cl.addSpacing(10)
        cl.addWidget(self.icon); cl.addSpacing(8); cl.addWidget(self.meta)

        outer = QVBoxLayout(self); outer.setAlignment(Qt.AlignCenter); outer.addWidget(card)

        # version badge (bottom-right)
        self.version = QLabel("v" + APP_VERSION, self)
        self.version.setStyleSheet(f"color:{FAINT}; font-size:16px; font-weight:600;")

    def _pill_bg(self, dot_color):
        self.pill.setStyleSheet("QFrame#pill { background:#f3f6f7; border:1px solid #e5e9ec;"
                                " border-radius:22px; }")
        self.dot.setStyleSheet(f"color:{dot_color}; font-size:14px;")

    def resizeEvent(self, e):
        self.version.adjustSize()
        self.version.move(self.width() - self.version.width() - 16,
                          self.height() - self.version.height() - 12)
        super().resizeEvent(e)

    def set_state(self, s):
        if s == "live":
            self.icon.setPixmap(self.px_air)
            self.pill_txt.setText("Live"); self.pill_txt.setStyleSheet(f"color:{TEAL_DARK}; font-size:19px; font-weight:800;")
            self._pill_bg(TEAL)
            self.meta.setText("The azan is being broadcast now.")
        elif s == "connecting":
            self.pill_txt.setText("Connecting…"); self.pill_txt.setStyleSheet(f"color:{TEXT}; font-size:19px; font-weight:700;")
            self._pill_bg(WARM)
        else:  # idle
            self.icon.setPixmap(self.px_off)
            self.pill_txt.setText("No live azan right now"); self.pill_txt.setStyleSheet(f"color:{WARM}; font-size:19px; font-weight:700;")
            self._pill_bg(FAINT)
            self.meta.setText("Keep this page open — it will play automatically when the azan is broadcast.")


def main():
    if not (API_KEY and API_SECRET):
        print("FATAL: AZAN_LK_KEY / AZAN_LK_SECRET not set — create ~/azan/azan.env "
              "(see azan.env.example) so run.sh can export them.", flush=True)
        time.sleep(30)   # avoid a tight crash-loop in the run.sh wrapper
        sys.exit(1)
    app = QApplication(sys.argv)
    app.setOverrideCursor(Qt.BlankCursor)
    # Load Raleway to match the web
    fid = QFontDatabase.addApplicationFont(os.path.join(ASSETS, "fonts", "Raleway.ttf"))
    fams = QFontDatabase.applicationFontFamilies(fid) if fid != -1 else []
    app.setFont(QFont(fams[0] if fams else "sans-serif", 12))
    win = Window()
    worker = Worker()
    worker.state.connect(win.set_state)
    win.showFullScreen()
    worker.start()
    sys.exit(app.exec_())


if __name__ == "__main__":
    main()
