/* -*- 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/gi18n-lib.h>
#include <gio/gdesktopappinfo.h>
#include <gio/gio.h>
#include <libgsystemservice/peer-manager.h>
#include <libmalcontent-timer/extension-agent-object.h>
#include <libmalcontent-timer/extension-agent-object-polkit.h>
#include <sys/types.h>
#include <pwd.h>


static void mct_extension_agent_object_polkit_request_extension_async (MctExtensionAgentObject *agent,
                                                                       const char              *record_type,
                                                                       const char              *identifier,
                                                                       uint64_t                 duration_secs,
                                                                       GVariant                *extra_data,
                                                                       GVariant                *subject,
                                                                       GUnixFDList             *subject_fd_list,
                                                                       GDBusMethodInvocation   *invocation,
                                                                       GCancellable            *cancellable,
                                                                       GAsyncReadyCallback      callback,
                                                                       void                    *user_data);
static gboolean mct_extension_agent_object_polkit_request_extension_finish (MctExtensionAgentObject  *agent,
                                                                            GAsyncResult             *result,
                                                                            gboolean                 *out_granted,
                                                                            GVariant                **out_extra_data,
                                                                            GError                  **error);

/**
 * MctExtensionAgentObjectPolkit:
 *
 * An implementation of [class@Malcontent.ExtensionAgentObject] which uses
 * polkit on the local machine to ask the parent to authorise screen time
 * extensions.
 *
 * It checks authorisation for the
 * `org.freedesktop.Malcontent.SessionLimits.Extend` polkit action, with details
 * specifying the user, record type/identifier and duration.
 *
 * Since: 0.14.0
 */
struct _MctExtensionAgentObjectPolkit
{
  MctExtensionAgentObject parent;
};

G_DEFINE_TYPE (MctExtensionAgentObjectPolkit, mct_extension_agent_object_polkit, MCT_TYPE_EXTENSION_AGENT_OBJECT)

static void
mct_extension_agent_object_polkit_class_init (MctExtensionAgentObjectPolkitClass *klass)
{
  MctExtensionAgentObjectClass *object_class = MCT_EXTENSION_AGENT_OBJECT_CLASS (klass);

  object_class->request_extension_async = mct_extension_agent_object_polkit_request_extension_async;
  object_class->request_extension_finish = mct_extension_agent_object_polkit_request_extension_finish;
}

static void
mct_extension_agent_object_polkit_init (MctExtensionAgentObjectPolkit *self)
{
}

static char *
look_up_app_name_for_id (const char *identifier)
{
  g_autoptr(GDesktopAppInfo) info = NULL;
  g_autofree char *desktop_id = NULL;

  desktop_id = g_strconcat (identifier, ".desktop", NULL);
  info = g_desktop_app_info_new (desktop_id);

  return (info != NULL) ? g_strdup (g_app_info_get_display_name (G_APP_INFO (info))) : g_strdup (identifier);
}

/* Copied from panels/common/cc-util.c in gnome-control-center */
static char *
format_hours_minutes (uint64_t duration_secs)
{
  g_autofree char *hours = NULL;
  g_autofree char *mins = NULL;
  g_autofree char *secs = NULL;
  unsigned int sec, min, hour;

  sec = duration_secs % 60;
  duration_secs = duration_secs - sec;
  min = (duration_secs % (60*60)) / 60;
  duration_secs = duration_secs - (min * 60);
  hour = duration_secs / (60*60);

  hours = g_strdup_printf (g_dngettext (GETTEXT_PACKAGE, "%u hour", "%u hours", hour), hour);
  mins = g_strdup_printf (g_dngettext (GETTEXT_PACKAGE, "%u minute", "%u minutes", min), min);
  secs = g_strdup_printf (g_dngettext (GETTEXT_PACKAGE, "%u second", "%u seconds", sec), sec);

  if (hour > 0)
    {
      if (min > 0 && sec > 0)
        {
          /* 5 hours 2 minutes 12 seconds */
          return g_strdup_printf (C_("hours minutes seconds", "%s %s %s"), hours, mins, secs);
        }
      else if (min > 0)
        {
          /* 5 hours 2 minutes */
          return g_strdup_printf (C_("hours minutes", "%s %s"), hours, mins);
        }
      else
        {
          /* 5 hours */
          return g_strdup_printf (C_("hours", "%s"), hours);
        }
    }
  else if (min > 0)
    {
      if (sec > 0)
        {
          /* 2 minutes 12 seconds */
          return g_strdup_printf (C_("minutes seconds", "%s %s"), mins, secs);
        }
      else
        {
          /* 2 minutes */
          return g_strdup_printf (C_("minutes", "%s"), mins);
        }
    }
  else if (sec > 0)
    {
      /* 10 seconds */
      return g_strdup (secs);
    }
  else
    {
      /* 0 seconds */
      return g_strdup (_("0 seconds"));
    }
}

