#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Copyright © 2018 Endless Mobile, Inc.
# Copyright © 2025 GNOME Foundation, Inc.
#
# SPDX-License-Identifier: LGPL-2.1-or-later
#
# 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 datetime
import itertools
import os
import pwd
import signal
import subprocess
import sys
import gi
from urllib.parse import urlparse

gi.require_version("Malcontent", "0")
from gi.repository import Malcontent, GLib, Gio  # noqa: E402


# Exit codes, which are a documented part of the API.
EXIT_SUCCESS = 0
EXIT_INVALID_OPTION = 1
EXIT_PERMISSION_DENIED = 2
EXIT_PATH_NOT_ALLOWED = 3
EXIT_DISABLED = 4
EXIT_FAILED = 5
EXIT_BUSY = 6


def __manager_error_to_exit_code(error):
    if error.matches(
        Malcontent.manager_error_quark(), Malcontent.ManagerError.INVALID_USER
    ):
        return EXIT_INVALID_OPTION
    elif error.matches(
        Malcontent.manager_error_quark(), Malcontent.ManagerError.PERMISSION_DENIED
    ):
        return EXIT_PERMISSION_DENIED
    elif error.matches(
        Malcontent.manager_error_quark(), Malcontent.ManagerError.INVALID_DATA
    ):
        return EXIT_INVALID_OPTION
    elif error.matches(
        Malcontent.manager_error_quark(), Malcontent.ManagerError.DISABLED
    ):
        return EXIT_DISABLED

    return EXIT_FAILED


def __child_timer_service_error_to_exit_code(error):
    # FIXME: This could use a proper GError domain if a client library was
    # added for malcontent-timerd (perhaps in libmalcontent). Until then, we
    # have to hard-code the D-Bus error names
    remote_error = Gio.DBusError.get_remote_error(error)
    if remote_error == "org.freedesktop.MalcontentTimer1.Child.Error.InvalidRecord":
        return EXIT_INVALID_OPTION
    elif remote_error == "org.freedesktop.MalcontentTimer1.Child.Error.StorageError":
        return EXIT_FAILED
    elif remote_error == "org.freedesktop.MalcontentTimer1.Child.Error.Busy":
        return EXIT_BUSY
    elif remote_error == "org.freedesktop.MalcontentTimer1.Child.Error.IdentifyingUser":
        return EXIT_FAILED
    elif remote_error == "org.freedesktop.MalcontentTimer1.Child.Error.QueryingPolicy":
        return EXIT_FAILED
    elif remote_error == "org.freedesktop.MalcontentTimer1.Child.Error.Disabled":
        return EXIT_DISABLED

    return EXIT_FAILED


def __parent_timer_service_error_to_exit_code(error):
    # FIXME: This could use a proper GError domain if a client library was
    # added for malcontent-timerd (perhaps in libmalcontent). Until then, we
    # have to hard-code the D-Bus error names
    remote_error = Gio.DBusError.get_remote_error(error)
    if remote_error == "org.freedesktop.MalcontentTimer1.Parent.Error.InvalidRecord":
        return EXIT_INVALID_OPTION
    elif remote_error == "org.freedesktop.MalcontentTimer1.Parent.Error.StorageError":
        return EXIT_FAILED
    elif remote_error == "org.freedesktop.MalcontentTimer1.Parent.Error.Busy":
        return EXIT_BUSY
    elif (
        remote_error == "org.freedesktop.MalcontentTimer1.Parent.Error.IdentifyingUser"
    ):
        return EXIT_FAILED
    elif (
        remote_error == "org.freedesktop.MalcontentTimer1.Parent.Error.PermissionDenied"
    ):
        return EXIT_PERMISSION_DENIED

    return EXIT_FAILED


def __web_filtering_service_error_to_exit_code(error):
    # FIXME: This could use a proper GError domain if a client library was
    # added for malcontent-webd (perhaps in libmalcontent). Until then, we
    # have to hard-code the D-Bus error names
    remote_error = Gio.DBusError.get_remote_error(error)
    if (
        remote_error
        == "org.freedesktop.MalcontentWeb1.Filtering.Error.InvalidFilterFormat"
    ):
        return EXIT_INVALID_OPTION
    elif remote_error == "org.freedesktop.MalcontentWeb1.Filtering.Error.FileSystem":
        return EXIT_FAILED
    elif remote_error == "org.freedesktop.MalcontentWeb1.Filtering.Error.Busy":
        return EXIT_BUSY
    elif remote_error == "org.freedesktop.MalcontentWeb1.Filtering.Error.Downloading":
        return EXIT_FAILED
    elif (
        remote_error == "org.freedesktop.MalcontentWeb1.Filtering.Error.QueryingPolicy"
    ):
        return EXIT_FAILED
    elif remote_error == "org.freedesktop.MalcontentWeb1.Filtering.Error.Disabled":
        return EXIT_DISABLED

    return EXIT_FAILED


def __get_app_filter(user_id, interactive):
    """Get the app filter for `user_id` off the bus.

    If `interactive` is `True`, interactive polkit authorisation dialogues will
    be allowed. An exception will be raised on failure."""
    if interactive:
        flags = Malcontent.ManagerGetValueFlags.INTERACTIVE
    else:
        flags = Malcontent.ManagerGetValueFlags.NONE

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    return manager.get_app_filter(user_id=user_id, flags=flags, cancellable=None)


def __get_app_filter_or_error(user_id, interactive):
    """Wrapper around __get_app_filter() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        return __get_app_filter(user_id, interactive)
    except GLib.Error as e:
        print(
            "Error getting app filter for user {}: {}".format(user_id, e.message),
            file=sys.stderr,
        )
        raise SystemExit(__manager_error_to_exit_code(e))


def __get_session_limits(user_id, interactive):
    """Get the session limits for `user_id` off the bus.

    If `interactive` is `True`, interactive polkit authorisation dialogues will
    be allowed. An exception will be raised on failure."""
    if interactive:
        flags = Malcontent.ManagerGetValueFlags.INTERACTIVE
    else:
        flags = Malcontent.ManagerGetValueFlags.NONE

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    return manager.get_session_limits(user_id=user_id, flags=flags, cancellable=None)


def __get_session_limits_or_error(user_id, interactive):
    """Wrapper around __get_session_limits() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        return __get_session_limits(user_id, interactive)
    except GLib.Error as e:
        print(
            "Error getting session limits for user {}: {}".format(user_id, e.message),
            file=sys.stderr,
        )
        raise SystemExit(__manager_error_to_exit_code(e))


