#!/usr/bin/python3
import json
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import tempfile
import time
import urllib.parse

os.environ.setdefault("FLATPAK_BWRAP", "/usr/local/bin/droiddeck-bwrap")

import gi

gi.require_version("Flatpak", "1.0")
from gi.repository import Flatpak, Gio, GLib

REMOTE = "flathub"
SYSTEM_BUS = "/run/dbus/system_bus_socket"
BUS_COMMANDS = ("install", "install-bundle", "uninstall", "update", "prune")
BUS_CONFIG = """<!DOCTYPE busconfig PUBLIC "-//freedesktop//DTD D-Bus Bus Configuration 1.0//EN"
 "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
<busconfig>
  <type>system</type>
  <listen>unix:path=%s</listen>
  <auth>EXTERNAL</auth>
  <policy context="default">
    <allow user="*"/>
    <allow own="*"/>
    <allow send_destination="*"/>
    <allow receive_sender="*"/>
  </policy>
</busconfig>
"""


def prepare_rootfs():
    target = "/run/host/monitor/resolv.conf"
    try:
        if not os.path.exists(target):
            os.makedirs(os.path.dirname(target), exist_ok=True)
            try:
                with open("/etc/resolv.conf", "rb") as src:
                    data = src.read()
            except OSError:
                data = b""
            with open(target, "wb") as dst:
                dst.write(data)
    except OSError as err:
        emit(e="warning", message="could not prepare %s: %s" % (target, err))


def bus_reachable(path):
    if isinstance(path, tuple):
        try:
            socket.create_connection(path, timeout=1).close()
            return True
        except (OSError, ValueError):
            return False
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    try:
        sock.connect(path)
        return True
    except OSError:
        return False
    finally:
        sock.close()


def system_bus_paths():
    address = os.environ.get("DBUS_SYSTEM_BUS_ADDRESS", "")
    if not address:
        return [SYSTEM_BUS]
    paths = []
    for entry in filter(None, address.split(";")):
        found = None
        kind, _, params = entry.partition(":")
        values = dict(part.partition("=")[::2] for part in params.split(","))
        if kind == "unix":
            if values.get("path"):
                found = urllib.parse.unquote_to_bytes(values["path"])
            elif values.get("abstract"):
                found = b"\0" + urllib.parse.unquote_to_bytes(values["abstract"])
        elif kind == "tcp":
            port = urllib.parse.unquote(values.get("port", ""))
            digits = port.lstrip("0")
            if not re.fullmatch(r"[0-9]+", port, re.ASCII) or not 0 < len(digits) <= 5 or int(digits) > 65535:
                continue
            found = (urllib.parse.unquote(values.get("host", "localhost")), int(digits))
        if found is None:
            return None
        paths.append(found)
    return paths


def system_bus_reachable():
    paths = system_bus_paths()
    return paths is None or any(bus_reachable(path) for path in paths)


def stop_system_bus(bus):
    if not bus:
        return
    proc, work, conf, path = bus
    if proc.poll() is None:
        proc.terminate()
        try:
            proc.wait(timeout=5)
        except subprocess.TimeoutExpired:
            proc.kill()
            proc.wait()
    remove_bus_files(work, conf, path)


def remove_bus_files(work, conf, path):
    for name in (path, conf):
        try:
            os.unlink(name)
        except OSError:
            pass
    try:
        os.rmdir(work)
    except OSError:
        pass


