#!/usr/bin/python3
"""Find hardware updates that are available but not installed.

Windows calls these optional updates: driver and firmware updates that exist,
that nothing will install on its own, and that a user has no way to discover
unless something tells them. On a stock Ubuntu the Nvidia driver is exactly
that — the archive has it, ubuntu-drivers knows which one fits, and nothing
ever mentions it.

Detection only. Nothing here needs root and nothing here changes the system,
which is what makes it safe to run from a timer in the user's session.
"""
from __future__ import annotations

import argparse
import json
import logging
import re
import shutil
import subprocess
import sys

log = logging.getLogger("motel-hardware-updates")

# Long enough for a slow LVFS metadata read, short enough that a hung helper
# does not leave the settings page spinning forever.
TIMEOUT = 60


def _run(cmd: list[str]) -> subprocess.CompletedProcess | None:
    if not shutil.which(cmd[0]):
        log.info("%s is not installed", cmd[0])
        return None
    try:
        return subprocess.run(cmd, capture_output=True, text=True,
                              timeout=TIMEOUT, check=False)
    except (OSError, subprocess.SubprocessError) as exc:
        log.warning("%s failed: %s", cmd[0], exc)
        return None


def _installed(package: str) -> bool:
    result = _run(["dpkg-query", "-W", "-f=${db:Status-Status}", package])
    return bool(result and result.stdout.strip() == "installed")


def drivers() -> list[dict]:
    """Proprietary drivers that apply to this machine and are not installed.

    ubuntu-drivers does the hard part — matching PCI ids to the driver series
    Ubuntu tested. We only decide what to show and how to say it.
    """
    result = _run(["ubuntu-drivers", "list", "--recommended"])
    if result is None or result.returncode != 0:
        return []

    found = []
    for line in result.stdout.splitlines():
        # "nvidia-driver-580, (kernel modules provided by linux-modules-nvidia-580-generic)"
        name = line.split(",")[0].strip()
        if not re.fullmatch(r"[a-z0-9][a-z0-9.+-]*", name):
            continue
        if _installed(name):
            continue

        # Prefer the prebuilt kernel modules over a DKMS build: they are
        # matched to the kernel ABI and updated alongside it, so a kernel
        # upgrade cannot leave the machine without a working graphics driver.
        modules = re.search(r"provided by ([a-z0-9][a-z0-9.+-]*)", line)
        packages = [name] + ([modules.group(1)] if modules else [])

        found.append({
            "kind": "driver",
            "id": name,
            "name": _describe(name),
            "detail": ("Tested by Ubuntu for the hardware in this computer. "
                       "This is proprietary software from the manufacturer."),
            "packages": packages,
            "needs_reboot": True,
        })
    return found


def _describe(package: str) -> str:
    match = re.match(r"nvidia-driver-(\d+)(-open)?", package)
    if match:
        variant = " (open kernel modules)" if match.group(2) else ""
        return f"NVIDIA graphics driver {match.group(1)}{variant}"
    return package


def firmware() -> list[dict]:
    """Firmware updates the vendor published to LVFS.

    Only ever as complete as the vendor makes it: a manufacturer that does not
    publish to LVFS produces nothing here, and there is no second channel we
    could fall back to.
    """
    result = _run(["fwupdmgr", "get-updates", "--json"])
    if result is None or not result.stdout.strip():
        return []

    try:
        data = json.loads(result.stdout)
    except ValueError:
        # "no updates" is reported as a non-JSON message on some versions.
        return []

    found = []
    for device in data.get("Devices", []):
        releases = device.get("Releases") or []
        if not releases:
            continue
        release = releases[0]

        flags = release.get("Flags") or []
        # Vendors name devices inconsistently: some already say "Firmware"
        # ("System Firmware"), some do not ("ThinkPad Dock"). Appending it
        # unconditionally produces "System Firmware firmware 1.19".
        device_name = device.get("Name", "Device")
        label = device_name if "firmware" in device_name.lower() \
            else f"{device_name} firmware"

        found.append({
            "kind": "firmware",
            "id": device.get("DeviceId", ""),
            "name": f"{label} {release.get('Version', '')}".strip(),
            "detail": (release.get("Description") or "")
                      .replace("<p>", "").replace("</p>", " ").strip()
                      or "Firmware update published by the manufacturer.",
            "packages": [],
            # Most capsule updates flash during the next boot.
            "needs_reboot": True,
            # fwupd marks the updates that cannot be undone. The user is told
            # before, not after.
            "irreversible": "no-rollback" in flags or "only-version-upgrade" in flags,
        })
    return found


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser(
        description="List available hardware driver and firmware updates.")
    parser.add_argument("--json", action="store_true",
                        help="Machine-readable output (used by the settings page).")
    parser.add_argument("--quiet-if-none", action="store_true",
                        help="Exit 1 when there is nothing to install.")
    args = parser.parse_args(argv[1:])

    logging.basicConfig(level=logging.WARNING, format="%(levelname)s: %(message)s")

    updates = drivers() + firmware()

    if args.json:
        print(json.dumps({"updates": updates}, indent=2))
    else:
        if not updates:
            print("No hardware updates are available.")
        for item in updates:
            print(f"[{item['kind']}] {item['name']}")

    if args.quiet_if_none and not updates:
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv))
