#!/usr/bin/python3
import json
import os
import sys

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"


def prepare_rootfs():
    """Some runtimes (KDE 6.10) ship etc/resolv.conf as a link to /run/host/monitor/resolv.conf.
    Deploying one, Flatpak removes that link only if GIO reports it existing, and GIO follows it:
    dangling here, it was left in place and making Flatpak's own link failed ("File exists").
    With the target present in the rootfs the link resolves and the deploy goes through."""
    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 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):
    """The installed ref of an app, or None."""
    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 install(app_id):
    inst = installation()
    arch = Flatpak.get_default_arch()
    if app_ref(inst, app_id) is not None:
        emit(e="done", note="already installed")
        return 0
    branch, last = None, None
    for candidate in ("stable", "master"):
        try:
            branch = inst.fetch_remote_ref_sync(REMOTE, Flatpak.RefKind.APP, app_id, arch, candidate, None).get_branch()
            break
        except GLib.Error as err:
            last = err
    if branch is None:
        emit(e="error", message="%s is not on Flathub for %s (%s)" % (app_id, arch, 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):
    """A single-file bundle (flatpak build-bundle), for apps Flathub does not carry."""
    inst = installation()
    bundle = Gio.File.new_for_path(path)
    try:
        app_id = Flatpak.BundleRef.new(bundle).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 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()
    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())