def __get_web_filter(user_id, interactive):
    """Get the web filter for `user_id` off the bus.

    If `interactive` is `True`, interactive polkit authorisation dialogues will
    be allowed. An exception will be raised on failure."""
    if interactive:
        flags = Malcontent.ManagerGetValueFlags.INTERACTIVE
    else:
        flags = Malcontent.ManagerGetValueFlags.NONE

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    return manager.get_web_filter(user_id=user_id, flags=flags, cancellable=None)


def __get_web_filter_or_error(user_id, interactive):
    """Wrapper around __get_web_filter() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        return __get_web_filter(user_id, interactive)
    except GLib.Error as e:
        print(
            "Error getting web filter for user {}: {}".format(user_id, e.message),
            file=sys.stderr,
        )
        raise SystemExit(__manager_error_to_exit_code(e))


def __set_app_filter(user_id, app_filter, interactive):
    """Set the app filter for `user_id` off the bus.

    If `interactive` is `True`, interactive polkit authorisation dialogues will
    be allowed. An exception will be raised on failure."""
    if interactive:
        flags = Malcontent.ManagerSetValueFlags.INTERACTIVE
    else:
        flags = Malcontent.ManagerSetValueFlags.NONE

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    manager.set_app_filter(
        user_id=user_id, app_filter=app_filter, flags=flags, cancellable=None
    )


def __set_app_filter_or_error(user_id, app_filter, interactive):
    """Wrapper around __set_app_filter() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        __set_app_filter(user_id, app_filter, interactive)
    except GLib.Error as e:
        print(
            "Error setting app filter for user {}: {}".format(user_id, e.message),
            file=sys.stderr,
        )
        raise SystemExit(__manager_error_to_exit_code(e))


def __set_session_limits(user_id, session_limits, interactive):
    """Set the session limits for `user_id` off the bus.

    If `interactive` is `True`, interactive polkit authorisation dialogues will
    be allowed. An exception will be raised on failure."""
    if interactive:
        flags = Malcontent.ManagerSetValueFlags.INTERACTIVE
    else:
        flags = Malcontent.ManagerSetValueFlags.NONE

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    manager.set_session_limits(
        user_id=user_id, session_limits=session_limits, flags=flags, cancellable=None
    )


def __set_session_limits_or_error(user_id, session_limits, interactive):
    """Wrapper around __set_session_limits() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        __set_session_limits(user_id, session_limits, interactive)
    except GLib.Error as e:
        print(
            "Error setting session limits for user {}: {}".format(user_id, e.message),
            file=sys.stderr,
        )
        raise SystemExit(__manager_error_to_exit_code(e))


def __set_web_filter(user_id, web_filter, interactive):
    """Set the web filter for `user_id` off the bus.

    If `interactive` is `True`, interactive polkit authorisation dialogues will
    be allowed. An exception will be raised on failure."""
    if interactive:
        flags = Malcontent.ManagerSetValueFlags.INTERACTIVE
    else:
        flags = Malcontent.ManagerSetValueFlags.NONE

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    manager.set_web_filter(
        user_id=user_id, web_filter=web_filter, flags=flags, cancellable=None
    )


def __set_web_filter_or_error(user_id, web_filter, interactive):
    """Wrapper around __set_web_filter() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        __set_web_filter(user_id, web_filter, interactive)
    except GLib.Error as e:
        print(
            "Error setting web filter for user {}: {}".format(user_id, e.message),
            file=sys.stderr,
        )
        raise SystemExit(__manager_error_to_exit_code(e))


def __lookup_user_id(user_id_or_username):
    """Convert a command-line specified username or ID into a
    (user ID, username) tuple, looking up the component which isn’t specified.
    If `user_id_or_username` is empty, use the current user ID.

    Raise KeyError if lookup fails."""
    if user_id_or_username == "":
        user_id = os.getuid()
        return (user_id, pwd.getpwuid(user_id).pw_name)
    elif user_id_or_username.isdigit():
        user_id = int(user_id_or_username)
        return (user_id, pwd.getpwuid(user_id).pw_name)
    else:
        username = user_id_or_username
        return (pwd.getpwnam(username).pw_uid, username)


def __lookup_user_id_or_error(user_id_or_username):
    """Wrapper around __lookup_user_id() which prints an error and raises
    SystemExit, rather than an internal exception."""
    try:
        return __lookup_user_id(user_id_or_username)
    except KeyError:
        print(
            "Error getting ID for username {}".format(user_id_or_username),
            file=sys.stderr,
        )
        raise SystemExit(EXIT_INVALID_OPTION)


oars_value_mapping = {
    Malcontent.AppFilterOarsValue.UNKNOWN: "unknown",
    Malcontent.AppFilterOarsValue.NONE: "none",
    Malcontent.AppFilterOarsValue.MILD: "mild",
    Malcontent.AppFilterOarsValue.MODERATE: "moderate",
    Malcontent.AppFilterOarsValue.INTENSE: "intense",
}


def __oars_value_to_string(value):
    """Convert an Malcontent.AppFilterOarsValue to a human-readable
    string."""
    try:
        return oars_value_mapping[value]
    except KeyError:
        return "invalid (OARS value {})".format(value)


def __oars_value_from_string(value_str):
    """Convert a human-readable string to an
    Malcontent.AppFilterOarsValue."""
    for k, v in oars_value_mapping.items():
        if v == value_str:
            return k
    raise KeyError("Unknown OARS value ‘{}’".format(value_str))


