#!/usr/bin/env python3
"""DyzineFrame Supervisor — Alt-Tab control window (Pi + Win11; not a tray icon).

Started by dyzineframe_supervisor.py (DISPLAY/WAYLAND on Pi; always on Win11).
Starts minimized to the taskbar (not hidden / withdrawn). Kiosk taskbars are
often auto-hidden — hover the edge or Alt-Tab. Geometry in run/supervisor_ui_state.json.

Controls:
  Enable / Disable — Auto-Restart policy (watchdog.disabled; persists reboot)
  CPU pin Enable / Disable — DF cores 0-2 vs all cores (cpu_affinity.disabled)
  Stop / Start     — quit DF / one-shot run (Start does not clear Disable)
  Restart          — write supervisor.cmd restart (respawn DF; does not Disable)
"""

from __future__ import annotations

import json
import os
import signal
import subprocess
import sys
import time
import tkinter as tk
from pathlib import Path
from tkinter import ttk

from cpu_affinity_control import (  # noqa: E402
    apply_live as apply_cpu_affinity_live,
    is_disabled as cpu_affinity_is_disabled,
    set_disabled as set_cpu_affinity_disabled,
)

APP_ROOT = Path(__file__).resolve().parent
DISABLE_PATH = APP_ROOT / "run" / "watchdog.disabled"
HOLD_PATH = APP_ROOT / "run" / "supervisor.hold"
CMD_PATH = APP_ROOT / "run" / "supervisor.cmd"
BACKOFF_PATH = APP_ROOT / "run" / "supervisor.backoff"
LOCK_PATH = APP_ROOT / "run" / "dyzineframe.lock"
STATE_PATH = APP_ROOT / "run" / "supervisor_ui_state.json"
WINDOW_TITLE = "DyzineFrame Supervisor"
STATUS_POLL_MS = 500
GEOMETRY_SAVE_MS = 400
DEFAULT_W = 420
DEFAULT_H = 310
UI_FONT = ("Segoe UI", 11) if sys.platform == "win32" else ("Sans", 11)
UI_FONT_SM = ("Segoe UI", 8) if sys.platform == "win32" else ("Sans", 8)
UI_FONT_HINT = ("Segoe UI", 9) if sys.platform == "win32" else ("Sans", 9)


def pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    if sys.platform == "win32":
        import ctypes

        handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
        if handle:
            ctypes.windll.kernel32.CloseHandle(handle)
            return True
        return False
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    return True


def lock_pid_alive() -> bool:
    if not LOCK_PATH.is_file():
        return False
    try:
        text = LOCK_PATH.read_text(encoding="utf-8").strip()
        pid = int(text) if text.isdigit() else 0
    except (OSError, ValueError):
        return False
    return pid_alive(pid)


def is_disabled() -> bool:
    return DISABLE_PATH.is_file()


def is_held() -> bool:
    return HOLD_PATH.is_file()


def read_backoff() -> dict[str, str] | None:
    if not BACKOFF_PATH.is_file():
        return None
    try:
        text = BACKOFF_PATH.read_text(encoding="utf-8")
    except OSError:
        return None
    data: dict[str, str] = {}
    for line in text.splitlines():
        if "=" not in line:
            continue
        key, _, val = line.partition("=")
        data[key.strip()] = val.strip()
    if "until" not in data:
        return None
    return data


def backoff_remaining_s(info: dict[str, str] | None) -> float:
    if not info:
        return 0.0
    try:
        until = float(info.get("until") or 0)
    except ValueError:
        return 0.0
    return max(0.0, until - time.time())


def load_geometry() -> str | None:
    if not STATE_PATH.is_file():
        return None
    try:
        data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
    except (OSError, ValueError, TypeError):
        return None
    if not isinstance(data, dict):
        return None
    try:
        w = int(data["w"])
        h = int(data["h"])
        x = int(data["x"])
        y = int(data["y"])
    except (KeyError, TypeError, ValueError):
        return None
    if w < 200 or h < 80:
        return None
    return f"{w}x{h}+{x}+{y}"


