/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*-
 *
 * 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
 *
 * Authors:
 *  - Philip Withnall <pwithnall@gnome.org>
 */

#include "config.h"

#include <glib.h>
#include <glib-object.h>
#include <glib/gi18n-lib.h>
#include <gio/gio.h>
#include <libmalcontent/manager.h>
#include <libmalcontent/web-filter.h>

#include "libmalcontent/web-filter-private.h"


/* struct _MctWebFilter is defined in web-filter-private.h */

G_DEFINE_BOXED_TYPE (MctWebFilter, mct_web_filter,
                     mct_web_filter_ref, mct_web_filter_unref)

/**
 * mct_web_filter_ref:
 * @filter: (transfer none): a web filter
 *
 * Increment the reference count of @filter, and return the same pointer to it.
 *
 * Returns: (transfer full): the same pointer as @filter
 * Since: 0.14.0
 */
MctWebFilter *
mct_web_filter_ref (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, NULL);
  g_return_val_if_fail (filter->ref_count >= 1, NULL);
  g_return_val_if_fail (filter->ref_count <= G_MAXINT - 1, NULL);

  filter->ref_count++;
  return filter;
}

/**
 * mct_web_filter_unref:
 * @filter: (transfer full): a web filter
 *
 * Decrement the reference count of @filter. If the reference count reaches
 * zero, free the @filter and all its resources.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_unref (MctWebFilter *filter)
{
  g_return_if_fail (filter != NULL);
  g_return_if_fail (filter->ref_count >= 1);

  filter->ref_count--;

  if (filter->ref_count <= 0)
    {
      g_clear_pointer (&filter->block_lists, g_hash_table_unref);
      g_clear_pointer (&filter->custom_block_list, g_strfreev);
      g_clear_pointer (&filter->allow_lists, g_hash_table_unref);
      g_clear_pointer (&filter->custom_allow_list, g_strfreev);

      g_free (filter);
    }
}

/**
 * mct_web_filter_get_user_id:
 * @filter: a web filter
 *
 * Get the user ID of the user this [struct@Malcontent.WebFilter] is for.
 *
 * Returns: user ID of the relevant user, or `(uid_t) -1` if unknown
 * Since: 0.14.0
 */
uid_t
mct_web_filter_get_user_id (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, (uid_t) -1);
  g_return_val_if_fail (filter->ref_count >= 1, (uid_t) -1);

  return filter->user_id;
}

/**
 * mct_web_filter_is_enabled:
 * @filter: a web filter
 *
 * Check whether any web filtering is enabled and is going to impose at least
 * one restriction on the user.
 *
 * This gives a high level view of whether web filter parental controls are
 * ‘enabled’ for the given user.
 *
 * Returns: true if the web filter object contains at least one restrictive
 *    filter, false if there are no filters in place
 * Since: 0.14.0
 */
gboolean
mct_web_filter_is_enabled (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, FALSE);
  g_return_val_if_fail (filter->ref_count >= 1, FALSE);

  return (filter->force_safe_search ||
          (filter->filter_type != MCT_WEB_FILTER_TYPE_NONE &&
           (filter->block_lists != NULL ||
            filter->custom_block_list_len > 0 ||
            filter->allow_lists != NULL ||
            filter->custom_allow_list_len > 0)));
}

/**
 * mct_web_filter_get_filter_type:
 * @filter: a web filter
 *
 * Gets the type of web filter.
 *
 * Returns: the currently active filter type
 * Since: 0.14.0
 */
MctWebFilterType
mct_web_filter_get_filter_type (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, MCT_WEB_FILTER_TYPE_NONE);
  g_return_val_if_fail (filter->ref_count >= 1, MCT_WEB_FILTER_TYPE_NONE);

  return filter->filter_type;
}

/**
 * mct_web_filter_get_block_lists:
 * @filter: a web filter
 *
 * Gets the block lists configured on the filter.
 *
 * These are a mapping from ID to the URI of the block list. See the
 * [struct@Malcontent.WebFilter] documentation for allowed ID values.
 *
 * A `NULL` return value is equivalent to an empty mapping.
 *
 * Returns: (element-type utf8 utf8) (nullable) (transfer none): mapping of ID
 *   to URI for block lists
 * Since: 0.14.0
 */
GHashTable *
mct_web_filter_get_block_lists (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, NULL);
  g_return_val_if_fail (filter->ref_count >= 1, NULL);

  return filter->block_lists;
}

/**
 * mct_web_filter_get_custom_block_list:
 * @filter: a web filter
 * @out_len: (out caller-allocates) (optional): return location for the array
 *   length, or `NULL` to ignore
 *
 * Gets the custom block list configured on the filter.
 *
 * This is an array of hostnames to block. Hostnames are plain strings, not
 * globs or regexps.
 *
 * A `NULL` return value is equivalent to an empty array.
 *
 * Returns: (array length=out_len) (nullable) (transfer none): array of
 *   hostnames to block
 * Since: 0.14.0
 */