/* Potentially we should be using accountsservice to do this nicely, but I
 * haven’t evaluated whether it’s safe to call into accountsservice in the
 * extension-agent context
 *
 * Note: This may return `NULL` without setting @error, if the given @uid
 * doesn’t exist at all. */
static char *
look_up_user_display_name (uid_t    uid,
                           GError **error)
{
  char buffer[4096];
  struct passwd pwbuf;
  struct passwd *result;
  int pwuid_errno;
  g_autofree char *display_name = NULL;
  g_autoptr(GError) local_error = NULL;

  pwuid_errno = getpwuid_r (uid, &pwbuf, buffer, sizeof (buffer), &result);

  if (result != NULL &&
      result->pw_gecos != NULL && result->pw_gecos[0] != '\0')
    {
      display_name = g_locale_to_utf8 (result->pw_gecos, -1, NULL, NULL, &local_error);
      if (display_name == NULL)
        {
          g_set_error (error, G_IO_ERROR, G_IO_ERROR_INVALID_DATA,
                       _("Error getting details for UID %d: %s"),
                       (int) uid, local_error->message);
          return NULL;
        }
    }
  else if (result != NULL)
    {
      display_name = g_strdup_printf ("%d", (int) uid);
    }
  else if (pwuid_errno == 0)
    {
      /* User not found. */
      return NULL;
    }
  else
    {
      /* Error calling getpwuid_r(). */
      g_set_error (error, G_IO_ERROR, g_io_error_from_errno (pwuid_errno),
                   _("Error getting details for UID %d: %s"),
                   (int) uid, g_strerror (pwuid_errno));
      return NULL;
    }

  return g_steal_pointer (&display_name);
}

/* https://www.freedesktop.org/software/polkit/docs/latest/eggdbus-interface-org.freedesktop.PolicyKit1.Authority.html#eggdbus-struct-Subject */
static uid_t
uid_from_polkit_subject (GVariant *subject)
{
  const char *subject_kind;
  g_autoptr(GVariant) subject_details = NULL;
  uid_t uid;

  g_assert (g_variant_is_of_type (subject, G_VARIANT_TYPE ("(sa{sv})")));

  g_variant_get (subject, "(&s@a{sv})", &subject_kind, &subject_details);

  if (g_str_equal (subject_kind, "unix-process"))
    {
      /* FIXME: This only handles the pidfd version of unix-process, but that’s
       * all we use in malcontent-timerd at the moment. validate_subject()
       * guarantees that this key is available. */
      gboolean success = g_variant_lookup (subject_details, "uid", "i", &uid);
      g_assert (success);

      return uid;
    }
  else
    {
      /* FIXME: Not supported yet */
      g_assert_not_reached ();
    }
}

static GDateTime *
get_end_of_day (GDateTime *day)
{
  return g_date_time_new_local (g_date_time_get_year (day),
                                g_date_time_get_month (day),
                                g_date_time_get_day_of_month (day),
                                23, 59, 59);
}

static void request_extension_check_authorization_cancelled_cb (GCancellable *cancellable,
                                                                void         *user_data);
static void request_extension_check_authorization_cb (GObject      *object,
                                                      GAsyncResult *result,
                                                      void         *user_data);

typedef struct
{
  /* In-progress state: */
  char *cancellation_id;  /* (not nullable) */
  GCancellable *cancellable;  /* (nullable) (owned) */
  unsigned long cancelled_id;  /* (owned) */

  /* Return values: */
  gboolean granted;
  GVariant *extra_data;  /* (nullable) (owned) (not floating) */
} RequestExtensionData;

static void
request_extension_data_free (RequestExtensionData *data)
{
  g_clear_pointer (&data->cancellation_id, g_free);
  g_cancellable_disconnect (data->cancellable, data->cancelled_id);
  g_clear_object (&data->cancellable);
  data->cancelled_id = 0;

  g_clear_pointer (&data->extra_data, g_variant_unref);
  g_free (data);
}

G_DEFINE_AUTOPTR_CLEANUP_FUNC (RequestExtensionData, request_extension_data_free)