def save_geometry(root: tk.Tk) -> None:
    try:
        root.update_idletasks()
        geo = root.geometry()
        if "x" not in geo:
            return
        size, _, pos = geo.partition("+")
        w_s, _, h_s = size.partition("x")
        parts = pos.split("+")
        if len(parts) < 2:
            return
        payload = {
            "w": int(w_s),
            "h": int(h_s),
            "x": int(parts[0]),
            "y": int(parts[1]),
            "updated": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
        }
        STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
        tmp = STATE_PATH.with_suffix(".tmp")
        tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
        tmp.replace(STATE_PATH)
    except (OSError, ValueError, tk.TclError):
        pass


def write_cmd(cmd: str) -> None:
    CMD_PATH.parent.mkdir(parents=True, exist_ok=True)
    CMD_PATH.write_text(f"{cmd}\n", encoding="utf-8")


def set_disabled(want: bool) -> None:
    DISABLE_PATH.parent.mkdir(parents=True, exist_ok=True)
    if want:
        DISABLE_PATH.write_text(
            "auto-restart off by DyzineFrame Supervisor UI\n", encoding="utf-8"
        )
    else:
        try:
            DISABLE_PATH.unlink(missing_ok=True)
        except OSError:
            pass


def set_hold(want: bool) -> None:
    HOLD_PATH.parent.mkdir(parents=True, exist_ok=True)
    if want:
        HOLD_PATH.write_text("stopped by DyzineFrame Supervisor UI\n", encoding="utf-8")
    else:
        try:
            HOLD_PATH.unlink(missing_ok=True)
        except OSError:
            pass


def force_kill_df() -> None:
    """Hard-kill DF by lock PID immediately (works when pygame is hung)."""
    if not LOCK_PATH.is_file():
        return
    try:
        text = LOCK_PATH.read_text(encoding="utf-8").strip()
        pid = int(text) if text.isdigit() else 0
    except (OSError, ValueError):
        return
    if not pid_alive(pid):
        return
    try:
        if sys.platform == "win32":
            subprocess.run(
                ["taskkill", "/F", "/PID", str(pid)],
                capture_output=True,
                check=False,
            )
        else:
            os.kill(pid, signal.SIGKILL)
    except OSError:
        pass
    try:
        LOCK_PATH.unlink(missing_ok=True)
    except OSError:
        pass


def _prepare_win_taskbar() -> None:
    """So the minimized window keeps a normal taskbar button (not a hidden tool window)."""
    if sys.platform != "win32":
        return
    try:
        import ctypes

        ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(
            "DyzineTek.DyzineFrame.Supervisor"
        )
    except Exception:
        pass