const char * const *
mct_web_filter_get_custom_block_list (MctWebFilter *filter,
                                      size_t       *out_len)
{
  g_return_val_if_fail (filter != NULL, NULL);
  g_return_val_if_fail (filter->ref_count >= 1, NULL);

  if (out_len != NULL)
    *out_len = filter->custom_block_list_len;

  return (const char * const *) filter->custom_block_list;
}

/**
 * mct_web_filter_get_allow_lists:
 * @filter: a web filter
 *
 * Get the allow lists configured on the filter.
 *
 * These are a mapping from ID to the URI of the allow list. See the
 * [struct@Malcontent.WebFilter] documentation for allowed ID values.
 *
 * A `NULL` return value is equivalent to an empty mapping.
 *
 * Returns: (element-type utf8 utf8) (nullable) (transfer none): mapping of ID
 *   to URI for allow lists
 * Since: 0.14.0
 */
GHashTable *
mct_web_filter_get_allow_lists (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, NULL);
  g_return_val_if_fail (filter->ref_count >= 1, NULL);

  return filter->allow_lists;
}

/**
 * mct_web_filter_get_custom_allow_list:
 * @filter: a web filter
 * @out_len: (out caller-allocates) (optional): return location for the array
 *   length, or `NULL` to ignore
 *
 * Gets the custom allow list configured on the filter.
 *
 * This is an array of hostnames to allow. Hostnames are plain strings, not
 * globs or regexps.
 *
 * A `NULL` return value is equivalent to an empty array.
 *
 * Returns: (array length=out_len) (nullable) (transfer none): array of
 *   hostnames to allow
 * Since: 0.14.0
 */
const char * const *
mct_web_filter_get_custom_allow_list (MctWebFilter *filter,
                                      size_t       *out_len)
{
  g_return_val_if_fail (filter != NULL, NULL);
  g_return_val_if_fail (filter->ref_count >= 1, NULL);

  if (out_len != NULL)
    *out_len = filter->custom_allow_list_len;

  return (const char * const *) filter->custom_allow_list;
}

/**
 * mct_web_filter_get_force_safe_search:
 * @filter: a web filter
 *
 * Gets the safe search preference for the filter.
 *
 * If enabled, search engines and other popular websites will be automatically
 * redirected to their ‘safe search’ variant, if supported.
 *
 * Returns: true if safe search is force-enabled, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_get_force_safe_search (MctWebFilter *filter)
{
  g_return_val_if_fail (filter != NULL, FALSE);
  g_return_val_if_fail (filter->ref_count >= 1, FALSE);

  return filter->force_safe_search;
}

static GVariant *
hash_table_to_ass (GHashTable *table)
{
  g_auto(GVariantBuilder) builder = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE ("a{ss}"));
  GHashTableIter iter;
  void *key, *value;

  g_hash_table_iter_init (&iter, table);

  while (g_hash_table_iter_next (&iter, &key, &value))
    {
      const char *key_str = key, *value_str = value;

      g_variant_builder_add (&builder, "{ss}", key_str, value_str);
    }

  return g_variant_builder_end (&builder);
}

/**
 * mct_web_filter_validate_filter_id:
 * @id: a potential filter list ID
 *
 * Validate a potential filter list ID.
 *
 * Filter list IDs must be non-empty UTF-8 strings.
 *
 * Returns: true if @id is a valid filter list ID, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_validate_filter_id (const char *id)
{
  g_return_val_if_fail (id != NULL, FALSE);
  return (id[0] != '\0');
}

static gboolean
validate_filter_uri (const char *filter_uri)
{
  g_return_val_if_fail (filter_uri != NULL, FALSE);
  return g_uri_is_valid (filter_uri, G_URI_FLAGS_NONE, NULL);
}

/**
 * mct_web_filter_validate_hostname:
 * @hostname: a potential hostname
 *
 * Validate a potential hostname.
 *
 * This checks against [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035).
 *
 * See [func@Malcontent.WebFilter.validate_domain_name] for validating
 * domain names instead. Domain names are entries in the DNS database, hostnames
 * are website addresses. Every hostname is a domain name, but not vice-versa.
 *
 * Returns: true if @hostname is a valid hostname, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_validate_hostname (const char *hostname)
{
  return mct_web_filter_validate_hostname_len (hostname, G_MAXSIZE);
}

/**
 * mct_web_filter_validate_hostname_len:
 * @hostname: a potential hostname
 * @max_len: length (in bytes) to check, or until the first nul byte is reached
 *
 * Validate a potential hostname.
 *
 * This checks against [RFC 1035](https://datatracker.ietf.org/doc/html/rfc1035).
 *
 * See [func@Malcontent.WebFilter.validate_domain_name_len] for validating
 * domain names instead. Domain names are entries in the DNS database, hostnames
 * are website addresses. Every hostname is a domain name, but not vice-versa.
 *
 * Returns: true if @hostname is a valid hostname, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_validate_hostname_len (const char *hostname,
                                      size_t      max_len)
{
  size_t i;
  size_t start_of_label = 0;

  g_return_val_if_fail (hostname != NULL, FALSE);

  /* A valid hostname must:
   *  - Be ≤253 characters long (https://web.archive.org/web/20190518124533/https://devblogs.microsoft.com/oldnewthing/?p=7873)
   *  - Have labels 1≤x≤63 characters long
   *  - Only contain characters A-Z, a-z, 0-9, `.` and `-`
   *  - Not have a label which starts or ends with a hyphen
   *  - Be non-empty
   *
   * See https://datatracker.ietf.org/doc/html/rfc1035
   */
  for (i = 0; hostname[i] != '\0' && i < max_len; i++)
    {
      if (!g_ascii_isalnum (hostname[i]) &&
          hostname[i] != '.' &&
          hostname[i] != '-')
        return FALSE;

      if (hostname[i] == '.')
        {
          if (i - start_of_label == 0 ||
              i - start_of_label > 63)
            return FALSE;

          if (hostname[start_of_label] == '-' ||
              hostname[i - 1] == '-')
            return FALSE;

          start_of_label = i + 1;
        }
    }

  /* Do the checks for the final label, if the hostname didn’t end in a `.` */
  if (i > 0 && hostname[i - 1] != '.')
    {
      if (i - start_of_label == 0 ||
          i - start_of_label > 63)
        return FALSE;

      if (hostname[start_of_label] == '-' ||
          hostname[i - 1] == '-')
        return FALSE;
    }

  if (i > 0 && i <= 253)
    {
      /* Double check against domain name validity, as hostnames are meant to be
       * a subset of domain names. */
      g_assert (mct_web_filter_validate_domain_name_len (hostname, max_len));
      return TRUE;
    }

  return FALSE;
}

