"""Shared TV control module — JointSpace, UPnP, ADB, Home Assistant, throttle.

Consolidates all TV/ADB/HA control functions used by sandman.py, sandman_bot.py,
and devices_server.py.  Import and call directly:

    import tv_control
    tv_control.js_key("Pause")
    vol = tv_control.get_volume()
"""

import concurrent.futures
import json
import logging
import os
import re
import socket
import ssl
import subprocess
import threading
import time
import urllib.request

import requests
from requests.auth import HTTPDigestAuth
import urllib3

urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)

log = logging.getLogger("tv_control")

# ─── Constants ────────────────────────────────────────────────────────────────

TV_IP = "192.168.1.214"

JS_DEVICE_ID = "3fde2764387abaaabccca55df0215dc8"
JS_AUTH_KEY = "9036395372af1e28d691784f42f7556ae5101d9178e4adc310903d135d2c4442"

UPNP_PORT = 49153
UPNP_SVC = "urn:schemas-upnp-org:service:RenderingControl:1"
UPNP_ENVELOPE = (
    '<?xml version="1.0"?>'
    '<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"'
    ' s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">'
    "<s:Body>{body}</s:Body></s:Envelope>"
)

ADB_TARGET_PORT = 5555

APP_NAMES = {
    "com.google.android.youtube.tv": "YouTube",
    "com.netflix.ninja": "Netflix",
    "com.amazon.amazonvideo.livingroom": "Prime Video",
    "org.droidtv.channels": "Live TV",
    "org.droidtv.playtv": "Live TV",
    "org.droidtv.contentexplorer": "Media Browser",
    "com.apple.atve.androidtv.appletv": "Apple TV+",
    "com.google.android.apps.tv.launcherx": "Home",
    "com.google.android.tvlauncher": "Home",
    "com.google.android.katniss": "Google TV",
    "org.droidtv.settings": "Settings",
    "com.disney.disneyplus": "Disney+",
    "tv.mewatch": "mewatch",
    "com.spotify.tv.android": "Spotify",
}

# ─── HA Supervisor Token ──────────────────────────────────────────────────────

SUPERVISOR_TOKEN = ""
try:
    with open("/data/.ssh/environment") as _f:
        for _line in _f:
            if _line.startswith("SUPERVISOR_TOKEN="):
                SUPERVISOR_TOKEN = _line.strip().split("=", 1)[1]
except Exception:
    pass

# ─── TV IP Cache ──────────────────────────────────────────────────────────────

TV_IP_CACHE = "/share/sandman_bot_tv_ip"


def _get_tv_ip() -> str:
    """Return the current TV IP, checking cache file if module-level TV_IP is default."""
    global TV_IP
    # Try cache file
    try:
        cached = open(TV_IP_CACHE).read().strip()
        if cached:
            TV_IP = cached
    except Exception:
        pass
    return TV_IP


# ─── Internal Auth ────────────────────────────────────────────────────────────

def _js_auth() -> HTTPDigestAuth:
    return HTTPDigestAuth(JS_DEVICE_ID, JS_AUTH_KEY)


def _js_base(ip: str = None) -> str:
    ip = ip or _get_tv_ip()
    return f"https://{ip}:1926/6"


def _adb_target(ip: str = None) -> str:
    ip = ip or _get_tv_ip()
    return f"{ip}:{ADB_TARGET_PORT}"


def _upnp_url(ip: str = None) -> str:
    ip = ip or _get_tv_ip()
    return f"http://{ip}:{UPNP_PORT}/upnp/control/RenderingControl1"


# ─── JointSpace Functions ────────────────────────────────────────────────────

def js_get(path: str, timeout: float = 5, ip: str = None) -> dict | None:
    """GET a JointSpace endpoint.  Returns parsed JSON or None."""
    ip = ip or _get_tv_ip()
    try:
        url = f"{_js_base(ip)}/{path.lstrip('/')}"
        r = requests.get(url, auth=_js_auth(), verify=False, timeout=timeout)
        r.raise_for_status()
        return r.json()
    except Exception as e:
        log.debug("js_get /%s failed: %s", path, e)
        return None


def js_post(path: str, data: dict, timeout: float = 5, ip: str = None) -> bool:
    """POST to a JointSpace endpoint.  Returns True on success."""
    ip = ip or _get_tv_ip()
    try:
        url = f"{_js_base(ip)}/{path.lstrip('/')}"
        r = requests.post(url, json=data, auth=_js_auth(), verify=False, timeout=timeout)
        return r.status_code < 400
    except Exception as e:
        log.debug("js_post /%s failed: %s", path, e)
        return False


def js_key(key: str, ip: str = None) -> bool:
    """Send a key press via JointSpace."""
    return js_post("input/key", {"key": key}, ip=ip)


# ─── UPnP Functions ──────────────────────────────────────────────────────────

def _upnp_request(action: str, body_inner: str, ip: str = None) -> str | None:
    """Send a UPnP SOAP request to RenderingControl.  Returns response body or None."""
    ip = ip or _get_tv_ip()
    url = _upnp_url(ip)
    body = UPNP_ENVELOPE.format(body=body_inner)
    headers = {
        "Content-Type": 'text/xml; charset="utf-8"',
        "SOAPAction": f'"{UPNP_SVC}#{action}"',
    }
    try:
        req = urllib.request.Request(url, data=body.encode(), headers=headers, method="POST")
        with urllib.request.urlopen(req, timeout=5) as resp:
            return resp.read().decode()
    except Exception as e:
        log.debug("UPnP %s failed: %s", action, e)
        return None


# The Philips OLED708 OSD shows volume on a native 0-VOLUME_MAX scale, but UPnP
# RenderingControl always reports/accepts 0-100 (and JointSpace audio/volume is
# unavailable on this set). We translate so all callers work in the OSD scale —
# i.e. get_volume()/set_volume() speak the same numbers the user sees on screen.
VOLUME_MAX = 60


def get_volume(ip: str = None) -> int | None:
    """Get current volume on the TV's native 0-VOLUME_MAX scale (matches the OSD).
    Reads UPnP RenderingControl (0-100) and rescales.  Returns int or None."""
    body = f'<u:GetVolume xmlns:u="{UPNP_SVC}"><InstanceID>0</InstanceID><Channel>Master</Channel></u:GetVolume>'
    resp = _upnp_request("GetVolume", body, ip=ip)
    if resp is None:
        return None
    m = re.search(r"<CurrentVolume>(\d+)</CurrentVolume>", resp)
    if not m:
        return None
    return round(int(m.group(1)) * VOLUME_MAX / 100)