def command_get_app_filter(user, quiet=False, interactive=True):
    """Get the app filter for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)
    app_filter = __get_app_filter_or_error(user_id, interactive)

    print("App filter for user {} retrieved:".format(username))

    sections = app_filter.get_oars_sections()
    for section in sections:
        value = app_filter.get_oars_value(section)
        print("  {}: {}".format(section, oars_value_mapping[value]))
    if not sections:
        print("  (No OARS values)")

    if app_filter.is_user_installation_allowed():
        print("App installation is allowed to user repository")
    else:
        print("App installation is disallowed to user repository")

    if app_filter.is_system_installation_allowed():
        print("App installation is allowed to system repository")
    else:
        print("App installation is disallowed to system repository")


def command_get_session_limits(user, now=None, quiet=False, interactive=True):
    """Get the session limits for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)
    session_limits = __get_session_limits_or_error(user_id, interactive)

    # FIXME: Need to pass the active session time today properly
    now_dt = GLib.DateTime.new_from_unix_local(now.timestamp())
    (user_allowed_now, time_remaining_secs, time_limit_enabled) = (
        session_limits.check_time_remaining(now_dt, 0)
    )

    (daily_schedule_set, start_time_secs, end_time_secs) = (
        session_limits.get_daily_schedule()
    )

    (daily_limit_set, daily_limit_secs) = session_limits.get_daily_limit()

    if not time_limit_enabled:
        print("Session limits are not enabled for user {}".format(username))
        return
    elif user_allowed_now:
        print(
            "Session limits are enabled for user {}, and they have {} "
            "seconds remaining".format(username, time_remaining_secs)
        )
    else:
        print(
            "Session limits are enabled for user {}, and they have no time "
            "remaining".format(username)
        )

    if daily_schedule_set:
        print(
            "Daily schedule is set for user {}, starts at {}s and ends at {}s "
            "since midnight".format(username, start_time_secs, end_time_secs)
        )
    else:
        print("Daily schedule is not set for user {}".format(username))

    if daily_limit_set:
        print(
            "Daily limit is set for user {}, and equals {}s "
            "per day".format(username, daily_limit_secs)
        )
    else:
        print("Daily limit is not set for user {}".format(username))


