#!/usr/bin/env python3
"""Install the Terminal Hotkey GNOME Shell extension.

Designed to be served over HTTP and piped to python3:

    curl -fsSL https://get.glimpsed.sh/terminal-hotkey | python3 -
    curl -fsSL https://get.glimpsed.sh/terminal-hotkey | python3 - --system

It also works from a checkout, where it installs the adjacent source tree
instead of downloading anything:

    ./install.py --system

Stdlib only, so it runs on a bare GNOME box with no pip involved.

Note on `gnome-extensions`: this script deliberately does not use
`gnome-extensions install`. That command only does per-user installs, does not
compile the settings schema, and its companion `gnome-extensions enable`
refuses any extension the running shell has not already loaded -- which is
every fresh install, since GNOME cannot load an extension without a restart.
Unpacking and setting `enabled-extensions` directly is what actually works.
"""

import argparse
import hashlib
import io
import json
import os
import pwd
import shutil
import subprocess
import sys
import zipfile
from urllib.request import urlopen

UUID = 'terminal-hotkey@bcarpenter'
PACKAGE = 'terminal-hotkey'
BASE_URL = f'https://get.glimpsed.sh/{PACKAGE}'
ZIP_URL = f'{BASE_URL}/{UUID}.shell-extension.zip'
ZIP_SHA256 = 'cf142adea211f4b034762daff370236b282e1a6a1e4458073205898058f6ed95'

SYSTEM_DIR = '/usr/share/gnome-shell/extensions'
USER_DIR = os.path.join(
    os.environ.get('XDG_DATA_HOME', os.path.expanduser('~/.local/share')),
    'gnome-shell/extensions')


class Failed(Exception):
    pass


def run(cmd, root=False, **kwargs):
    """Run cmd, escalating with sudo only when root is needed and we lack it."""
    if root and os.geteuid() != 0:
        cmd = ['sudo', *cmd]
    return subprocess.run(cmd, check=True, **kwargs)


def preflight():
    """Refuse early and clearly rather than half-installing."""
    for tool in ('gnome-shell', 'glib-compile-schemas', 'gsettings'):
        if shutil.which(tool) is None:
            raise Failed(f'{tool} not found; this does not look like a GNOME system')

    out = subprocess.run(['gnome-shell', '--version'],
                         capture_output=True, text=True, check=True).stdout
    version = out.strip().split()[-1]
    print(f'  GNOME Shell {version}')
    return version.split('.')[0]


def fetch(source):
    """Return the package as (zipfile, None) or (None, source directory)."""
    if source:
        if os.path.isdir(source):
            return None, source
        with open(source, 'rb') as fh:
            return zipfile.ZipFile(io.BytesIO(fh.read())), None

    local = os.path.join(os.path.dirname(os.path.abspath(__file__)), UUID) \
        if __file__ != '<stdin>' else None
    if local and os.path.isdir(local):
        print(f'  using local source {local}')
        return None, local

    print(f'  downloading {ZIP_URL}')
    with urlopen(ZIP_URL) as response:
        blob = response.read()

    if ZIP_SHA256:
        digest = hashlib.sha256(blob).hexdigest()
        if digest != ZIP_SHA256:
            raise Failed(f'checksum mismatch: expected {ZIP_SHA256}, got {digest}')
        print('  checksum verified')

    return zipfile.ZipFile(io.BytesIO(blob)), None


def check_supported(archive, source_dir, shell_major):
    raw = archive.read('metadata.json') if archive \
        else open(os.path.join(source_dir, 'metadata.json'), 'rb').read()
    supported = json.loads(raw).get('shell-version', [])
    if supported and shell_major not in [str(v).split('.')[0] for v in supported]:
        raise Failed(
            f'extension supports GNOME Shell {", ".join(supported)}, '
            f'but this system runs {shell_major}')


def install_files(archive, source_dir, dest, system, dry_run):
    print(f'  installing to {dest}')
    if dry_run:
        return

    run(['rm', '-rf', dest], root=system)
    run(['mkdir', '-p', dest], root=system)

    if archive:
        staging = f'/tmp/{UUID}.staging'
        shutil.rmtree(staging, ignore_errors=True)
        archive.extractall(staging)
        run(['cp', '-r', f'{staging}/.', dest], root=system)
        shutil.rmtree(staging, ignore_errors=True)
    else:
        run(['cp', '-r', f'{source_dir}/.', dest], root=system)

    schemas = os.path.join(dest, 'schemas')
    if os.path.isdir(schemas):
        run(['glib-compile-schemas', '--strict', schemas], root=system)

    if system:
        run(['chown', '-R', 'root:root', dest], root=True)
        run(['chmod', '-R', 'a+rX', dest], root=True)


def session_env(user):
    """Environment for talking to `user`'s dconf, live session or not."""
    env = dict(os.environ)
    uid = pwd.getpwnam(user).pw_uid
    bus = f'/run/user/{uid}/bus'
    env['HOME'] = pwd.getpwnam(user).pw_dir
    if os.path.exists(bus):
        env['DBUS_SESSION_BUS_ADDRESS'] = f'unix:path={bus}'
    else:
        env.pop('DBUS_SESSION_BUS_ADDRESS', None)
        env.pop('XDG_RUNTIME_DIR', None)
    return env