def set_volume(vol: int, ip: str = None) -> bool:
    """Set volume on the TV's native 0-VOLUME_MAX scale (matches the OSD).
    Rescales to UPnP 0-100 internally."""
    vol = max(0, min(VOLUME_MAX, vol))
    upnp = round(vol * 100 / VOLUME_MAX)
    body = (f'<u:SetVolume xmlns:u="{UPNP_SVC}">'
            f"<InstanceID>0</InstanceID><Channel>Master</Channel>"
            f"<DesiredVolume>{upnp}</DesiredVolume></u:SetVolume>")
    return _upnp_request("SetVolume", body, ip=ip) is not None


def get_mute(ip: str = None) -> bool | None:
    """Get mute state via UPnP.  Returns bool or None."""
    body = f'<u:GetMute xmlns:u="{UPNP_SVC}"><InstanceID>0</InstanceID><Channel>Master</Channel></u:GetMute>'
    resp = _upnp_request("GetMute", body, ip=ip)
    if resp is None:
        return None
    m = re.search(r"<CurrentMute>(\d+)</CurrentMute>", resp)
    return (m.group(1) == "1") if m else None


def set_mute(muted: bool, ip: str = None) -> bool:
    """Set mute state via UPnP."""
    val = "1" if muted else "0"
    body = (f'<u:SetMute xmlns:u="{UPNP_SVC}">'
            f"<InstanceID>0</InstanceID><Channel>Master</Channel>"
            f"<DesiredMute>{val}</DesiredMute></u:SetMute>")
    return _upnp_request("SetMute", body, ip=ip) is not None


# ─── TV State ────────────────────────────────────────────────────────────────

def get_power_state(ip: str = None) -> str:
    """Return 'On', 'Standby', or 'Unknown'."""
    data = js_get("powerstate", ip=ip)
    if data:
        return data.get("powerstate", "Unknown").capitalize()
    return "Unknown"


def get_audio_state(ip: str = None) -> dict:
    """Return {'volume': int (0-VOLUME_MAX), 'muted': bool}.
    Uses UPnP — JointSpace audio/volume is unavailable on this set."""
    vol = get_volume(ip=ip)
    muted = get_mute(ip=ip)
    return {"volume": vol if vol is not None else 0, "muted": bool(muted)}


# ─── SSDP Discovery ─────────────────────────────────────────────────────────

def discover_tv(timeout: float = 5.0) -> str | None:
    """Find the Philips TV via SSDP multicast, falling back to JointSpace probe."""
    # Method 1: SSDP
    try:
        ssdp_msg = (
            "M-SEARCH * HTTP/1.1\r\n"
            "HOST: 239.255.255.250:1900\r\n"
            'MAN: "ssdp:discover"\r\n'
            f"MX: {int(timeout)}\r\n"
            "ST: ssdp:all\r\n\r\n"
        )
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
        sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
        sock.settimeout(min(timeout, 3))
        sock.sendto(ssdp_msg.encode(), ("239.255.255.250", 1900))
        try:
            while True:
                data, addr = sock.recvfrom(4096)
                text = data.decode(errors="replace")
                if "Philips" in text or "PhilipsIntelSDK" in text or "android" in text.lower():
                    sock.close()
                    log.info("SSDP discovered TV at %s", addr[0])
                    return addr[0]
        except socket.timeout:
            pass
        finally:
            try:
                sock.close()
            except Exception:
                pass
    except Exception:
        pass

    # Method 2: JointSpace probe on common subnet IPs
    def probe(check_ip):
        try:
            ctx = ssl._create_unverified_context()
            urllib.request.urlopen(
                urllib.request.Request(f"https://{check_ip}:1926/6/system"),
                timeout=2, context=ctx,
            )
            return check_ip
        except Exception:
            return None

    # Scan the whole subnet, not just .2-.29 — the TV's DHCP lease can land
    # anywhere (e.g. after a mainboard swap it came back as .144), and SSDP
    # multicast is flaky across the mesh, so this probe is the real safety net.
    candidates = [f"192.168.1.{i}" for i in range(2, 255)]
    with concurrent.futures.ThreadPoolExecutor(max_workers=40) as pool:
        futs = {pool.submit(probe, ip): ip for ip in candidates}
        for f in concurrent.futures.as_completed(futs):
            result = f.result()
            if result:
                log.info("Probe discovered TV at %s", result)
                return result
    return None


# ─── Ambilight ───────────────────────────────────────────────────────────────

def ambilight_toggle(ip: str = None):
    """Toggle ambilight via menu navigation (AmbilightOnOff -> up 15x -> Confirm -> Back)."""
    ip = ip or _get_tv_ip()
    try:
        s = requests.Session()
        s.auth = _js_auth()
        s.verify = False
        url = f"{_js_base(ip)}/input/key"
        s.post(url, json={"key": "AmbilightOnOff"}, timeout=3)
        time.sleep(0.15)
        for _ in range(15):
            s.post(url, json={"key": "CursorUp"}, timeout=3)
        time.sleep(0.05)
        s.post(url, json={"key": "Confirm"}, timeout=3)
        s.post(url, json={"key": "Back"}, timeout=3)
    except Exception as e:
        log.warning("Ambilight toggle failed: %s", e)


def ambilight_glitch(off_duration: float = 3.0, ip: str = None):
    """Toggle ambilight off, wait, toggle back on."""
    ambilight_toggle(ip=ip)
    time.sleep(off_duration)
    ambilight_toggle(ip=ip)


# ─── ADB Functions ───────────────────────────────────────────────────────────

def adb_connect(ip: str = None) -> bool:
    """Connect to TV via ADB.  Returns True on success."""
    target = _adb_target(ip)
    try:
        r = subprocess.run(["adb", "connect", target],
                           capture_output=True, text=True, timeout=10)
        return "connected" in r.stdout.lower() or "already" in r.stdout.lower()
    except Exception as e:
        log.debug("adb_connect failed: %s", e)
        return False


def adb_screenshot(output_path: str = "/tmp/tv_screen.png", ip: str = None) -> str | None:
    """Take a screenshot via ADB.  Returns local path on success, None on failure."""
    target = _adb_target(ip)
    try:
        adb_connect(ip)
        subprocess.run(
            ["adb", "-s", target, "shell", "screencap", "-p", "/sdcard/screen.png"],
            capture_output=True, timeout=15, check=True,
        )
        subprocess.run(
            ["adb", "-s", target, "pull", "/sdcard/screen.png", output_path],
            capture_output=True, timeout=15, check=True,
        )
        return output_path
    except Exception as e:
        log.warning("adb_screenshot failed: %s", e)
        return None