/**
 * mct_web_filter_validate_domain_name:
 * @domain_name: a potential domain name
 *
 * Validate a potential domain name.
 *
 * This checks against [RFC 2181](https://datatracker.ietf.org/doc/html/rfc2181).
 *
 * See [func@Malcontent.WebFilter.validate_hostname] for validating
 * hostnames instead. Domain names are entries in the DNS database, hostnames
 * are website addresses. Every hostname is a domain name, but not vice-versa.
 *
 * Returns: true if @domain_name is a valid domain name, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_validate_domain_name (const char *domain_name)
{
  return mct_web_filter_validate_domain_name_len (domain_name, G_MAXSIZE);
}

/**
 * mct_web_filter_validate_domain_name_len:
 * @domain_name: a potential domain_name
 * @max_len: length (in bytes) to check, or until the first nul byte is reached
 *
 * Validate a potential domain name.
 *
 * This checks against [RFC 2181](https://datatracker.ietf.org/doc/html/rfc2181).
 *
 * See [func@Malcontent.WebFilter.validate_hostname_len] for validating
 * hostnames instead. Domain names are entries in the DNS database, hostnames
 * are website addresses. Every hostname is a domain name, but not vice-versa.
 *
 * Returns: true if @domain_name is a valid domain_name, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_validate_domain_name_len (const char *domain_name,
                                         size_t      max_len)
{
  size_t i;
  size_t start_of_label = 0;

  g_return_val_if_fail (domain_name != NULL, FALSE);

  /* A valid domain_name must:
   *  - Be ≤253 characters long (https://web.archive.org/web/20190518124533/https://devblogs.microsoft.com/oldnewthing/?p=7873)
   *  - Have labels 1≤x≤63 characters long (https://datatracker.ietf.org/doc/html/rfc2181#section-11)
   *  - Be non-empty
   *
   * In addition, we impose the requirements that each octet is a valid ASCII
   * character (i.e. non-nul and <128) because nobody reasonably does anything
   * else, and dealing with the byte strings which result from allowing other
   * octets would be a recipe for bugs.
   *
   * See https://datatracker.ietf.org/doc/html/rfc2181
   */
  for (i = 0; domain_name[i] != '\0' && i < max_len; i++)
    {
      if (domain_name[i] == 0 ||
          (unsigned char) domain_name[i] >= 128)
        return FALSE;

      if (domain_name[i] == '.')
        {
          if (i - start_of_label == 0 ||
              i - start_of_label > 63)
            return FALSE;

          start_of_label = i + 1;
        }
    }

  /* Do the checks for the final label, if the domain_name didn’t end in a `.` */
  if (i > 0 && domain_name[i - 1] != '.')
    {
      if (i - start_of_label == 0 ||
          i - start_of_label > 63)
        return FALSE;
    }

  return (i > 0 && i <= 253);
}

static GHashTable *
ass_to_hash_table (GVariant  *ass,
                   uid_t      user_id,
                   GError   **error)
{
  GVariantIter iter;
  const char *filter_list_id, *filter_uri;
  g_autoptr(GHashTable) table = NULL;

  g_variant_iter_init (&iter, ass);
  table = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);

  while (g_variant_iter_loop (&iter, "{&s&s}", &filter_list_id, &filter_uri))
    {
      if (!mct_web_filter_validate_filter_id (filter_list_id) ||
          !validate_filter_uri (filter_uri))
        {
          g_set_error (error, MCT_MANAGER_ERROR,
                       MCT_MANAGER_ERROR_INVALID_DATA,
                       _("Web filter for user %u references an invalid filter list ‘%s’ at ‘%s’"),
                       (unsigned int) user_id, filter_list_id, filter_uri);
          return NULL;
        }

      g_hash_table_replace (table, g_strdup (filter_list_id), g_strdup (filter_uri));
    }

  return g_steal_pointer (&table);
}