def command_get_web_filter(user, quiet=False, interactive=True):
    """Get the web filter for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)
    web_filter = __get_web_filter_or_error(user_id, interactive)

    print("Web filter for user {} retrieved:".format(username))

    filter_type = web_filter.get_filter_type()
    if filter_type == Malcontent.WebFilterType.NONE:
        print("Web filtering is not enabled for user {}".format(username))
        return

    if filter_type == Malcontent.WebFilterType.BLOCKLIST:
        print(
            "Web filtering is enabled, allowing websites by default unless in a block-list"
        )
    else:
        print(
            "Web filtering is enabled, blocking websites by default unless in an allow-list"
        )

    # The block list is only effective if in BLOCKLIST mode
    if filter_type == Malcontent.WebFilterType.BLOCKLIST:
        block_lists = web_filter.get_block_lists()
        print("Block lists:")
        for id, filter_uri in block_lists.items():
            print("  {}: {}".format(id, filter_uri))
        if not block_lists:
            print("  (No lists)")

        custom_block_list = web_filter.get_custom_block_list()
        print("Custom block list:")
        for entry in custom_block_list:
            print("  {}".format(entry))
        if not custom_block_list:
            print("  (No entries)")

    # The allowlist is always effective
    allow_lists = web_filter.get_allow_lists()
    print("Allow lists:")
    for id, filter_uri in allow_lists.items():
        print("  {}: {}".format(id, filter_uri))
    if not allow_lists:
        print("  (No lists)")

    custom_allow_list = web_filter.get_custom_allow_list()
    print("Custom allow list:")
    for entry in custom_allow_list:
        print("  {}".format(entry))
    if not custom_allow_list:
        print("  (No entries)")

    if web_filter.get_force_safe_search():
        print("Safe search is force-enabled")
    else:
        print("Safe search is not force-enabled")


def command_monitor(user, quiet=False, interactive=True):
    """Monitor parental controls changes for the given user."""
    if user == "":
        (filter_user_id, filter_username) = (0, "")
    else:
        (filter_user_id, filter_username) = __lookup_user_id_or_error(user)
    apply_filter = user != ""

    def _on_parental_controls_changed(manager, changed_user_id, type_name):
        if not apply_filter or changed_user_id == filter_user_id:
            print("{} changed for user ID {}".format(type_name, changed_user_id))

    connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
    manager = Malcontent.Manager.new(connection)
    manager.connect(
        "app-filter-changed",
        lambda m, u: _on_parental_controls_changed(m, u, "App filter"),
    )
    manager.connect(
        "session-limits-changed",
        lambda m, u: _on_parental_controls_changed(m, u, "Session limits"),
    )
    manager.connect(
        "web-filter-changed",
        lambda m, u: _on_parental_controls_changed(m, u, "Web filter"),
    )

    if apply_filter:
        print(
            "Monitoring parental controls changes for user {}".format(filter_username)
        )
    else:
        print("Monitoring parental controls changes for all users")

    # Loop until Ctrl+C is pressed.
    context = GLib.MainContext.default()
    while True:
        try:
            context.iteration(may_block=True)
        except KeyboardInterrupt:
            break


# Simple check to check whether @arg is a valid flatpak ref - it uses the
# same logic as 'MctAppFilter' to determine it and should be kept in sync
# with its implementation
def is_valid_flatpak_ref(arg):
    parts = arg.split("/")
    return (
        len(parts) == 4
        and (parts[0] == "app" or parts[0] == "runtime")
        and parts[1] != ""
        and parts[2] != ""
        and parts[3] != ""
    )


# Simple check to check whether @arg is a valid content type - it uses the
# same logic as 'MctAppFilter' to determine it and should be kept in sync
# with its implementation
def is_valid_content_type(arg):
    parts = arg.split("/")
    return len(parts) == 2 and parts[0] != "" and parts[1] != ""


def command_check_app_filter(user, arg, quiet=False, interactive=True):
    """Check the given path, content type or flatpak ref is runnable by the
    given user, according to their app filter."""
    (user_id, username) = __lookup_user_id_or_error(user)
    app_filter = __get_app_filter_or_error(user_id, interactive)

    is_maybe_flatpak_id = arg.startswith("app/") and arg.count("/") < 3
    is_maybe_flatpak_ref = is_valid_flatpak_ref(arg)
    # Only check if arg is a valid content type if not already considered a
    # valid flatpak id, otherwise we always get multiple types recognised
    # when passing flatpak IDs as argument
    is_maybe_content_type = not is_maybe_flatpak_id and is_valid_content_type(arg)
    is_maybe_path = os.path.exists(arg)
    is_maybe_desktop_file = arg.endswith(".desktop")

    recognised_types = sum(
        [
            is_maybe_flatpak_id,
            is_maybe_flatpak_ref,
            is_maybe_content_type,
            is_maybe_path,
        ]
    )
    if recognised_types == 0:
        print("Unknown argument ‘{}’".format(arg), file=sys.stderr)
        raise SystemExit(EXIT_INVALID_OPTION)
    elif recognised_types > 1:
        print(
            "Ambiguous argument ‘{}’ recognised as multiple types".format(arg),
            file=sys.stderr,
        )
        raise SystemExit(EXIT_INVALID_OPTION)
    elif is_maybe_flatpak_id:
        # Flatpak app ID
        arg = arg[4:]
        is_allowed = app_filter.is_flatpak_app_allowed(arg)
        noun = "Flatpak app ID"
    elif is_maybe_flatpak_ref:
        # Flatpak ref
        is_allowed = app_filter.is_flatpak_ref_allowed(arg)
        noun = "Flatpak ref"
    elif is_maybe_content_type:
        # Content type
        is_allowed = app_filter.is_content_type_allowed(arg)
        noun = "Content type"
    elif is_maybe_path and is_maybe_desktop_file:
        path = os.path.abspath(arg)
        app_info = Gio.DesktopAppInfo.new_from_filename(path)
        is_allowed = app_filter.is_appinfo_allowed(app_info)
        noun = "Desktop file"
    elif is_maybe_path:
        path = os.path.abspath(arg)
        is_allowed = app_filter.is_path_allowed(path)
        noun = "Path"
    else:
        raise AssertionError("code should not be reached")

    if is_allowed:
        if not quiet:
            print(
                "{} {} is allowed by app filter for user {}".format(noun, arg, username)
            )
        return
    else:
        if not quiet:
            print(
                "{} {} is not allowed by app filter for user {}".format(
                    noun, arg, username
                )
            )
        raise SystemExit(EXIT_PATH_NOT_ALLOWED)


def command_oars_section(user, section, quiet=False, interactive=True):
    """Get the value of the given OARS section for the given user, according
    to their OARS filter."""
    (user_id, username) = __lookup_user_id_or_error(user)
    app_filter = __get_app_filter_or_error(user_id, interactive)

    value = app_filter.get_oars_value(section)
    print(
        "OARS section ‘{}’ for user {} has value ‘{}’".format(
            section, username, __oars_value_to_string(value)
        )
    )


def command_set_app_filter(
    user,
    allow_user_installation=True,
    allow_system_installation=False,
    app_filter_args=None,
    quiet=False,
    interactive=True,
):
    """Set the app filter for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)
    builder = Malcontent.AppFilterBuilder.new()
    builder.set_allow_user_installation(allow_user_installation)
    builder.set_allow_system_installation(allow_system_installation)

    for arg in app_filter_args:
        if "=" in arg:
            [section, value_str] = arg.split("=", 2)
            try:
                value = __oars_value_from_string(value_str)
            except KeyError:
                print("Unknown OARS value ‘{}’".format(value_str), file=sys.stderr)
                raise SystemExit(EXIT_INVALID_OPTION)
            builder.set_oars_value(section, value)
        else:
            is_maybe_flatpak_ref = is_valid_flatpak_ref(arg)
            is_maybe_content_type = is_valid_content_type(arg)
            is_maybe_path = os.path.exists(arg)

            recognised_types = sum(
                [is_maybe_flatpak_ref, is_maybe_content_type, is_maybe_path]
            )
            if recognised_types == 0:
                print("Unknown argument ‘{}’".format(arg), file=sys.stderr)
                raise SystemExit(EXIT_INVALID_OPTION)
            elif recognised_types > 1:
                print(
                    "Ambiguous argument ‘{}’ recognised as multiple types".format(arg),
                    file=sys.stderr,
                )
                raise SystemExit(EXIT_INVALID_OPTION)
            elif is_maybe_flatpak_ref:
                builder.blocklist_flatpak_ref(arg)
            elif is_maybe_content_type:
                builder.blocklist_content_type(arg)
            elif is_maybe_path:
                path = os.path.abspath(arg)
                builder.blocklist_path(path)
            else:
                raise AssertionError("code should not be reached")

    app_filter = builder.end()

    __set_app_filter_or_error(user_id, app_filter, interactive)

    if not quiet:
        print("App filter for user {} set".format(username))


def __time_to_secs(time):
    return time.hour * 3600 + time.minute * 60 + time.second