def adb_get_current_app(ip: str = None) -> tuple:
    """Get current foreground app, playback state, and extras via ADB.

    Returns (package_name, playback_state, extras_dict) where extras may
    contain 'title', 'artist', 'position_s'.
    Any element may be None on failure.
    """
    target = _adb_target(ip)
    try:
        extras = {}

        # Top activity
        r = subprocess.run(["adb", "-s", target, "shell",
                            "dumpsys", "activity", "activities"],
                           capture_output=True, text=True, timeout=8)
        pkg = None
        for line in r.stdout.splitlines():
            if "topResumedActivity" in line:
                m = re.search(r"(\S+)/\S+", line)
                if m:
                    pkg = m.group(1).split()[-1]
                break

        # Media session — playback state + metadata
        state = None
        r2 = subprocess.run(["adb", "-s", target, "shell",
                             "dumpsys", "media_session"],
                            capture_output=True, text=True, timeout=8)
        found_active = False
        for line in r2.stdout.splitlines():
            if "active=true" in line:
                found_active = True
            if found_active and "state=PlaybackState" in line:
                if "PLAYING" in line:
                    state = "PLAYING"
                elif "PAUSED" in line:
                    state = "PAUSED"
                if state:
                    pos_m = re.search(r"position=(\d+)", line)
                    upd_m = re.search(r"updated=(\d+)", line)
                    spd_m = re.search(r"speed=([\d.]+)", line)
                    if pos_m and upd_m:
                        pos_ms = int(pos_m.group(1))
                        updated = int(upd_m.group(1))
                        speed = float(spd_m.group(1)) if spd_m else 1.0
                        try:
                            up_r = subprocess.run(
                                ["adb", "-s", target, "shell", "cat /proc/uptime"],
                                capture_output=True, text=True, timeout=3)
                            uptime_ms = int(float(up_r.stdout.split()[0]) * 1000)
                            elapsed_ms = (uptime_ms - updated) * speed
                            real_pos_ms = pos_ms + elapsed_ms
                            if real_pos_ms > 0:
                                extras["position_s"] = int(real_pos_ms // 1000)
                        except Exception:
                            if pos_ms > 0:
                                extras["position_s"] = pos_ms // 1000
            if found_active and "metadata:" in line and "size=" in line:
                m = re.search(r"description=(.+?)(?:,\s*null)?$", line)
                if m:
                    desc = m.group(1).strip().rstrip(", null").rstrip(",")
                    parts = [p.strip() for p in desc.split(",")]
                    if parts:
                        extras["title"] = parts[0]
                    if len(parts) > 1:
                        extras["artist"] = parts[1]
                found_active = False

        return pkg, state, extras
    except Exception as e:
        log.warning("adb_get_current_app failed: %s", e)
        return None, None, None


def adb_launch_app(package: str, ip: str = None) -> bool:
    """Launch an app on the TV via ADB.  Returns True on success."""
    target = _adb_target(ip)
    launch_intents = {
        "com.netflix.ninja": "com.netflix.ninja/.MainActivity",
        "com.google.android.youtube.tv": "com.google.android.youtube.tv/com.google.android.apps.youtube.tv.activity.ShellActivity",
        "com.apple.atve.androidtv.appletv": "com.apple.atve.androidtv.appletv/.MainActivity",
        "com.disney.disneyplus": "com.disney.disneyplus/.ui.splash.LaunchActivity",
        "com.amazon.amazonvideo.livingroom": "com.amazon.amazonvideo.livingroom/com.amazon.ignition.IgnitionActivity",
        "org.droidtv.playtv": "org.droidtv.playtv/.PlayTvActivity",
    }
    try:
        adb_connect(ip)
        component = launch_intents.get(package)
        if component:
            subprocess.run(["adb", "-s", target, "shell",
                           f"am start -a android.intent.action.MAIN -n {component} --activity-clear-top"],
                           capture_output=True, timeout=10)
        else:
            r = subprocess.run(["adb", "-s", target, "shell",
                                f"monkey -p {package} -c android.intent.category.LEANBACK_LAUNCHER 1"],
                               capture_output=True, timeout=10)
            if r.returncode != 0:
                subprocess.run(["adb", "-s", target, "shell",
                                f"monkey -p {package} -c android.intent.category.LAUNCHER 1"],
                               capture_output=True, timeout=10)
        return True
    except Exception as e:
        log.warning("adb_launch_app failed: %s", e)
        return False


# ─── HA Smart Home ───────────────────────────────────────────────────────────

def ha_api(method: str, path: str, data: dict = None) -> dict | None:
    """Call HA Supervisor API.  Returns parsed JSON or None."""
    try:
        url = f"http://supervisor/core/api{path}"
        body = json.dumps(data).encode() if data else None
        req = urllib.request.Request(
            url, data=body,
            headers={
                "Authorization": f"Bearer {SUPERVISOR_TOKEN}",
                "Content-Type": "application/json",
            },
            method=method,
        )
        resp = urllib.request.urlopen(req, timeout=10)
        return json.loads(resp.read())
    except Exception as e:
        log.debug("ha_api %s %s failed: %s", method, path, e)
        return None


def ha_get_device_states(device_list: list) -> list:
    """Return [(entity_id, friendly_name, state), ...] for given entity IDs."""
    states = ha_api("GET", "/states")
    if not states:
        return []
    result = []
    for e in states:
        eid = e["entity_id"]
        if eid in device_list:
            name = e.get("attributes", {}).get("friendly_name", eid)
            result.append((eid, name, e["state"]))
    return result


def ha_switch_toggle(entity_id: str) -> bool:
    """Toggle a HA switch/light.  Returns True on success."""
    try:
        state_data = ha_api("GET", f"/states/{entity_id}")
        if not state_data:
            return False
        current = state_data.get("state", "off")
        domain = entity_id.split(".")[0]
        service = "turn_off" if current == "on" else "turn_on"
        ha_api("POST", f"/services/{domain}/{service}", {"entity_id": entity_id})
        return True
    except Exception as e:
        log.warning("ha_switch_toggle failed: %s", e)
        return False


def ha_switch_flicker(entity_id: str, duration: float):
    """Toggle a HA switch for `duration` seconds, then restore original state.

    The visible flicker is bounded by: toggle API latency + sleep + restore API
    latency.  To keep it short, we use a 2s timeout on the individual calls and
    skip the sleep when duration is very small — the API round-trip itself
    provides enough visible gap.
    """
    try:
        state_data = ha_api("GET", f"/states/{entity_id}")
        if not state_data:
            return
        current = state_data.get("state", "off")
        domain = entity_id.split(".")[0]
        toggle_svc = "turn_on" if current == "off" else "turn_off"
        restore_svc = "turn_off" if current == "off" else "turn_on"
        # Toggle off (or on)
        _ha_api_fast("POST", f"/services/{domain}/{toggle_svc}", {"entity_id": entity_id})
        if duration > 0:
            time.sleep(duration)
        # Restore immediately
        _ha_api_fast("POST", f"/services/{domain}/{restore_svc}", {"entity_id": entity_id})
    except Exception as e:
        log.warning("ha_switch_flicker failed: %s", e)


def _ha_api_fast(method: str, path: str, data: dict = None) -> dict | None:
    """Like ha_api but with a short 2s timeout for time-sensitive operations."""
    try:
        url = f"http://supervisor/core/api{path}"
        body = json.dumps(data).encode() if data else None
        req = urllib.request.Request(
            url, data=body,
            headers={
                "Authorization": f"Bearer {SUPERVISOR_TOKEN}",
                "Content-Type": "application/json",
            },
            method=method,
        )
        resp = urllib.request.urlopen(req, timeout=2)
        return json.loads(resp.read())
    except Exception as e:
        log.debug("_ha_api_fast %s %s failed: %s", method, path, e)
        return None


# ─── Throttle ────────────────────────────────────────────────────────────────

def throttle_apply(tv_ip: str = None, bandwidth_kbps: int = 5000) -> bool:
    """Apply tc HTB throttle targeting the TV IP.  Returns True on success."""
    tv_ip = tv_ip or _get_tv_ip()
    dev = "end0"
    try:
        # Remove existing rules first
        subprocess.run(["tc", "qdisc", "del", "dev", dev, "root"],
                       capture_output=True)
        if bandwidth_kbps <= 0:
            return True
        subprocess.run(["tc", "qdisc", "add", "dev", dev, "root", "handle", "1:",
                        "htb", "default", "10"],
                       capture_output=True, check=True)
        subprocess.run(["tc", "class", "add", "dev", dev, "parent", "1:", "classid",
                        "1:10", "htb", "rate", "1000mbit"],
                       capture_output=True, check=True)
        subprocess.run(["tc", "class", "add", "dev", dev, "parent", "1:", "classid",
                        "1:20", "htb", "rate", f"{bandwidth_kbps}kbit",
                        "ceil", f"{bandwidth_kbps}kbit"],
                       capture_output=True, check=True)
        # Exempt monitoring traffic so sandman can still see the TV while throttled:
        # ICMP (ping) + JointSpace HTTPS (1926) + ADB (5555) → unthrottled class 1:10.
        # Higher priority (lower prio number) than the catchall below.
        subprocess.run(["tc", "filter", "add", "dev", dev, "parent", "1:", "protocol",
                        "ip", "prio", "1", "u32",
                        "match", "ip", "dst", f"{tv_ip}/32",
                        "match", "ip", "protocol", "1", "0xff",
                        "flowid", "1:10"],
                       capture_output=True, check=True)
        for port in (1926, 5555):
            subprocess.run(["tc", "filter", "add", "dev", dev, "parent", "1:", "protocol",
                            "ip", "prio", "1", "u32",
                            "match", "ip", "dst", f"{tv_ip}/32",
                            "match", "ip", "protocol", "6", "0xff",
                            "match", "ip", "dport", str(port), "0xffff",
                            "flowid", "1:10"],
                           capture_output=True, check=True)
        # Catchall: everything else to TV → throttled class 1:20.
        subprocess.run(["tc", "filter", "add", "dev", dev, "parent", "1:", "protocol",
                        "ip", "prio", "2", "u32", "match", "ip", "dst",
                        f"{tv_ip}/32", "flowid", "1:20"],
                       capture_output=True, check=True)
        return True
    except Exception as e:
        log.warning("throttle_apply failed: %s", e)
        return False


def speaker_play_sound(sound_file: str, volume: float = 0.3) -> bool:
    """Play a sound file on the Xiaomi Sound Pro speaker via HA media_player."""
    try:
        ha_api("POST", "/services/media_player/volume_set", {
            "entity_id": "media_player.sound_pro_1264",
            "volume_level": volume
        })
        ha_api("POST", "/services/media_player/play_media", {
            "entity_id": "media_player.sound_pro_1264",
            "media_content_type": "music",
            "media_content_id": f"http://192.168.1.187:8888/sounds/{sound_file}"
        })
        return True
    except Exception as e:
        log.warning("speaker_play_sound failed: %s", e)
        return False


SPEAKER_SOUNDS = [
    "knock_2.mp3",
    "cough_single.mp3",
    "throat_final2.mp3",
    "tongue_click.mp3",
    "custom_20to22.mp3",
    "exhale_loud.mp3",
    "exhale_soft.mp3",
]


def throttle_remove() -> bool:
    """Remove all tc throttle rules.  Returns True on success."""
    dev = "end0"
    try:
        subprocess.run(["tc", "qdisc", "del", "dev", dev, "root"],
                       capture_output=True)
        return True
    except Exception as e:
        log.warning("throttle_remove failed: %s", e)
        return False


# ─── YouTube Lounge (cast / second-screen control) ────────────────────────────
#
# Pairs once via DIAL on LAN (silent, no on-TV code), persists a long-lived
# lounge_token, then sends setPlaylist/seekTo/etc. commands to the TV's YouTube
# app — works whether the app is open locally or mid-cast from a phone.
# Used by the youtube_ad_break sandman action.

LOUNGE_TOKENS_PATH = "/share/.ha_cache/.lounge_tokens.json"
LOUNGE_DEVICE_NAME = "YongWah"  # mimics dad's phone — appears briefly during cast
LOUNGE_PAIRING_URL = "https://www.youtube.com/api/lounge/pairing/get_lounge_token_batch"
LOUNGE_BIND_URL = "https://www.youtube.com/api/lounge/bc/bind"
DIAL_SSDP_ST = "urn:dial-multiscreen-org:service:dial:1"
import random as _random
import xml.etree.ElementTree as _ET


def _lounge_load_tokens() -> dict:
    try:
        with open(LOUNGE_TOKENS_PATH) as f:
            return json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        return {}


def _lounge_save_tokens(tokens: dict) -> None:
    os.makedirs(os.path.dirname(LOUNGE_TOKENS_PATH), exist_ok=True)
    tmp = LOUNGE_TOKENS_PATH + ".tmp"
    with open(tmp, "w") as f:
        json.dump(tokens, f, indent=2)
    os.replace(tmp, LOUNGE_TOKENS_PATH)


def _ssdp_find_dial(timeout: float = 4.0) -> list[tuple[str, str]]:
    """SSDP M-SEARCH for DIAL services on LAN. Returns [(ip, location_url), ...]."""
    msg = (
        "M-SEARCH * HTTP/1.1\r\n"
        "HOST: 239.255.255.250:1900\r\n"
        'MAN: "ssdp:discover"\r\n'
        "MX: 3\r\n"
        f"ST: {DIAL_SSDP_ST}\r\n"
        "\r\n"
    ).encode()
    sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
    sock.settimeout(timeout)
    found = []
    try:
        sock.sendto(msg, ("239.255.255.250", 1900))
        deadline = time.time() + timeout
        while time.time() < deadline:
            try:
                data, addr = sock.recvfrom(4096)
            except socket.timeout:
                break
            loc = None
            for line in data.decode(errors="ignore").splitlines():
                if line.lower().startswith("location:"):
                    loc = line.split(":", 1)[1].strip()
                    break
            if loc:
                found.append((addr[0], loc))
    finally:
        sock.close()
    return found


def _dial_get_apps_url(location_url: str) -> str | None:
    """Fetch DIAL device description and return the Application-URL header."""
    try:
        r = requests.get(location_url, timeout=5)
        r.raise_for_status()
        return r.headers.get("Application-URL")
    except Exception as e:
        log.warning("DIAL desc fetch failed for %s: %s", location_url, e)
        return None


def _dial_get_youtube_screen_id(apps_url: str) -> str | None:
    """GET <apps_url>/YouTube and parse <screenId> from the response.

    YouTube must be running in the foreground for screen_id to be exposed.
    Returns None if YouTube isn't running or DIAL doesn't expose YouTube."""
    url = apps_url.rstrip("/") + "/YouTube"
    try:
        r = requests.get(url, timeout=5)
        if r.status_code != 200:
            return None
        # Strip default namespace so XPath stays simple
        body = re.sub(r'\sxmlns="[^"]+"', "", r.text, count=1)
        root = _ET.fromstring(body)
        sid_el = root.find(".//screenId")
        if sid_el is not None and sid_el.text:
            return sid_el.text.strip()
    except Exception as e:
        log.warning("DIAL YouTube probe failed: %s", e)
    return None


def lounge_pair_via_dial(tv_ip: str = TV_IP, screen_name: str = "philips") -> dict | None:
    """Discover the TV's DIAL service, fetch the YouTube screen_id while the
    YouTube app is foreground, and exchange it for a long-lived lounge_token.

    Persists the result to LOUNGE_TOKENS_PATH under `screen_name`. Silent on TV
    — no code displayed, no popup. Returns the token entry dict, or None if
    pairing failed (e.g. YouTube not running). Must be called when YouTube is
    actually visible on the TV, otherwise DIAL returns 404 for /apps/YouTube."""
    apps_url = None
    for ip, loc in _ssdp_find_dial(timeout=4.0):
        if ip == tv_ip:
            apps_url = _dial_get_apps_url(loc)
            if apps_url:
                break
    if not apps_url:
        # Common Philips Android TV fallback — DIAL on Chromecast port
        apps_url = f"http://{tv_ip}:8008/apps"
    screen_id = _dial_get_youtube_screen_id(apps_url)
    if not screen_id:
        log.warning("lounge_pair_via_dial: no screen_id (YouTube must be foreground on %s)", tv_ip)
        return None
    try:
        r = requests.post(LOUNGE_PAIRING_URL,
                          data={"screen_ids": screen_id},
                          timeout=10)
        r.raise_for_status()
        screens = r.json().get("screens", [])
        if not screens:
            return None
        lounge_token = screens[0].get("loungeToken")
        if not lounge_token:
            return None
    except Exception as e:
        log.warning("lounge pairing exchange failed: %s", e)
        return None
    entry = {
        "screen_id": screen_id,
        "lounge_token": lounge_token,
        "tv_ip": tv_ip,
        "paired_at": int(time.time()),
    }
    tokens = _lounge_load_tokens()
    tokens[screen_name] = entry
    _lounge_save_tokens(tokens)
    log.info("lounge paired: screen=%s tv=%s token=%s...", screen_name, tv_ip, lounge_token[:8])
    return entry


def lounge_pair_with_code(pairing_code: str, screen_name: str = "philips",
                          tv_ip: str = TV_IP) -> dict | None:
    """Exchange a 12-digit YouTube TV pairing code for a long-lived lounge_token.

    Used when DIAL discovery doesn't expose YouTube (modern Google TVs / Cast V2
    devices). User reads the code from TV → Settings → Watch on TV → "Link with
    TV code", then passes it here. Code is single-use and expires in ~5 minutes,
    but the resulting lounge_token is good for months/years.

    Accepts code with or without dashes (e.g. "123-456-789-012" or "123456789012").
    Returns the persisted token entry, or None on failure."""
    code = re.sub(r"[^0-9]", "", pairing_code or "")
    if len(code) != 12:
        log.warning("lounge_pair_with_code: bad code length %d (expected 12 digits)", len(code))
        return None
    try:
        r = requests.post(
            "https://www.youtube.com/api/lounge/pairing/get_screen",
            data={"pairing_code": code},
            headers={"X-YouTube-Client-Name": "1", "X-YouTube-Client-Version": "2.0"},
            timeout=10,
        )
        if r.status_code != 200:
            log.warning("lounge get_screen returned %d: %s", r.status_code, r.text[:200])
            return None
        data = r.json()
    except Exception as e:
        log.warning("lounge_pair_with_code request failed: %s", e)
        return None
    screen = data.get("screen") or {}
    screen_id = screen.get("screenId")
    lounge_token = screen.get("loungeToken")
    if not (screen_id and lounge_token):
        log.warning("lounge get_screen missing fields: %s", list(screen.keys()))
        return None
    entry = {
        "screen_id": screen_id,
        "lounge_token": lounge_token,
        "tv_ip": tv_ip,
        "paired_at": int(time.time()),
        "method": "code",
    }
    tokens = _lounge_load_tokens()
    tokens[screen_name] = entry
    _lounge_save_tokens(tokens)
    log.info("lounge paired via code: screen=%s token=%s...", screen_name, lounge_token[:8])
    return entry


def lounge_get_token(screen_name: str = "philips") -> dict | None:
    """Return cached token entry or None."""
    return _lounge_load_tokens().get(screen_name)


def lounge_clear_token(screen_name: str = "philips") -> None:
    tokens = _lounge_load_tokens()
    tokens.pop(screen_name, None)
    _lounge_save_tokens(tokens)


def _lounge_parse_chunked(text: str) -> list[tuple[str, object]]:
    """Parse YouTube Lounge /bind chunked response.

    Format: lines alternate between length count and JSON payload. Payload is
    an array of [event_id, [event_type, ...event_data]] entries. We flatten
    those to (event_type, event_data) tuples, where event_data is whatever
    follows event_type in the inner array (single value if one item, list if
    multiple)."""
    out = []
    i = 0
    while i < len(text):
        nl = text.find("\n", i)
        if nl < 0:
            break
        try:
            n = int(text[i:nl].strip())
        except ValueError:
            i = nl + 1
            continue
        chunk = text[nl + 1: nl + 1 + n]
        i = nl + 1 + n
        try:
            arr = json.loads(chunk)
        except json.JSONDecodeError:
            continue
        for entry in arr:
            if not (isinstance(entry, list) and len(entry) >= 2 and isinstance(entry[1], list)):
                continue
            inner = entry[1]
            if not inner:
                continue
            etype = inner[0]
            edata = inner[1] if len(inner) == 2 else inner[1:]
            out.append((etype, edata))
    return out


class LoungeSession:
    """Minimal sync YouTube Lounge client. Connect → send commands → optionally
    poll for state. Not async — designed to run inside a sandman action thread.

    Does NOT keep a long-poll open continuously; we sip state on demand around
    each setPlaylist call (enough for cycle-on-failure detection)."""

    def __init__(self, screen_name: str = "philips"):
        self.screen_name = screen_name
        entry = lounge_get_token(screen_name)
        if not entry:
            raise RuntimeError(f"no lounge token for screen '{screen_name}'; run lounge_pair_via_dial first")
        self.screen_id = entry["screen_id"]
        self.lounge_token = entry["lounge_token"]
        # Stable device-instance ID — without this, the TV shows "New Device
        # Connected: YongWah" every reconnect because YouTube treats each
        # randomly-seeded id as a different controller instance.
        device_id = entry.get("device_id")
        if not device_id:
            device_id = "%032x" % _random.getrandbits(128)
            entry["device_id"] = device_id
            tokens = _lounge_load_tokens()
            tokens[screen_name] = entry
            _lounge_save_tokens(tokens)
        self.device_id = device_id
        self.session = requests.Session()
        self.sid = None
        self.gsessionid = None
        self.aid = "0"
        self.now_playing = {}  # videoId, currentTime, state
        self._rid = _random.randint(10000, 99999)
        # Liveness + ad-state tracking. last_event_at lets callers tell whether
        # poll_state actually got a fresh response from the receiver vs replaying
        # a server-cached state (the "ghost" state that keeps showing up when
        # the TV's YouTube has stopped publishing). content_video_id is captured
        # separately from now_playing.videoId so a later nowPlaying event with
        # the ad's videoId doesn't clobber the real content target.
        self.last_event_at = 0.0
        self.content_video_id = None
        self.ad_active = False
        # Serializes _command (send) vs poll_state (long-poll). Reentrant so
        # a single thread holding the lock for set_playlist_verified can call
        # poll_state internally without deadlocking. The keepalive thread polls
        # in a loop and releases between polls; campaign threads grab the lock
        # for send-and-verify bursts.
        self._lock = threading.RLock()

    def _next_rid(self) -> str:
        self._rid += 1
        return str(self._rid)

    def _absorb(self, events: list) -> None:
        if events:
            self.last_event_at = time.time()
        for etype, edata in events:
            if etype == "c" and isinstance(edata, list):
                self.sid = edata[0] if edata else None
            elif etype == "S":
                self.gsessionid = edata
            elif etype == "nowPlaying" and isinstance(edata, dict):
                for k in ("videoId", "currentTime", "state", "duration", "listId"):
                    if k in edata:
                        self.now_playing[k] = edata[k]
                # If the main video resumed (videoId matches our captured
                # contentVideoId), clear the ad_active flag.
                if (self.content_video_id
                        and edata.get("videoId") == self.content_video_id):
                    self.ad_active = False
            elif etype == "onStateChange" and isinstance(edata, dict):
                for k in ("currentTime", "state", "duration"):
                    if k in edata:
                        self.now_playing[k] = edata[k]
            elif etype == "playlistModified" and isinstance(edata, dict):
                # The receiver reports this whenever the current playlist
                # changes — including initial connect when YouTube has a video
                # cued or playing. Use the head video as our resume target.
                vid = edata.get("videoId")
                if vid:
                    self.now_playing.setdefault("videoId", vid)
                # videoIds is "id1,id2,id3"; first = currently selected
                vids = edata.get("videoIds")
                if vids and "videoId" not in self.now_playing:
                    self.now_playing["videoId"] = vids.split(",")[0]
            elif etype == "onAdStateChange" and isinstance(edata, dict):
                # YouTube serving its own ad. contentVideoId is the original
                # video the user clicked into — keep it in a separate slot so
                # later nowPlaying events for the ad's own videoId can't
                # overwrite our resume target.
                cvid = edata.get("contentVideoId")
                if cvid:
                    self.content_video_id = cvid
                    self.ad_active = True
                    # Only seed now_playing.videoId if we don't already have a
                    # real one; don't clobber a fresh nowPlaying.
                    self.now_playing.setdefault("videoId", cvid)
            elif etype == "noop":
                pass

    def reconnect(self, timeout: float = 10.0) -> bool:
        """Tear down a dead session and re-bind on the same lounge_token.
        Used to recover from `LoungeSessionDead` without re-pairing via DIAL.
        Issues a fresh SID/gsessionid; existing long-polls on the old SID will
        return 400 once and unwind cleanly. Returns True on success."""
        with self._lock:
            try:
                self.session.close()
            except Exception:
                pass
            self.session = requests.Session()
            self.sid = None
            self.gsessionid = None
            self.aid = "0"
            self.last_event_at = 0.0
            return self.connect(timeout=timeout)

    def connect(self, timeout: float = 10.0) -> bool:
        """Open a session. Returns True on success. Captures now-playing state."""
        params = {
            "RID": self._next_rid(),
            "VER": "8",
            "CVER": "1",
            "device": "REMOTE_CONTROL",
            "name": LOUNGE_DEVICE_NAME,
            "app": "youtube-desktop",
            "loungeIdToken": self.lounge_token,
            "id": self.device_id,
        }
        headers = {
            "X-YouTube-LoungeId-Token": self.lounge_token,
            "Content-Type": "application/x-www-form-urlencoded",
            "Origin": "https://www.youtube.com",
        }
        r = self.session.post(LOUNGE_BIND_URL, params=params,
                              data="count=0", headers=headers, timeout=timeout)
        if r.status_code == 401:
            raise LoungeAuthError("lounge token rejected (401)")
        r.raise_for_status()
        self._absorb(_lounge_parse_chunked(r.text))
        return self.sid is not None and self.gsessionid is not None

    def _command(self, command: str, params: dict | None = None, timeout: float = 8.0) -> bool:
        params = params or {}
        url_params = {
            "RID": self._next_rid(),
            "VER": "8",
            "CVER": "1",
            "SID": self.sid,
            "gsessionid": self.gsessionid,
            "loungeIdToken": self.lounge_token,
        }
        form = {"count": "1", "ofs": "0", "req0__sc": command}
        for k, v in params.items():
            form[f"req0_{k}"] = str(v)
        headers = {
            "X-YouTube-LoungeId-Token": self.lounge_token,
            "Content-Type": "application/x-www-form-urlencoded",
            "Origin": "https://www.youtube.com",
        }
        with self._lock:
            r = self.session.post(LOUNGE_BIND_URL, params=url_params,
                                  data=form, headers=headers, timeout=timeout)
            if r.status_code == 401:
                raise LoungeAuthError("lounge token rejected (401)")
            _lounge_check_dead(r)
            r.raise_for_status()
            # /bind responses can include state events even on POST (rare but happens)
            if r.text:
                try:
                    self._absorb(_lounge_parse_chunked(r.text))
                except Exception:
                    pass
        return True

    def set_playlist(self, video_id: str, current_time: float = 0.0) -> bool:
        return self._command("setPlaylist", {
            "videoId": video_id,
            "currentTime": str(current_time),
            "currentIndex": "-1",
            "audioOnly": "false",
            "params": "",
            "playerParams": "",
        })

    def request_now_playing(self) -> dict:
        """Best-effort current-state read. Asks the receiver to broadcast and
        polls briefly, but this TV's YouTube often doesn't echo state to
        getNowPlaying — so we fall back to whatever connect() captured (the
        receiver's snapshot at session-start, which is usually the most-recent
        real video and our best resume target).

        Returns {} only if connect() also captured nothing."""
        pre = self.last_event_at
        try:
            self._command("getNowPlaying")
        except LoungeSessionDead as e:
            log.warning("request_now_playing: %s — reconnecting", e)
            try:
                self.reconnect()
                pre = self.last_event_at
            except Exception as e2:
                log.warning("request_now_playing reconnect: %s", e2)
                return dict(self.now_playing)
        except Exception:
            pass
        # Poll briefly in case the receiver does happen to echo back.
        for _ in range(3):
            try:
                self.poll_state(timeout=2.0)
            except LoungeSessionDead:
                break
            if self.last_event_at > pre:
                break
        return dict(self.now_playing)

    def set_playlist_verified(self, video_id: str, current_time: float = 0.0,
                              verify_timeout: float = 2.5) -> bool:
        """setPlaylist with belt-and-suspenders delivery. We can't reliably
        "verify" the command because this TV's YouTube receiver accepts and
        renders setPlaylist but does NOT always echo state back through the
        Lounge channel — so a successful command still looks "silent" to us.

        Strategy: send once, poll briefly for any fresh receiver event. If we
        got fresh activity, assume warm and return True. If silent, the link
        MIGHT be cold (or just non-echoing); send a second time as a warmup —
        empirically the second command often sticks when the first didn't.

        Returns True if at least one attempt produced fresh events (link
        confirmed warm), False if both attempts were silent. Callers should
        treat False as "link probably cold, but the command may still have
        landed" — log it but don't abort."""
        pre = self.last_event_at
        try:
            self.set_playlist(video_id, current_time)
        except LoungeAuthError:
            raise
        except LoungeSessionDead as e:
            log.warning("set_playlist_verified send 1: %s — reconnecting", e)
            try:
                self.reconnect()
                self.set_playlist(video_id, current_time)
                pre = self.last_event_at  # fresh session, restart liveness window
            except LoungeAuthError:
                raise
            except Exception as e2:
                log.warning("set_playlist_verified reconnect+resend 1: %s", e2)
        except Exception as e:
            log.warning("set_playlist_verified send 1: %s", e)
        deadline = time.time() + verify_timeout
        while time.time() < deadline:
            try:
                self.poll_state(timeout=min(1.0, max(0.1, deadline - time.time())))
            except LoungeSessionDead:
                break  # send was already retried on fresh session; verify is best-effort
            if self.last_event_at > pre:
                return True
        # Silent — try once more as a cold-link warmup
        try:
            self.set_playlist(video_id, current_time)
        except LoungeAuthError:
            raise
        except LoungeSessionDead as e:
            log.warning("set_playlist_verified send 2: %s — reconnecting", e)
            try:
                self.reconnect()
                self.set_playlist(video_id, current_time)
                pre = self.last_event_at
            except LoungeAuthError:
                raise
            except Exception as e2:
                log.warning("set_playlist_verified reconnect+resend 2: %s", e2)
        except Exception as e:
            log.warning("set_playlist_verified send 2: %s", e)
        deadline = time.time() + verify_timeout
        while time.time() < deadline:
            try:
                self.poll_state(timeout=min(1.0, max(0.1, deadline - time.time())))
            except LoungeSessionDead:
                break  # send was already retried on fresh session; verify is best-effort
            if self.last_event_at > pre:
                return True
        return False

    def play(self) -> bool:
        return self._command("play")

    def pause(self) -> bool:
        return self._command("pause")

    def seek_to(self, current_time: float) -> bool:
        return self._command("seekTo", {"newTime": str(current_time)})

    def poll_state(self, timeout: float = 4.0) -> dict:
        """One-shot long-poll for state events. Updates now_playing in place
        and returns a copy. Returns whatever's accumulated even on timeout."""
        params = {
            "RID": "rpc",
            "SID": self.sid,
            "CI": "0",
            "AID": self.aid,
            "TYPE": "xmlhttp",
            "gsessionid": self.gsessionid,
            "VER": "8",
            "CVER": "1",
            "loungeIdToken": self.lounge_token,
        }
        headers = {
            "X-YouTube-LoungeId-Token": self.lounge_token,
            "Origin": "https://www.youtube.com",
        }
        with self._lock:
            try:
                r = self.session.get(LOUNGE_BIND_URL, params=params,
                                     headers=headers, timeout=timeout)
                _lounge_check_dead(r)
                if r.text:
                    self._absorb(_lounge_parse_chunked(r.text))
            except (requests.Timeout, requests.ConnectionError):
                pass
            return dict(self.now_playing)


class LoungeAuthError(Exception):
    """Lounge token rejected (401). Caller should re-pair via DIAL."""
    pass


class LoungeSessionDead(Exception):
    """Lounge SID/gsessionid invalidated (400 Unknown SID). The lounge_token
    is still valid — caller should re-bind on the same token rather than
    re-pair. Distinct from LoungeAuthError so the keeper/campaign can
    transparently recover without DIAL."""
    pass


def _lounge_check_dead(r) -> None:
    """Raise LoungeSessionDead if the response body indicates an invalidated
    SID. YouTube returns 400 with body 'Unknown SID' when the SID has expired,
    been evicted, or rotated — at that point every command on this session
    will keep failing until we mint a fresh SID via re-bind."""
    if r.status_code == 400 and "Unknown SID" in (r.text or ""):
        raise LoungeSessionDead("lounge SID invalidated (400 Unknown SID)")


class LoungeKeeper:
    """Module-level singleton that holds a long-lived LoungeSession and runs
    a background poll loop. The point: this TV's YouTube only renders Lounge
    setPlaylist commands while it considers a controller actively present.
    Connect-fire-disconnect (the previous pattern) lets the receiver mark us
    as gone within minutes, after which our setPlaylist commands return 200
    but render nothing on screen.

    A continuous long-poll keeps the TV's controller list populated with us,
    so commands stay live. Reconnects with backoff if the session dies.
    Campaign code grabs `.session` (the live LoungeSession) and sends through
    it directly — the keeper's RLock serializes them safely."""

    _instance: "LoungeKeeper | None" = None
    _instance_lock = threading.Lock()

    def __init__(self, screen_name: str = "philips"):
        self.screen_name = screen_name
        self.session: LoungeSession | None = None
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._ready = threading.Event()

    @classmethod
    def get(cls, screen_name: str = "philips") -> "LoungeKeeper":
        with cls._instance_lock:
            if cls._instance is None:
                inst = cls(screen_name)
                inst.start()
                cls._instance = inst
            return cls._instance

    def start(self) -> None:
        if self._thread and self._thread.is_alive():
            return
        self._stop.clear()
        self._thread = threading.Thread(target=self._run, daemon=True,
                                         name=f"LoungeKeeper-{self.screen_name}")
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()

    def wait_ready(self, timeout: float = 10.0) -> bool:
        return self._ready.wait(timeout=timeout)

    def get_session(self) -> "LoungeSession | None":
        """Return the live session if connected, else None. Caller should
        check before sending and fall back to a fresh session if absent."""
        return self.session if self.session and self.session.sid else None

    def _run(self) -> None:
        backoff = 2.0
        while not self._stop.is_set():
            try:
                if not lounge_get_token(self.screen_name):
                    log.info("LoungeKeeper: no token cached; sleeping")
                    self._stop.wait(60)
                    continue
                sess = LoungeSession(screen_name=self.screen_name)
                sess.connect()
                self.session = sess
                self._ready.set()
                log.info("LoungeKeeper: connected (sid=%s)", sess.sid)
                backoff = 2.0
                # Long-poll forever. Each poll holds the connection open up
                # to ~30s waiting for receiver events; that wait IS the
                # keepalive — the server sees our long-poll and tells the TV
                # a controller is active. Brief sleep between polls so the
                # send lock is occasionally free for campaign threads.
                while not self._stop.is_set():
                    sess.poll_state(timeout=30.0)
                    self._stop.wait(0.2)
            except LoungeAuthError:
                log.warning("LoungeKeeper: token rejected — needs re-pair")
                self.session = None
                self._stop.wait(120)
            except LoungeSessionDead as e:
                log.info("LoungeKeeper: %s; re-binding immediately", e)
                self.session = None
                backoff = 2.0
                self._stop.wait(1)
            except Exception as e:
                log.warning("LoungeKeeper: %s; reconnecting in %.0fs", e, backoff)
                self.session = None
                self._stop.wait(backoff)
                backoff = min(backoff * 2, 60)


def lounge_keeper_start(screen_name: str = "philips") -> LoungeKeeper:
    """Start (or return) the background keepalive that holds the TV's Lounge
    subscription warm. Idempotent — safe to call multiple times."""
    return LoungeKeeper.get(screen_name)


def lounge_set_playlist_with_repair(video_id: str, current_time: float = 0.0,
                                    screen_name: str = "philips",
                                    tv_ip: str = TV_IP) -> tuple[bool, dict]:
    """Convenience wrapper: connect, send setPlaylist, return (ok, now_playing).
    On 401 (rotated token), re-pair via DIAL once and retry. Used by the
    sandman ad-break action; logs to standard tv_control logger so the
    Telegram bot's /lounge command can surface activity."""
    try:
        sess = LoungeSession(screen_name=screen_name)
        if not sess.connect():
            return False, {}
        sess.set_playlist(video_id, current_time)
        return True, sess.poll_state(timeout=4.0)
    except LoungeAuthError:
        log.warning("lounge token rotated for %s — re-pairing via DIAL", screen_name)
        if lounge_pair_via_dial(tv_ip=tv_ip, screen_name=screen_name):
            try:
                sess = LoungeSession(screen_name=screen_name)
                if not sess.connect():
                    return False, {}
                sess.set_playlist(video_id, current_time)
                return True, sess.poll_state(timeout=4.0)
            except Exception as e:
                log.warning("lounge retry after re-pair failed: %s", e)
        return False, {}
    except (RuntimeError, requests.RequestException) as e:
        log.warning("lounge_set_playlist failed: %s", e)
        return False, {}
