#!/usr/bin/python3 -I
"""Privileged signed OTA installation, maintenance policy and recovery.

The app may request a check but cannot select an archive, signing key, window or
recovery target. Initial baseline import and recovery are explicit root actions.
"""

import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import stat
import subprocess
import sys

RECOVERY_ENGINE = Path("/var/lib/4web-kiosk/recovery-engine")
# This one exact root-owned emergency path survives a package downgrade.
SNAPSHOT = Path(__file__) == RECOVERY_ENGINE / "kiosk-updater"
sys.path.insert(0, str(RECOVERY_ENGINE) if SNAPSHOT else "/usr/lib/4web-kiosk")
from kiosk import operations, updates  # noqa: E402


DOWNLOADS = updates.SPOOL_DIRECTORY / "download"
RELEASES = updates.SPOOL_DIRECTORY / "releases"
UNIT_PATTERN = re.compile(r"[a-zA-Z0-9@_][a-zA-Z0-9@._-]{0,127}\.(service|target)")


def log(message):
    print(f"4web-kiosk-updater: {message}", flush=True)


def installed_version():
    result = subprocess.run(
        ["dpkg-query", "-W", "-f=${Version}", updates.PACKAGE],
        capture_output=True, text=True, check=False,
    )
    version = result.stdout.strip()
    if result.returncode != 0 or not version:
        raise updates.UpdateError(f"{updates.PACKAGE} ist nicht über dpkg installiert.")
    return version


def read_pem(path, kind):
    limit = updates.MAX_KEY_BYTES if kind == "Signaturschlüssel" else updates.MAX_CA_BYTES
    return updates.read_trusted_file(path, limit)


def install(package, restart="", recovery=False):
    environment = dict(os.environ, DEBIAN_FRONTEND="noninteractive")
    command = ["apt-get", "install", "-y", "--no-install-recommends"]
    if recovery:
        command.append("--allow-downgrades")
    result = subprocess.run(command + [str(package)], capture_output=True,
                            text=True, env=environment, check=False)
    if result.returncode != 0:
        detail = (result.stderr or result.stdout).strip().splitlines()[-3:]
        raise updates.UpdateError("apt-get hat die Installation abgelehnt: " + " ".join(detail))
    log(f"installed {package.name}")
    if restart:
        subprocess.run(["systemctl", "restart", restart], check=True)


def snapshot_recovery_engine():
    """Retain the trusted recovery driver before APT removes newer modules."""
    if SNAPSHOT:
        return
    RECOVERY_ENGINE.mkdir(mode=0o700, parents=True, exist_ok=True)
    updates.check_trusted_directory(RECOVERY_ENGINE)
    package = RECOVERY_ENGINE / "kiosk"
    package.mkdir(mode=0o700, exist_ok=True)
    updates.check_trusted_directory(package)
    library = Path(operations.__file__).parent
    # Dependencies are read through trusted root-owned paths, then atomically
    # replaced. The emergency runtime has no app-writable configuration paths.
    for name in ("__init__.py", "updates.py", "operations.py", "healthprobe.py"):
        source = updates.read_trusted_file(library / name, 1024 * 1024)
        operations.atomic_root_file(package / name, source)
    source = updates.read_trusted_file(Path(__file__), 1024 * 1024)
    operations.atomic_root_file(RECOVERY_ENGINE / "kiosk-updater", source)


def suspend_updates_for_recovery():
    snapshot_recovery_engine()
    subprocess.run(["systemctl", "disable", "--now", "4web-kiosk-updater.timer", "4web-kiosk-updater.path"], check=True)
    log("Automatische Updates bleiben nach Wiederherstellung deaktiviert. Erst nach administrativer Prüfung eines reparierten Releases wieder aktivieren.")
    log("Bei Unterbrechung: sudo /usr/bin/python3 -I /var/lib/4web-kiosk/recovery-engine/kiosk-updater --status bzw. --recover --app-closed")


