#!/usr/bin/env python3
"""Keep an OLED-safe clock dream on the TV when it would otherwise be off."""

import datetime as dt
import functools
import http.server
import json
import logging
import os
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path

HERE = Path(__file__).resolve().parent
DEPLOY_DIR = Path("/share/.ha_cache/tv_clock")
CONFIG_PATH = Path(os.environ.get("TV_CLOCK_CONFIG", HERE / "clock_display.json"))
STATE_PATH = Path(os.environ.get("TV_CLOCK_STATE", DEPLOY_DIR / "state.json"))
APK_PATH = Path(os.environ.get("TV_CLOCK_APK", HERE / "quiet-clock.apk"))
LOG_PATH = Path(os.environ.get("TV_CLOCK_LOG", DEPLOY_DIR / "clock_display.log"))
COMPONENT = "com.yingcong.tvclock/.ClockDreamService"
PACKAGE = "com.yingcong.tvclock"
STREAM_DIR = DEPLOY_DIR / "stream"
STREAM_PORT = 8890
STREAM_URL = f"http://192.168.1.187:{STREAM_PORT}/clock.m3u8"
FFMPEG_PID_PATH = DEPLOY_DIR / "ffmpeg.pid"
TIME_TEXT_PATH = DEPLOY_DIR / "time.txt"
DATE_TEXT_PATH = DEPLOY_DIR / "date.txt"
FONT_PATH = "/usr/share/fonts/noto/NotoSansCJK-Regular.ttc"


class ClockStreamHandler(http.server.SimpleHTTPRequestHandler):
    extensions_map = {
        **http.server.SimpleHTTPRequestHandler.extensions_map,
        ".m3u8": "application/vnd.apple.mpegurl",
        ".ts": "video/mp2t",
    }

    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Headers", "Range")
        self.send_header("Access-Control-Expose-Headers", "Content-Length, Content-Range")
        self.send_header("Cache-Control", "no-store")
        super().end_headers()

    def log_message(self, _format, *args):
        pass


def start_stream_server():
    STREAM_DIR.mkdir(parents=True, exist_ok=True)
    handler = functools.partial(ClockStreamHandler, directory=str(STREAM_DIR))
    server = http.server.ThreadingHTTPServer(("0.0.0.0", STREAM_PORT), handler)
    thread = threading.Thread(target=server.serve_forever, name="ClockStreamHTTP", daemon=True)
    thread.start()
    return server

sys.path.insert(0, "/share/.ha_cache")
try:
    import tvmod as tv_control
except ImportError:
    import tv_control


def load_json(path, default):
    try:
        return json.loads(Path(path).read_text())
    except Exception:
        return dict(default)


def save_json(path, value):
    path = Path(path)
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    tmp.write_text(json.dumps(value, indent=2) + "\n")
    tmp.replace(path)


def minutes(value):
    hour, minute = (int(part) for part in value.split(":"))
    return hour * 60 + minute


def within_waking_hours(now, config):
    current = now.hour * 60 + now.minute
    start = minutes(config["waking_start"])
    end = minutes(config["waking_end"])
    if start <= end:
        return start <= current < end
    return current >= start or current < end


def run_adb(ip, *args, timeout=15):
    target = f"{ip}:5555"
    subprocess.run(["adb", "connect", target], capture_output=True, timeout=10)
    return subprocess.run(
        ["adb", "-s", target, *args], capture_output=True, text=True, timeout=timeout
    )


def tcp_open(ip, port, timeout=1.0):
    try:
        with socket.create_connection((ip, port), timeout=timeout):
            return True
    except OSError:
        return False


def dream_is_active(ip):
    if not tcp_open(ip, 5555):
        return False
    result = run_adb(ip, "shell", "dumpsys", "dreams")
    text = (result.stdout or "") + (result.stderr or "")
    return COMPONENT in text and any(
        marker in text for marker in ("mCurrentDream", "isDreaming=true", "mDreaming=true")
    )


def ensure_installed(ip):
    if not tcp_open(ip, 5555):
        return False
    found = run_adb(ip, "shell", "pm", "path", PACKAGE)
    if found.returncode == 0 and "package:" in found.stdout:
        return True
    if not APK_PATH.exists():
        logging.error("clock APK missing: %s", APK_PATH)
        return False
    installed = run_adb(ip, "install", "-r", str(APK_PATH), timeout=90)
    if installed.returncode != 0:
        logging.error("APK install failed: %s", (installed.stderr or installed.stdout).strip())
        return False
    return True


def start_dream(ip):
    if not ensure_installed(ip):
        return False
    commands = (
        ("settings", "put", "secure", "screensaver_enabled", "1"),
        ("settings", "put", "secure", "screensaver_components", COMPONENT),
        ("cmd", "dreams", "start-dreaming"),
    )
    for command in commands:
        result = run_adb(ip, "shell", *command)
        if result.returncode != 0:
            logging.error("ADB command failed (%s): %s", " ".join(command), result.stderr.strip())
            return False
    time.sleep(3)
    return dream_is_active(ip)


