#!/usr/bin/python3
"""Root helper for Moodtop OS (run via `sudo -n`, allowed for the sudo group by /etc/sudoers.d/mood).

Moodtop is a personal laptop OS, so there is no teacher PIN: anyone in the sudo group may run these
fixed, validated actions. Extra actions are loaded from /usr/lib/mood/runtime/admin.d/*.py
(each exports ACTIONS = {"name": fn(args: list[str]) -> JSON-able}).
Prints JSON on stdout; errors go to stderr with a non-zero exit code.
"""
import importlib.util
import json
import os
import re
import subprocess
import sys
from pathlib import Path

POLICY = Path("/etc/mood/policy.json")
HOSTS_MARK = "# >>> mood safesearch"
SAFE_HOSTS = {
    "216.239.38.120": ["www.google.com", "google.com", "www.google.co.uk", "www.google.com.sg", "www.google.com.au", "www.google.ca"],
    "216.239.38.119": ["www.youtube.com", "m.youtube.com", "youtubei.googleapis.com", "youtube.googleapis.com", "www.youtube-nocookie.com"],
    "204.79.197.220": ["www.bing.com", "bing.com"],
}


class Fail(Exception):
    pass


STDIN = ""


def policy():
    try:
        return json.loads(POLICY.read_text())
    except Exception:
        return {"safesearch": False, "blocklist": [], "allowlist": [], "allowTerminal": True, "allowDevtools": True}


def write_hosts(on):
    hosts = Path("/etc/hosts").read_text()
    hosts = re.sub(rf"\n?{HOSTS_MARK}.*?# <<< mood safesearch\n?", "\n", hosts, flags=re.S).rstrip() + "\n"
    if on:
        lines = [HOSTS_MARK] + [f"{ip} {' '.join(names)}" for ip, names in SAFE_HOSTS.items()] + ["# <<< mood safesearch"]
        hosts += "\n".join(lines) + "\n"
    Path("/etc/hosts").write_text(hosts)


def a_check(args):
    return {"ok": True, "pinSet": False}


def a_apply_policy(args):
    write_hosts(policy().get("safesearch", False))
    return True


def a_policy(args):
    p = policy()
    new = json.loads(args[0])
    for k in ("safesearch", "blocklist", "allowlist", "allowTerminal", "allowDevtools", "homepage", "hiddenApps"):
        if k in new:
            p[k] = new[k]
    p["blocklist"] = sorted({d.strip().lower() for d in p.get("blocklist", []) if d.strip()})
    p["allowlist"] = sorted({d.strip().lower() for d in p.get("allowlist", []) if d.strip()})
    POLICY.parent.mkdir(parents=True, exist_ok=True)
    POLICY.write_text(json.dumps(p, indent=2))
    os.chmod(POLICY, 0o644)
    write_hosts(p.get("safesearch", False))
    return p


def a_timezone(args):
    tz = args[0]
    if not re.fullmatch(r"[A-Za-z0-9_+\-/]+", tz) or not Path("/usr/share/zoneinfo", tz).is_file():
        raise Fail("unknown time zone")
    subprocess.run(["timedatectl", "set-timezone", tz], check=True)
    return True


def a_hostname(args):
    if not args or not re.fullmatch(r"[a-zA-Z0-9-]{1,40}", args[0]):
        raise Fail("invalid hostname")
    subprocess.run(["hostnamectl", "set-hostname", args[0]], check=True)
    return True


def a_updates(args):
    subprocess.run(["apt-get", "update", "-qq"], check=False, capture_output=True)
    out = subprocess.run(["apt-get", "-s", "-qq", "full-upgrade"], capture_output=True, text=True).stdout
    pkgs = [l.split()[1] for l in out.splitlines() if l.startswith("Inst ")]
    return {"count": len(pkgs), "packages": pkgs[:300]}


def a_upgrade(args):
    env = dict(os.environ, DEBIAN_FRONTEND="noninteractive")
    r = subprocess.run(["apt-get", "-y", "-o", "Dpkg::Options::=--force-confold", "full-upgrade"], env=env, capture_output=True, text=True)
    if r.returncode:
        raise Fail(r.stderr[-2000:])
    return {"ok": True}


def a_keyboard(args):
    layout = args[0] if args else "gb"
    variant = args[1] if len(args) > 1 else ""
    if not re.fullmatch(r"[a-z]{2,8}(,[a-z]{2,8})*", layout) or not re.fullmatch(r"[a-z0-9_,-]*", variant):
        raise Fail("bad layout")
    Path("/etc/default/keyboard").write_text(f'XKBMODEL="pc105"\nXKBLAYOUT="{layout}"\nXKBVARIANT="{variant}"\nXKBOPTIONS=""\nBACKSPACE="guess"\n')
    return True


ACTIONS = {
    "check": a_check, "apply-policy": a_apply_policy, "policy": a_policy, "timezone": a_timezone,
    "hostname": a_hostname, "updates": a_updates, "upgrade": a_upgrade, "keyboard": a_keyboard,
}


def load_extra():
    d = Path("/usr/lib/mood/runtime/admin.d")
    for f in sorted(d.glob("*.py")) if d.is_dir() else []:
        st = f.stat()
        if st.st_uid != 0 or st.st_mode & 0o022:  # never run anything a user could have written
            continue
        spec = importlib.util.spec_from_file_location(f"mood_admin_{f.stem}", f)
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        ACTIONS.update(getattr(mod, "ACTIONS", {}))


def main():
    if os.getuid() != 0:
        print("mood-admin must run as root", file=sys.stderr)
        sys.exit(1)
    if len(sys.argv) < 2:
        print("usage: mood-admin <action> [args]", file=sys.stderr)
        sys.exit(1)
    global STDIN
    if not sys.stdin.isatty():
        # first stdin line: legacy PIN slot from moodhost.admin(); actions that need a secret
        # (e.g. install-start's password) read it from here so it never shows up in argv/ps
        STDIN = sys.stdin.readline().rstrip("\n")
    load_extra()
    action, args = sys.argv[1], sys.argv[2:]
    fn = ACTIONS.get(action)
    if not fn:
        print(f"unknown action {action}", file=sys.stderr)
        sys.exit(1)
    try:
        print(json.dumps(fn(args)))
    except Fail as e:
        print(str(e), file=sys.stderr)
        sys.exit(3)
    except subprocess.CalledProcessError as e:
        print(f"{action} failed ({e.returncode})", file=sys.stderr)
        sys.exit(4)
    except Exception as e:  # admin.d actions raise plain errors with a user-facing message
        print(str(e) or f"{action} failed", file=sys.stderr)
        sys.exit(5)


main()
