#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright © 2013 Sjoerd Simons <sjoerd.simons@collabora.co.uk>
# Copyright © 2017 Endless Mobile, Inc.
#
# SPDX-License-Identifier: LGPL-2.1-or-later
#
# Quick test command line for eos-updater
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this library; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301 USA

import argparse
import os
import sys
from gi.repository import Gio, GLib

BUS_NAME = 'com.endlessm.Updater'
OBJECT_PATH = '/com/endlessm/Updater'
INTERFACE = 'com.endlessm.Updater'

UPDATER_STATES = [
    "None",
    "Ready",
    "Error",
    "Polling",
    "UpdateAvailable",
    "Fetching",
    "UpdateReady",
    "ApplyingUpdate",
    "UpdateApplied"
]

# Mapping from command name (given on the command line), to a tuple of
# (D-Bus method to call, state we expect eos-updater to transition into while
#  performing that action)
METHODS = {
    'poll': ('Poll', 'Polling'),
    'fetch': ('FetchFull', 'Fetching'),
    'apply': ('Apply', 'ApplyingUpdate'),
    'cancel': ('Cancel', 'Error'),
}

# Various well-known file locations for eos-updater
EOS_UPDATE_SERVER_CONFS = [
    '/etc/eos-updater/eos-update-server.conf',
    '/usr/local/share/eos-updater/eos-update-server.conf',
    '/usr/share/eos-updater/eos-update-server.conf',
]
EOS_UPDATER_CONFS = [
    '/etc/eos-updater/eos-updater.conf',
    '/usr/local/share/eos-updater/eos-updater.conf',
    '/usr/share/eos-updater/eos-updater.conf',
]

current_proxy = None


def signal_emitted(proxy, sender, signal, parameters):
    if signal != "StateChanged":
        return
    state = UPDATER_STATES[parameters[0]]
    print("======= State changed to: " + state + " =======")
    proxy.set_cached_property("State", parameters.get_child_value(0))


def dump_daemon_properties(proxy):
    property_names = proxy.get_cached_property_names()
    if not property_names:
        return

    print("======= Properties =======")
    width = max(len(x) for x in property_names)

    s = proxy.get_cached_property("State").get_uint32()
    print("{:>{}}: {}".format("State", width, UPDATER_STATES[s]))

    for x in proxy.get_cached_property_names():
        if x == "State":  # Handled above
            continue

        value = proxy.get_cached_property(x)
        if x in (
            "DownloadSize",
            "DownloadedBytes",
            "FullDownloadSize",
            "FullUnpackedSize",
            "UnpackedSize",
        ):
            if value.get_int64() >= 0:
                value = GLib.format_size_full(
                    value.get_int64(), GLib.FormatSizeFlags.LONG_FORMAT
                )
            else:
                value = "Unknown"

        print("{:>{}}: {}".format(x, width, value))

    print("")


def g_properties_changed_cb(proxy, changed, invalidated):
    dump_daemon_properties(proxy)


def name_appeared_cb(connection, name, name_owner):
    global current_proxy

    print("======= Ownership =======")
    print("%s appeared as %s" % (name, name_owner))

    proxy = Gio.DBusProxy.new_sync(connection,
                                   Gio.DBusProxyFlags.NONE,
                                   None,  # interface info
                                   name_owner,
                                   OBJECT_PATH,
                                   INTERFACE,
                                   None)  # cancellable

    proxy.connect('g-properties-changed', g_properties_changed_cb)
    proxy.connect('g-signal', signal_emitted)

    dump_daemon_properties(proxy)
    current_proxy = proxy


def name_vanished_cb(connection, name):
    global current_proxy

    current_proxy = None

    print("======= Ownership =======")
    print("%s disappeared" % name)


def command_with_proxy(proxy, command, block, quiet=False, parameters=None):
    (function_name, in_progress_state) = METHODS[command]

    def signal_cb(proxy, sender_name, signal_name, parameters, user_data):
        nonlocal seen_in_progress_state, seen_next_state

        if signal_name != 'StateChanged':
            return

        new_state = UPDATER_STATES[parameters[0]]
        if not quiet:
            print('Changed state to ' + new_state)
            proxy.set_cached_property("State", parameters.get_child_value(0))

        if new_state == in_progress_state:
            seen_in_progress_state = True
            # If it got an error, then we shouldn't expect a next state
            # automatically
            if new_state == "Error":
                seen_next_state = True
        elif new_state != in_progress_state and seen_in_progress_state:
            seen_next_state = True

    signal_id = proxy.connect('g-signal', signal_cb, None)
    context = GLib.main_context_default()

    # Call the method, wait for the updater to enter the expected in-progress
    # state; then wait for it to leave that state again (either to Error, or
    # the next state corresponding to the method).
    qualified_method_name = f"{INTERFACE}.{function_name}"
    proxy.call_sync(qualified_method_name, parameters, 0, -1, None)

    cancelled = False
    seen_in_progress_state = False
    seen_next_state = False

    while (block and
           not cancelled and
           not (seen_in_progress_state and seen_next_state)):
        try:
            context.iteration(True)
        except KeyboardInterrupt:
            cancelled = True

    proxy.disconnect(signal_id)

    # Print the final state of the properties before exiting.
    if block and not quiet:
        dump_daemon_properties(proxy)

    state = proxy.get_cached_property("State").get_uint32()
    if cancelled or UPDATER_STATES[state] == "Error":
        return 1
    return 0