def reload_hint(user):
    """How to get the shell to pick up the new code, which is not the same
    answer twice: X11 restarts the shell in place, Wayland cannot."""
    runtime = f'/run/user/{pwd.getpwnam(user).pw_uid}'
    try:
        wayland = any(name.startswith('wayland-') for name in os.listdir(runtime))
    except OSError:
        wayland = True  # cannot tell; the log-out advice is right either way

    if wayland:
        return ('Log out and back in to load it -- GNOME Shell cannot reload\n'
                'extensions on Wayland.')
    return ('Restart GNOME Shell to load it: press Alt+F2, type r, then\n'
            'Enter. (Only X11 can do that; Wayland needs a log out.)')


def gsettings(user, args, env):
    """Run gsettings as `user`, going through sudo only when we are not them."""
    cmd = ['gsettings', *args]
    if user != pwd.getpwuid(os.geteuid()).pw_name:
        prefix = ['sudo', '-u', user, '-H']
        if 'DBUS_SESSION_BUS_ADDRESS' in env:
            prefix += [f'DBUS_SESSION_BUS_ADDRESS={env["DBUS_SESSION_BUS_ADDRESS"]}']
            cmd = prefix + cmd
        else:
            cmd = prefix + ['dbus-run-session', '--', *cmd]
    return subprocess.run(cmd, capture_output=True, text=True, check=True,
                          env=env).stdout.strip()


def uuid_list(user, key, env):
    """One of the shell's extension uuid lists, as a plain list of strings."""
    current = gsettings(user, ['get', 'org.gnome.shell', key], env)
    if current in ('@as []', '[]'):
        return []
    return [e.strip().strip("'") for e in current.strip('[]').split(',') if e.strip()]


def set_uuid_list(user, key, uuids, env, dry_run):
    value = '[' + ', '.join(f"'{u}'" for u in uuids) + ']'
    if not dry_run:
        gsettings(user, ['set', 'org.gnome.shell', key, value], env)


def enable(user, dry_run):
    """Add the uuid to enabled-extensions and take it out of disabled-extensions.

    Both lists matter: disabled-extensions vetoes enabled-extensions, so an
    extension named in both sits there inert, which is easy to mistake for the
    extension being broken.

    `gnome-extensions enable` cannot be used here: it fails for an extension
    the running shell has not loaded, which is always true of a fresh install.
    """
    env = session_env(user)
    enabled = uuid_list(user, 'enabled-extensions', env)
    disabled = uuid_list(user, 'disabled-extensions', env)

    if UUID in disabled:
        print(f'  removing from disabled-extensions for {user}')
        set_uuid_list(user, 'disabled-extensions',
                      [u for u in disabled if u != UUID], env, dry_run)

    if UUID in enabled:
        print(f'  already enabled for {user}')
        return

    print(f'  enabling for {user}')
    set_uuid_list(user, 'enabled-extensions', enabled + [UUID], env, dry_run)


def main():
    parser = argparse.ArgumentParser(description='Install the Terminal Hotkey extension.')
    where = parser.add_mutually_exclusive_group()
    where.add_argument('--user', action='store_const', dest='system', const=False,
                       help='install for the current user only (default)')
    where.add_argument('--system', action='store_const', dest='system', const=True,
                       help='install for every user on the machine (uses sudo)')
    parser.add_argument('--source', metavar='DIR|ZIP',
                        help='install from a local directory or zip instead of downloading')
    parser.add_argument('--no-enable', action='store_true',
                        help='install the files but leave the extension disabled')
    parser.add_argument('--dry-run', action='store_true',
                        help='report what would happen, change nothing')
    parser.set_defaults(system=False)
    args = parser.parse_args()

    try:
        print(f'{UUID}')
        shell_major = preflight()
        archive, source_dir = fetch(args.source)
        check_supported(archive, source_dir, shell_major)

        dest = os.path.join(SYSTEM_DIR if args.system else USER_DIR, UUID)
        other = os.path.join(USER_DIR if args.system else SYSTEM_DIR, UUID)
        install_files(archive, source_dir, dest, args.system, args.dry_run)

        if os.path.exists(other):
            where_ = 'per-user' if args.system else 'system-wide'
            print(f'  note: a {where_} copy also exists at {other}')
            if args.system:
                print('        it takes precedence; remove it to use this one')

        user = os.environ.get('SUDO_USER') or pwd.getpwuid(os.geteuid()).pw_name
        if args.no_enable:
            print(f'  not enabling (--no-enable); run:  gsettings set org.gnome.shell '
                  f"enabled-extensions \"[..., '{UUID}']\"")
        elif user == 'root':
            print('  cannot tell which user to enable for; run this without sudo')
        else:
            enable(user, args.dry_run)

        print()
        print(reload_hint(user if user != 'root' else
                          pwd.getpwuid(os.geteuid()).pw_name))
        if args.system:
            print()
            print('Other users on this machine get the files but must enable it')
            print('themselves; re-run this script as them, or use --no-enable.')
    except Failed as exc:
        sys.exit(f'error: {exc}')
    except subprocess.CalledProcessError as exc:
        sys.exit(f'error: command failed: {" ".join(exc.cmd)}')


if __name__ == '__main__':
    main()