def command_set_session_limits(
    user, limit_type=None, quiet=False, interactive=True, **kwargs
):
    """Set the session limits for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)
    old_session_limits = __get_session_limits_or_error(user_id, interactive)
    (old_daily_schedule_set, old_start_time_secs, old_end_time_secs) = (
        old_session_limits.get_daily_schedule()
    )
    (old_daily_limit_set, old_daily_limit_secs) = old_session_limits.get_daily_limit()
    builder = Malcontent.SessionLimitsBuilder.new()

    match limit_type:
        case "none":
            builder.set_none()
        case "daily-schedule":
            builder.set_daily_schedule(
                True,
                __time_to_secs(kwargs["start_time"]),
                __time_to_secs(kwargs["end_time"]),
            )
            if old_daily_limit_set:
                builder.set_daily_limit(True, old_daily_limit_secs)
        case "daily-limit":
            if old_daily_schedule_set:
                builder.set_daily_schedule(True, old_start_time_secs, old_end_time_secs)
            builder.set_daily_limit(True, kwargs["daily_limit"])
        case _:
            print("Unknown limit type ‘{}’".format(limit_type), file=sys.stderr)
            raise SystemExit(EXIT_INVALID_OPTION)

    session_limits = builder.end()

    __set_session_limits_or_error(user_id, session_limits, interactive)

    if not quiet:
        print("Session limits for user {} set".format(username))


web_filter_type_mapping = {
    Malcontent.WebFilterType.NONE: "none",
    Malcontent.WebFilterType.BLOCKLIST: "block-list",
    Malcontent.WebFilterType.ALLOWLIST: "allow-list",
}


def __web_filter_type_from_string(value_str):
    """Convert a human-readable string to an
    Malcontent.WebFilterType."""
    for k, v in web_filter_type_mapping.items():
        if v == value_str:
            return k
    raise KeyError("Unknown web filter type ‘{}’".format(value_str))


def command_set_web_filter(
    user,
    filter_type=Malcontent.WebFilterType.NONE,
    force_safe_search=False,
    web_filter_args=None,
    quiet=False,
    interactive=True,
):
    """Set the web filter for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)
    builder = Malcontent.WebFilterBuilder.new()
    builder.set_filter_type(filter_type)
    builder.set_force_safe_search(force_safe_search)

    for arg in web_filter_args:
        if "=" in arg:
            [prefixed_id, filter_uri] = arg.split("=", 2)
            id = prefixed_id[1:] if prefixed_id[0] == "~" else prefixed_id

            if not Malcontent.web_filter_validate_filter_id(id):
                print("Unknown filter ID ‘{}’".format(id), file=sys.stderr)
                raise SystemExit(EXIT_INVALID_OPTION)

            try:
                urlparse(filter_uri)
            except AttributeError:
                print("Unknown filter URI ‘{}’".format(filter_uri), file=sys.stderr)
                raise SystemExit(EXIT_INVALID_OPTION)

            if prefixed_id[0] == "~":
                builder.add_allow_list(id, filter_uri)
            else:
                builder.add_block_list(id, filter_uri)
        else:
            hostname = arg[1:] if arg[0] == "~" else arg

            if not Malcontent.web_filter_validate_hostname(hostname):
                print("Unknown hostname ‘{}’".format(hostname), file=sys.stderr)
                raise SystemExit(EXIT_INVALID_OPTION)

            if arg[0] == "~":
                builder.add_custom_allow_list_entry(hostname)
            else:
                builder.add_custom_block_list_entry(hostname)

    web_filter = builder.end()

    __set_web_filter_or_error(user_id, web_filter, interactive)

    if not quiet:
        print("Web filter for user {} set".format(username))


def __dbus_call_flags_for_interactive(interactive):
    if interactive:
        return Gio.DBusCallFlags.ALLOW_INTERACTIVE_AUTHORIZATION
    else:
        return Gio.DBusCallFlags.NONE


def command_record_usage(usage_entries, quiet=False, interactive=True):
    """Record usage entries for the current user."""
    variant_entries = []

    # FIXME: This could do with more argparse validation
    def __validate_usage_record_type(record_type, identifier):
        match record_type:
            case "login-session":
                if identifier != "":
                    raise Exception(
                        "Identifier must be empty for ‘login-session’ entries"
                    )
            case "app":
                if identifier == "":
                    raise Exception("Identifier must be non-empty for ‘app’ entries")
            case x:
                raise Exception("Unknown record type ‘{}’".format(x))
        return record_type

    try:
        variant_entries = [
            (
                datetime.datetime.strptime(batch[0], "%Y-%m-%dT%H:%M:%S%z").timestamp(),
                datetime.datetime.strptime(batch[1], "%Y-%m-%dT%H:%M:%S%z").timestamp(),
                __validate_usage_record_type(
                    batch[2], batch[3] if len(batch) > 3 else ""
                ),
                batch[3] if len(batch) > 3 else "",
            )
            for batch in itertools.batched(usage_entries, 4)
        ]
    except Exception as e:
        print("Error parsing usage entries: {}".format(e.message), file=sys.stderr)
        raise SystemExit(EXIT_INVALID_OPTION)

    try:
        connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
        connection.call_sync(
            bus_name="org.freedesktop.MalcontentTimer1",
            object_path="/org/freedesktop/MalcontentTimer1",
            interface_name="org.freedesktop.MalcontentTimer1.Child",
            method_name="RecordUsage",
            parameters=GLib.Variant("(a(ttss))", (variant_entries,)),
            reply_type=None,
            flags=__dbus_call_flags_for_interactive(interactive),
            timeout_msec=-1,
            cancellable=None,
        )
    except GLib.Error as e:
        print(
            "Error recording usage entries for current user: {}".format(e.message),
            file=sys.stderr,
        )
        raise SystemExit(__child_timer_service_error_to_exit_code(e))

    if not quiet:
        print("Usage entries recorded for current user")


def command_query_usage(user, record_type, identifier, quiet=False, interactive=True):
    """Query usage entries for the given user."""
    (user_id, username) = __lookup_user_id_or_error(user)

    try:
        connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
        (usage_entries,) = connection.call_sync(
            bus_name="org.freedesktop.MalcontentTimer1",
            object_path="/org/freedesktop/MalcontentTimer1",
            interface_name="org.freedesktop.MalcontentTimer1.Parent",
            method_name="QueryUsage",
            parameters=GLib.Variant("(uss)", (user_id, record_type, identifier)),
            reply_type=GLib.VariantType("(a(tt))"),
            flags=__dbus_call_flags_for_interactive(interactive),
            timeout_msec=-1,
            cancellable=None,
        )
    except GLib.Error as e:
        print(
            "Error querying usage entries for user ‘{}’: {}".format(
                username, e.message
            ),
            file=sys.stderr,
        )
        raise SystemExit(__parent_timer_service_error_to_exit_code(e))

    if usage_entries:
        print("Matching usage entries for user ‘{}’:".format(username))
        for start_time_secs, end_time_secs in usage_entries:
            start_time_dt = datetime.datetime.fromtimestamp(start_time_secs)
            end_time_dt = datetime.datetime.fromtimestamp(end_time_secs)
            print(" - {}–{}".format(start_time_dt.isoformat(), end_time_dt.isoformat()))
    else:
        print("No matching usage entries for user ‘{}’".format(username))