def ffmpeg_is_running():
    try:
        pid = int(FFMPEG_PID_PATH.read_text().strip())
        os.kill(pid, 0)
        return True
    except (OSError, ValueError, FileNotFoundError):
        return False


def stop_stream():
    try:
        pid = int(FFMPEG_PID_PATH.read_text().strip())
        os.kill(pid, 15)
    except (OSError, ValueError, FileNotFoundError):
        pass
    try:
        FFMPEG_PID_PATH.unlink()
    except FileNotFoundError:
        pass


def update_clock_text():
    now = dt.datetime.now()
    weekdays = "一二三四五六日"
    TIME_TEXT_PATH.write_text(now.strftime("%H:%M") + "\n")
    DATE_TEXT_PATH.write_text(
        f"{now.year}年{now.month}月{now.day}日　星期{weekdays[now.weekday()]}\n"
    )


def ensure_stream():
    playlist = STREAM_DIR / "clock.m3u8"
    if ffmpeg_is_running() and playlist.exists():
        return True

    stop_stream()
    STREAM_DIR.mkdir(parents=True, exist_ok=True)
    for old in STREAM_DIR.glob("segment-*.ts"):
        old.unlink()
    try:
        playlist.unlink()
    except FileNotFoundError:
        pass

    update_clock_text()
    # The modular minute offsets traverse the full frame without keeping any
    # glyph on the same OLED pixels.
    drawtext = (
        f"drawtext=fontfile={FONT_PATH}:"
        f"textfile={TIME_TEXT_PATH}:reload=1:"
        "fontcolor=0x606060:fontsize=132:"
        "x='(w-text_w)/2+220*sin(floor(t/60)*1.7)':"
        "y='h/2-110+120*cos(floor(t/60)*1.3)',"
        f"drawtext=fontfile={FONT_PATH}:"
        f"textfile={DATE_TEXT_PATH}:reload=1:"
        "fontcolor=0x404040:fontsize=52:"
        "x='(w-text_w)/2+220*sin(floor(t/60)*1.7)':"
        "y='h/2+55+120*cos(floor(t/60)*1.3)'"
    )
    command = [
        "ffmpeg", "-hide_banner", "-loglevel", "warning",
        "-re", "-f", "lavfi", "-i", "color=c=black:s=1280x720:r=1",
        "-re", "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
        "-vf", drawtext,
        "-c:v", "libx264", "-preset", "veryfast", "-tune", "stillimage",
        "-profile:v", "main", "-level", "3.1", "-pix_fmt", "yuv420p",
        "-g", "2", "-keyint_min", "2", "-sc_threshold", "0",
        "-c:a", "aac", "-b:a", "32k", "-ar", "48000",
        "-f", "hls", "-hls_time", "2", "-hls_list_size", "6",
        "-hls_flags", "delete_segments+omit_endlist+independent_segments",
        "-hls_segment_filename", str(STREAM_DIR / "segment-%05d.ts"),
        str(playlist),
    ]
    with open(DEPLOY_DIR / "ffmpeg.log", "ab") as ffmpeg_log:
        proc = subprocess.Popen(
            command, stdin=subprocess.DEVNULL, stdout=ffmpeg_log,
            stderr=subprocess.STDOUT, start_new_session=True,
        )
    FFMPEG_PID_PATH.write_text(str(proc.pid))
    deadline = time.monotonic() + 15
    while time.monotonic() < deadline:
        if playlist.exists() and playlist.stat().st_size > 0:
            return True
        if proc.poll() is not None:
            logging.error("clock stream encoder exited; see %s", DEPLOY_DIR / "ffmpeg.log")
            return False
        time.sleep(0.5)
    logging.error("clock stream did not become ready")
    return False


def get_cast(ip):
    import pychromecast
    casts, browser = pychromecast.get_chromecasts(known_hosts=[ip], timeout=10)
    cast = next((item for item in casts if item.cast_info.host == ip), None)
    return pychromecast, browser, cast


def cast_clock_is_active(ip):
    if not ffmpeg_is_running():
        return False
    try:
        pychromecast, browser, cast = get_cast(ip)
        try:
            if cast is None:
                return False
            cast.wait(timeout=10)
            if cast.status.app_id != "CC1AD845":
                return False
            cast.media_controller.update_status()
            time.sleep(1)
            status = cast.media_controller.status
            if status.content_id not in (None, STREAM_URL):
                return False
            return status.idle_reason != "ERROR"
        finally:
            browser.stop_discovery()
            if cast is not None:
                cast.disconnect()
    except Exception as exc:
        logging.debug("Cast status failed: %s", exc)
        return False


