/* -*- mode: C; c-file-style: "gnu"; indent-tabs-mode: nil; -*-
 *
 * Copyright 2024, 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/gi18n-lib.h>
#include <glib-object.h>
#include <gio/gio.h>
#include <gvdb/gvdb-builder.h>
#include <gvdb/gvdb-reader.h>
#include <libmalcontent-timer/timer-store.h>


static const struct
  {
    const char *str;
  }
record_types[] =
  {
    [MCT_TIMER_STORE_RECORD_TYPE_LOGIN_SESSION] = {
      .str = "login-session",
    },
    [MCT_TIMER_STORE_RECORD_TYPE_APP] = {
      .str = "app",
    },
  };

/**
 * mct_timer_store_record_type_to_string:
 * @record_type: a record type
 *
 * Gets the string form of @record_type.
 *
 * Returns: string version of @record_type
 * Since: 0.14.0
 */
const char *
mct_timer_store_record_type_to_string (MctTimerStoreRecordType record_type)
{
  return record_types[record_type].str;
}

/**
 * mct_timer_store_record_type_from_string:
 * @str: a string representing a record type
 *
 * Converts @str to a [type@Malcontent.TimerStoreRecordType].
 *
 * It is an error to call this with a string which does not represent a
 * [type@Malcontent.TimerStoreRecordType].
 *
 * Returns: a record type
 * Since: 0.14.0
 */
MctTimerStoreRecordType
mct_timer_store_record_type_from_string (const char *str)
{
  for (size_t i = 0; i < G_N_ELEMENTS (record_types); i++)
    {
      if (g_str_equal (str, record_types[i].str))
        return (MctTimerStoreRecordType) i;
    }

  g_assert_not_reached ();
}

/**
 * mct_timer_store_record_type_validate_string:
 * @record_type_str: a string potentially representing a record type
 * @error: return location for an error, or `NULL` to ignore
 *
 * Validates whether @record_type_str is a valid
 * [type@Malcontent.TimerStoreRecordType].
 *
 * If @record_type_str is not a valid record type string,
 * [error@Gio.IOErrorEnum.INVALID_DATA] is returned.
 *
 * Returns: true if @record_type_str is valid, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_timer_store_record_type_validate_string (const char  *record_type_str,
                                             GError     **error)
{
  for (size_t i = 0; i < G_N_ELEMENTS (record_types); i++)
    {
      if (g_strcmp0 (record_types[i].str, record_type_str) == 0)
        return TRUE;
    }

  /* Couldn’t find it */
  g_set_error (error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
               _("Invalid record type ‘%s’"), record_type_str);
  return FALSE;
}

/**
 * mct_timer_store_record_type_validate_identifier:
 * @record_type: a record type
 * @identifier: identifier potentially in the format required by @record_type
 * @error: return location for an error, or `NULL` to ignore
 *
 * Validates whether @identifier is in a valid format for @record_type.
 *
 * Different record types have different identifier formats.
 *
 * If @identifier is not in a valid format for @record_type,
 * [error@Gio.IOErrorEnum.INVALID_DATA] is returned.
 *
 * Returns: true if @identifier is valid for @record_type, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_timer_store_record_type_validate_identifier (MctTimerStoreRecordType   record_type,
                                                 const char               *identifier,
                                                 GError                  **error)
{
  if ((record_type == MCT_TIMER_STORE_RECORD_TYPE_LOGIN_SESSION && *identifier != '\0') ||
      (record_type == MCT_TIMER_STORE_RECORD_TYPE_APP && !g_application_id_is_valid (identifier)))
    {
      g_set_error (error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
                   _("Invalid identifier ‘%s’"), identifier);
      return FALSE;
    }

  return TRUE;
}


static void mct_timer_store_constructed  (GObject      *object);
static void mct_timer_store_dispose      (GObject      *object);
static void mct_timer_store_finalize      (GObject      *object);

static void mct_timer_store_get_property (GObject      *object,
                                          guint         property_id,
                                          GValue        *value,
                                          GParamSpec   *pspec);
static void mct_timer_store_set_property (GObject      *object,
                                          guint         property_id,
                                          const GValue *value,
                                          GParamSpec   *pspec);

/**
 * MctTimerStore:
 *
 * A data store which contains screen time usage data for multiple users.
 *
 * It guarantees to be atomic for reads or writes on a single user’s data. Reads
 * or writes across multiple users are not atomic.
 *
 * A user’s store file has to be explicitly opened before it can be modified.
 * All modifications made to the file while it’s opened are queued up and
 * applied atomically when it’s closed. This allows the edits to be atomic, and
 * means errors are all handled in one place.
 *
 * It’s supported to have more than one user’s store file open simultaneously,
 * but simultaneous operations from two clients on the same user’s file are not
 * supported and will result in a [error@Gio.IOErrorEnum.BUSY] error.
 *
 * ## Format
 *
 * The database format is not part of the public API, but is documented here for
 * simplicity.
 *
 * There is one GVDB file per username, all stored in the same directory. Each
 * file is memory mapped for reads, and atomically overwritten when saved to.
 *
 * Inside each GVDB file is a table for each record type. Each entry in the
 * table is a mapping from an identifier (which may be the empty string) to a
 * variant of type `a(tt)`, which is the array of time spans. Each element in
 * the variant is conceptually a [struct@Malcontent.TimeSpan].
 *
 * When a GVDB file is written, the time spans are coalesced (so overlapping
 * time spans are combined), then trimmed against an expiry cutoff (so old data
 * expires) and sorted in increasing order of start (then end) time.
 *
 * There is no metadata header in these GVDB files. If the database format needs
 * to change in future, a new version can be put into a versioned subdirectory.
 *
 * Since: 0.14.0
 */