class SupervisorApp:
    def __init__(self) -> None:
        _prepare_win_taskbar()
        self.root = tk.Tk()
        self.root.title(WINDOW_TITLE)
        self.root.minsize(320, 180)
        try:
            # Never tool-window: that hides the taskbar button on Win11.
            self.root.attributes("-toolwindow", 0)
        except tk.TclError:
            pass
        self._geom_after: str | None = None
        self._building = True
        self._syncing_radio = False
        self._syncing_cpu = False
        self._backoff_raised = False
        self._pending_start_at: float | None = None
        self._status_fg = "#1a1a1a"

        geo = load_geometry()
        if geo:
            try:
                self.root.geometry(geo)
            except tk.TclError:
                self.root.geometry(f"{DEFAULT_W}x{DEFAULT_H}")
        else:
            self.root.geometry(f"{DEFAULT_W}x{DEFAULT_H}")

        frm = ttk.Frame(self.root, padding=10)
        frm.pack(fill=tk.BOTH, expand=True)

        self.status = tk.StringVar(value="Starting…")
        self.status_label = tk.Label(
            frm, textvariable=self.status, font=UI_FONT, fg=self._status_fg, anchor=tk.W, justify=tk.LEFT
        )
        self.status_label.pack(anchor=tk.W, pady=(0, 8))

        mode = ttk.LabelFrame(frm, text="Auto-Restart", padding=6)
        mode.pack(fill=tk.X, pady=(0, 8))
        ttk.Label(
            mode,
            text="Persists across reboot. Disable does not stop a running app.",
            font=UI_FONT_SM,
        ).pack(anchor=tk.W, pady=(0, 4))
        radios = ttk.Frame(mode)
        radios.pack(fill=tk.X)
        self.mode_var = tk.StringVar(value="enable")
        ttk.Radiobutton(
            radios,
            text="Enable",
            value="enable",
            variable=self.mode_var,
            command=self.on_mode_change,
        ).pack(side=tk.LEFT, padx=(0, 12))
        ttk.Radiobutton(
            radios,
            text="Disable",
            value="disable",
            variable=self.mode_var,
            command=self.on_mode_change,
        ).pack(side=tk.LEFT)

        if sys.platform != "win32":
            cpu = ttk.LabelFrame(frm, text="CPU pin", padding=6)
            cpu.pack(fill=tk.X, pady=(0, 8))
            ttk.Label(
                cpu,
                text="Enable = DF cores 0–2 (EtherGold keeps core 3). Disable = all cores (MP4 / fireworks).",
                font=UI_FONT_SM,
            ).pack(anchor=tk.W, pady=(0, 4))
            cpu_radios = ttk.Frame(cpu)
            cpu_radios.pack(fill=tk.X)
            self.cpu_var = tk.StringVar(value="enable")
            ttk.Radiobutton(
                cpu_radios,
                text="Enable",
                value="enable",
                variable=self.cpu_var,
                command=self.on_cpu_change,
            ).pack(side=tk.LEFT, padx=(0, 12))
            ttk.Radiobutton(
                cpu_radios,
                text="Disable",
                value="disable",
                variable=self.cpu_var,
                command=self.on_cpu_change,
            ).pack(side=tk.LEFT)
        else:
            self.cpu_var = tk.StringVar(value="enable")

        actions = ttk.Frame(frm)
        actions.pack(fill=tk.X)
        ttk.Button(actions, text="Stop", command=self.on_stop).pack(
            side=tk.LEFT, padx=(0, 6)
        )
        ttk.Button(actions, text="Restart", command=self.on_restart).pack(
            side=tk.LEFT, padx=(0, 6)
        )
        self.start_btn = ttk.Button(actions, text="Start", command=self.on_start)
        self.start_btn.pack(side=tk.LEFT)

        hint = ttk.Label(
            frm,
            text="Starts minimized to the taskbar (not hidden). Alt-Tab if DF is fullscreen. "
            "Stop force-kills; Restart respawns; Start = one session.",
            font=UI_FONT_HINT,
        )
        hint.pack(anchor=tk.W, pady=(10, 0))

        self.root.protocol("WM_DELETE_WINDOW", self.on_close)
        self.root.bind("<Configure>", self.on_configure)

        self.root.after(50, self._initial_minimize_to_taskbar)
        self.root.after(STATUS_POLL_MS, self.refresh_status)
        self._building = False
        self._sync_radio_from_disk()

    def _initial_minimize_to_taskbar(self) -> None:
        """Minimize to taskbar — never withdraw/hide the window.

        Win11 kiosk: brief normal show first so Alt-Tab / taskbar get a real
        button before iconify (immediate iconify often never registered).
        """
        try:
            self.root.update_idletasks()
            self.root.deiconify()
            self.root.lift()
            self.root.attributes("-topmost", True)
            self.root.after(200, lambda: self.root.attributes("-topmost", False))
            # Delay iconify so the shell catalogs the window.
            self.root.after(2500, self._iconify_safe)
        except tk.TclError:
            pass

    def _iconify_safe(self) -> None:
        try:
            self.root.iconify()
        except tk.TclError:
            pass

    def _sync_radio_from_disk(self) -> None:
        self._syncing_radio = True
        self._syncing_cpu = True
        try:
            self.mode_var.set("disable" if is_disabled() else "enable")
            if sys.platform != "win32":
                self.cpu_var.set("disable" if cpu_affinity_is_disabled() else "enable")
        finally:
            self._syncing_radio = False
            self._syncing_cpu = False

    def on_mode_change(self) -> None:
        if self._syncing_radio:
            return
        if self.mode_var.get() == "disable":
            set_disabled(True)
            self.status.set("Auto-Restart off (persists)")
        else:
            set_disabled(False)
            self.status.set("Auto-Restart on")
        self.root.after(STATUS_POLL_MS, self.refresh_status)

    def on_cpu_change(self) -> None:
        if self._syncing_cpu:
            return
        want_off = self.cpu_var.get() == "disable"
        set_cpu_affinity_disabled(want_off)
        notes = apply_cpu_affinity_live(enabled=not want_off)
        if want_off:
            self.status.set("CPU pin off — DF all cores (persists)")
        else:
            self.status.set("CPU pin on — DF 0–2")
        if notes:
            self.status.set(self.status.get() + " · " + notes[0])
        self.root.after(STATUS_POLL_MS, self.refresh_status)

    def on_stop(self) -> None:
        set_hold(True)
        write_cmd("stop")
        force_kill_df()
        self.status.set("Stop — force-kill sent")
        self.root.after(STATUS_POLL_MS, self.refresh_status)

    def on_restart(self) -> None:
        set_hold(False)
        write_cmd("restart")
        self.status.set("Restart requested — supervisor will respawn DF")
        self.root.after(STATUS_POLL_MS, self.refresh_status)

    def on_start(self) -> None:
        set_hold(False)
        write_cmd("start")
        self._pending_start_at = time.time()
        if backoff_remaining_s(read_backoff()) > 0:
            self.status.set("Start now — skipping crash wait…")
        else:
            self.status.set("Start requested (session)…")
        self.root.after(STATUS_POLL_MS, self.refresh_status)

    def on_configure(self, _event: tk.Event) -> None:
        if self._building:
            return
        try:
            if self.root.state() != "normal":
                return
        except tk.TclError:
            return
        if self._geom_after is not None:
            self.root.after_cancel(self._geom_after)
        self._geom_after = self.root.after(GEOMETRY_SAVE_MS, self._debounced_save)

    def _debounced_save(self) -> None:
        self._geom_after = None
        save_geometry(self.root)

    def on_close(self) -> None:
        save_geometry(self.root)
        self.root.destroy()

    def refresh_status(self) -> None:
        try:
            self._sync_radio_from_disk()
            running = lock_pid_alive()
            backoff = read_backoff()
            remain = backoff_remaining_s(backoff)
            in_backoff = remain > 0 and not running
            fg = "#1a1a1a"
            start_label = "Start"
            if in_backoff:
                mins = int(remain) // 60
                secs = int(remain) % 60
                count = backoff.get("count", "?") if backoff else "?"
                fg = "#c41e3a"
                start_label = "Start now"
                self.status.set(
                    f"Crash backoff {mins}:{secs:02d} — {count} exits under 30s "
                    "(waiting 300s)"
                )
                if not self._backoff_raised:
                    try:
                        self.root.deiconify()
                        self.root.lift()
                    except tk.TclError:
                        pass
                    self._backoff_raised = True
            else:
                self._backoff_raised = False
                if (
                    self._pending_start_at is not None
                    and CMD_PATH.is_file()
                    and (time.time() - self._pending_start_at) >= 2.0
                ):
                    fg = "#c41e3a"
                    self.status.set("Supervisor not running — Start was not received")
                elif is_held() and not running:
                    self.status.set("Stopped")
                    self._pending_start_at = None
                elif running and is_disabled():
                    self.status.set("Running (auto-restart off)")
                    self._pending_start_at = None
                elif running:
                    self.status.set("Running")
                    self._pending_start_at = None
                elif is_disabled():
                    self.status.set("Idle — Auto-Restart off")
                else:
                    self.status.set("Starting…")
            try:
                self.start_btn.configure(text=start_label)
            except tk.TclError:
                pass
            if fg != self._status_fg:
                self._status_fg = fg
                self.status_label.configure(fg=fg)
        except Exception:
            self.status.set("Status unknown")
        try:
            self.root.after(STATUS_POLL_MS, self.refresh_status)
        except tk.TclError:
            pass

    def run(self) -> None:
        self.root.mainloop()


def main() -> int:
    if sys.platform != "win32":
        if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
            print("No DISPLAY/WAYLAND_DISPLAY", file=sys.stderr)
            return 2
    try:
        SupervisorApp().run()
    except tk.TclError as exc:
        print(f"Supervisor UI failed: {exc}", file=sys.stderr)
        return 1
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
