#!/usr/bin/python3 -I
"""Explicit initial OTA provisioning with the bundled or a custom publisher key.

Run the installed command with sudo. No private key or server credentials are
accepted. Existing trust is never overwritten; subsequent changes use sudoedit.
"""

import argparse
import grp
import os
from pathlib import Path
import pwd
import re
import stat
import subprocess
import sys

sys.path.insert(0, "/usr/lib/4web-kiosk")
from kiosk import operations, updates  # noqa: E402


CONFIG = Path("/etc/4web-kiosk")
STATE = Path("/var/lib/4web-kiosk")
EXAMPLES = Path("/usr/share/doc/4web-kiosk/examples")
UNITS = Path("/etc/systemd/system")
UNIT_NAMES = ("4web-kiosk-updater.service", "4web-kiosk-updater.timer", "4web-kiosk-updater.path")
RESTART_PATTERN = re.compile(r"[a-zA-Z0-9_][a-zA-Z0-9@._-]{0,127}\.(service|target)")


def public_input(path, limit):
    """Read only bounded regular files, including if the source is swapped."""
    descriptor = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
    with os.fdopen(descriptor, "rb") as handle:
        if not stat.S_ISREG(os.fstat(handle.fileno()).st_mode):
            raise updates.UpdateError("Schlüssel und CA müssen reguläre Dateien sein.")
        data = handle.read(limit + 1)
    if len(data) > limit:
        raise updates.UpdateError("Schlüssel oder CA-Datei ist zu groß.")
    return data


def trusted_directory(path):
    path.mkdir(mode=0o755, exist_ok=True)
    updates.check_trusted_directory(path)


def write_new(path, data):
    # O_EXCL refuses an existing file, including a dangling symlink. This helper
    # does not silently replace the publisher that a machine already trusts.
    descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o644)
    with os.fdopen(descriptor, "wb") as handle:
        os.fchown(handle.fileno(), 0, 0)
        os.fchmod(handle.fileno(), 0o644)
        handle.write(data)
        handle.flush()
        os.fsync(handle.fileno())


def publisher_inputs(args):
    """The official channel has shipped trust; other origins need an explicit key."""
    url = updates.secure_url(args.url or updates.DEFAULT_MANIFEST_URL)
    if args.key:
        key_data = public_input(args.key, updates.MAX_KEY_BYTES)
    elif url == updates.DEFAULT_MANIFEST_URL:
        key_data = updates.read_trusted_file(updates.DEFAULT_RELEASE_KEY_FILE, updates.MAX_KEY_BYTES)
    else:
        raise updates.UpdateError("Für einen eigenen Update-Server ist --key mit seinem öffentlichen Herausgeberschlüssel erforderlich.")
    return url, updates.canonical_public_key(key_data)


def provision(args):
    if os.geteuid() != 0:
        raise updates.UpdateError("Diesen installierten Befehl einmalig mit sudo ausführen.")
    if not Path("/run/systemd/system").is_dir():
        raise updates.UpdateError("Für automatische Installation wird ein laufendes systemd benötigt.")
    url, key = publisher_inputs(args)
    if any(character in url for character in "\r\n"):
        raise updates.UpdateError("Die Manifest-Adresse darf keinen Zeilenumbruch enthalten.")
    ca = updates.parse_ca_pem(public_input(args.ca, updates.MAX_CA_BYTES)) if args.ca else ""
    if args.restart_unit and not RESTART_PATTERN.fullmatch(args.restart_unit):
        raise updates.UpdateError("--restart-unit muss eine systemd-Service-/Target-Unit nennen.")
    user = pwd.getpwnam(args.user) if args.user else None
    if user and user.pw_uid == 0:
        raise updates.UpdateError("--user muss ein normales Kiosk-Benutzerkonto sein.")

    trusted_directory(CONFIG)
    updates.check_trusted_directory(UNITS)
    trusted_directory(STATE)
    policy = policy_values(args)
    config = (f"url={url}\nkey={CONFIG / 'release-key.pem'}\n"
              f"ca={CONFIG / 'ca.crt' if ca else ''}\nreboot={args.restart_unit or ''}\n")
    config += "".join(f"{name}={value}\n" for name, value in policy.items())
    files = {
        CONFIG / "release-key.pem": key.encode(),
        CONFIG / "update.conf": config.encode(),
    }
    if ca:
        files[CONFIG / "ca.crt"] = ca.encode()
    for name in UNIT_NAMES:
        files[UNITS / name] = updates.read_trusted_file(EXAMPLES / name, 16 * 1024)
    for path in files:
        if path.exists() or path.is_symlink():
            raise updates.UpdateError(f"{path} existiert bereits. Vorhandenes OTA-Vertrauen wird nicht überschrieben; bitte mit sudoedit prüfen.")

    # Prepare trigger permissions before publishing a configuration. This group
    # grants only a check request, never permission to choose the root package.
    if user:
        try:
            group = grp.getgrnam("4web-kiosk")
        except KeyError:
            subprocess.run(["/usr/sbin/groupadd", "--system", "4web-kiosk"], check=True)
            group = grp.getgrnam("4web-kiosk")
        spool = STATE / "spool"
        spool.mkdir(mode=0o700, exist_ok=True)
        info = spool.lstat()
        if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o002:
            raise updates.UpdateError("Unsicheres Spool-Verzeichnis; es muss root gehören und darf kein Link sein.")
        spool.chown(0, group.gr_gid)
        spool.chmod(0o2770)
        subprocess.run(["/usr/sbin/usermod", "-aG", "4web-kiosk", "--", user.pw_name], check=True)

    created = []
    try:
        # Publish update.conf last, after all of its trust inputs are complete.
        config_path = CONFIG / "update.conf"
        for path in [p for p in files if p != config_path] + [config_path]:
            write_new(path, files[path])
            created.append(path)
        updates.read_service_config(config_path, trusted=True)
    except Exception:
        for path in reversed(created):
            path.unlink(missing_ok=True)
        raise
    subprocess.run(["/usr/bin/systemctl", "daemon-reload"], check=True)
    subprocess.run(["/usr/bin/systemctl", "enable", "--now", "4web-kiosk-updater.timer", "4web-kiosk-updater.path"], check=True)
    print("Signierte automatische Updates sind eingerichtet (Prüfung alle 15 Minuten; Installation nur im Wartungsfenster).")
    print(f"Manifest: {url}\nHerausgeberschlüssel SHA-256: {updates.key_fingerprint(key)}")
    print("Erste Prüfung: sudo systemctl start 4web-kiosk-updater.service")
    if user:
        print(f"{user.pw_name} muss sich für die neue Gruppenzugehörigkeit neu anmelden.")
    if not args.restart_unit:
        print("Die neue App-Version wird nach dem nächsten App-Neustart aktiv; Sitzungen werden nicht automatisch beendet.")


