#!/usr/bin/env python3

"""Online accounts, through GNOME Online Accounts.

GOA is a daemon plus a D-Bus API, and it is already running in this session --
gvfs activates it, and the four accounts on this machine work without
gnome-shell involved anywhere. What GNOME owns is only the *panel*; the accounts
themselves are ordinary D-Bus objects that anything may read and modify.

So everything except the initial sign-in is available to us: listing accounts,
turning individual services on and off, and removing an account. That is the
whole Online Accounts panel apart from one OAuth handshake.

Adding an account is the part that splits. The daemon's AddAccount takes
credentials as an ARGUMENT -- it stores them, it does not obtain them, and every
part of that storing happens inside goa-daemon, which does have the backend.
For password-based providers (Nextcloud, IMAP) that argument is a username and a
password, which a settings app can reasonably collect, so those are added here.
For Google it is an OAuth token, and the code that runs that exchange lives in
libgoa-backend, which Fedora ships without a GIR binding -- reachable from C
only. Reimplementing it would mean our own Google client credentials. So OAuth
sign-in is handed to GNOME's panel, and only the sign-in.

A password reaches this script on stdin and nowhere else. argv is world-readable
through /proc, so a password passed as an argument is published to every process
on the machine.

Usage:
  panama-accounts list | snapshot
  panama-accounts set <object-path> <service> <true|false>
  panama-accounts remove <object-path>
  panama-accounts add-nextcloud SERVER USERNAME              (password on stdin)
  panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME  (password on stdin)
"""

import json
import os
import re
import sys
import urllib.parse


class BoundaryError(RuntimeError):
    """A user-visible validation or sign-in failure."""


# A canned GOA, for contract runs: a JSON file in the shape `list` prints. With
# it set, nothing here imports gi or touches the session bus, and every change a
# verb would have made is appended to PANAMA_ACCOUNTS_LOG instead -- with the
# password replaced by its length, so a contract can prove it was read from
# stdin without a secret ever reaching a file.
FIXTURE_ENV = "PANAMA_ACCOUNTS_FIXTURE"
LOG_ENV = "PANAMA_ACCOUNTS_LOG"

# Every service GOA models. The account object carries one interface per service
# it supports, so presence of the interface is what "this account can do mail"
# means -- there is no capability list to read.
SERVICES = [
    ("mail", "Mail", "get_mail", "mail_disabled"),
    ("calendar", "Calendar", "get_calendar", "calendar_disabled"),
    ("contacts", "Contacts", "get_contacts", "contacts_disabled"),
    ("files", "Files", "get_files", "files_disabled"),
    ("photos", "Photos", "get_photos", "photos_disabled"),
    ("music", "Music", "get_music", "music_disabled"),
    ("chat", "Chat", "get_chat", "chat_disabled"),
]


def load_client():
    import gi

    gi.require_version("Goa", "1.0")
    from gi.repository import Goa

    return Goa.Client.new_sync(None)


def describe(obj):
    account = obj.get_account()
    services = []
    for key, label, getter, disabled_prop in SERVICES:
        if getattr(obj, getter)() is None:
            continue
        services.append({
            "key": key,
            "label": label,
            "enabled": not getattr(account.props, disabled_prop),
        })

    return {
        "path": obj.get_object_path(),
        "provider": account.props.provider_type,
        "providerName": account.props.provider_name,
        # GOA hands back a serialised GThemedIcon: ". GThemedIcon name1 name2 …",
        # a preference-ordered fallback chain. Passed on as that list rather than
        # resolved here, because which of those names exists is a property of the
        # icon theme in use, which this has no business deciding.
        "providerIcons": themed_icon_names(account.props.provider_icon),
        # PresentationIdentity is the human one (an email address); Identity is
        # the internal handle and is not always readable.
        "identity": account.props.presentation_identity or account.props.identity,
        # GOA raises this when stored credentials stop working -- an expired
        # token, a changed password. It is the one piece of state a user must
        # act on, and nothing else surfaces it.
        "needsAttention": bool(account.props.attention_needed),
        "services": services,
    }


def themed_icon_names(icon) -> list[str]:
    """The icon names out of a serialised GThemedIcon, best first.

    The string form is ". GThemedIcon mail-unread-symbolic mail-symbolic mail",
    where the leading "." and the type name are structure rather than content.
    Anything that is not that shape yields nothing, so a caller gets an empty
    list rather than a name that will never resolve.
    """
    if icon is None:
        return []
    try:
        text = icon.to_string()
    except Exception:
        text = str(icon)
    parts = str(text or "").split()
    if len(parts) < 3 or parts[0] != "." or parts[1] != "GThemedIcon":
        return []
    return [name for name in parts[2:] if name]


