#!/usr/bin/env python3

"""Gaming: what the machine is doing, and what it should do while you play.

The interesting part is not reporting gamemode's state -- it is reacting to it.
gamemode can run a script when a game asks for it and another when the game
exits, so `hook start` and `hook end` are what let Panama switch the power
profile and silence notifications for exactly the duration of a game, and put
both back afterwards. Everything else here is honest reporting.

    panama-gaming snapshot
    panama-gaming set-overlay true|false
    panama-gaming set-overlay-preset fps|detailed
    panama-gaming install-hooks | remove-hooks
    panama-gaming hook start|end          (called by gamemode, not by a person)
"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

MANGOHUD_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "MangoHud" / "MangoHud.conf"
GAMEMODE_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "gamemode.ini"
ENVIRONMENT_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "environment.d" / "panama-mangohud.conf"

# What the overlay shows. Deliberately two presets rather than exposing every
# MangoHud key: this is a settings page, not a config file with a nicer font.
PRESETS = {
    "fps": ["fps", "frametime=0", "no_display=0", "position=top-left",
            "font_size=22", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
    "detailed": ["fps", "frametime", "gpu_stats", "gpu_temp", "gpu_power",
                 "cpu_stats", "cpu_temp", "ram", "vram", "position=top-left",
                 "font_size=20", "background_alpha=0.4", "toggle_hud=Shift_R+F12"],
}


class BoundaryError(RuntimeError):
    """A user-visible failure."""


def run(command: list[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
    try:
        return subprocess.run(command, capture_output=True, text=True,
                              timeout=timeout, check=False)
    except (OSError, subprocess.TimeoutExpired) as error:
        raise BoundaryError(f"{command[0]} did not answer.") from error


def read_int(path: Path) -> int | None:
    try:
        return int(path.read_text().strip())
    except (OSError, ValueError):
        return None


def gpus() -> list[dict]:
    """Every AMD/Intel GPU with a hwmon node, warmest first.

    Read from sysfs rather than a tool: it costs nothing, needs no daemon, and
    a page that polls while it is open should not be spawning processes.
    """
    found = []
    for hwmon in sorted(Path("/sys/class/hwmon").glob("hwmon*")):
        try:
            name = (hwmon / "name").read_text().strip()
        except OSError:
            continue
        if name not in ("amdgpu", "i915", "xe", "nouveau"):
            continue
        device = (hwmon / "device").resolve()
        temperature = read_int(hwmon / "temp1_input")
        power = read_int(hwmon / "power1_average")
        used = read_int(device / "mem_info_vram_used")
        total = read_int(device / "mem_info_vram_total")
        model = ""
        try:
            model = (device / "product_name").read_text().strip()
        except OSError:
            model = ""
        found.append({
            "driver": name,
            "model": model,
            "temperatureC": round(temperature / 1000, 1) if temperature else None,
            "watts": round(power / 1000000, 1) if power else None,
            "vramUsedBytes": used or 0,
            "vramTotalBytes": total or 0,
            # A card with no VRAM reported is the integrated one sharing system
            # memory; saying "0 of 0 GB" would look broken.
            "discrete": bool(total and total > 1073741824),
        })
    found.sort(key=lambda entry: (not entry["discrete"], entry["driver"]))
    return found


def governor() -> str:
    try:
        return Path("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor").read_text().strip()
    except OSError:
        return ""


def game_mode() -> dict:
    available = bool(shutil.which("gamemoded"))
    state = {"available": available, "active": False, "daemonRunning": False,
             "governorWhileGaming": "", "governorNow": governor()}
    if not available:
        return state
    status = run(["gamemoded", "-s"]).stdout.strip()
    state["active"] = "is active" in status
    state["daemonRunning"] = "gamemode is" in status

    # What it would switch the governor to, from its own configuration.
    for path in (GAMEMODE_CONFIG, Path("/usr/share/gamemode/gamemode.ini")):
        try:
            text = path.read_text()
        except OSError:
            continue
        found = re.search(r"^\s*desiredgov\s*=\s*(\S+)", text, re.M)
        if found:
            state["governorWhileGaming"] = found.group(1)
            break
    return state


def hooks_installed() -> bool:
    try:
        text = GAMEMODE_CONFIG.read_text()
    except OSError:
        return False
    return "panama-gaming hook start" in text


def overlay() -> dict:
    installed = bool(shutil.which("mangohud"))
    preset = ""
    if MANGOHUD_CONFIG.is_file():
        try:
            body = MANGOHUD_CONFIG.read_text()
            preset = "detailed" if "gpu_stats" in body else "fps"
        except OSError:
            preset = ""
    return {
        "installed": installed,
        "configured": MANGOHUD_CONFIG.is_file(),
        "preset": preset or "fps",
        # Global enablement is an environment variable read at session start, so
        # a change here does not affect anything already running.
        "globallyEnabled": ENVIRONMENT_CONFIG.is_file(),
        "toggleKey": "Shift_R+F12",
    }


def library() -> dict:
    root = Path.home() / ".local/share/Steam"
    games = len(list((root / "steamapps").glob("*.acf"))) if (root / "steamapps").is_dir() else 0
    tools = []
    for directory in (root / "compatibilitytools.d", root / "steamapps/common"):
        if not directory.is_dir():
            continue
        for entry in sorted(directory.iterdir()):
            if entry.is_dir() and entry.name.lower().startswith("proton"):
                tools.append({
                    "name": entry.name,
                    "community": directory.name == "compatibilitytools.d",
                })
    return {
        "path": str(root),
        "games": games,
        "protonBuilds": tools,
        "gamescope": bool(shutil.which("gamescope")),
        "steam": bool(shutil.which("steam")),
    }


def snapshot() -> dict:
    return {
        "gameMode": {**game_mode(), "hooksInstalled": hooks_installed()},
        "gpus": gpus(),
        "overlay": overlay(),
        "library": library(),
        "error": "",
    }


def set_overlay_preset(preset: str) -> None:
    if preset not in PRESETS:
        raise BoundaryError("That is not an overlay preset.")
    MANGOHUD_CONFIG.parent.mkdir(parents=True, exist_ok=True)
    header = ("# Written by Panama's Gaming settings.\n"
              "# Edits here are replaced when the preset changes.\n")
    MANGOHUD_CONFIG.write_text(header + "\n".join(PRESETS[preset]) + "\n", encoding="utf-8")


def set_overlay(enabled: bool) -> None:
    if not shutil.which("mangohud"):
        raise BoundaryError("MangoHud is not installed.")
    if enabled:
        if not MANGOHUD_CONFIG.is_file():
            set_overlay_preset("fps")
        ENVIRONMENT_CONFIG.parent.mkdir(parents=True, exist_ok=True)
        ENVIRONMENT_CONFIG.write_text(
            "# Written by Panama's Gaming settings.\n"
            "# Read when the session starts, so this reaches applications\n"
            "# launched afterwards rather than ones already running.\n"
            "MANGOHUD=1\n", encoding="utf-8")
    else:
        try:
            ENVIRONMENT_CONFIG.unlink()
        except OSError:
            pass


def install_hooks() -> None:
    """Point gamemode's start and end hooks at this script.

    Written by editing rather than replacing: gamemode.ini is the user's file
    and may hold settings this page does not manage.
    """
    script = str(Path(__file__).resolve())
    lines = []
    if GAMEMODE_CONFIG.is_file():
        lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
                 if "panama-gaming hook" not in line]
    text = "\n".join(lines)
    if "[custom]" not in text:
        text += "\n\n[custom]\n"
    text = re.sub(r"\[custom\]\n",
                  f"[custom]\nstart={script} hook start\nend={script} hook end\n",
                  text, count=1)
    GAMEMODE_CONFIG.parent.mkdir(parents=True, exist_ok=True)
    GAMEMODE_CONFIG.write_text(text.strip() + "\n", encoding="utf-8")


def remove_hooks() -> None:
    if not GAMEMODE_CONFIG.is_file():
        return
    lines = [line for line in GAMEMODE_CONFIG.read_text().splitlines()
             if "panama-gaming hook" not in line]
    GAMEMODE_CONFIG.write_text("\n".join(lines).strip() + "\n", encoding="utf-8")


def hook(phase: str) -> None:
    """Called by gamemode when a game starts and when it stops.

    Reads what the user asked for from the settings file directly: the shell may
    not be running, and a hook that depends on a running shell would silently do
    nothing for someone who restarted it mid-session.
    """
    if phase not in ("start", "end"):
        raise BoundaryError("That is not a hook phase.")

    settings_path = (Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
                     / "panama" / "settings.json")
    try:
        settings = json.loads(settings_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        settings = {}

    scripts = Path(__file__).resolve().parent
    # What was true before the game started, so ending it restores that rather
    # than imposing a default. Without this, finishing a game would silently
    # undo a power profile or a Do Not Disturb the user had chosen themselves.
    state_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-gaming.json"

    if phase == "start":
        before = {}
        if settings.get("gamingPerformanceProfile", True):
            current = run([str(scripts / "panama-power-profile"), "list"], timeout=15)
            try:
                before["profile"] = json.loads(current.stdout or "{}").get("active", "")
            except json.JSONDecodeError:
                before["profile"] = ""
            run([str(scripts / "panama-power-profile"), "set", "performance"], timeout=15)

        # Reported, not acted on. Silencing is a focus mode's job now, and two
        # things writing Do Not Disturb would each restore whatever the other
        # happened to leave behind. The hook knows a game started; what that
        # should mean is decided in one place.
        if shutil.which("qs"):
            run(["qs", "ipc", "call", "focus", "gameStarted"], timeout=10)

        try:
            state_path.write_text(json.dumps(before), encoding="utf-8")
        except OSError:
            pass

        if settings.get("gamingNotifyOnStart", False) and shutil.which("notify-send"):
            run(["notify-send", "-a", "Panama", "Game Mode",
                 "Performance profile engaged."], timeout=10)
        return

    # end
    try:
        before = json.loads(state_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        before = {}
    if before.get("profile"):
        run([str(scripts / "panama-power-profile"), "set", before["profile"]], timeout=15)
    if shutil.which("qs"):
        run(["qs", "ipc", "call", "focus", "gameEnded"], timeout=10)
    try:
        state_path.unlink()
    except OSError:
        pass


def main(arguments: list[str]) -> int:
    try:
        if arguments == ["snapshot"]:
            print(json.dumps(snapshot(), separators=(",", ":")))
            return 0
        if len(arguments) == 2 and arguments[0] == "hook":
            hook(arguments[1])
            return 0
        if len(arguments) == 2 and arguments[0] == "set-overlay":
            set_overlay(arguments[1] == "true")
        elif len(arguments) == 2 and arguments[0] == "set-overlay-preset":
            set_overlay_preset(arguments[1])
        elif arguments == ["install-hooks"]:
            install_hooks()
        elif arguments == ["remove-hooks"]:
            remove_hooks()
        else:
            raise BoundaryError(
                "Usage: panama-gaming snapshot | set-overlay true|false | "
                "set-overlay-preset fps|detailed | install-hooks | remove-hooks | "
                "hook start|end")
    except BoundaryError as error:
        state = snapshot()
        state["error"] = str(error)
        print(json.dumps(state, separators=(",", ":")))
        return 0

    print(json.dumps(snapshot(), separators=(",", ":")))
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