struct _MctTimerStore
{
  GObject parent;

  GFile *store_directory;  /* (owned) (not nullable) */

  /* Map from username to (
   *   map from MctTimerStoreRecordType to (
   *     map from identifier (utf8) to (
   *       array of MctTimeSpans
   *     )
   *   )
   * )
   */
  GHashTable *open_data;  /* (owned) (nullable) (element-type utf8 GHashTable<MctTimerStoreRecordType, GHashTable<utf8,GPtrArray<MctTimeSpan>>>) */
};

typedef enum
{
  PROP_STORE_DIRECTORY = 1,
} MctTimerStoreProperty;

static GParamSpec *props[PROP_STORE_DIRECTORY + 1] = { NULL, };

typedef enum
{
  SIGNAL_ESTIMATED_END_TIMES_CHANGED = 0,
} MctTimerStoreSignal;

static unsigned int signals[SIGNAL_ESTIMATED_END_TIMES_CHANGED + 1] = { 0, };

G_DEFINE_TYPE (MctTimerStore, mct_timer_store, G_TYPE_OBJECT)

static void
mct_timer_store_class_init (MctTimerStoreClass *klass)
{
  GObjectClass *object_class = (GObjectClass *) klass;

  object_class->constructed = mct_timer_store_constructed;
  object_class->dispose = mct_timer_store_dispose;
  object_class->finalize = mct_timer_store_finalize;
  object_class->get_property = mct_timer_store_get_property;
  object_class->set_property = mct_timer_store_set_property;

  /**
   * MctTimerStore:store-directory: (not nullable)
   *
   * The directory which contains the timer store.
   *
   * Since: 0.14.0
   */
  props[PROP_STORE_DIRECTORY] =
      g_param_spec_object ("store-directory", NULL, NULL,
                           G_TYPE_FILE,
                           G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS);

  g_object_class_install_properties (object_class, G_N_ELEMENTS (props), props);

  /**
   * MctTimerStore::estimated-end-times-changed:
   * @self: a #MctTimerStore
   * @username: name of the user for whom the estimated end times changed
   *
   * Emitted when any of the estimated end times for the given user might have
   * changed.
   *
   * Typically you will want to call
   * [method@Mct.TimerStore.calculate_total_times_between] on receiving this
   * signal.
   *
   * Since: 0.14.0
   */
  signals[SIGNAL_ESTIMATED_END_TIMES_CHANGED] =
      g_signal_new ("estimated-end-times-changed", G_TYPE_FROM_CLASS (klass),
                    G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL,
                    G_TYPE_NONE, 1, G_TYPE_STRING);
}

static void
mct_timer_store_init (MctTimerStore *self)
{
  self->open_data = g_hash_table_new_full (g_str_hash, g_str_equal,
                                           g_free, (GDestroyNotify) g_hash_table_unref);
}

static void
mct_timer_store_constructed (GObject *object)
{
  MctTimerStore *self = MCT_TIMER_STORE (object);

  G_OBJECT_CLASS (mct_timer_store_parent_class)->constructed (object);

  /* Check we have our construction properties. */
  g_assert (G_IS_FILE (self->store_directory));
}

static void
mct_timer_store_dispose (GObject *object)
{
  MctTimerStore *self = MCT_TIMER_STORE (object);

  g_clear_object (&self->store_directory);

  /* Chain up to the parent class */
  G_OBJECT_CLASS (mct_timer_store_parent_class)->dispose (object);
}

static void
mct_timer_store_finalize (GObject *object)
{
  MctTimerStore *self = MCT_TIMER_STORE (object);

  /* Should have been saved already. */
  g_assert (g_hash_table_size (self->open_data) == 0);
  g_clear_pointer (&self->open_data, g_hash_table_unref);

  /* Chain up to the parent class */
  G_OBJECT_CLASS (mct_timer_store_parent_class)->finalize (object);
}

static void
mct_timer_store_get_property (GObject    *object,
                              guint       property_id,
                              GValue     *value,
                              GParamSpec *pspec)
{
  MctTimerStore *self = MCT_TIMER_STORE (object);

  switch ((MctTimerStoreProperty) property_id)
    {
    case PROP_STORE_DIRECTORY:
      g_value_set_object (value, self->store_directory);
      break;
    default:
      g_assert_not_reached ();
    }
}

static void
mct_timer_store_set_property (GObject      *object,
                              guint         property_id,
                              const GValue *value,
                              GParamSpec   *pspec)
{
  MctTimerStore *self = MCT_TIMER_STORE (object);

  switch ((MctTimerStoreProperty) property_id)
    {
    case PROP_STORE_DIRECTORY:
      /* Construct only. */
      g_assert (self->store_directory == NULL);
      self->store_directory = g_value_dup_object (value);
      break;
    default:
      g_assert_not_reached ();
    }
}