def policy_values(args, current=None):
    policy = dict(current or operations.POLICY_DEFAULTS)
    for name in ("maintenance_start", "maintenance_end", "maintenance_days", "maintenance_zone"):
        value = getattr(args, name, None)
        if value is not None:
            policy[name] = value
    always = getattr(args, "maintenance_always", None)
    if always is not None:
        policy["maintenance_always"] = "yes" if always else "no"
    elif getattr(args, "maintenance_start", None) is not None or getattr(args, "maintenance_end", None) is not None:
        policy["maintenance_always"] = "no"
    operations.MaintenanceWindow(policy)
    return policy


def set_window(args):
    if os.geteuid() != 0:
        raise updates.UpdateError("Wartungsfenster nur mit sudo ändern.")
    path = CONFIG / "update.conf"
    raw = updates.read_trusted_file(path, updates.MAX_MANIFEST_BYTES).decode("utf-8")
    updates.read_service_config(path, trusted=True)
    policy = policy_values(args, operations.read_policy(path))
    retained = []
    for line in raw.splitlines():
        name, separator, _ = line.strip().partition("=")
        if not separator or name.strip() not in policy:
            retained.append(line)
    content = "\n".join(retained).rstrip() + "\n" + "".join(f"{name}={value}\n" for name, value in policy.items())
    operations.atomic_root_file(path, content.encode(), 0o644)
    # The previous six-hour timer could miss a short window on every run.
    # Updating policy deliberately refreshes the packaged 15-minute timer.
    timer = "4web-kiosk-updater.timer"
    updates.read_trusted_file(UNITS / timer, 16 * 1024)
    source = updates.read_trusted_file(EXAMPLES / timer, 16 * 1024)
    operations.atomic_root_file(UNITS / timer, source, 0o644)
    subprocess.run(["/usr/bin/systemctl", "daemon-reload"], check=True)
    subprocess.run(["/usr/bin/systemctl", "restart", timer], check=True)
    print("Wartungsfenster gespeichert: " + operations.MaintenanceWindow(policy).summary())


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--url", help="HTTPS-Manifest-Adresse; Standard: offizieller 4WEB-Update-Server")
    parser.add_argument("--key", type=Path, help="Öffentlicher RSA-Herausgeberschlüssel; für eigenen Server erforderlich")
    parser.add_argument("--ca", type=Path, help="Optionales internes CA-Zertifikat")
    parser.add_argument("--user", help="Kiosk-Konto, das Prüfungen aus der App anstoßen darf")
    parser.add_argument("--restart-unit", help="Optional: diese Unit nach Updates neu starten (beendet laufende Sitzungen)")
    parser.add_argument("--set-window", action="store_true", help="Nur das Wartungsfenster einer bestehenden Einrichtung ändern; Schlüssel bleiben erhalten")
    parser.add_argument("--maintenance-start", metavar="HH:MM", help="Beginn des Installationsfensters, Standard 02:00")
    parser.add_argument("--maintenance-end", metavar="HH:MM", help="Ende des Installationsfensters, Standard 04:00")
    parser.add_argument("--maintenance-days", help="Start-Wochentage 1=Mo bis 7=So, z.B. 1,2,3,4,5; Standard täglich")
    parser.add_argument("--maintenance-zone", help="IANA-Zeitzone, z.B. Europe/Berlin, oder local (Standard)")
    parser.add_argument("--maintenance-always", action="store_true", default=None, help="Installation ausdrücklich jederzeit erlauben")
    args = parser.parse_args()
    if args.set_window:
        if any((args.url, args.key, args.ca, args.user, args.restart_unit)):
            parser.error("--set-window ändert nur Wartungszeiten, keine Vertrauensdaten")
    try:
        set_window(args) if args.set_window else provision(args)
        return 0
    except (updates.UpdateError, OSError, KeyError, UnicodeError, subprocess.SubprocessError) as error:
        print(f"OTA-Einrichtung fehlgeschlagen: {error}", file=sys.stderr)
        return 1


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