def find(client, path):
    for obj in client.get_accounts():
        if obj.get_object_path() == path:
            return obj
    return None


# ── Adding a password account ────────────────────────────────────────────────
#
# The keys below are not invented. They are the keys goa-daemon writes into
# ~/.config/goa-1.0/accounts.conf, which is to say the ones each provider's
# build_object reads back -- taken from GOA 3.58's goaowncloudprovider.c and
# goaimapsmtpprovider.c, and confirmed against the accounts already on this
# machine. A key GOA does not recognize is silently ignored, so getting one
# wrong produces an account that exists and does nothing.


def read_password() -> str:
    """The password, from stdin. Never an argument, at any point in the chain."""
    secret = sys.stdin.buffer.read()
    # A trailing newline from a pipe is not part of the password.
    if secret.endswith(b"\n"):
        secret = secret[:-1]
    if not secret:
        raise BoundaryError("No password was provided.")
    return secret.decode("utf-8", "surrogateescape")


# RFC 1123 hostname (or a dotted IPv4). These strings end up in accounts.conf
# and in URIs other processes fetch, so a shell metacharacter or a space is a
# malformed address whichever way it got here -- refuse it at the boundary.
HOSTNAME = re.compile(
    r"^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?"
    r"(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$")


def valid_host(text: str) -> bool:
    return bool(text) and len(text) <= 253 and bool(HOSTNAME.match(text))


def nextcloud_uris(server: str) -> tuple[str, str, str]:
    """(WebDAV URI, DAV URI, host) for a Nextcloud address.

    `remote.php/webdav` and `remote.php/dav` are Nextcloud's fixed layout, and
    are exactly what GOA's own provider derives from the server it was given.
    """
    text = (server or "").strip()
    if not text:
        raise BoundaryError("Enter the address of your Nextcloud server.")
    if "://" not in text:
        text = "https://" + text

    parsed = urllib.parse.urlsplit(text)
    if parsed.scheme not in ("http", "https"):
        raise BoundaryError("A server address starts with https://.")
    if not parsed.hostname or not valid_host(parsed.hostname):
        raise BoundaryError("That is not a server address.")
    # urlsplit is permissive about what follows the host; a path is fine, but
    # spaces or shell punctuation anywhere in the address are not an URL that
    # a Nextcloud server has.
    if re.search(r"[\s;|&`$<>\"']", text):
        raise BoundaryError("That is not a server address.")

    base = f"{parsed.scheme}://{parsed.netloc}{parsed.path.rstrip('/')}"
    return base + "/remote.php/webdav/", base + "/remote.php/dav/", parsed.hostname


def nextcloud_account(server: str, username: str) -> tuple[str, str, dict]:
    """(identity, presentation identity, details). Validation and nothing else,
    so the same refusals run whether or not there is a GOA to talk to."""
    identity = (username or "").strip()
    if not identity:
        raise BoundaryError("Enter the user name for that server.")
    webdav, dav, host = nextcloud_uris(server)

    return (
        identity,
        # What the account is shown as. A bare user name says nothing about
        # which server it is on, and people keep more than one.
        identity if "@" in identity else f"{identity}@{host}",
        {
            "Uri": webdav,
            "FilesEnabled": "true",
            "CalendarEnabled": "true",
            "CalDavUri": dav,
            "ContactsEnabled": "true",
            "CardDavUri": dav,
            "AcceptSslErrors": "false",
        },
    )


def add_nextcloud(server: str, username: str, password: str) -> str:
    from gi.repository import GLib

    identity, presentation, details = nextcloud_account(server, username)
    return add_account("owncloud", identity, presentation,
                       {"password": GLib.Variant("s", password)},
                       details, "Nextcloud")