static GVariant *
strv_to_as (const char * const *strv,
            size_t              strv_len)
{
  g_auto(GVariantBuilder) builder = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE ("as"));

  for (size_t i = 0; i < strv_len; i++)
    g_variant_builder_add (&builder, "s", strv[i]);

  return g_variant_builder_end (&builder);
}

static char **
as_to_strv (GVariant  *as,
            size_t    *out_strv_len,
            uid_t      user_id,
            GError   **error)
{
  GVariantIter iter;
  const char *filter_list_entry;
  g_autoptr(GPtrArray) array = NULL;

  g_variant_iter_init (&iter, as);
  array = g_ptr_array_new_null_terminated (g_variant_n_children (as), g_free, TRUE);

  while (g_variant_iter_loop (&iter, "&s", &filter_list_entry))
    {
      if (!mct_web_filter_validate_hostname (filter_list_entry))
        {
          g_set_error (error, MCT_MANAGER_ERROR,
                       MCT_MANAGER_ERROR_INVALID_DATA,
                       _("Web filter for user %u contains an invalid entry ‘%s’"),
                       (unsigned int) user_id, filter_list_entry);
          if (out_strv_len != NULL)
            *out_strv_len = 0;
          return NULL;
        }

      g_ptr_array_add (array, g_strdup (filter_list_entry));
    }

  g_ptr_array_sort_values (array, (GCompareFunc) g_strcmp0);

  if (out_strv_len != NULL)
    *out_strv_len = array->len;

  return (char **) g_ptr_array_steal (array, out_strv_len);
}

/**
 * mct_web_filter_serialize:
 * @filter: a web filter
 *
 * Build a [struct@GLib.Variant] which contains the web filter from @filter, in
 * an opaque variant format.
 *
 * This format may change in future, but [func@Malcontent.WebFilter.deserialize]
 * is guaranteed to always be able to load any variant produced by the current
 * or any previous version of [method@Malcontent.WebFilter.serialize].
 *
 * Returns: (transfer floating): a new, floating [struct@GLib.Variant]
 *   containing the web filter
 * Since: 0.14.0
 */
GVariant *
mct_web_filter_serialize (MctWebFilter *filter)
{
  g_auto(GVariantBuilder) builder = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE ("a{sv}"));

  g_return_val_if_fail (filter != NULL, NULL);
  g_return_val_if_fail (filter->ref_count >= 1, NULL);

  /* The serialisation format is exactly the
   * `org.freedesktop.Malcontent.WebFilter` D-Bus interface. */
  g_variant_builder_add (&builder, "{sv}", "FilterType",
                         g_variant_new_uint32 (filter->filter_type));
  if (filter->block_lists != NULL)
    g_variant_builder_add (&builder, "{sv}", "BlockLists",
                           hash_table_to_ass (filter->block_lists));
  if (filter->custom_block_list != NULL)
    g_variant_builder_add (&builder, "{sv}", "CustomBlockList",
                           strv_to_as ((const char * const *) filter->custom_block_list, filter->custom_block_list_len));
  if (filter->allow_lists != NULL)
    g_variant_builder_add (&builder, "{sv}", "AllowLists",
                           hash_table_to_ass (filter->allow_lists));
  if (filter->custom_allow_list != NULL)
    g_variant_builder_add (&builder, "{sv}", "CustomAllowList",
                           strv_to_as ((const char * const *) filter->custom_allow_list, filter->custom_allow_list_len));
  g_variant_builder_add (&builder, "{sv}", "ForceSafeSearch",
                         g_variant_new_boolean (filter->force_safe_search));

  return g_variant_builder_end (&builder);
}

/**
 * mct_web_filter_deserialize:
 * @variant: a serialized web filter variant
 * @user_id: the ID of the user the web filter relates to
 * @error: return location for a [type@GLib.Error], or `NULL`
 *
 * Deserialize a set of web filters previously serialized with
 * [method@Malcontent.WebFilter.serialize].
 *
 * This function guarantees to be able to deserialize any serialized form from
 * this version or older versions of libmalcontent.
 *
 * If deserialization fails, [error@Malcontent.ManagerError.INVALID_DATA] will
 * be returned.
 *
 * Returns: (transfer full): deserialized web filter
 * Since: 0.14.0
 */