/**
 * mct_timer_store_new:
 * @store_directory: (transfer none) (not nullable): directory containing the timer store
 *
 * Create a new [class@Malcontent.TimerStore] instance which stores its data in
 * @store_directory.
 *
 * Returns: (transfer full): a new [class@Malcontent.TimerStore]
 * Since: 0.14.0
 */
MctTimerStore *
mct_timer_store_new (GFile *store_directory)
{
  g_return_val_if_fail (G_IS_FILE (store_directory), NULL);

  return g_object_new (MCT_TYPE_TIMER_STORE,
                       "store-directory", store_directory,
                       NULL);
}

/**
 * mct_timer_store_get_store_directory:
 * @self: a [class@Malcontent.TimerStore]
 *
 * Get the value of [prop@Malcontent.TimerStore.store-directory].
 *
 * Returns: (transfer none) (not nullable): the directory storing the data for
 *    the timer store
 * Since: 0.14.0
 */
GFile *
mct_timer_store_get_store_directory (MctTimerStore *self)
{
  g_return_val_if_fail (MCT_IS_TIMER_STORE (self), NULL);

  return self->store_directory;
}

static gboolean
validate_username (const char *username)
{
  /* Ideally we should validate against the relaxed mode rules followed by
   * systemd (https://systemd.io/USER_NAMES/#relaxed-mode), but for the moment
   * all that we’re really concerned about is that the username can’t be used
   * for directory traversal. */
  return (username != NULL && *username != '\0' &&
          g_utf8_validate (username, -1, NULL) &&
          strchr (username, '.') == NULL &&
          strchr (username, '/') == NULL);
}

static GFile *
get_database_file_for_username (MctTimerStore *self,
                                const char    *username)
{
  g_autofree char *filename = g_strconcat (username, ".gvdb", NULL);
  return g_file_get_child (self->store_directory, filename);
}

/**
 * mct_timer_store_open_username_async:
 * @self: a [class@Malcontent.TimerStore]
 * @username: username to open the database file of
 * @cancellable: a cancellable
 * @callback: callback function to invoke once the asynchronous operation is
 *   complete
 * @user_data: data to pass to @callback
 *
 * Open the database file for @username for reading and writing.
 *
 * This is an asynchronous operation and must be completed by calling
 * [method@Malcontent.TimerStore.open_username_finish]. On success, it will
 * return a transaction handle which must be passed to exactly one of
 * [method@Malcontent.TimerStore.save_transaction_async] or
 * [method@Malcontent.TimerStore.roll_back_transaction] to commit or abort the
 * transaction once you’ve finished adding time spans or calculating for the
 * user.
 *
 * If this method returns an error, you do not need to call
 * [method@Malcontent.TimerStore.save_transaction_async] or
 * [method@Malcontent.TimerStore.roll_back_transaction].
 *
 * If no database file exists for @username, one is created.
 *
 * The asynchronous operation can return [error@GLib.FileError] if there was a
 * file system error opening an existing database file. In particular, if the
 * database file exists but is corrupt, [error@GLib.FileError.INVAL] will be
 * returned. If another caller already has the file open for @username,
 * [error@Gio.IOErrorEnum.BUSY] will be returned.
 *
 * Since: 0.14.0
 */