def imap_account(email: str, imap_host: str, smtp_host: str,
                 username: str) -> tuple[str, str, dict]:
    address = (email or "").strip()
    if "@" not in address or address.startswith("@") or address.endswith("@"):
        raise BoundaryError("Enter the email address for this account.")
    imap = (imap_host or "").strip()
    smtp = (smtp_host or "").strip()
    if not imap or not smtp:
        raise BoundaryError("Enter both the incoming and outgoing server.")
    if not valid_host(imap) or not valid_host(smtp):
        raise BoundaryError("Mail servers are host names, like imap.example.org.")
    identity = (username or "").strip() or address

    # The modern defaults, and the ones every mail provider this is likely to
    # meet uses: IMAP over TLS on 993, submission with STARTTLS on 587. GOA
    # offers a dropdown for the other combinations; a settings page that asked
    # four encryption questions to add a mailbox would be worse than one that
    # gets it right and lets GNOME's panel handle the unusual server.
    return (
        address, address,
        {
            "Enabled": "true",
            "EmailAddress": address,
            "Name": address,
            "ImapHost": imap,
            "ImapUserName": identity,
            "ImapUseSsl": "true",
            "ImapUseTls": "false",
            "ImapAcceptSslErrors": "false",
            "SmtpHost": smtp,
            "SmtpUseAuth": "true",
            "SmtpUserName": identity,
            "SmtpAuthLogin": "false",
            "SmtpAuthPlain": "true",
            "SmtpUseSsl": "false",
            "SmtpUseTls": "true",
            "SmtpAcceptSslErrors": "false",
        },
    )


def add_imap(email: str, imap_host: str, smtp_host: str, username: str,
             password: str) -> str:
    from gi.repository import GLib

    identity, presentation, details = imap_account(email, imap_host, smtp_host, username)
    # One password for both servers: submission almost always takes the same
    # credentials as the mailbox, and asking twice for the same secret is how a
    # form gets abandoned. GOA stores them under separate keys regardless.
    return add_account("imap_smtp", identity, presentation,
                       {"imap-password": GLib.Variant("s", password),
                        "smtp-password": GLib.Variant("s", password)},
                       details, "mail")


def add_account(provider: str, identity: str, presentation: str,
                credentials: dict, details: dict, label: str) -> str:
    """Hand GOA a new account, then make it prove the credentials work.

    The daemon does not check what it is given -- it writes the account out and
    stores the password. Without the second step a mistyped password produces an
    account that exists, looks fine, and never syncs, which is the failure this
    page is supposed to be able to explain.
    """
    from gi.repository import GLib

    client = load_client()
    manager = client.get_manager()
    if manager is None:
        raise BoundaryError("GNOME Online Accounts is not answering.")
    try:
        if not manager.call_is_supported_provider_sync(provider, None):
            raise BoundaryError(f"This system cannot add {label} accounts.")
        path = manager.call_add_account_sync(
            provider, identity, presentation,
            GLib.Variant("a{sv}", credentials),
            GLib.Variant("a{ss}", details), None)
    except GLib.Error as failure:
        raise BoundaryError(clean(failure.message)) from failure

    verify(path, label)
    return path


def verify(path: str, label: str) -> None:
    """Sign in once, and undo the account if that fails."""
    from gi.repository import GLib

    # A fresh client: the one that made the call has not necessarily seen the
    # object appear yet, and this is the cheapest way to wait for it properly.
    obj = find(load_client(), path)
    if obj is None:
        # It was added; this process simply cannot see it yet. Reporting a
        # failure here would be a lie, and the account list will show it.
        return

    account = obj.get_account()
    try:
        account.set_default_timeout(60000)
        account.call_ensure_credentials_sync(None)
    except GLib.Error as failure:
        try:
            account.call_remove_sync(None)
        except GLib.Error:
            pass
        raise BoundaryError(
            f"Those {label} details were not accepted: {clean(failure.message)}"
        ) from failure


def clean(message: str) -> str:
    """The useful sentence of a GOA error, without the D-Bus type prefix."""
    trimmed = re.sub(r"^GDBus\.Error:[^:]+:\s*", "", str(message or "")).strip()
    return trimmed.splitlines()[0][:200] if trimmed else "That did not work."


# ── The canned GOA ───────────────────────────────────────────────────────────


def fixture() -> dict | None:
    path = os.environ.get(FIXTURE_ENV)
    if not path:
        return None
    try:
        with open(path, encoding="utf-8") as handle:
            listing = json.load(handle)
    except (OSError, ValueError) as failure:
        return {"accounts": [], "error": f"The accounts fixture could not be read: {failure}"}
    listing.setdefault("accounts", [])
    listing.setdefault("error", "")
    return listing


def record(verb: str, arguments: list, password: str | None = None) -> None:
    path = os.environ.get(LOG_ENV) or (os.environ.get(FIXTURE_ENV, "") + ".log")
    if not path:
        return
    entry = {"verb": verb, "arguments": arguments}
    if password is not None:
        # Its length, never the thing itself: the point of the log is to prove
        # the password came in on stdin, not to keep a copy of it.
        entry["passwordBytes"] = len(password)
    try:
        with open(path, "a", encoding="utf-8") as handle:
            handle.write(json.dumps(entry, separators=(",", ":")) + "\n")
    except OSError:
        pass