def private_system_bus():
    if system_bus_reachable():
        return None
    daemon = shutil.which("dbus-daemon")
    if not daemon:
        emit(e="warning", message="no system bus and no dbus-daemon to start one")
        return None
    try:
        work = tempfile.mkdtemp(prefix="droiddeck-flatpak-bus-")
    except OSError as err:
        emit(e="warning", message="could not start a private system bus: %s" % err)
        return None
    conf = os.path.join(work, "bus.conf")
    path = os.path.join(work, "bus")
    try:
        with open(conf, "w") as f:
            f.write(BUS_CONFIG % path)
        proc = subprocess.Popen([daemon, "--config-file=" + conf, "--nofork", "--nopidfile"],
                                stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    except OSError as err:
        remove_bus_files(work, conf, path)
        emit(e="warning", message="could not start a private system bus: %s" % err)
        return None
    bus = (proc, work, conf, path)
    for _ in range(50):
        if bus_reachable(path) or proc.poll() is not None:
            break
        time.sleep(0.1)
    if not bus_reachable(path):
        stop_system_bus(bus)
        emit(e="warning", message="the private system bus did not start")
        return None
    os.environ["DBUS_SYSTEM_BUS_ADDRESS"] = "unix:path=" + path
    return bus


def emit(**kw):
    sys.stdout.write(json.dumps(kw) + "\n")
    sys.stdout.flush()


def installation():
    return Flatpak.Installation.new_user(None)


def app_ref(inst, app_id):
    for ref in inst.list_installed_refs_by_kind(Flatpak.RefKind.APP, None):
        if ref.get_name() == app_id:
            return ref
    return None


def transaction(inst):
    t = Flatpak.Transaction.new_for_installation(inst, None)
    t.set_no_interaction(True)
    state = {"i": 0, "n": 0, "error": None}

    def on_ready(tr):
        state["n"] = len(tr.get_operations())
        return True

    def on_new_operation(tr, op, progress):
        state["i"] += 1
        emit(e="op", i=state["i"], n=state["n"], kind=op.get_operation_type().value_nick, ref=op.get_ref())
        progress.set_update_frequency(500)

        def changed(p):
            emit(e="progress", i=state["i"], n=state["n"], percent=p.get_progress(), status=p.get_status() or "")
        progress.connect("changed", changed)

    def on_operation_error(tr, op, err, details):
        try:
            non_fatal = bool(int(details) & 1)
        except (TypeError, ValueError):
            non_fatal = False
        if not non_fatal:
            try:
                related = bool(op.get_related_to_ops())
            except (AttributeError, TypeError):
                related = False
            ref_id = (op.get_ref() or "").split("/")[1:2]
            ref_id = ref_id[0] if ref_id else ""
            non_fatal = related or ref_id.endswith((".Locale", ".Debug")) or ".GL." in ref_id or "codecs" in ref_id
        emit(e="warning" if non_fatal else "error", message="%s: %s" % (op.get_ref(), err.message))
        if not non_fatal:
            state["error"] = err.message
        return non_fatal

    t.connect("ready", on_ready)
    t.connect("new-operation", on_new_operation)
    t.connect("operation-error", on_operation_error)
    t.connect("choose-remote-for-ref", lambda tr, ref, runtime_ref, remotes: 0)
    t.connect("end-of-lifed-with-rebase", lambda *a: False)
    t.connect("add-new-remote", lambda *a: False)
    return t, state


def run(t, state, report_done=True):
    try:
        t.run(None)
    except GLib.Error as err:
        if state["error"] is None and not err.matches(Flatpak.error_quark(), Flatpak.Error.ABORTED):
            state["error"] = err.message
    if state["error"]:
        emit(e="error", message=state["error"])
        return 1
    if report_done:
        emit(e="done")
    return 0


def arches():
    native = Flatpak.get_default_arch()
    return [native, "x86_64"] if native == "aarch64" else [native]


def install(app_id):
    inst = installation()
    if app_ref(inst, app_id) is not None:
        emit(e="done", note="already installed")
        return 0
    arch, branch, last = None, None, None
    for candidate_arch in arches():
        for candidate in ("stable", "master"):
            try:
                branch = inst.fetch_remote_ref_sync(REMOTE, Flatpak.RefKind.APP, app_id, candidate_arch, candidate,
                                                    None).get_branch()
                arch = candidate_arch
                break
            except GLib.Error as err:
                last = err
        if branch is not None:
            break
    if branch is None:
        emit(e="error", message="%s is not on Flathub for %s (%s)" % (app_id, " or ".join(arches()),
                                                                      last.message if last else "no branch"))
        return 1
    t, state = transaction(inst)
    t.add_install(REMOTE, "app/%s/%s/%s" % (app_id, arch, branch), None)
    rc = run(t, state, report_done=False)
    if rc == 0 and app_ref(installation(), app_id) is None:
        emit(e="error", message="%s did not install; see flatpak-install.log" % app_id)
        return 1
    if rc == 0:
        emit(e="done")
    return rc


def install_bundle(path):
    inst = installation()
    bundle = Gio.File.new_for_path(path)
    try:
        ref = Flatpak.BundleRef.new(bundle)
        app_id = ref.get_name()
    except GLib.Error as err:
        emit(e="error", message="%s is not a Flatpak bundle (%s)" % (os.path.basename(path), err.message))
        return 1
    if ref.get_arch() not in arches():
        emit(e="error", code="arch", id=app_id, arch=ref.get_arch(),
             message="%s is built for %s, not %s" % (app_id, ref.get_arch(), " or ".join(arches())))
        return 1
    if app_ref(inst, app_id) is not None:
        emit(e="done", id=app_id, note="already installed")
        return 0
    t, state = transaction(inst)
    t.add_install_bundle(bundle, None)
    rc = run(t, state, report_done=False)
    if rc == 0 and app_ref(installation(), app_id) is None:
        emit(e="error", message="%s did not install; see flatpak-install-bundle.log" % app_id)
        return 1
    if rc == 0:
        emit(e="done", id=app_id)
    return rc


def uninstall(app_id):
    inst = installation()
    ref = app_ref(inst, app_id)
    if ref is None:
        emit(e="done", note="not installed")
        return 0
    t, state = transaction(inst)
    t.add_uninstall(ref.format_ref())
    rc = run(t, state)
    if rc == 0:
        try:
            unused = inst.list_unused_refs(None, None)
        except GLib.Error:
            unused = []
        if unused:
            t, state = transaction(inst)
            for r in unused:
                t.add_uninstall(r.format_ref())
            run(t, state)
    return rc


def prune():
    inst = installation()
    try:
        unused = inst.list_unused_refs(None, None)
    except GLib.Error as err:
        emit(e="error", message=err.message)
        return 1
    if unused:
        t, state = transaction(inst)
        for r in unused:
            t.add_uninstall(r.format_ref())
        if run(t, state, report_done=False):
            return 1
    try:
        inst.prune_local_repo(None)
    except GLib.Error as err:
        emit(e="warning", message=err.message)
    emit(e="done", removed=len(unused))
    return 0


def updatable(inst):
    try:
        return inst.list_installed_refs_for_update(None)
    except GLib.Error as err:
        emit(e="error", message=err.message)
        return None


def update(app_id=None):
    inst = installation()
    refs = updatable(inst)
    if refs is None:
        return 1
    if app_id:
        refs = [r for r in refs if r.get_name() == app_id or r.get_kind() == Flatpak.RefKind.RUNTIME]
    if not refs:
        emit(e="done", note="up to date")
        return 0
    t, state = transaction(inst)
    for r in refs:
        t.add_update(r.format_ref(), None, None)
    return run(t, state)


def updates():
    refs = updatable(installation())
    if refs is None:
        return 1
    emit(e="updates", apps=sorted({r.get_name() for r in refs if r.get_kind() == Flatpak.RefKind.APP}),
         runtimes=len([r for r in refs if r.get_kind() == Flatpak.RefKind.RUNTIME]))
    return 0


def main():
    if len(sys.argv) < 2:
        sys.stderr.write(__doc__ or "usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates|prune [app-id|file]\n")
        return 2
    cmd, args = sys.argv[1], sys.argv[2:]
    if cmd in ("install", "install-bundle", "update"):
        prepare_rootfs()
    bus = private_system_bus() if cmd in BUS_COMMANDS else None
    if bus:
        signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))
    try:
        return command(cmd, args)
    finally:
        stop_system_bus(bus)


def command(cmd, args):
    if cmd == "install" and args:
        return install(args[0])
    if cmd == "install-bundle" and args:
        return install_bundle(args[0])
    if cmd == "uninstall" and args:
        return uninstall(args[0])
    if cmd == "update":
        return update(args[0] if args else None)
    if cmd == "updates":
        return updates()
    if cmd == "prune":
        return prune()
    sys.stderr.write("usage: droiddeck-flatpak install|install-bundle|uninstall|update|updates|prune [app-id|file]\n")
    return 2


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