void
mct_timer_store_open_username_async (MctTimerStore       *self,
                                     const char          *username,
                                     GCancellable        *cancellable,
                                     GAsyncReadyCallback  callback,
                                     void                *user_data)
{
  g_autoptr(GTask) task = NULL;
  g_autoptr(GFile) database_file = NULL;
  g_autoptr(GvdbTable) database_table = NULL;
  g_autoptr(GError) local_error = NULL;
  g_autoptr(GHashTable) data = NULL;  /* (element-type MctTimerStoreRecordType GHashTable<utf8, GPtrArray<MctTimeSpan>>) */
  g_autofree char *transaction_key_owned = NULL;
  const char *transaction_key;

  g_return_if_fail (MCT_IS_TIMER_STORE (self));
  g_return_if_fail (validate_username (username));
  g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));

  task = g_task_new (self, cancellable, callback, user_data);
  g_task_set_source_tag (task, mct_timer_store_open_username_async);

  /* Shouldn’t already have this user open. */
  if (g_hash_table_contains (self->open_data, username))
    {
      g_task_return_new_error (task, G_IO_ERROR, G_IO_ERROR_BUSY,
                               _("Database is already open for ‘%s’"), username);
      return;
    }

  /* Load the existing file for this user. This is sync (memory mapped) for now,
   * but the timer store API allows for us to make it explicitly async in future
   * if needed. */
  database_file = get_database_file_for_username (self, username);
  database_table = gvdb_table_new (g_file_peek_path (database_file), TRUE, &local_error);

  if (database_table == NULL &&
      !g_error_matches (local_error, G_FILE_ERROR, G_FILE_ERROR_NOENT))
    {
      g_task_return_error (task, g_steal_pointer (&local_error));
      return;
    }

  data = g_hash_table_new_full (g_direct_hash, g_direct_equal,
                                NULL, (GDestroyNotify) g_hash_table_unref);

  for (size_t i = 0; i < G_N_ELEMENTS (record_types); i++)
    {
      const char *record_type = record_types[i].str;
      g_autoptr(GvdbTable) record_table = NULL;
      g_auto(GStrv) identifiers = NULL;
      GHashTable *record_data;  /* (element-type utf8 GPtrArray<MctTimeSpan>) */
      g_autoptr(GHashTable) record_data_owned = NULL;  /* (element-type utf8 GPtrArray<MctTimeSpan>) */

      record_table = (database_table != NULL) ? gvdb_table_get_table (database_table, record_type) : NULL;
      identifiers = (record_table != NULL) ? gvdb_table_get_names (record_table, NULL) : NULL;

      record_data = record_data_owned = g_hash_table_new_full (g_str_hash, g_str_equal,
                                                               g_free, (GDestroyNotify) g_ptr_array_unref);
      g_hash_table_insert (data, GINT_TO_POINTER (i), g_steal_pointer (&record_data_owned));

      for (size_t j = 0; identifiers != NULL && identifiers[j] != NULL; j++)
        {
          const char *identifier = identifiers[j];
          g_autoptr(GVariant) time_spans_variant = NULL;
          GVariantIter time_spans_iter;
          g_autoptr(GPtrArray) time_spans_array = NULL;  /* (element-type MctTimeSpan) */
          uint64_t start_time, end_time;

          /* Load the time spans into an array for now, for ease of modification.
           * In future, this could be expanded to keep them as a GVariant until
           * modified (copy-on-write), since the typical use case will only be
           * to add spans to one or two identifiers. */
          time_spans_variant = gvdb_table_get_value (record_table, identifier);

          if (!g_variant_is_of_type (time_spans_variant, G_VARIANT_TYPE ("a(tt)")))
            {
              g_task_return_new_error (task, G_FILE_ERROR, G_FILE_ERROR_INVAL,
                                       _("Corrupt file ‘%s’"), g_file_peek_path (database_file));
              return;
            }

          g_variant_iter_init (&time_spans_iter, time_spans_variant);
          time_spans_array = g_ptr_array_new_full (g_variant_n_children (time_spans_variant), (GDestroyNotify) mct_time_span_free);

          while (g_variant_iter_loop (&time_spans_iter, "(tt)", &start_time, &end_time))
            g_ptr_array_add (time_spans_array, mct_time_span_new (start_time, end_time));

          g_hash_table_insert (record_data, g_strdup (identifier), g_steal_pointer (&time_spans_array));
        }
    }

  /* Mark the user’s file as open */
  transaction_key = transaction_key_owned = g_strdup (username);
  g_hash_table_insert (self->open_data,
                       g_steal_pointer (&transaction_key_owned), g_steal_pointer (&data));

  g_task_return_pointer (task, (void *) transaction_key, NULL);
}

/**
 * mct_timer_store_open_username_finish:
 * @self: a [class@Malcontent.TimerStore]
 * @result: result of the asynchronous operation
 * @error: return location for an error, or `NULL` to ignore
 *
 * Finish an asynchronous operation started with
 * [method@Malcontent.TimerStore.open_username_async].
 *
 * See the documentation for that method for details of the return value and
 * possible errors.
 *
 * Returns: an opaque transaction identifier, or `NULL` on failure
 * Since: 0.14.0
 */
const MctTimerStoreTransaction *
mct_timer_store_open_username_finish (MctTimerStore  *self,
                                      GAsyncResult   *result,
                                      GError        **error)
{
  g_return_val_if_fail (MCT_IS_TIMER_STORE (self), FALSE);
  g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);

  return g_task_propagate_pointer (G_TASK (result), error);
}

static void
coalesce_and_sort_time_spans (GPtrArray *time_spans_array)
{
  /* Sort in ascending order of start time then by ascending order of end time */
  g_ptr_array_sort_values (time_spans_array, (GCompareFunc) mct_time_span_compare);

  /* Coalesce time spans which overlap */
  for (unsigned int i = 1; i < time_spans_array->len; /* increment is conditional, below */)
    {
      MctTimeSpan *a = g_ptr_array_index (time_spans_array, i - 1);
      MctTimeSpan *b = g_ptr_array_index (time_spans_array, i);
      const uint64_t a_start = mct_time_span_get_start_time_secs (a);
      const uint64_t a_end = mct_time_span_get_end_time_secs (a);
      const uint64_t b_start = mct_time_span_get_start_time_secs (b);
      const uint64_t b_end = mct_time_span_get_end_time_secs (b);

      g_debug ("Considering spans %" G_GUINT64_FORMAT "-%" G_GUINT64_FORMAT " and %" G_GUINT64_FORMAT "-%" G_GUINT64_FORMAT,
               a_start, a_end, b_start, b_end);

      if (a_end >= b_start)
        {
          g_debug (" ⇒ merging");
          /* Merge them */
          g_clear_pointer (&time_spans_array->pdata[i - 1], mct_time_span_free);
          time_spans_array->pdata[i - 1] = mct_time_span_new (a_start, MAX (a_end, b_end));
          g_ptr_array_remove_index (time_spans_array, i);
        }
      else
        {
          i++;
          continue;
        }
    }
}