def start_cast(ip):
    if not ensure_stream():
        return False
    try:
        pychromecast, browser, cast = get_cast(ip)
        try:
            if cast is None:
                return False
            cast.wait(timeout=15)
            cast.media_controller.play_media(
                STREAM_URL, "application/vnd.apple.mpegurl",
                stream_type="LIVE", title="",
            )
            cast.media_controller.block_until_active(timeout=15)
            time.sleep(2)
            status = cast.media_controller.status
            return status.content_id == STREAM_URL and status.player_state in ("PLAYING", "BUFFERING")
        finally:
            browser.stop_discovery()
            if cast is not None:
                cast.disconnect()
    except Exception:
        logging.exception("failed to start clock Cast session")
        return False


def clock_is_active(ip):
    return dream_is_active(ip) if tcp_open(ip, 5555) else cast_clock_is_active(ip)


def start_clock(ip):
    if tcp_open(ip, 5555):
        return start_dream(ip)
    return start_cast(ip)


def send_wol(mac):
    raw = bytes.fromhex(mac.replace(":", "").replace("-", ""))
    packet = b"\xff" * 6 + raw * 16
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
        sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        for _ in range(3):
            for broadcast in ("255.255.255.255", "192.168.1.255"):
                for port in (7, 9):
                    sock.sendto(packet, (broadcast, port))
            time.sleep(0.1)


def discover_until(timeout_seconds):
    deadline = time.monotonic() + timeout_seconds
    while time.monotonic() < deadline:
        ip = tv_control.discover_tv(timeout=4)
        if ip:
            return ip
        time.sleep(5)
    return None


def current_ip():
    ip = tv_control._get_tv_ip()
    if tv_control.get_power_state(ip=ip) != "Unknown":
        return ip
    return tv_control.discover_tv(timeout=4)


def standby(ip):
    return tv_control.js_key("Standby", ip=ip)


def main():
    DEPLOY_DIR.mkdir(parents=True, exist_ok=True)
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s %(levelname)s %(message)s",
        handlers=[logging.FileHandler(LOG_PATH), logging.StreamHandler()],
    )
    stream_server = start_stream_server()
    state = load_json(STATE_PATH, {"mode": "unknown", "last_wake_attempt": 0})

    while True:
        config = load_json(CONFIG_PATH, {})
        try:
            if not config.get("enabled", True):
                time.sleep(60)
                continue

            if ffmpeg_is_running():
                update_clock_text()

            awake_hours = within_waking_hours(dt.datetime.now(), config)
            ip = current_ip()

            if ip:
                active = clock_is_active(ip)
                if not awake_hours:
                    if active and state.get("mode") == "clock":
                        logging.info("waking hours ended; putting clock display in standby")
                        standby(ip)
                    stop_stream()
                    state["mode"] = "off_hours"
                elif active:
                    state["mode"] = "clock"
                    state["ip"] = ip
                    state["inactive_checks"] = 0
                elif state.get("mode") == "clock":
                    # Cast status can briefly disappear during receiver handshakes. Require
                    # three consecutive misses before interpreting it as a viewer exit.
                    misses = int(state.get("inactive_checks", 0)) + 1
                    state["inactive_checks"] = misses
                    if misses >= 3:
                        logging.info("clock exited by viewer; suspending until the TV next powers off")
                        stop_stream()
                        state["mode"] = "viewing"
                elif state.get("mode") == "waking":
                    # TV has just been woken by this service.
                    if start_clock(ip):
                        logging.info("clock dream started on %s", ip)
                        state["mode"] = "clock"
                        state["ip"] = ip
                else:
                    # A reachable TV that we did not wake belongs to the viewer.
                    # Install the tiny package silently, but never take over the screen.
                    ensure_installed(ip)
                    stop_stream()
                    state["mode"] = "viewing"
                    state["ip"] = ip
            elif awake_hours:
                # Unreachable means standby/deep sleep. Re-wake only on a bounded cadence.
                if state.get("mode") == "viewing":
                    state["mode"] = "off"
                retry_seconds = int(config.get("wake_retry_minutes", 10)) * 60
                if time.time() - float(state.get("last_wake_attempt", 0)) >= retry_seconds:
                    state["last_wake_attempt"] = time.time()
                    state["mode"] = "waking"
                    save_json(STATE_PATH, state)
                    logging.info("TV is off during waking hours; sending Wake-on-LAN")
                    for mac in config.get("tv_macs", []):
                        send_wol(mac)
                    ip = discover_until(int(config.get("boot_timeout_seconds", 180)))
                    if ip and start_clock(ip):
                        logging.info("TV woke; clock dream started on %s", ip)
                        state["mode"] = "clock"
                        state["ip"] = ip
                    else:
                        logging.warning("TV did not become controllable after wake attempt")
                        state["mode"] = "off"
            else:
                stop_stream()
                state["mode"] = "off_hours"

            save_json(STATE_PATH, state)
        except Exception:
            logging.exception("clock display loop failed")
        time.sleep(max(10, int(config.get("check_interval_seconds", 20))))


if __name__ == "__main__":
    main()