static void
mct_extension_agent_object_polkit_request_extension_async (MctExtensionAgentObject *agent,
                                                           const char              *record_type,
                                                           const char              *identifier,
                                                           uint64_t                 duration_secs,
                                                           GVariant                *extra_data,
                                                           GVariant                *subject,
                                                           GUnixFDList             *subject_fd_list,
                                                           GDBusMethodInvocation   *invocation,
                                                           GCancellable            *cancellable,
                                                           GAsyncReadyCallback      callback,
                                                           void                    *user_data)
{
  MctExtensionAgentObjectPolkit *self = MCT_EXTENSION_AGENT_OBJECT_POLKIT (agent);
  g_autoptr(GTask) task = NULL;
  g_autoptr(RequestExtensionData) data_owned = NULL;
  RequestExtensionData *data;
  GDBusCallFlags call_flags;
  const char *action_id;
  g_autoptr(GVariant) details = NULL;  /* a{ss} */
  uint32_t flags;
  const char *polkit_message;
  g_autofree char *app_name = NULL;
  g_autofree char *child_user_display_name = NULL;
  g_autofree char *duration_str = NULL;
  g_autofree char *time_str = NULL;
  g_autoptr(GDateTime) now = NULL;
  g_autoptr(GDateTime) end_time = NULL;
  uid_t child_user_uid;
  g_autoptr(GError) local_error = NULL;

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

  /* We want to handle cancellation explicitly */
  g_task_set_check_cancellable (task, FALSE);

  data = data_owned = g_new0 (RequestExtensionData, 1);
  g_task_set_task_data (task, g_steal_pointer (&data_owned), (GDestroyNotify) request_extension_data_free);

  /* In this simple agent implementation, let’s always delegate to polkit for
   * the policy decision. Using libpolkit-gobject would be overkill here, and
   * would not make it straightforward to pass the subject through from the
   * caller, so just use the polkit D-Bus API directly. */
  call_flags = G_DBUS_CALL_FLAGS_NONE;

  action_id = "org.freedesktop.Malcontent.SessionLimits.Extend";
  flags = 0;

  /* Handle cancellation */
  if (cancellable != NULL)
    {
      data->cancellation_id = g_strdup_printf ("cancellation-%u", g_random_int ());
      data->cancellable = g_object_ref (cancellable);
      data->cancelled_id = g_cancellable_connect (cancellable,
                                                  G_CALLBACK (request_extension_check_authorization_cancelled_cb),
                                                  task, NULL);
    }
  else
    {
      /* Cancellation is not needed */
      data->cancellation_id = g_strdup ("");
    }

  if (g_str_equal (record_type, "app"))
    {
      polkit_message = (duration_secs > 0)
          /* Translators: The placeholders in $(brackets) are inserted into the string
           * later by polkit. Please do not translate the text inside the brackets. */
          ? N_("$(child_user_display_name) has requested an app screen time limit extension of $(duration_str) (until $(time_str)) for $(app_name)")
          /* Translators: The placeholders in $(brackets) are inserted into the string
           * later by polkit. Please do not translate the text inside the brackets. */
          : N_("$(child_user_display_name) has requested an app screen time limit extension until the end of today (at $(time_str)) for $(app_name)");
      app_name = look_up_app_name_for_id (identifier);
    }
  else if (g_str_equal (record_type, "login-session"))
    {
      polkit_message = (duration_secs > 0)
          /* Translators: The placeholders in $(brackets) are inserted into the string
           * later by polkit. Please do not translate the text inside the brackets. */
          ? N_("$(child_user_display_name) has requested a device screen time limit extension of $(duration_str) (until $(time_str))")
          /* Translators: The placeholders in $(brackets) are inserted into the string
           * later by polkit. Please do not translate the text inside the brackets. */
          : N_("$(child_user_display_name) has requested a device screen time limit extension until the end of today (at $(time_str))");
      app_name = NULL;
    }
  else
    {
      g_assert_not_reached ();
    }

  child_user_uid = uid_from_polkit_subject (subject);
  g_assert (child_user_uid != (uid_t) -1);
  child_user_display_name = look_up_user_display_name (child_user_uid, &local_error);
  if (child_user_display_name == NULL)
    {
      g_task_return_new_error (task, MCT_EXTENSION_AGENT_OBJECT_ERROR,
                               MCT_EXTENSION_AGENT_OBJECT_ERROR_IDENTIFYING_USER,
                               _("Error identifying user: %s"),
                               (local_error != NULL) ? local_error->message : _("Invalid or unknown user"));
      return;
    }

  duration_str = format_hours_minutes (duration_secs);
  now = g_date_time_new_now_local ();
  if (duration_secs == 0)
    end_time = get_end_of_day (now);
  else
    end_time = g_date_time_add_seconds (now, duration_secs);
  time_str = g_date_time_format (end_time, "%c");

  /* See https://www.freedesktop.org/software/polkit/docs/latest/eggdbus-interface-org.freedesktop.PolicyKit1.Authority.html#eggdbus-method-org.freedesktop.PolicyKit1.Authority.CheckAuthorization
   * for docs about the available details */
  g_auto(GVariantBuilder) details_builder = G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE ("a{ss}"));
  g_variant_builder_add (&details_builder, "{ss}", "polkit.message", polkit_message);
  g_variant_builder_add (&details_builder, "{ss}", "polkit.gettext_domain", GETTEXT_PACKAGE);
  g_variant_builder_add (&details_builder, "{ss}", "polkit.icon_name", "org.freedesktop.MalcontentControl");
  g_variant_builder_add (&details_builder, "{ss}", "child_user_display_name", child_user_display_name);
  if (duration_secs > 0)
    g_variant_builder_add (&details_builder, "{ss}", "duration_str", duration_str);
  g_variant_builder_add (&details_builder, "{ss}", "time_str", time_str);
  if (app_name != NULL)
    g_variant_builder_add (&details_builder, "{ss}", "app_name", app_name);
  details = g_variant_ref_sink (g_variant_builder_end (&details_builder));

  if (g_dbus_message_get_flags (g_dbus_method_invocation_get_message (invocation)) & G_DBUS_MESSAGE_FLAGS_ALLOW_INTERACTIVE_AUTHORIZATION)
    {
      call_flags |= G_DBUS_CALL_FLAGS_ALLOW_INTERACTIVE_AUTHORIZATION;
      flags |= 1 << 0;  /* AllowUserInteraction */
    }

  /* Start authorisation */
  g_dbus_connection_call_with_unix_fd_list (mct_extension_agent_object_get_connection (agent),
                                            "org.freedesktop.PolicyKit1",
                                            "/org/freedesktop/PolicyKit1/Authority",
                                            "org.freedesktop.PolicyKit1.Authority",
                                            "CheckAuthorization",
                                            g_variant_new ("(@rs@a{ss}us)",
                                                           subject,
                                                           action_id,
                                                           details,
                                                           flags,
                                                           data->cancellation_id),
                                            /* polkit unfortunately seems to have one extra level of tuple nesting */
                                            G_VARIANT_TYPE ("((bba{ss}))"),
                                            call_flags,
                                            -1,  /* timeout (ms) */
                                            subject_fd_list,
                                            cancellable,
                                            request_extension_check_authorization_cb,
                                            g_steal_pointer (&task));
}