/* The input @time_spans_array must be sorted as by coalesce_and_sort_time_spans() */
static void
trim_expired_time_spans (GPtrArray *time_spans_array,
                         uint64_t expiry_cutoff_secs)
{
  size_t new_first_element = 0;

  for (unsigned int i = 0; i < time_spans_array->len; i++)
    {
      MctTimeSpan *a = g_ptr_array_index (time_spans_array, i);
      const uint64_t a_start = mct_time_span_get_start_time_secs (a);
      const uint64_t a_end = mct_time_span_get_end_time_secs (a);

      g_debug ("Considering span %" G_GUINT64_FORMAT "-%" G_GUINT64_FORMAT,
               a_start, a_end);

      if (a_end <= expiry_cutoff_secs)
        {
          g_debug (" ⇒ removing as completely expired");
          new_first_element = i + 1;
        }
      else if (a_start <= expiry_cutoff_secs)
        {
          g_debug (" ⇒ trimming as partially expired");
          g_clear_pointer (&time_spans_array->pdata[i], mct_time_span_free);
          time_spans_array->pdata[i] = mct_time_span_new (expiry_cutoff_secs, a_end);
        }
      else
        {
          /* We’ve reached entries which are entirely after the cutoff */
          break;
        }
    }

  /* Shift the array to remove the first @new_first_element entries */
  if (new_first_element > 0)
    g_ptr_array_remove_range (time_spans_array, 0, new_first_element);
}

static void save_transaction_write_contents_cb (GObject      *object,
                                                GAsyncResult *result,
                                                void         *user_data);

typedef struct
{
  char *username;  /* (owned) (not nullable) */
  GHashTable *gvdb_data_table;  /* (owned) (not nullable) */
} SaveTransactionData;

static void
save_transaction_data_free (SaveTransactionData *data)
{
  g_clear_pointer (&data->gvdb_data_table, g_hash_table_unref);
  g_clear_pointer (&data->username, g_free);
  g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (SaveTransactionData, save_transaction_data_free)

static SaveTransactionData *
save_transaction_data_new (const char *username,
                           GHashTable *gvdb_data_table)
{
  g_autoptr(SaveTransactionData) data = g_new0 (SaveTransactionData, 1);
  data->username = g_strdup (username);
  data->gvdb_data_table = g_hash_table_ref (gvdb_data_table);
  return g_steal_pointer (&data);
}

/**
 * mct_timer_store_save_transaction_async:
 * @self: a timer store
 * @transaction: an open transaction handle
 * @expiry_cutoff_secs: earliest time to save (entries before this are trimmed),
 *   or zero to not expire entries
 * @cancellable: (nullable): a cancellable, or `NULL` to ignore
 * @callback: callback to call when the async operation completes
 * @user_data: data to pass to @callback
 *
 * Save the currently open transaction to disk.
 *
 * This saves all pending changes to the data for @transaction, which must have
 * been started by calling [method@Mct.TimerStore.open_username_async].
 *
 * The changes are kept in memory if saving to disk fails. If so, a
 * [error@Gio.IOErrorEnum] is returned and you may try saving again by calling
 * this method again. The final save operation must succeed, or
 * [method@Mct.TimerStore.roll_back_transaction] called, before the
 * [class@Mct.TimerStore] is disposed.
 *
 * Since: 0.14.0
 */
void
mct_timer_store_save_transaction_async (MctTimerStore                  *self,
                                        const MctTimerStoreTransaction *transaction,
                                        uint64_t                        expiry_cutoff_secs,
                                        GCancellable                   *cancellable,
                                        GAsyncReadyCallback             callback,
                                        void                           *user_data)
{
  g_autoptr(GTask) task = NULL;
  const char *username;
  GHashTable *open_user_data;  /* (element-type MctTimerStoreRecordType, GHashTable<utf8,GPtrArray<MctTimeSpan>>) */
  GHashTableIter data_iter;
  void *record_type_ptr;
  GHashTable *record_data;
  g_autoptr(GHashTable) gvdb_data_table = NULL;
  g_autoptr(GFile) database_file = NULL;
  g_autofree char *database_directory_path = NULL;
  g_autoptr(GError) local_error = NULL;

  g_return_if_fail (MCT_IS_TIMER_STORE (self));
  g_return_if_fail (transaction != NULL);
  g_return_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable));

  /* Must already have an open user. */
  username = (const char *) transaction;
  open_user_data = g_hash_table_lookup (self->open_data, username);
  g_return_if_fail (open_user_data != NULL);

  task = g_task_new (self, cancellable, callback, user_data);
  g_task_set_source_tag (task, mct_timer_store_save_transaction_async);
  g_task_set_task_data (task, (void *) username, NULL);

  /* Iterate over the internal data and turn it into a GVDB table. */
  g_hash_table_iter_init (&data_iter, open_user_data);
  gvdb_data_table = gvdb_hash_table_new (NULL, NULL);

  while (g_hash_table_iter_next (&data_iter, &record_type_ptr, (void **) &record_data))
    {
      MctTimerStoreRecordType record_type = GPOINTER_TO_INT (record_type_ptr);
      GHashTableIter record_iter;
      const char *identifier;
      GPtrArray *time_spans_array;  /* (element-type MctTimeSpan) */
      g_autoptr(GHashTable) gvdb_record_table = NULL;

      g_hash_table_iter_init (&record_iter, record_data);
      gvdb_record_table = gvdb_hash_table_new (gvdb_data_table, mct_timer_store_record_type_to_string (record_type));

      while (g_hash_table_iter_next (&record_iter, (void **) &identifier, (void **) &time_spans_array))
        {
          GvdbItem *item;
          g_auto(GVariantBuilder) time_spans_builder = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE ("a(tt)"));

          /* Ensure the time spans are coalesced and sorted */
          coalesce_and_sort_time_spans (time_spans_array);

          /* Expire old entries */
          if (expiry_cutoff_secs > 0)
            trim_expired_time_spans (time_spans_array, expiry_cutoff_secs);

          /* Turn them into a GVariant */
          for (unsigned int i = 0; i < time_spans_array->len; i++)
            {
              MctTimeSpan *time_span = g_ptr_array_index (time_spans_array, i);
              g_variant_builder_add (&time_spans_builder, "(tt)",
                                     mct_time_span_get_start_time_secs (time_span),
                                     mct_time_span_get_end_time_secs (time_span));
            }

          item = gvdb_hash_table_insert (gvdb_record_table, identifier);
          gvdb_item_set_value (item, g_variant_builder_end (&time_spans_builder));
        }
    }

  /* Ensure the directory exists */
  database_file = get_database_file_for_username (self, username);
  database_directory_path = g_path_get_dirname (g_file_peek_path (database_file));

  if (g_mkdir_with_parents (database_directory_path, 0700) != 0)
    {
      int saved_errno = errno;
      g_debug ("Failed to create directory ‘%s’: %s", database_directory_path,
               g_strerror (saved_errno));
      /* continue anyway and let the error ultimately be reported by the file
       * writing code */
    }

  /* Write out the file */
  g_task_set_task_data (task, save_transaction_data_new (username, gvdb_data_table), (GDestroyNotify) save_transaction_data_free);
  gvdb_table_write_contents_async (gvdb_data_table,
                                   g_file_peek_path (database_file),
                                   G_BYTE_ORDER != G_LITTLE_ENDIAN,
                                   cancellable, save_transaction_write_contents_cb,
                                   g_steal_pointer (&task));
}