def command_get_estimated_times(record_type, quiet=False, interactive=True):
    """Get estimated session times for the current user."""
    try:
        connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
        (now_secs, times_secs) = connection.call_sync(
            bus_name="org.freedesktop.MalcontentTimer1",
            object_path="/org/freedesktop/MalcontentTimer1",
            interface_name="org.freedesktop.MalcontentTimer1.Child",
            method_name="GetEstimatedTimes",
            parameters=GLib.Variant("(s)", (record_type,)),
            reply_type=GLib.VariantType("(ta{s(btttt)})"),
            flags=__dbus_call_flags_for_interactive(interactive),
            timeout_msec=-1,
            cancellable=None,
        )
    except GLib.Error as e:
        print(
            "Error getting estimated session times for current user: {}".format(
                e.message
            ),
            file=sys.stderr,
        )
        raise SystemExit(__child_timer_service_error_to_exit_code(e))

    if times_secs and record_type == "login-session":
        (
            limit_reached,
            current_session_start_time_secs,
            current_session_estimated_end_time_secs,
            next_session_start_time_secs,
            next_session_estimated_end_time_secs,
        ) = times_secs[""]
        current_session_estimated_end_dt = datetime.datetime.fromtimestamp(
            current_session_estimated_end_time_secs
        )
        if not limit_reached:
            print(
                "Login session will end at {}".format(
                    current_session_estimated_end_dt.isoformat()
                )
            )
        else:
            print(
                "Login session ended at {}".format(
                    current_session_estimated_end_dt.isoformat()
                )
            )
        if next_session_start_time_secs != 0:
            next_dt = datetime.datetime.fromtimestamp(next_session_start_time_secs)
            print("Next login session can start at {}".format(next_dt.isoformat()))
    elif times_secs:
        print("Upcoming end times of type ‘{}’ for current user:".format(record_type))
        for identifier, (
            limit_reached,
            current_session_start_time_secs,
            current_session_estimated_end_time_secs,
            next_session_start_time_secs,
            next_session_estimated_end_time_secs,
        ) in times_secs.items():
            dt = datetime.datetime.fromtimestamp(
                current_session_estimated_end_time_secs
            )
            print(" - {}: {}".format(identifier, dt.isoformat()))
    else:
        print("No upcoming end times of type ‘{}’ for current user".format(record_type))


def command_request_extension(
    record_type, identifier, duration, wait=False, quiet=False, interactive=True
):
    """Request a session time limit extension for the current user."""
    # Don’t support any extra data for now, but it could be added as additional
    # command line arguments in future
    extra_data = {}

    context = GLib.MainContext.default()
    cookie = None
    received_response = False
    granted = False
    extra_data = None

    def _extension_response_cb(
        connection, sender_name, object_path, interface_name, signal_name, parameters
    ):
        nonlocal granted, received_response, cookie, extra_data
        assert object_path == "/org/freedesktop/MalcontentTimer1"
        assert interface_name == "org.freedesktop.MalcontentTimer1.Child"
        assert signal_name == "ExtensionResponse"
        assert parameters.is_of_type(GLib.VariantType("(boa{sv})"))

        (response_granted, response_cookie, response_extra_data) = parameters

        if response_cookie == cookie:
            granted = response_granted
            extra_data = response_extra_data
            received_response = True
            context.wakeup()

    try:
        connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)

        # Subscribe to the response if --wait was passed
        if wait:
            signal_id = connection.signal_subscribe(
                sender="org.freedesktop.MalcontentTimer1",
                interface_name="org.freedesktop.MalcontentTimer1.Child",
                member="ExtensionResponse",
                object_path="/org/freedesktop/MalcontentTimer1",
                arg0=None,
                flags=Gio.DBusSignalFlags.NONE,
                callback=_extension_response_cb,
            )
        else:
            signal_id = 0

        # Make the extension request
        (cookie,) = connection.call_sync(
            bus_name="org.freedesktop.MalcontentTimer1",
            object_path="/org/freedesktop/MalcontentTimer1",
            interface_name="org.freedesktop.MalcontentTimer1.Child",
            method_name="RequestExtension",
            parameters=GLib.Variant(
                "(ssta{sv})", (record_type, identifier, duration, extra_data)
            ),
            reply_type=GLib.VariantType("(o)"),
            flags=__dbus_call_flags_for_interactive(interactive),
            timeout_msec=-1,
            cancellable=None,
        )
    except GLib.Error as e:
        print(
            "Error requesting session time limit extension for current user: {}".format(
                e.message
            ),
            file=sys.stderr,
        )
        raise SystemExit(__child_timer_service_error_to_exit_code(e))

    print("Requested session time limit extension for current user")

    if wait:
        print("Waiting for response…")

        while not received_response:
            try:
                context.iteration(may_block=True)
            except KeyboardInterrupt:
                break

        if received_response:
            print(
                "Got response: extension request was {}".format(
                    "granted" if granted else "not granted"
                )
            )
            if extra_data:
                print("Extra data: {}".format(extra_data))

    if signal_id != 0:
        connection.signal_unsubscribe(signal_id)


def command_update_web_filters(user, quiet=False, interactive=True):
    """Update compiled web filters for one user or all users."""
    if user:
        (user_id, username) = __lookup_user_id_or_error(user)
    else:
        (user_id, username) = (0, "")

    try:
        connection = Gio.bus_get_sync(Gio.BusType.SYSTEM)
        connection.call_sync(
            bus_name="org.freedesktop.MalcontentWeb1",
            object_path="/org/freedesktop/MalcontentWeb1",
            interface_name="org.freedesktop.MalcontentWeb1.Filtering",
            method_name="UpdateFilters",
            parameters=GLib.Variant("(u)", (user_id,)),
            reply_type=None,
            flags=__dbus_call_flags_for_interactive(interactive),
            timeout_msec=GLib.MAXINT,
            cancellable=None,
        )
    except GLib.Error as e:
        if user_id != 0:
            print(
                "Error updating web filters for user {}: {}".format(
                    username, e.message
                ),
                file=sys.stderr,
            )
        else:
            print(
                "Error updating web filters for all users: {}".format(e.message),
                file=sys.stderr,
            )
        raise SystemExit(__web_filtering_service_error_to_exit_code(e))

    if user_id != 0:
        print("Updated web filters for user {}".format(username))
    else:
        print("Updated web filters for all users")