static void
request_extension_check_authorization_cancelled_cb (GCancellable *cancellable,
                                                    void         *user_data)
{
  GTask *task = G_TASK (user_data);
  MctExtensionAgentObjectPolkit *self = g_task_get_source_object (task);
  RequestExtensionData *data = g_task_get_task_data (task);

  /* Calling this should cause the CheckAuthorization() method call to return
   * early, so cleanup will be handled in request_extension_check_authorization_cb() */
  g_dbus_connection_call (mct_extension_agent_object_get_connection (MCT_EXTENSION_AGENT_OBJECT (self)),
                          "org.freedesktop.PolicyKit1",
                          "/org/freedesktop/PolicyKit1/Authority",
                          "org.freedesktop.PolicyKit1.Authority",
                          "CancelCheckAuthorization",
                          g_variant_new ("(s)", data->cancellation_id),
                          NULL,  /* don’t care about the return */
                          G_DBUS_CALL_FLAGS_NONE,
                          -1,  /* timeout (ms) */
                          NULL,  /* cancellable */
                          NULL,  /* callback */
                          NULL);
}

static void
request_extension_check_authorization_cb (GObject      *object,
                                          GAsyncResult *result,
                                          void         *user_data)
{
  GDBusConnection *connection = G_DBUS_CONNECTION (object);
  g_autoptr(GTask) task = g_steal_pointer (&user_data);
  g_autoptr(GVariant) reply = NULL;
  RequestExtensionData *data = g_task_get_task_data (task);
  gboolean is_authorized = FALSE, is_challenge = FALSE;
  g_autoptr(GVariant) details = NULL;
  g_autoptr(GError) local_error = NULL;

  reply = g_dbus_connection_call_finish (connection, result, &local_error);

  if (reply == NULL)
    {
      g_autofree char *remote_error = g_dbus_error_get_remote_error (local_error);

      if (g_strcmp0 (remote_error, "org.freedesktop.PolicyKit1.Error.Failed") == 0 ||
          g_strcmp0 (remote_error, "org.freedesktop.PolicyKit1.Error.NotSupported") == 0 ||
          g_strcmp0 (remote_error, "org.freedesktop.PolicyKit1.Error.CancellationIdNotUnique") == 0)
        {
          /* Treat these as internal errors */
          g_task_return_new_error (task, MCT_EXTENSION_AGENT_OBJECT_ERROR,
                                   MCT_EXTENSION_AGENT_OBJECT_ERROR_FAILED,
                                   _("Error requesting extension: %s"),
                                   local_error->message);
          return;
        }
      else if (g_strcmp0 (remote_error, "org.freedesktop.PolicyKit1.Error.NotAuthorized") == 0)
        {
          /* This probably means our polkit action isn’t set up properly (for
           * example, the action owner might not be matching the process UID),
           * but let’s treat it like the request was denied. */
          /* fall through */
        }
      else if (g_strcmp0 (remote_error, "org.freedesktop.PolicyKit1.Error.Cancelled") == 0 ||
               g_error_matches (local_error, G_IO_ERROR, G_IO_ERROR_CANCELLED))
        {
          /* In-progress request cancelled */
          g_task_return_new_error (task, MCT_EXTENSION_AGENT_OBJECT_ERROR,
                                   MCT_EXTENSION_AGENT_OBJECT_ERROR_CANCELLED,
                                   _("Error requesting extension: %s"),
                                   _("Request cancelled"));
          return;
        }
      else
        {
          g_task_return_new_error (task, MCT_EXTENSION_AGENT_OBJECT_ERROR,
                                   MCT_EXTENSION_AGENT_OBJECT_ERROR_FAILED,
                                   _("Error requesting extension: %s"),
                                   local_error->message);
          return;
        }
    }

  /* This is an AuthorizationResult structure from polkit. We only really care
   * about @is_authorized. */
  if (reply != NULL)
    g_variant_get (reply, "((bb@a{ss}))", &is_authorized, &is_challenge, &details);

  data->granted = is_authorized;
  data->extra_data = g_variant_ref_sink (g_variant_new_parsed ("@a{sv} {}"));  /* no extra data to return */

  g_task_return_boolean (task, TRUE); /* this means success, not necessarily that permission was granted */
}