def require_window(window):
    if not window.permits():
        raise updates.UpdateError("Installation außerhalb des administrativen Wartungsfensters gesperrt: " + window.summary())


def finish_restart(restart, window):
    if restart and window.permits():
        subprocess.run(["systemctl", "restart", restart], check=True)
        return " Die konfigurierte Sitzung wurde neu gestartet."
    if restart:
        return " Das Wartungsfenster ist beendet; Sitzung wurde nicht neu gestartet."
    return " Neue Sitzungen verwenden die geprüfte Version; laufende Sitzung bleibt erhalten."


def verify_health(journal):
    try:
        candidate = operations.record_manifest(journal.data["candidate"])
        if installed_version() != candidate.version:
            raise updates.UpdateError("Die installierte Version entspricht nicht dem vorgemerkten Update.")
        operations.run_health_probe()
        journal.confirm()
    except Exception as error:
        journal.fail(str(error))
        raise


def run(config_path, mode=None, package_path=None, app_closed=False):
    config = updates.read_service_config(config_path, trusted=True)
    url = config["url"].strip()
    if not url:
        raise updates.UpdateError("In update.conf ist keine url= eingetragen.")
    if not config["key"].strip():
        raise updates.UpdateError("In update.conf fehlt key= mit dem öffentlichen Signaturschlüssel. Der Dienst installiert nur signierte Pakete.")
    restart = config["reboot"].strip()
    if restart in ("", "no", "none"):
        restart = ""
    elif not UNIT_PATTERN.fullmatch(restart):
        raise updates.UpdateError("reboot= muss leer sein oder eine systemd-Unit nennen.")
    ca_pem = updates.parse_ca_pem(read_pem(config["ca"], "CA-Zertifikat")) if config["ca"].strip() else ""
    key_pem = updates.canonical_public_key(read_pem(config["key"], "Signaturschlüssel"))
    window = operations.MaintenanceWindow(operations.read_policy(config_path))
    current = installed_version()
    journal = operations.ReleaseJournal(RELEASES)

    if mode == "status":
        print(json.dumps({"window": window.summary(), "installed": current, **operations.public_summary(config_path)}, ensure_ascii=False, indent=2))
        return 0
    if isinstance(mode, Path):
        if package_path is None:
            raise updates.UpdateError("--seed-baseline benötigt --package mit dem installierten signierten Paket.")
        manifest = updates.Manifest.parse(operations.public_bytes(mode, updates.MAX_MANIFEST_BYTES))
        if manifest.version != current:
            raise updates.UpdateError("Die Basis muss exakt zur installierten Paketversion passen.")
        record = journal.stage(package_path, manifest, key_pem)
        operations.run_health_probe()
        journal.baseline(record)
        updates.write_service_status("current", "Signierte installierte Basis und Qt-/Chromium-Gesundheit bestätigt.", current)
        return 0
    if mode == "recover":
        require_window(window)
        record = journal.recovery_record()
        package, manifest = journal.package(record, key_pem)
        comparison = updates.compare_versions(manifest.version, current)
        resume = journal.data["recovery"] is not None or (journal.data["candidate"] is not None and journal.data["phase"] in ("failed", "pending"))
        if comparison > 0 or (comparison == 0 and not resume):
            raise updates.UpdateError("Die gespeicherte Wiederherstellungsversion ist keine ältere bestätigte Version.")
        if not app_closed and not (restart and restart.endswith(".service")):
            raise updates.UpdateError("Vor Wiederherstellung alle Browser-Instanzen schließen und --app-closed angeben, oder eine dedizierte Sitzungs-Service-Unit als reboot= konfigurieren.")
        suspend_updates_for_recovery()
        if restart and restart.endswith(".service"):
            subprocess.run(["systemctl", "stop", restart], check=True)
        journal.data.update(phase="pending", recovery=record, detail="Administrativ bestätigte Wiederherstellung läuft; automatische Updates bleiben deaktiviert.")
        journal.save()
        try:
            if comparison < 0:
                require_window(window)
                install(package, recovery=True)
            if installed_version() != manifest.version:
                raise updates.UpdateError("Die Wiederherstellungsversion wurde nicht korrekt installiert.")
            operations.run_health_probe()
            journal.recovered(record)
        except Exception as error:
            journal.fail("Wiederherstellung fehlgeschlagen: " + str(error))
            raise
        message = "Signierte bestätigte Version wiederhergestellt. Automatische Updates bleiben bis zur administrativen Prüfung deaktiviert." + finish_restart(restart, window)
        updates.write_service_status("current", message, manifest.version)
        return 0
    if mode == "retry":
        if journal.data["candidate"] is None:
            raise updates.UpdateError("Kein fehlgeschlagenes Update zur erneuten Prüfung vorhanden.")
        manifest = operations.record_manifest(journal.data["candidate"])
        journal.package(journal.data["candidate"], key_pem)
        if manifest.version == current:
            verify_health(journal)
            updates.write_service_status("current", "Gesundheitsprüfung administrativ erneut ausgeführt und bestanden.", current)
            return 0
        journal.data["blocked"] = [digest for digest in journal.data["blocked"] if digest != manifest.sha256]
        journal.data["phase"] = "staged"
        journal.save()
    elif journal.data["phase"] == "pending" and journal.data["recovery"] is not None:
        record = journal.data["recovery"]
        _, manifest = journal.package(record, key_pem)
        try:
            if current != manifest.version:
                raise updates.UpdateError("Unterbrochene Wiederherstellung ist noch nicht installiert; --recover erneut prüfen.")
            operations.run_health_probe()
            journal.recovered(record)
        except Exception as error:
            journal.fail(str(error))
            raise
        updates.write_service_status("current", "Unterbrochene Wiederherstellung durch Gesundheitsprüfung bestätigt.", current)
        return 0
    elif journal.data["phase"] == "pending":
        # The service may have been interrupted between APT and confirmation.
        # Do not reinstall or trust a user marker: run the new code again.
        verify_health(journal)
        updates.write_service_status("current", "Unterbrochenes Update durch Qt-/Chromium-Prüfung bestätigt.", current)
        return 0

    candidate = journal.data["candidate"]
    if candidate is not None and journal.data["phase"] in ("failed", "pending"):
        raise updates.UpdateError("Die letzte Installation ist nicht bestätigt. --recover oder --retry-failed als Administrator prüfen.")
    if candidate is not None:
        package, manifest = journal.package(candidate, key_pem)
        if updates.compare_versions(manifest.version, current) <= 0:
            raise updates.UpdateError("Das vorgemerkte Paket ist nicht neuer; Wiederherstellungsstatus administrativ prüfen.")
    else:
        updates.write_service_status("checking", "Update-Server wird abgefragt. Wartung: " + window.summary(), current)
        engine = updates.Engine(url, ca_pem, key_pem, current)
        manifest = engine.fetch_manifest()
        if not engine.is_newer(manifest):
            updates.write_service_status("current", f"Version {current} ist aktuell. Wartung: " + window.summary(), current)
            return 0
        if journal.blocked(manifest):
            raise updates.UpdateError("Dieses Release wurde nach einer fehlgeschlagenen Installation gesperrt. Ein neuer signierter Build oder eine administrative Prüfung wird benötigt.")
        DOWNLOADS.mkdir(mode=0o700, parents=True, exist_ok=True)
        updates.check_trusted_directory(DOWNLOADS)
        updates.write_service_status("downloading", "Signiertes Update wird geladen; Installation nur im Wartungsfenster.", manifest.version)
        downloaded = engine.download(manifest, DOWNLOADS)
        record = journal.stage(downloaded, manifest, key_pem)
        journal.candidate(record)
        downloaded.unlink(missing_ok=True)
        package, manifest = journal.package(record, key_pem)

    if journal.data["good"] is None:
        updates.write_service_status("ready", "Update geprüft. Vor der ersten automatischen Installation muss die installierte signierte Basis mit --seed-baseline übernommen werden.", manifest.version)
        return 0
    if not window.permits():
        updates.write_service_status("ready", "Update geprüft; wartet auf Wartungsfenster: " + window.summary(), manifest.version)
        return 0
    # A stored good baseline must still be intact under the current publisher
    # key before any package mutation, otherwise reliable recovery is missing.
    journal.package(journal.data["good"], key_pem)
    if operations.record_manifest(journal.data["good"]).version != current:
        raise updates.UpdateError("Die bestätigte Basis stimmt nicht mit der installierten Version überein; --seed-baseline mit dem signierten installierten Paket aktualisieren.")
    journal.pending()
    updates.write_service_status("installing", "Installation im Wartungsfenster; danach isolierte Gesundheitsprüfung.", manifest.version)
    try:
        if not window.permits():
            journal.data.update(phase="staged", detail="Wartungsfenster vor Installationsbeginn beendet; Paket bleibt vorgemerkt.")
            journal.save()
            updates.write_service_status("ready", journal.data["detail"], manifest.version)
            return 0
        install(package)
        verify_health(journal)
    except Exception as error:
        if journal.data["phase"] != "failed":
            journal.fail(str(error))
        raise
    updates.write_service_status("current", "Signiertes Update und Qt-/Chromium-Gesundheit bestätigt." + finish_restart(restart, window), manifest.version)
    return 0


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--config", type=Path, default=updates.SERVICE_CONFIG_FILE)
    modes = parser.add_mutually_exclusive_group()
    modes.add_argument("--status", action="store_true", help="Wartungsfenster und Wiederherstellungsstatus anzeigen")
    modes.add_argument("--seed-baseline", type=Path, metavar="MANIFEST", help="Signiertes Manifest der aktuell installierten Basis übernehmen")
    modes.add_argument("--recover", action="store_true", help="Gespeicherte bestätigte Vorgängerversion im Wartungsfenster wiederherstellen")
    modes.add_argument("--retry-failed", action="store_true", help="Fehlgeschlagenen Kandidaten administrativ erneut prüfen")
    parser.add_argument("--app-closed", action="store_true", help="Nur Wiederherstellung: bestätigen, dass alle Browser-Instanzen geschlossen sind (ohne dedizierte Sitzungs-Service-Unit erforderlich)")
    parser.add_argument("--package", type=Path, help="Signierte installierte .deb-Basis, nur zusammen mit --seed-baseline")
    args = parser.parse_args()
    if os.geteuid() != 0:
        print("Dieser Dienst muss als root laufen.", file=sys.stderr)
        return 2
    if args.app_closed and not args.recover:
        parser.error("--app-closed ist ausschließlich mit --recover erlaubt")
    if args.package is not None and args.seed_baseline is None:
        parser.error("--package ist ausschließlich mit --seed-baseline erlaubt")
    descriptor = None
    try:
        updates.SPOOL_DIRECTORY.mkdir(mode=0o755, parents=True, exist_ok=True)
        updates.check_trusted_directory(updates.SPOOL_DIRECTORY)
        descriptor = os.open(updates.SPOOL_DIRECTORY / "operation.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
        info = os.fstat(descriptor)
        if not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
            raise updates.UpdateError("Unsichere Dienstsperrdatei.")
        try:
            fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            raise updates.UpdateError("Eine andere OTA-Operation läuft bereits.") from None
        mode = ("status" if args.status else "recover" if args.recover else "retry" if args.retry_failed else args.seed_baseline)
        return run(args.config, mode, args.package, args.app_closed)
    except Exception as error:
        log(f"failed: {type(error).__name__}: {error}")
        try:
            updates.check_trusted_directory(updates.SPOOL_DIRECTORY)
            updates.write_service_status("failed", str(error))
        except updates.UpdateError:
            pass
        return 1
    finally:
        if descriptor is not None:
            os.close(descriptor)


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