def command_dump_web_filters(user, quiet=False, interactive=True):
    """Dump compiled web filters for one user or all users."""
    if user:
        (_, username) = __lookup_user_id_or_error(user)
    else:
        (_, username) = (0, "")

    filter_lists_dir = "/var/lib/malcontent-nss/filter-lists"
    found_a_filter = False

    try:
        for file in os.listdir(filter_lists_dir):
            filename = os.fsdecode(file)
            full_path = os.path.join(filter_lists_dir, file)

            if username != "" and filename != username:
                continue

            found_a_filter = True

            if not quiet and username == "":
                print("Compiled web filters for user {}:".format(filename))
                sys.stdout.flush()

            try:
                subprocess.run(
                    ["cdb", "-d", full_path],
                    check=True,
                    stdin=(None if interactive else subprocess.DEVNULL),
                    stdout=(None if not quiet else subprocess.DEVNULL),
                    stderr=(None if not quiet else subprocess.DEVNULL),
                )
            except subprocess.CalledProcessError as e:
                # Don’t care if the user quits `less` after running
                # `malcontent-client dump-web-filters $user | less` and causes a
                # SIGPIPE
                if e.returncode == -signal.Signals.SIGPIPE:
                    pass
                else:
                    raise e

            sys.stdout.flush()
            sys.stderr.flush()
    except FileNotFoundError:
        pass

    # Print an error if a specific user was queried but they have no web filters
    if username != "" and not quiet:
        full_path = os.path.join(filter_lists_dir, username)
        if not os.path.isfile(full_path):
            print(
                "User {} has no web filters set".format(username),
                file=sys.stderr,
            )
    elif username == "" and not quiet and not found_a_filter:
        print("No web filters found", file=sys.stderr)