def command_dbus(command, block, quiet=False, parameters=None):
    """Run the given command and wait for it to complete."""
    bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
    proxy = Gio.DBusProxy.new_sync(bus, 0, None,
                                   BUS_NAME,
                                   OBJECT_PATH,
                                   INTERFACE,
                                   None)

    return command_with_proxy(proxy, command, block, quiet=quiet, parameters=parameters)


def command_poll(block, quiet=False):
    return command_dbus('poll', block, quiet)


def command_fetch(block, quiet=False, force=False, timeout=0):
    parameters = GLib.Variant('(a{sv})', ({
        'force': GLib.Variant('b', force),
        'scheduling-timeout-seconds': GLib.Variant('u', timeout),
    },))
    return command_dbus('fetch', block, quiet, parameters)


def command_apply(block, quiet=False):
    return command_dbus('apply', block, quiet)


def command_cancel(block, quiet=False):
    return command_dbus('cancel', block, quiet)


def command_update(quiet=False, force=False, timeout=0):
    """Run the entire update process."""
    bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
    proxy = Gio.DBusProxy.new_sync(bus, 0, None,
                                   BUS_NAME,
                                   OBJECT_PATH,
                                   INTERFACE,
                                   None)

    state = proxy.get_cached_property("State").get_uint32()
    if UPDATER_STATES[state] == "UpdateApplied":
        if not quiet:
            dump_daemon_properties(proxy)
        return 0

    poll_result = command_with_proxy(proxy, 'poll', block=True, quiet=quiet)
    if poll_result != 0:
        return poll_result

    state = proxy.get_cached_property("State").get_uint32()
    if UPDATER_STATES[state] == "Ready":
        print("No update available")
        return 0

    fetch_parameters = GLib.Variant('(a{sv})', ({
        'force': GLib.Variant('b', force),
        'scheduling-timeout-seconds': GLib.Variant('u', timeout),
    },))
    fetch_result = command_with_proxy(
        proxy, 'fetch', block=True, quiet=quiet, parameters=fetch_parameters,
    )
    if fetch_result != 0:
        return fetch_result

    apply_result = command_with_proxy(proxy, 'apply', block=True, quiet=quiet)
    if apply_result != 0:
        return apply_result

    return 0


def command_monitor(quiet=False):
    """Watch properties and signals from the updater indefinitely."""
    bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
    loop = GLib.MainLoop()
    name_watch_id = Gio.bus_watch_name_on_connection(
        bus, BUS_NAME, Gio.BusNameWatcherFlags.NONE,
        name_appeared_cb, name_vanished_cb)

    try:
        loop.run()
    except KeyboardInterrupt:
        pass

    Gio.bus_unwatch_name(name_watch_id)

    return 0


def command_server(server_command, quiet=False):
    """Enable or disable eos-update-server."""
    if server_command == 'enable':
        new_state = True
    elif server_command == 'disable':
        new_state = False
    else:
        return 0

    # Must be run as root.
    if os.geteuid() != 0:
        print('Error: Must be run as root.', file=sys.stderr)
        return 1

    # If we are enabling eos-update-server, check that eos-updater is
    # configured to get updates from the internet; we do not support mesh
    # update networks yet.
    if new_state:
        download_order = []
        for path in EOS_UPDATER_CONFS:
            try:
                conf = GLib.KeyFile()
                conf.load_from_file(path, GLib.KeyFileFlags.NONE)
                download_order = conf.get_string_list('Download', 'Order')
                break
            except GLib.Error:
                continue

        if 'main' not in download_order:
            print('The ‘main’ update source is not enabled. Add it to '
                  'Download.Order in {}.'.format(EOS_UPDATER_CONFS[0]),
                  file=sys.stderr)
            return 1

    # Change the server configuration to enable/disable it.
    # FIXME: This config file handling will eventually move into a library.
    # See https://phabricator.endlessm.com/T15740
    key_file = GLib.KeyFile()
    for path in EOS_UPDATE_SERVER_CONFS:
        try:
            key_file.load_from_file(path, GLib.KeyFileFlags.KEEP_COMMENTS)
            break
        except GLib.Error as exc:
            if not exc.matches(GLib.quark_from_string('g-file-error-quark'),
                               GLib.FileError.NOENT):
                print('Error loading existing configuration '
                      'from {}: {}'.format(path, exc.message), file=sys.stderr)
                return 1

    old_state = key_file.get_boolean('Local Network Updates',
                                     'AdvertiseUpdates')

    if old_state == new_state:
        if not quiet:
            print('eos-update-server is already ' +
                  ('enabled' if new_state else 'disabled'))
        return 0

    key_file.set_boolean('Local Network Updates', 'AdvertiseUpdates',
                         new_state)

    # Save the updated config file. Create the directory first.
    try:
        os.makedirs(os.path.dirname(EOS_UPDATE_SERVER_CONFS[0]),
                    mode=0o755, exist_ok=True)
    except Exception:
        # Fall through and try to write the file anyway.
        pass

    try:
        key_file.save_to_file(EOS_UPDATE_SERVER_CONFS[0])
        if not quiet:
            print('Updated {}'.format(EOS_UPDATE_SERVER_CONFS[0]))
    except GLib.Error as exc:
        print('Error writing new configuration to {}: {}'.format(
            EOS_UPDATE_SERVER_CONFS[0], exc.message), file=sys.stderr)
        return 1

    return 0