static void
save_transaction_write_contents_cb (GObject      *object,
                                    GAsyncResult *result,
                                    void         *user_data)
{
  g_autoptr(GTask) task = g_steal_pointer (&user_data);
  MctTimerStore *self = g_task_get_source_object (task);
  SaveTransactionData *data = g_task_get_task_data (task);
  g_autoptr(GError) local_error = NULL;

  /* Finish the write */
  gvdb_table_write_contents_finish (data->gvdb_data_table, result, &local_error);

  if (local_error != NULL)
    {
      /* Don’t clear data and don’t emit a signal on error. This means callers
       * will have to call mct_timer_store_roll_back_transaction() before
       * disposing of the TimerStore, if they can’t save again. */
      g_task_return_error (task, g_steal_pointer (&local_error));
    }
  else
    {
      /* Clear the open state from memory now it’s saved to disk. */
      mct_timer_store_roll_back_transaction (self, data->username);

      /* Notify of changes. It might be possible to improve the specificity of this
       * by working out whether the additions to the time spans in the database have
       * affected estimated end times. But the likelihood is that they have (users
       * will typically not be adding historic time spans). */
      g_signal_emit (self, signals[SIGNAL_ESTIMATED_END_TIMES_CHANGED], 0, data->username);

      g_task_return_boolean (task, TRUE);
    }
}

/**
 * mct_timer_store_save_transaction_finish:
 * @self: a timer store
 * @result: result of the asynchronous operation
 * @error: return location for a [type@GLib.Error], or `NULL` to ignore
 *
 * Finish an asynchronous operation started with
 * [method@Mct.TimerStore.save_transaction_async].
 *
 * Returns: true if the operation succeeded, false otherwise
 * Since: 0.14.0
 */
gboolean
mct_timer_store_save_transaction_finish (MctTimerStore  *self,
                                         GAsyncResult   *result,
                                         GError        **error)
{
  g_return_val_if_fail (MCT_IS_TIMER_STORE (self), FALSE);
  g_return_val_if_fail (g_task_is_valid (result, self), FALSE);
  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);

  return g_task_propagate_boolean (G_TASK (result), error);
}

/**
 * mct_timer_store_roll_back_transaction:
 * @self: a timer store
 * @transaction: an open transaction handle
 *
 * Cancel the given @transaction and discard any pending changes from it.
 *
 * Since: 0.14.0
 */
void
mct_timer_store_roll_back_transaction (MctTimerStore                  *self,
                                       const MctTimerStoreTransaction *transaction)
{
  gboolean removed;

  g_return_if_fail (MCT_IS_TIMER_STORE (self));
  g_return_if_fail (transaction != NULL);

  /* Must already have an open user. Throw away the transaction. */
  removed = g_hash_table_remove (self->open_data, transaction);
  g_return_if_fail (removed);
}

/**
 * mct_timer_store_add_time_spans:
 * @self: a timer store
 * @transaction: an open transaction handle
 * @record_type: type of record to add
 * @identifier: identifier for the record, must match the format required
 *   by @record_type
 * @time_spans: (array length=n_time_spans): zero or more time spans to add to
 *   the database
 * @n_time_spans: number of time spans in @time_spans
 *
 * Add time spans to the database file represented by @transaction.
 *
 * This is the way to record session and app time usage by the user whose
 * database file is open as @transaction.
 *
 * Since: 0.14.0
 */
