#!/usr/bin/env python3
"""Add, change or remove an account in GNOME Online Accounts, the way Settings does.

Settings adds accounts by calling AddAccount on the Online Accounts service,
which saves the account and keeps its password in the GNOME keyring. This
calls the same method. The service can't change a password, so when you sign
in again, Settings saves the new one in the keyring itself and asks the
service to check it (EnsureCredentials); update does the same. Passwords come
on stdin rather than as arguments, so they don't show up in the process list.

Usage:
  goa-account exists <provider> <presentation-identity>
  goa-account add < account.json
  goa-account update <provider> <presentation-identity> < credentials.json
  goa-account check <provider> <presentation-identity>
  goa-account remove <provider> <presentation-identity>

account.json:
  {"provider": "owncloud", "identity": "...", "presentation_identity": "...",
   "credentials": {"password": "..."}, "details": {"Uri": "...", ...}}

credentials.json is the account's "credentials", e.g. {"password": "..."}.
check signs in with what's saved, and fails if the server says no. update
exits with 3 if it saved the password but the server didn't accept it.
"""

import json
import sys

import gi

gi.require_version("Gio", "2.0")
gi.require_version("Secret", "1")
from gi.repository import Gio, GLib, Secret  # noqa: E402

BUS_NAME = "org.gnome.OnlineAccounts"

# How Online Accounts keeps passwords in the keyring: the credentials printed
# as a GVariant, under "<provider>:gen<N>:<account id>"
SCHEMA = Secret.Schema.new("org.gnome.OnlineAccounts", Secret.SchemaFlags.DONT_MATCH_NAME,
                           {"goa-identity": Secret.SchemaAttributeType.STRING})


def call(bus, path, interface, method, parameters, reply_type):
    return bus.call_sync(BUS_NAME, path, interface, method, parameters,
                         GLib.VariantType(reply_type), Gio.DBusCallFlags.NONE,
                         -1, None).unpack()


def accounts(bus):
    (objects,) = call(bus, "/org/gnome/OnlineAccounts",
                      "org.freedesktop.DBus.ObjectManager", "GetManagedObjects",
                      None, "(a{oa{sa{sv}}})")
    for path, interfaces in objects.items():
        account = interfaces.get("org.gnome.OnlineAccounts.Account")
        if account:
            yield path, account


def find_accounts(bus, provider, presentation_identity):
    return [(path, account) for path, account in accounts(bus)
            if account.get("ProviderType") == provider
            and account.get("PresentationIdentity") == presentation_identity]


def find(bus, provider, presentation_identity):
    return [path for path, _ in find_accounts(bus, provider, presentation_identity)]


def exists(bus, provider, presentation_identity):
    return bool(find(bus, provider, presentation_identity))


def add(bus, account):
    credentials = {key: GLib.Variant("s", value)
                   for key, value in account["credentials"].items()}
    parameters = GLib.Variant("(sssa{sv}a{ss})", (
        account["provider"], account["identity"],
        account["presentation_identity"], credentials, account["details"]))
    call(bus, "/org/gnome/OnlineAccounts/Manager",
         "org.gnome.OnlineAccounts.Manager", "AddAccount", parameters, "(o)")


def ensure_credentials(bus, path):
    call(bus, path, "org.gnome.OnlineAccounts.Account", "EnsureCredentials", None, "(i)")


# N in the keyring's name comes from the provider, so this saves over the
# account's own item rather than making one
def store_credentials(provider, account_id, credentials):
    items = Secret.password_search_sync(
        None, {"xdg:schema": "org.gnome.OnlineAccounts"},
        Secret.SearchFlags.ALL | Secret.SearchFlags.UNLOCK, None)
    identities = [item.get_attributes().get("goa-identity", "") for item in items]
    identities = [identity for identity in identities
                  if identity.startswith(f"{provider}:gen") and identity.endswith(f":{account_id}")]
    if not identities:
        raise LookupError(f"there's no saved password for {account_id} in the keyring")
    secret = GLib.Variant("a{sv}", {key: GLib.Variant("s", value)
                                    for key, value in credentials.items()}).print_(True)
    for identity in identities:
        Secret.password_store_sync(SCHEMA, {"goa-identity": identity}, Secret.COLLECTION_DEFAULT,
                                   f"GOA {provider} credentials for identity {account_id}",
                                   secret, None)


def update(bus, provider, presentation_identity, credentials):
    found = find_accounts(bus, provider, presentation_identity)
    if not found:
        raise LookupError(f"there's no {provider} account for {presentation_identity}")
    for path, account in found:
        store_credentials(provider, account["Id"], credentials)
    try:
        for path, _ in found:
            ensure_credentials(bus, path)
    except GLib.Error as error:
        print(f"GNOME Online Accounts: {error.message}", file=sys.stderr)
        return 3
    return 0


def check(bus, provider, presentation_identity):
    found = find(bus, provider, presentation_identity)
    if not found:
        raise LookupError(f"there's no {provider} account for {presentation_identity}")
    for path in found:
        ensure_credentials(bus, path)


def remove(bus, provider, presentation_identity):
    for path in find(bus, provider, presentation_identity):
        call(bus, path, "org.gnome.OnlineAccounts.Account", "Remove", None, "()")


def main():
    bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
    if len(sys.argv) == 4 and sys.argv[1] == "exists":
        return 0 if exists(bus, sys.argv[2], sys.argv[3]) else 1
    if len(sys.argv) == 2 and sys.argv[1] == "add":
        account = json.load(sys.stdin)
        if exists(bus, account["provider"], account["presentation_identity"]):
            return 0
        add(bus, account)
        return 0
    if len(sys.argv) == 4 and sys.argv[1] == "update":
        return update(bus, sys.argv[2], sys.argv[3], json.load(sys.stdin))
    if len(sys.argv) == 4 and sys.argv[1] == "check":
        check(bus, sys.argv[2], sys.argv[3])
        return 0
    if len(sys.argv) == 4 and sys.argv[1] == "remove":
        remove(bus, sys.argv[2], sys.argv[3])
        return 0
    print(__doc__.strip().split("\n\n")[2], file=sys.stderr)
    return 2


if __name__ == "__main__":
    try:
        sys.exit(main())
    except GLib.Error as error:
        print(f"GNOME Online Accounts: {error.message}", file=sys.stderr)
        sys.exit(1)
    except LookupError as error:
        print(f"GNOME Online Accounts: {error}", file=sys.stderr)
        sys.exit(1)