def nonnegative_int(value):
    try:
        int_value = int(value)
    except ValueError:
        int_value = -1
    if int_value < 0:
        raise argparse.ArgumentTypeError(
            'invalid non-negative integer ‘%s’' % value)
    return int_value


def main():
    # Parse command line arguments
    parser = argparse.ArgumentParser(description='Control eos-updater stages.')
    subparsers = parser.add_subparsers(metavar='command',
                                       help='command to pass to eos-updater; '
                                            'omit to monitor the updater')
    parser.set_defaults(function=command_monitor)
    parser.add_argument('--quiet', action='store_true',
                        help='output no informational messages')
    parser.set_defaults(quiet=False)

    # Common options for the D-Bus method subcommands.
    common_parser = argparse.ArgumentParser(add_help=False)
    group = common_parser.add_mutually_exclusive_group()
    group.add_argument('--no-block', dest='block',
                       action='store_false',
                       help='do not wait until eos-updater has finished all '
                            'processing in the requested state')
    group.add_argument('--block', dest='block', action='store_true',
                       help='opposite of --no-block')
    common_parser.set_defaults(block=True)

    parser_poll = subparsers.add_parser('poll', parents=[common_parser],
                                        help='check for updates')
    parser_poll.set_defaults(function=command_poll)

    parser_fetch = subparsers.add_parser('fetch', parents=[common_parser],
                                         help='download available updates')
    parser_fetch.set_defaults(function=command_fetch)
    parser_fetch.add_argument('--force', action='store_true',
                              help='force fetching even if on a metered '
                                   'network connection')
    parser_fetch.add_argument('--timeout', type=nonnegative_int, default=0,
                              help='timeout to wait for permission from the '
                                   'scheduler for, or zero (default) to wait '
                                   'indefinitely')

    parser_apply = subparsers.add_parser('apply', parents=[common_parser],
                                         help='deploy downloaded updates')
    parser_apply.set_defaults(function=command_apply)

    parser_cancel = subparsers.add_parser('cancel', parents=[common_parser],
                                          help='cancel an on-going operation')
    parser_cancel.set_defaults(function=command_cancel)

    parser_monitor = subparsers.add_parser('monitor',
                                           help='watch eos-updater properties')
    parser_monitor.set_defaults(function=command_monitor)

    parser_server = subparsers.add_parser('server',
                                          help='control eos-update-server '
                                               'configuration')
    parser_server.set_defaults(function=command_server)
    server_subparsers = parser_server.add_subparsers(
        dest='server_command', metavar='command',
        help='command to run regarding the server')
    server_subparsers.add_parser('enable', help='enable eos-update-server')
    server_subparsers.add_parser('disable', help='disable eos-update-server')

    parser_update = subparsers.add_parser('update',
                                          help='run the entire update process')
    parser_update.set_defaults(function=command_update)
    parser_update.add_argument('--force', action='store_true',
                               help='force fetching even if on a metered '
                                    'network connection (this only affects '
                                    'the fetch stage)')
    parser_update.add_argument('--timeout', type=nonnegative_int, default=0,
                               help='timeout to wait for permission from the '
                                    'scheduler for, or zero (default) to wait '
                                    'indefinitely (this only affects the '
                                    'fetch stage)')

    # Parse the command line arguments and run the subcommand.
    args = parser.parse_args()
    args_dict = dict((k, v) for k, v in vars(args).items() if k != 'function')
    sys.exit(args.function(**args_dict))


if __name__ == '__main__':
    main()