MctWebFilter *
mct_web_filter_deserialize (GVariant  *variant,
                            uid_t      user_id,
                            GError   **error)
{
  g_autoptr(MctWebFilter) web_filter = NULL;
  guint32 filter_type;
  g_autoptr(GHashTable) block_lists = NULL, allow_lists = NULL;  /* (element-type utf8 utf8) */
  g_auto(GStrv) custom_block_list = NULL, custom_allow_list = NULL;
  size_t custom_block_list_len = 0, custom_allow_list_len = 0;
  g_autoptr(GVariant) block_lists_variant = NULL;
  g_autoptr(GVariant) custom_block_list_variant = NULL;
  g_autoptr(GVariant) allow_lists_variant = NULL;
  g_autoptr(GVariant) custom_allow_list_variant = NULL;
  gboolean force_safe_search = FALSE;
  g_autoptr(GError) local_error = NULL;

  g_return_val_if_fail (variant != NULL, NULL);
  g_return_val_if_fail (error == NULL || *error == NULL, NULL);

  /* Check the overall type. */
  if (!g_variant_is_of_type (variant, G_VARIANT_TYPE ("a{sv}")))
    {
      g_set_error (error, MCT_MANAGER_ERROR,
                   MCT_MANAGER_ERROR_INVALID_DATA,
                   _("Web filter for user %u was in an unrecognized format"),
                   (unsigned int) user_id);
      return NULL;
    }

  /* Extract the properties we care about. The default values here should be
   * kept in sync with those in the `org.freedesktop.Malcontent.WebFilter`
   * D-Bus interface. */
  if (!g_variant_lookup (variant, "FilterType", "u",
                         &filter_type))
    {
      /* Default value. */
      filter_type = MCT_WEB_FILTER_TYPE_NONE;
    }

  /* Check that the filter type is something we support. */
  G_STATIC_ASSERT (sizeof (filter_type) >= sizeof (MctWebFilterType));

  if ((unsigned int) filter_type > MCT_WEB_FILTER_TYPE_ALLOWLIST)
    {
      g_set_error (error, MCT_MANAGER_ERROR,
                   MCT_MANAGER_ERROR_INVALID_DATA,
                   _("Web filter for user %u has an unrecognized type ‘%u’"),
                   (unsigned int) user_id, filter_type);
      return NULL;
    }

  if (g_variant_lookup (variant, "BlockLists", "@a{ss}", &block_lists_variant))
    {
      block_lists = ass_to_hash_table (block_lists_variant, user_id, error);
      if (block_lists == NULL)
        return NULL;
    }
  else
    {
      /* Default value. */
      block_lists = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
    }

  if (g_variant_lookup (variant, "CustomBlockList", "@as", &custom_block_list_variant))
    {
      custom_block_list = as_to_strv (custom_block_list_variant, &custom_block_list_len, user_id, &local_error);
      if (local_error != NULL)
        {
          g_propagate_error (error, g_steal_pointer (&local_error));
          return NULL;
        }
    }
  else
    {
      /* Default value. */
      custom_block_list = NULL;
      custom_block_list_len = 0;
    }

  if (g_variant_lookup (variant, "AllowLists", "@a{ss}", &allow_lists_variant))
    {
      allow_lists = ass_to_hash_table (allow_lists_variant, user_id, error);
      if (allow_lists == NULL)
        return NULL;
    }
  else
    {
      /* Default value. */
      allow_lists = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);
    }

  if (g_variant_lookup (variant, "CustomAllowList", "@as", &custom_allow_list_variant))
    {
      custom_allow_list = as_to_strv (custom_allow_list_variant, &custom_allow_list_len, user_id, &local_error);
      if (local_error != NULL)
        {
          g_propagate_error (error, g_steal_pointer (&local_error));
          return NULL;
        }
    }
  else
    {
      /* Default value. */
      custom_allow_list = NULL;
      custom_allow_list_len = 0;
    }

  if (!g_variant_lookup (variant, "ForceSafeSearch", "b", &force_safe_search))
    {
      /* Default value. */
      force_safe_search = FALSE;
    }

  /* Success. Create an `MctWebFilter` object to contain the results. */
  web_filter = g_new0 (MctWebFilter, 1);
  web_filter->ref_count = 1;
  web_filter->user_id = user_id;
  web_filter->filter_type = filter_type;
  web_filter->block_lists = g_steal_pointer (&block_lists);
  web_filter->custom_block_list = g_steal_pointer (&custom_block_list);
  web_filter->custom_block_list_len = custom_block_list_len;
  web_filter->allow_lists = g_steal_pointer (&allow_lists);
  web_filter->custom_allow_list = g_steal_pointer (&custom_allow_list);
  web_filter->custom_allow_list_len = custom_allow_list_len;
  web_filter->force_safe_search = force_safe_search;

  return g_steal_pointer (&web_filter);
}

static gboolean
hash_table_equal0 (GHashTable *a,
                   GHashTable *b,
                   GEqualFunc  value_equal_func)
{
  GHashTableIter iter;
  void *key, *a_value, *b_value;

  if (a == NULL && b == NULL)
    return TRUE;
  else if (a == NULL || b == NULL)
    return FALSE;

  if (g_hash_table_size (a) != g_hash_table_size (b))
    return FALSE;

  g_hash_table_iter_init (&iter, a);

  while (g_hash_table_iter_next (&iter, &key, &a_value))
    {
      if (!g_hash_table_lookup_extended (b, key, NULL, &b_value))
        return FALSE;

      if (!value_equal_func (a_value, b_value))
        return FALSE;
    }

  return TRUE;
}

static gboolean
strv_equal0 (const char * const *a,
             const char * const *b)
{
  if (a == NULL && b == NULL)
    return TRUE;
  else if (a == NULL || b == NULL)
    return FALSE;

  return g_strv_equal (a, b);
}