def added(operation) -> int:
    """Run an add, and answer with the fresh account list either way.

    Errors come back inside that list rather than on stderr, so the page has
    both facts -- what went wrong and what is there now -- from one read.
    """
    error = ""
    try:
        operation()
    except BoundaryError as failure:
        error = str(failure)

    try:
        accounts = [describe(obj) for obj in load_client().get_accounts()]
    except Exception:  # noqa: BLE001 - the error above is the one worth saying
        accounts = []
    print(json.dumps({"accounts": accounts, "error": error}))
    return 0


def replay(action: str, arguments: list, canned: dict) -> int:
    """Every verb, against the canned GOA. No bus, no gi, no account changed."""
    error = ""
    try:
        if action in ("list", "snapshot"):
            pass
        elif action in ("set", "remove"):
            record(action, arguments[1:])
        elif action == "add-nextcloud":
            if len(arguments) != 3:
                raise BoundaryError("Usage: add-nextcloud SERVER USERNAME")
            password = read_password()
            nextcloud_account(arguments[1], arguments[2])
            record(action, arguments[1:], password)
        elif action == "add-imap":
            if len(arguments) != 5:
                raise BoundaryError("Usage: add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME")
            password = read_password()
            imap_account(arguments[1], arguments[2], arguments[3], arguments[4])
            record(action, arguments[1:], password)
        else:
            raise BoundaryError(f"Unknown command {action!r}.")
    except BoundaryError as failure:
        error = str(failure)

    print(json.dumps({**canned, "error": error or canned.get("error", "")}))
    return 0


def main():
    arguments = sys.argv[1:]
    action = arguments[0] if arguments else "list"

    canned = fixture()
    if canned is not None:
        return replay(action, arguments, canned)

    if action == "add-nextcloud":
        if len(arguments) != 3:
            print("usage: panama-accounts add-nextcloud SERVER USERNAME", file=sys.stderr)
            return 2
        return added(lambda: add_nextcloud(arguments[1], arguments[2], read_password()))

    if action == "add-imap":
        if len(arguments) != 5:
            print("usage: panama-accounts add-imap EMAIL IMAP_HOST SMTP_HOST USERNAME",
                  file=sys.stderr)
            return 2
        return added(lambda: add_imap(arguments[1], arguments[2], arguments[3],
                                      arguments[4], read_password()))

    try:
        client = load_client()
    except Exception as error:  # noqa: BLE001 - any failure here means "no GOA"
        # A machine without GOA is a legitimate state, not a crash.
        print(json.dumps({
            "accounts": [],
            "error": f"GNOME Online Accounts is not available: {error}",
        }))
        return 0

    # "snapshot" is what every other Panama helper calls this, and what the
    # service asks for; "list" is what this one has always been called.
    if action in ("list", "snapshot"):
        print(json.dumps({
            "accounts": [describe(obj) for obj in client.get_accounts()],
            "error": "",
        }))
        return 0

    if action == "set":
        if len(sys.argv) != 5:
            print("usage: panama-accounts set <path> <service> <true|false>", file=sys.stderr)
            return 2
        path, service, value = sys.argv[2], sys.argv[3], sys.argv[4]
        obj = find(client, path)
        if obj is None:
            print(f"panama-accounts: no account at {path}", file=sys.stderr)
            return 1
        match = next((s for s in SERVICES if s[0] == service), None)
        if match is None:
            print(f"panama-accounts: unknown service {service!r}", file=sys.stderr)
            return 2
        if getattr(obj, match[2])() is None:
            print(f"panama-accounts: this account does not support {service}", file=sys.stderr)
            return 1
        # The property is "disabled", so enabling a service clears it.
        setattr(obj.get_account().props, match[3], value.lower() not in ("true", "1", "yes"))
        return 0

    if action == "remove":
        if len(sys.argv) != 3:
            print("usage: panama-accounts remove <path>", file=sys.stderr)
            return 2
        obj = find(client, sys.argv[2])
        if obj is None:
            print(f"panama-accounts: no account at {sys.argv[2]}", file=sys.stderr)
            return 1
        obj.get_account().call_remove_sync(None)
        return 0

    print("usage: panama-accounts [list|snapshot|set|remove|add-nextcloud|add-imap]",
          file=sys.stderr)
    return 2


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