static gboolean
mct_extension_agent_object_polkit_request_extension_finish (MctExtensionAgentObject  *agent,
                                                            GAsyncResult             *result,
                                                            gboolean                 *out_granted,
                                                            GVariant                **out_extra_data,
                                                            GError                  **error)
{
  gboolean success;
  gboolean granted;
  GVariant *extra_data;
  RequestExtensionData *data;

  g_return_val_if_fail (MCT_IS_EXTENSION_AGENT_OBJECT_POLKIT (agent), FALSE);
  g_return_val_if_fail (g_task_is_valid (result, agent), FALSE);
  g_return_val_if_fail (error == NULL || *error == NULL, FALSE);

  success = g_task_propagate_boolean (G_TASK (result), error);
  data = g_task_get_task_data (G_TASK (result));

  /* Should be using MCT_EXTENSION_AGENT_OBJECT_ERROR_CANCELLED instead */
  if (error != NULL && *error != NULL)
    g_assert (!g_error_matches (*error, G_IO_ERROR, G_IO_ERROR_CANCELLED));

  if (success)
    {
      granted = data->granted;
      extra_data = data->extra_data;
    }
  else
    {
      granted = FALSE;
      extra_data = NULL;
    }

  if (out_granted != NULL)
    *out_granted = granted;
  if (out_extra_data != NULL)
    *out_extra_data = (extra_data != NULL) ? g_variant_ref (extra_data) : NULL;

  return success;
}

/**
 * mct_extension_agent_object_polkit_new:
 * @connection: (transfer none): D-Bus connection to export objects on
 * @object_path: root path to export objects below; must be a valid D-Bus object
 *    path
 *
 * Create a new [class@Malcontent.ExtensionAgentObjectPolkit] instance which is
 * set up to run as a service.
 *
 * Returns: (transfer full): a new [class@Malcontent.ExtensionAgentObjectPolkit]
 * Since: 0.14.0
 */
MctExtensionAgentObjectPolkit *
mct_extension_agent_object_polkit_new (GDBusConnection *connection,
                                       const char      *object_path)
{
  g_return_val_if_fail (G_IS_DBUS_CONNECTION (connection), NULL);
  g_return_val_if_fail (g_variant_is_object_path (object_path), NULL);

  return g_object_new (MCT_TYPE_EXTENSION_AGENT_OBJECT_POLKIT,
                       "connection", connection,
                       "object-path", object_path,
                       NULL);
}