/**
 * mct_web_filter_equal:
 * @a: (not nullable): a web filter
 * @b: (not nullable): a web filter
 *
 * Check whether web filters @a and @b are equal.
 *
 * Returns: true if @a and @b are equal, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_web_filter_equal (MctWebFilter *a,
                      MctWebFilter *b)
{
  g_return_val_if_fail (a != NULL, FALSE);
  g_return_val_if_fail (a->ref_count >= 1, FALSE);
  g_return_val_if_fail (b != NULL, FALSE);
  g_return_val_if_fail (b->ref_count >= 1, FALSE);

  return (a->user_id == b->user_id &&
          a->filter_type == b->filter_type &&
          hash_table_equal0 (a->block_lists, b->block_lists, g_str_equal) &&
          a->custom_block_list_len == b->custom_block_list_len &&
          strv_equal0 ((const char * const *) a->custom_block_list,
                       (const char * const *) b->custom_block_list) &&
          hash_table_equal0 (a->allow_lists, b->allow_lists, g_str_equal) &&
          a->custom_allow_list_len == b->custom_allow_list_len &&
          strv_equal0 ((const char * const *) a->custom_allow_list,
                       (const char * const *) b->custom_allow_list) &&
          a->force_safe_search == b->force_safe_search);
}

/*
 * Actual implementation of `MctWebFilterBuilder`.
 *
 * All members are `NULL` if un-initialised, cleared, or ended.
 */
typedef struct
{
  MctWebFilterType filter_type;

  GHashTable *block_lists;  /* (nullable) (owned) */
  GPtrArray *custom_block_list;  /* (nullable) (owned) */
  GHashTable *allow_lists;  /* (nullable) (owned) */
  GPtrArray *custom_allow_list;  /* (nullable) (owned) */

  gboolean force_safe_search;

  /*< private >*/
  gpointer padding[10];
} MctWebFilterBuilderReal;

G_STATIC_ASSERT (sizeof (MctWebFilterBuilderReal) ==
                 sizeof (MctWebFilterBuilder));
G_STATIC_ASSERT (__alignof__ (MctWebFilterBuilderReal) ==
                 __alignof__ (MctWebFilterBuilder));

G_DEFINE_BOXED_TYPE (MctWebFilterBuilder, mct_web_filter_builder,
                     mct_web_filter_builder_copy, mct_web_filter_builder_free)

/**
 * mct_web_filter_builder_init:
 * @builder: an uninitialised [struct@Malcontent.WebFilterBuilder]
 *
 * Initialise the given @builder so it can be used to construct a new
 * [struct@Malcontent.WebFilter].
 *
 * @builder must have been allocated on the stack, and must not already be
 * initialised.
 *
 * Construct the [struct@Malcontent.WebFilter] by calling methods on @builder,
 * followed by [method@Malcontent.WebFilterBuilder.end]. To abort construction,
 * use [method@Malcontent.WebFilterBuilder.clear].
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_init (MctWebFilterBuilder *builder)
{
  MctWebFilterBuilder local_builder = MCT_WEB_FILTER_BUILDER_INIT ();
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);
  g_return_if_fail (_builder->filter_type == MCT_WEB_FILTER_TYPE_NONE);

  memcpy (builder, &local_builder, sizeof (local_builder));
}

/**
 * mct_web_filter_builder_clear:
 * @builder: a web filter builder
 *
 * Clear @builder, freeing any internal state in it.
 *
 * This will not free the top-level storage for @builder itself, which is
 * assumed to be allocated on the stack.
 *
 * If called on an already-cleared [struct@Malcontent.WebFilterBuilder], this
 * function is idempotent.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_clear (MctWebFilterBuilder *builder)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);

  g_clear_pointer (&_builder->block_lists, g_hash_table_unref);
  g_clear_pointer (&_builder->custom_block_list, g_ptr_array_unref);
  g_clear_pointer (&_builder->allow_lists, g_hash_table_unref);
  g_clear_pointer (&_builder->custom_allow_list, g_ptr_array_unref);

  _builder->filter_type = MCT_WEB_FILTER_TYPE_NONE;
  _builder->force_safe_search = FALSE;
}

/**
 * mct_web_filter_builder_new:
 *
 * Construct a new [struct@Malcontent.WebFilterBuilder] on the heap.
 *
 * This is intended for language bindings. The returned builder must eventually
 * be freed with [method@Malcontent.WebFilterBuilder.free], but can be cleared
 * zero or more times with [method@Malcontent.WebFilterBuilder.clear] first.
 *
 * Returns: (transfer full): a new heap-allocated
 *   [struct@Malcontent.WebFilterBuilder]
 * Since: 0.14.0
 */
MctWebFilterBuilder *
mct_web_filter_builder_new (void)
{
  g_autoptr(MctWebFilterBuilder) builder = NULL;

  builder = g_new0 (MctWebFilterBuilder, 1);
  mct_web_filter_builder_init (builder);

  return g_steal_pointer (&builder);
}

static void *
identity_copy (const void *src,
               void *user_data)
{
  return (void *) src;
}