def main():
    # Parse command line arguments
    parser = argparse.ArgumentParser(description="Query and update parental controls.")
    subparsers = parser.add_subparsers(
        metavar="command", help="command to run (default: ‘get-app-filter’)"
    )
    parser.set_defaults(function=command_get_app_filter)
    parser.add_argument(
        "-q", "--quiet", action="store_true", help="output no informational messages"
    )
    parser.set_defaults(quiet=False)

    # Common options for the subcommands which might need authorisation.
    common_parser = argparse.ArgumentParser(add_help=False)
    group = common_parser.add_mutually_exclusive_group()
    group.add_argument(
        "-n",
        "--no-interactive",
        dest="interactive",
        action="store_false",
        help="do not allow interactive polkit authorization dialogues",
    )
    group.add_argument(
        "--interactive",
        dest="interactive",
        action="store_true",
        help="opposite of --no-interactive",
    )
    common_parser.set_defaults(interactive=True)

    # ‘get-app-filter’ command
    parser_get_app_filter = subparsers.add_parser(
        "get-app-filter",
        parents=[common_parser],
        help="get current app filter settings",
    )
    parser_get_app_filter.set_defaults(function=command_get_app_filter)
    parser_get_app_filter.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to get the "
        "app filter for (default: current "
        "user)",
    )

    # ‘get-session-limits’ command
    parser_get_session_limits = subparsers.add_parser(
        "get-session-limits",
        parents=[common_parser],
        help="get current session limit settings",
    )
    parser_get_session_limits.set_defaults(function=command_get_session_limits)
    parser_get_session_limits.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to get "
        "the session limits for (default: "
        "current user)",
    )
    parser_get_session_limits.add_argument(
        "--now",
        metavar="yyyy-mm-ddThh:mm:ssZ",
        type=lambda d: datetime.datetime.strptime(d, "%Y-%m-%dT%H:%M:%S%z"),
        default=datetime.datetime.now(),
        help="date/time to use as the value for ‘now’ (default: wall clock time)",
    )

    # ‘get-web-filter’ command
    parser_get_web_filter = subparsers.add_parser(
        "get-web-filter",
        parents=[common_parser],
        help="get current web filter settings",
    )
    parser_get_web_filter.set_defaults(function=command_get_web_filter)
    parser_get_web_filter.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to get "
        "the web filter for (default: "
        "current user)",
    )

    # ‘monitor’ command
    parser_monitor = subparsers.add_parser(
        "monitor", help="monitor parental controls settings changes"
    )
    parser_monitor.set_defaults(function=command_monitor)
    parser_monitor.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to monitor the parental "
        "controls for (default: all users)",
    )

    # ‘check-app-filter’ command
    parser_check_app_filter = subparsers.add_parser(
        "check-app-filter",
        parents=[common_parser],
        help="check whether a path, content type or "
        "flatpak ref is allowed by app filter",
    )
    parser_check_app_filter.set_defaults(function=command_check_app_filter)
    parser_check_app_filter.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to get the "
        "app filter for (default: "
        "current user)",
    )
    parser_check_app_filter.add_argument(
        "arg", help="path to a program, content type or flatpak ref to check"
    )

    # ‘oars-section’ command
    parser_oars_section = subparsers.add_parser(
        "oars-section",
        parents=[common_parser],
        help="get the value of a given OARS section",
    )
    parser_oars_section.set_defaults(function=command_oars_section)
    parser_oars_section.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to get the "
        "OARS filter for (default: current "
        "user)",
    )
    parser_oars_section.add_argument("section", help="OARS section to get")

    # ‘set-app-filter’ command
    parser_set_app_filter = subparsers.add_parser(
        "set-app-filter",
        parents=[common_parser],
        help="set current app filter settings",
    )
    parser_set_app_filter.set_defaults(function=command_set_app_filter)
    parser_set_app_filter.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to set the "
        "app filter for (default: current "
        "user)",
    )
    parser_set_app_filter.add_argument(
        "--allow-user-installation",
        dest="allow_user_installation",
        action="store_true",
        help="allow installation to the user flatpak repo in general",
    )
    parser_set_app_filter.add_argument(
        "--disallow-user-installation",
        dest="allow_user_installation",
        action="store_false",
        help="unconditionally disallow installation to the user flatpak repo",
    )
    parser_set_app_filter.add_argument(
        "--allow-system-installation",
        dest="allow_system_installation",
        action="store_true",
        help="allow installation to the system flatpak repo in general",
    )
    parser_set_app_filter.add_argument(
        "--disallow-system-installation",
        dest="allow_system_installation",
        action="store_false",
        help="unconditionally disallow installation to the system flatpak repo",
    )
    parser_set_app_filter.add_argument(
        "app_filter_args",
        nargs="*",
        help="paths, content types or flatpak "
        "refs to blocklist and OARS "
        "section=value pairs to store",
    )
    parser_set_app_filter.set_defaults(
        allow_user_installation=True, allow_system_installation=False
    )

    # ‘set-session-limits’ command
    parser_set_session_limits = subparsers.add_parser(
        "set-session-limits",
        parents=[common_parser],
        help="set current session limits settings",
    )
    parser_set_session_limits.set_defaults(function=command_set_session_limits)
    parser_set_session_limits.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to set the "
        "session limits for (default: current "
        "user)",
    )
    session_limits_subparsers = parser_set_session_limits.add_subparsers(
        dest="limit_type", required=True, help="session limit type"
    )
    session_limits_subparsers.add_parser("none", help="no session limits")
    parser_session_limits_daily_schedule = session_limits_subparsers.add_parser(
        "daily-schedule", help="daily schedule with fixed start and end times"
    )
    parser_session_limits_daily_schedule.add_argument(
        "--start-time",
        metavar="hh[:mm[:ss]]",
        required=True,
        type=lambda d: datetime.time.fromisoformat(d),
        help="start time for daily schedule",
    )
    parser_session_limits_daily_schedule.add_argument(
        "--end-time",
        metavar="hh[:mm[:ss]]",
        required=True,
        type=lambda d: datetime.time.fromisoformat(d),
        help="end time for daily schedule",
    )
    parser_session_limits_daily_limit = session_limits_subparsers.add_parser(
        "daily-limit", help="daily limit with fixed session time"
    )
    parser_session_limits_daily_limit.add_argument(
        "--daily-limit",
        metavar="seconds",
        required=True,
        type=int,
        help="daily limit length in seconds",
    )

    # ‘set-web-filter’ command
    parser_set_web_filter = subparsers.add_parser(
        "set-web-filter",
        parents=[common_parser],
        help="set current web filter settings",
    )
    parser_set_web_filter.set_defaults(function=command_set_web_filter)
    parser_set_web_filter.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to set the "
        "web filter for (default: current "
        "user)",
    )
    parser_set_web_filter.add_argument(
        "--force-safe-search",
        dest="force_safe_search",
        action="store_true",
        help="force safe search to be enabled in search engines",
    )
    parser_set_web_filter.add_argument(
        "--filter-type",
        dest="filter_type",
        type=__web_filter_type_from_string,
        choices=web_filter_type_mapping.keys(),
        metavar="{" + ",".join(web_filter_type_mapping.values()) + "}",
        help="whether to disable the filter, or block or allow by default",
    )
    parser_set_web_filter.add_argument(
        "web_filter_args",
        nargs="*",
        help="id=filter-URI pairs or individual hostnames to block (or allow, "
        "if prefixed with ‘~’)",
    )
    parser_set_web_filter.set_defaults(
        force_safe_search=False,
        filter_type=Malcontent.WebFilterType.NONE,
    )

    # ‘record-usage’ command
    parser_record_usage = subparsers.add_parser(
        "record-usage",
        parents=[common_parser],
        help="add usage records to the current user’s timer history",
    )
    parser_record_usage.set_defaults(function=command_record_usage)
    parser_record_usage.add_argument(
        "usage_entries",
        nargs="*",
        help="zero or more usage entries of the form: start-time end-time "
        "record-type identifier",
    )

    # ‘query-usage’ command
    parser_query_usage = subparsers.add_parser(
        "query-usage",
        parents=[common_parser],
        help="query a child user’s timer history",
    )
    parser_query_usage.set_defaults(function=command_query_usage)
    parser_query_usage.add_argument(
        "user",
        help="user ID or username to query the history for",
    )
    parser_query_usage.add_argument(
        "record_type",
        choices=["login-session", "app"],
        help="record type to query for",
    )
    parser_query_usage.add_argument(
        "identifier",
        help="identifier to query for",
    )

    # ‘get-estimated-times’ command
    parser_get_estimated_times = subparsers.add_parser(
        "get-estimated-times",
        parents=[common_parser],
        help="use the current user’s timer history to estimate when their "
        "session (of the given record type) will end and start again",
    )
    parser_get_estimated_times.set_defaults(function=command_get_estimated_times)
    parser_get_estimated_times.add_argument(
        "record_type",
        choices=["login-session", "app"],
        help="record type to get estimated times for",
    )

    # ‘request-extension’ command
    parser_request_extension = subparsers.add_parser(
        "request-extension",
        parents=[common_parser],
        help="request a session time limit extension for the current user",
    )
    parser_request_extension.set_defaults(function=command_request_extension)
    parser_request_extension.add_argument(
        "record_type",
        choices=["login-session", "app"],
        help="record type to request extension for",
    )
    parser_request_extension.add_argument(
        "identifier",
        help="identifier to request extension for",
    )
    parser_request_extension.add_argument(
        "duration",
        metavar="seconds",
        default=0,
        nargs="?",
        type=int,
        help="extension length in seconds (default: to the end of today)",
    )
    parser_request_extension.add_argument(
        "--wait",
        dest="wait",
        action="store_true",
        help="wait for a response before exiting",
    )

    # ‘update-web-filters’ command
    parser_update_web_filters = subparsers.add_parser(
        "update-web-filters",
        parents=[common_parser],
        help="trigger an update of the compiled web filter for one user or "
        "all users",
    )
    parser_update_web_filters.set_defaults(function=command_update_web_filters)
    parser_update_web_filters.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to update the " "web filter for (default: all users)",
    )

    # ‘dump-web-filters’ command
    parser_dump_web_filters = subparsers.add_parser(
        "dump-web-filters",
        parents=[common_parser],
        help="dump the compiled web filter for one user or all users",
    )
    parser_dump_web_filters.set_defaults(function=command_dump_web_filters)
    parser_dump_web_filters.add_argument(
        "user",
        default="",
        nargs="?",
        help="user ID or username to dump the web filter for (default: all users)",
    )

    # 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")
    args.function(**args_dict)


if __name__ == "__main__":
    main()