void
mct_timer_store_add_time_spans (MctTimerStore                  *self,
                                const MctTimerStoreTransaction *transaction,
                                MctTimerStoreRecordType         record_type,
                                const char                     *identifier,
                                const MctTimeSpan * const      *time_spans,
                                size_t                          n_time_spans)
{
  const char *username;
  GHashTable *open_user_data;  /* (element-type MctTimerStoreRecordType, GHashTable<utf8,GPtrArray<MctTimeSpan>>) */
  GHashTable *record_data;  /* (element-type utf8 GPtrArray<MctTimeSpan>) */
  GPtrArray *time_spans_array;  /* (element-type MctTimeSpan) */

  g_return_if_fail (MCT_IS_TIMER_STORE (self));
  g_return_if_fail (transaction != NULL);
  g_return_if_fail (mct_timer_store_record_type_validate_identifier (record_type, identifier, NULL));
  g_return_if_fail (n_time_spans == 0 || time_spans != NULL);

  /* Must already have an open user. */
  username = (const char *) transaction;
  open_user_data = g_hash_table_lookup (self->open_data, username);
  g_return_if_fail (open_user_data != NULL);

  /* Look up the array for the time spans by (record_type, identifier) */
  record_data = g_hash_table_lookup (open_user_data, GINT_TO_POINTER (record_type));

  if (record_data == NULL)
    {
      g_autoptr(GHashTable) record_data_owned = NULL;  /* (element-type utf8 GPtrArray<MctTimeSpan>) */

      record_data = record_data_owned = g_hash_table_new_full (g_str_hash, g_str_equal,
                                                               g_free, (GDestroyNotify) g_ptr_array_unref);
      g_hash_table_insert (open_user_data, GINT_TO_POINTER (record_type), g_steal_pointer (&record_data_owned));
    }

  time_spans_array = g_hash_table_lookup (record_data, identifier);
  if (time_spans_array == NULL)
    {
      g_autoptr(GPtrArray) time_spans_array_owned = NULL;  /* (element-type MctTimeSpan) */
      time_spans_array = time_spans_array_owned = g_ptr_array_new_full (n_time_spans, (GDestroyNotify) mct_time_span_free);
      g_hash_table_insert (record_data, g_strdup (identifier), g_steal_pointer (&time_spans_array_owned));
    }

  /* Add the time spans. Don’t bother sorting for now; that will happen when saving. */
  for (size_t i = 0; i < n_time_spans; i++)
    g_ptr_array_add (time_spans_array, mct_time_span_copy (time_spans[i]));
}

/**
 * mct_timer_store_calculate_total_times_between:
 * @self: a timer store
 * @transaction: an open transaction handle
 * @record_type: type of record to query
 * @since_secs: lower limit (inclusive) on the period to calculate times
 *   between, in seconds since the Unix epoch
 * @until_secs: upper limit (inclusive) on the period to calculate times
 *   between, in seconds since the Unix epoch
 *
 * Calculates times the user has spent on all identifiers for the given
 * @record_type between @since_secs and @until_secs.
 *
 * This is the main method for querying the user’s usage history. It returns a
 * map from identifier to total usage time, in seconds, for each identifier of
 * the given @record_type. The returned map may be empty if there are no
 * records.
 *
 * The calculation is limited to the period [@since_secs, @until_secs] (in
 * [ISO 31-11 notation](https://en.wikipedia.org/wiki/Interval_(mathematics)#Including_or_excluding_endpoints)),
 * with records which cross either of those limits being truncated to the limit.
 * To query for all time, pass `0` for @since_secs and `UINT64_MAX` for
 * @until_secs.
 *
 * @transaction must refer to a valid transaction started with
 * [method@Malcontent.TimerStore.open_username_async]. If you don’t plan to
 * write changes to the user’s database file after calculating times, call
 * [method@Malcontent.TimerStore.roll_back_transaction] afterwards.
 *
 * Returns: (transfer full) (element-type utf8 uint64_t): potentially empty map
 *   from identifier to total usage time (in seconds)
 * Since: 0.14.0
 */