static GHashTable *
hash_table_copy (GHashTable *src,
                 GCopyFunc   key_copy_func,
                 void       *key_copy_user_data,
                 GCopyFunc   value_copy_func,
                 void       *value_copy_user_data)
{
  GHashTableIter iter;
  void *key, *value;
  g_autoptr(GHashTable) dest = NULL;

  g_assert (src != NULL);

  g_hash_table_iter_init (&iter, src);
  dest = g_hash_table_new_similar (src);

  if (key_copy_func == NULL)
    key_copy_func = identity_copy;
  if (value_copy_func == NULL)
    value_copy_func = identity_copy;

  while (g_hash_table_iter_next (&iter, &key, &value))
    {
      void *key_copy, *value_copy;

      key_copy = key_copy_func (key, key_copy_user_data);
      value_copy = value_copy_func (value, value_copy_user_data);

      g_hash_table_insert (dest, g_steal_pointer (&key_copy), g_steal_pointer (&value_copy));
    }

  return g_steal_pointer (&dest);
}

static void *
str_copy (const void *str,
          void       *user_data)
{
  return g_strdup (str);
}

/**
 * mct_web_filter_builder_copy:
 * @builder: a web filter builder
 *
 * Copy the given @builder to a newly-allocated
 * [struct@Malcontent.WebFilterBuilder] on the heap.
 *
 * This is safe to use with cleared, stack-allocated
 * [struct@Malcontent.WebFilterBuilder]s.
 *
 * Returns: (transfer full): a copy of @builder
 * Since: 0.14.0
 */
MctWebFilterBuilder *
mct_web_filter_builder_copy (MctWebFilterBuilder *builder)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;
  g_autoptr(MctWebFilterBuilder) copy = NULL;
  MctWebFilterBuilderReal *_copy;

  g_return_val_if_fail (builder != NULL, NULL);

  copy = mct_web_filter_builder_new ();
  _copy = (MctWebFilterBuilderReal *) copy;

  mct_web_filter_builder_clear (copy);
  _copy->filter_type = _builder->filter_type;
  _copy->block_lists = (_builder->block_lists != NULL) ? hash_table_copy (_builder->block_lists, str_copy, NULL, str_copy, NULL) : NULL;
  _copy->custom_block_list = (_builder->custom_block_list != NULL) ? g_ptr_array_copy (_builder->custom_block_list, str_copy, NULL) : NULL;
  _copy->allow_lists = (_builder->allow_lists != NULL) ? hash_table_copy (_builder->allow_lists, str_copy, NULL, str_copy, NULL) : NULL;
  _copy->custom_block_list = (_builder->custom_block_list != NULL) ? g_ptr_array_copy (_builder->custom_block_list, str_copy, NULL) : NULL;
  _copy->force_safe_search = _builder->force_safe_search;

  return g_steal_pointer (&copy);
}

/**
 * mct_web_filter_builder_free:
 * @builder: a heap-allocated [struct@Malcontent.WebFilterBuilder]
 *
 * Free an [struct@Malcontent.WebFilterBuilder] originally allocated using
 * [ctor@Malcontent.WebFilterBuilder.new].
 *
 * This must not be called on stack-allocated builders initialised using
 * [method@Malcontent.WebFilterBuilder.init].
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_free (MctWebFilterBuilder *builder)
{
  g_return_if_fail (builder != NULL);

  mct_web_filter_builder_clear (builder);
  g_free (builder);
}

/**
 * mct_web_filter_builder_end:
 * @builder: an initialised [struct@Malcontent.WebFilterBuilder]
 *
 * Finish constructing an [struct@Malcontent.WebFilter] with the given @builder,
 * and return it.
 *
 * The [struct@Malcontent.WebFilterBuilder] will be cleared as if
 * [method@Malcontent.WebFilterBuilder.clear] had been called.
 *
 * Returns: (transfer full): a newly constructed [struct@Malcontent.WebFilter]
 * Since: 0.14.0
 */
MctWebFilter *
mct_web_filter_builder_end (MctWebFilterBuilder *builder)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;
  g_autoptr(MctWebFilter) web_filter = NULL;

  g_return_val_if_fail (_builder != NULL, NULL);

  /* Build the `MctWebFilter`. */
  web_filter = g_new0 (MctWebFilter, 1);
  web_filter->ref_count = 1;
  web_filter->user_id = -1;
  web_filter->filter_type = _builder->filter_type;

  if (_builder->custom_block_list != NULL)
    g_ptr_array_sort_values (_builder->custom_block_list, (GCompareFunc) g_strcmp0);
  if (_builder->custom_allow_list != NULL)
    g_ptr_array_sort_values (_builder->custom_allow_list, (GCompareFunc) g_strcmp0);

  web_filter->block_lists = (_builder->block_lists != NULL) ? g_hash_table_ref (_builder->block_lists) : NULL;
  web_filter->custom_block_list = (_builder->custom_block_list != NULL) ? (char **) g_ptr_array_steal (_builder->custom_block_list, &web_filter->custom_block_list_len) : NULL;
  web_filter->allow_lists = (_builder->allow_lists != NULL) ? g_hash_table_ref (_builder->allow_lists) : NULL;
  web_filter->custom_allow_list = (_builder->custom_allow_list != NULL) ? (char **) g_ptr_array_steal (_builder->custom_allow_list, &web_filter->custom_allow_list_len) : NULL;

  web_filter->force_safe_search = _builder->force_safe_search;

  mct_web_filter_builder_clear (builder);

  return g_steal_pointer (&web_filter);
}