GHashTable *
mct_timer_store_calculate_total_times_between (MctTimerStore                  *self,
                                               const MctTimerStoreTransaction *transaction,
                                               MctTimerStoreRecordType         record_type,
                                               uint64_t                        since_secs,
                                               uint64_t                        until_secs)
{
  const char *username;
  GHashTable *open_user_data;  /* (element-type MctTimerStoreRecordType, GHashTable<utf8,GPtrArray<MctTimeSpan>>) */
  g_autoptr(GHashTable) total_times = NULL;  /* (element-type utf8 uint64_t) */
  GHashTable *record_data;  /* (element-type utf8 GPtrArray<MctTimeSpan>) */
  GHashTableIter record_iter;
  const char *identifier;
  GPtrArray *time_spans_array;  /* (element-type MctTimeSpan) */

  g_return_val_if_fail (MCT_IS_TIMER_STORE (self), NULL);
  g_return_val_if_fail (transaction != NULL, NULL);
  g_return_val_if_fail (since_secs < until_secs, NULL);

  /* Must already have an open user. */
  username = (const char *) transaction;
  open_user_data = g_hash_table_lookup (self->open_data, username);
  g_return_val_if_fail (open_user_data != NULL, NULL);

  total_times = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free);

  /* Look up the record type */
  record_data = g_hash_table_lookup (open_user_data, GINT_TO_POINTER (record_type));

  if (record_data == NULL)
    return g_steal_pointer (&total_times);

  /* For each identifier and set of time spans, sum up the time spans in the requested range */
  g_hash_table_iter_init (&record_iter, record_data);

  while (g_hash_table_iter_next (&record_iter, (void **) &identifier, (void **) &time_spans_array))
    {
      uint64_t identifier_total_time = 0;
      unsigned int sum_range_left = 0, sum_range_right_exclusive = time_spans_array->len;

      /* Binary searches to find the first time span which includes @since_secs,
       * and the last time span which includes @until_secs. */
      for (unsigned int left = 0, right = time_spans_array->len;
           left < right;
           /* conditional increment inside loop */)
        {
          unsigned int middle = (left + right) / 2;
          const MctTimeSpan *middle_time_span = g_ptr_array_index (time_spans_array, middle);

          if (mct_time_span_get_end_time_secs (middle_time_span) < since_secs)
            left = middle + 1;
          else
            right = middle;

          sum_range_left = left;
        }

      for (unsigned int left = 0, right = time_spans_array->len;
           left < right;
           /* conditional increment inside loop */)
        {
          unsigned int middle = (left + right) / 2;
          const MctTimeSpan *middle_time_span = g_ptr_array_index (time_spans_array, middle);

          if (mct_time_span_get_start_time_secs (middle_time_span) > until_secs)
            right = middle;
          else
            left = middle + 1;

          sum_range_right_exclusive = right;
        }

      /* Sum the spans using saturation arithmetic in case of overflow */
      for (unsigned int i = sum_range_left; i < sum_range_right_exclusive; i++)
        {
          const MctTimeSpan *time_span = g_ptr_array_index (time_spans_array, i);
          uint64_t start_secs, end_secs;

          start_secs = MAX (since_secs, mct_time_span_get_start_time_secs (time_span));
          end_secs = MIN (mct_time_span_get_end_time_secs (time_span), until_secs);
          g_assert (start_secs <= end_secs);

          if (!g_uint64_checked_add (&identifier_total_time,
                                     identifier_total_time,
                                     end_secs - start_secs))
            {
              identifier_total_time = G_MAXUINT64;
              break;
            }
        }

      if (identifier_total_time > 0)
        g_hash_table_insert (total_times,
                             g_strdup (identifier),
                             g_memdup2 (&identifier_total_time, sizeof (identifier_total_time)));
    }

  return g_steal_pointer (&total_times);
}

/**
 * mct_timer_store_query_time_spans:
 * @self: a timer store
 * @transaction: an open transaction handle
 * @record_type: type of record to query
 * @identifier: identifier for the record, must match the format required
 *   by @record_type
 * @out_n_time_spans: (out caller-allocates) (not optional): return location for
 *   the number of time spans in the return value
 *
 * Queries the time spans for @record_type and @identifier from the database
 * open in @transaction.
 *
 * @transaction must refer to a valid transaction started with
 * [method@Malcontent.TimerStore.open_username_async]. If you don’t plan to
 * write changes to the user’s database file after querying time spans, call
 * [method@Malcontent.TimerStore.roll_back_transaction] afterwards.
 *
 * Returns: (transfer none) (nullable): time spans for @record_type and
 *   @identifier, valid only while @transaction is
 * Since: 0.14.0
 */
const MctTimeSpan * const *
mct_timer_store_query_time_spans (MctTimerStore                  *self,
                                  const MctTimerStoreTransaction *transaction,
                                  MctTimerStoreRecordType         record_type,
                                  const char                     *identifier,
                                  size_t                         *out_n_time_spans)
{
  const char *username;
  GHashTable *open_user_data;  /* (element-type MctTimerStoreRecordType, GHashTable<utf8,GPtrArray<MctTimeSpan>>) */
  GHashTable *record_data;  /* (element-type utf8 GPtrArray<MctTimeSpan>) */
  GPtrArray *time_spans_array = NULL;  /* (element-type MctTimeSpan) */

  g_return_val_if_fail (MCT_IS_TIMER_STORE (self), NULL);
  g_return_val_if_fail (transaction != NULL, NULL);
  g_return_val_if_fail (mct_timer_store_record_type_validate_identifier (record_type, identifier, NULL), NULL);
  g_return_val_if_fail (out_n_time_spans != NULL, NULL);

  /* Must already have an open user. */
  username = (const char *) transaction;
  open_user_data = g_hash_table_lookup (self->open_data, username);
  g_return_val_if_fail (open_user_data != NULL, NULL);

  /* Look up the record type */
  record_data = g_hash_table_lookup (open_user_data, GINT_TO_POINTER (record_type));

  if (record_data != NULL)
    time_spans_array = g_hash_table_lookup (record_data, identifier);

  if (time_spans_array == NULL)
    {
      *out_n_time_spans = 0;
      return NULL;
    }

  *out_n_time_spans = time_spans_array->len;
  return (const MctTimeSpan * const *) time_spans_array->pdata;
}