/**
 * mct_web_filter_builder_set_filter_type:
 * @builder: an initialised [struct@Malcontent.WebFilterBuilder]
 * @filter_type: type of web filter
 *
 * Set the type of web filter to apply to the user.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_set_filter_type (MctWebFilterBuilder *builder,
                                        MctWebFilterType     filter_type)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);

  _builder->filter_type = filter_type;
}

/**
 * mct_web_filter_builder_add_block_list:
 * @builder: a web filter builder
 * @id: (not nullable): ID of the filter
 * @filter_uri: (not nullable): URI of the filter to download
 *
 * Adds a block list to the [struct@Malcontent.WebFilter], mapping the given @id
 * to a filter list downloadable at @filter_uri.
 *
 * All the entries at @filter_uri will be blocked. They must all be hostnames;
 * see the top-level documentation for [struct@Malcontent.WebFilter] for details
 * of the allowed filter list and ID formats.
 *
 * The filter list will be downloaded when the user’s web filter is compiled,
 * not when this function is called.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_add_block_list (MctWebFilterBuilder *builder,
                                       const char          *id,
                                       const char          *filter_uri)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);
  g_return_if_fail (mct_web_filter_validate_filter_id (id));
  g_return_if_fail (validate_filter_uri (filter_uri));

  if (_builder->block_lists == NULL)
    _builder->block_lists = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);

  g_hash_table_replace (_builder->block_lists, g_strdup (id), g_strdup (filter_uri));
}

/**
 * mct_web_filter_builder_add_custom_block_list_entry:
 * @builder: a web filter builder
 * @hostname: (not nullable): hostname to block
 *
 * Adds a single hostname to the [struct@Malcontent.WebFilter], to be blocked.
 *
 * See the top-level documentation for [struct@Malcontent.WebFilter] for details
 * of the allowed filter list and ID formats.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_add_custom_block_list_entry (MctWebFilterBuilder *builder,
                                                    const char          *hostname)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);
  g_return_if_fail (mct_web_filter_validate_hostname (hostname));

  if (_builder->custom_block_list == NULL)
    _builder->custom_block_list = g_ptr_array_new_null_terminated (0, g_free, TRUE);

  g_ptr_array_add (_builder->custom_block_list, g_strdup (hostname));
}

/**
 * mct_web_filter_builder_add_allow_list:
 * @builder: a web filter builder
 * @id: (not nullable): ID of the filter
 * @filter_uri: (not nullable): URI of the filter to download
 *
 * Adds a allow list to the [struct@Malcontent.WebFilter], mapping the given @id
 * to a filter list downloadable at @filter_uri.
 *
 * All the entries at @filter_uri will be allowed. They must all be hostnames;
 * see the top-level documentation for [struct@Malcontent.WebFilter] for details
 * of the allowed filter list and ID formats.
 *
 * The filter list will be downloaded when the user’s web filter is compiled,
 * not when this function is called.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_add_allow_list (MctWebFilterBuilder *builder,
                                       const char          *id,
                                       const char          *filter_uri)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);
  g_return_if_fail (mct_web_filter_validate_filter_id (id));
  g_return_if_fail (validate_filter_uri (filter_uri));

  if (_builder->allow_lists == NULL)
    _builder->allow_lists = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);

  g_hash_table_replace (_builder->allow_lists, g_strdup (id), g_strdup (filter_uri));
}

/**
 * mct_web_filter_builder_add_custom_allow_list_entry:
 * @builder: a web filter builder
 * @hostname: (not nullable): hostname to allow
 *
 * Adds a single hostname to the [struct@Malcontent.WebFilter], to be allowed.
 *
 * See the top-level documentation for [struct@Malcontent.WebFilter] for details
 * of the allowed filter list and ID formats.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_add_custom_allow_list_entry (MctWebFilterBuilder *builder,
                                                    const char          *hostname)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);
  g_return_if_fail (mct_web_filter_validate_hostname (hostname));

  if (_builder->custom_allow_list == NULL)
    _builder->custom_allow_list = g_ptr_array_new_null_terminated (0, g_free, TRUE);

  g_ptr_array_add (_builder->custom_allow_list, g_strdup (hostname));
}

/**
 * mct_web_filter_builder_set_force_safe_search:
 * @builder: a web filter builder
 * @force_safe_search: true to force safe search to be enabled, false otherwise
 *
 * Sets the safe search preference for the [struct@Malcontent.WebFilter].
 *
 * If enabled, search engines and other popular websites will be automatically
 * redirected to their ‘safe search’ variant, if supported.
 *
 * Since: 0.14.0
 */
void
mct_web_filter_builder_set_force_safe_search (MctWebFilterBuilder *builder,
                                              gboolean             force_safe_search)
{
  MctWebFilterBuilderReal *_builder = (MctWebFilterBuilderReal *) builder;

  g_return_if_fail (_builder != NULL);

  _builder->force_safe_search = force_safe_search;
}
