/*
 * Copyright (C) 2025 The Phosh.mobi e.V.
 *
 * SPDX-License-Identifier: GPL-3.0+
 *
 * Author: Evangelos Ribeiro Tzaras <devrtz@fortysixandtwo.eu>
 */

#define G_LOG_DOMAIN "cbd-manager"

#include "cbd-manager.h"
#include "cbd-channel-manager.h"
#include "cbd-mm-manager.h"
#include "cbd-message.h"
#include "lcb-util-priv.h"

#include <errno.h>
#include <fcntl.h>
#include <libmm-glib.h>
#include <glib/gstdio.h>


typedef struct _CbdManager {
  LcbDBusCbdSkeleton parent;

  GListStore           *messages;
  guint                 n_messages;

  GCancellable         *cancellable;
  CbdMmManager         *mm_manager;

  GSettings            *settings;
  char                 *db_path;
} CbdManager;


static void cbd_manager_cb_iface_init (LcbDBusCbdIface *iface);

G_DEFINE_FINAL_TYPE_WITH_CODE (CbdManager,
			       cbd_manager,
			       LCB_DBUS_TYPE_CBD_SKELETON,
			       G_IMPLEMENT_INTERFACE (LCB_DBUS_TYPE_CBD,
						      cbd_manager_cb_iface_init));

#define MSG_DEDUP_MAX_TIMEDELTA (60 * 60 * 24) /* seconds */
#define MSG_DEDUP_MAX_TIMEDELTA_JP (60 * 60) /* seconds */


static gboolean
cbd_manager_handle_open_messages (LcbDBusCbd            *object,
                                  GDBusMethodInvocation *invocation)
{
  g_autoptr (GUnixFDList) fd_list = g_unix_fd_list_new ();
  g_autoptr (GError) error = NULL;
  CbdManager *self = CBD_MANAGER (object);
  GVariant *handle;
  GVariant *tuple;
  g_autofd int fd = -1;
  int fd_index;

  fd = open (self->db_path, O_RDONLY | O_CLOEXEC, S_IRUSR);

  if (fd == -1) {
    g_dbus_method_invocation_return_error_literal (invocation,
                                                   G_FILE_ERROR, G_FILE_ERROR_NOENT,
                                                   "File not found");
    return TRUE;
  }

  /* TODO seal fd, fnctl(2) tells us this only works for fds created by memfd_create(2) */
  fd_index = g_unix_fd_list_append (fd_list, fd, &error);

  g_info ("Passing file descriptor '%d' to sender '%s'",
          fd, g_dbus_method_invocation_get_sender (invocation));

  handle = g_variant_new_handle (fd_index);
  tuple = g_variant_new_tuple (&handle, 1);
  g_dbus_method_invocation_return_value_with_unix_fd_list (invocation,
                                                           tuple,
                                                           fd_list);


  return TRUE;
}


static void
cbd_manager_cb_iface_init (LcbDBusCbdIface *iface)
{
  iface->handle_open_messages = cbd_manager_handle_open_messages;
}


static gboolean
is_same_operator (LcbMessage *msg1, LcbMessage *msg2)
{
  const char *op1 = lcb_message_get_operator_code (msg1);
  const char *op2 = lcb_message_get_operator_code (msg2);

  /* If any operator is missing we consider them different */
  if (!op1 || !op2)
    return FALSE;

  return g_str_equal (op1, op2);
}


static gboolean
is_timestamp_close (LcbMessage *msg1, LcbMessage *msg2)
{
  const char *code = lcb_message_get_operator_code (msg1);
  gint64 max_delta = MSG_DEDUP_MAX_TIMEDELTA;
  gint64 ts1, ts2;

  if (code && (g_str_has_prefix (code, "440") || g_str_has_prefix (code, "441")))
    max_delta = MSG_DEDUP_MAX_TIMEDELTA_JP;

  ts1 = lcb_message_get_timestamp (msg1);
  ts2 = lcb_message_get_timestamp (msg2);

  return ABS (ts1 - ts2) < max_delta;
}


static gboolean
has_same_cbm_params (LcbMessage *msg1, LcbMessage *msg2)
{
  return lcb_message_get_channel (msg1) == lcb_message_get_channel (msg2) &&
    lcb_message_get_msg_code (msg1) == lcb_message_get_msg_code (msg2) &&
    lcb_message_get_update (msg1) == lcb_message_get_update (msg2);
  /* TODO: Check GS flags as well once available form MM
   * https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/1005 */
}


static gboolean
has_same_text (LcbMessage *msg1, LcbMessage *msg2)
{
  const char *text1 = lcb_message_get_text (msg1);
  const char *text2 = lcb_message_get_text (msg2);

  if (!text1 || !text2)
    return FALSE;

  return g_str_equal (text1, text2);
}

/**
 * cbd_manager_msg_is_recent:
 * @self: The manager
 * @msg: The message to check
 *
 * Checks whether a message is a recent and not an already existing
 * message.
 *
 * See 3GPP 23.041 8.2: Duplication Detection Function
 *
 * Returns: `TRUE` if the message is recent and not a duplicate, otherwise `FALSE`.
 */
static gboolean
cbd_manager_msg_is_recent (CbdManager *self, LcbMessage *msg)
{
  for (int i = 0; i < g_list_model_get_n_items (G_LIST_MODEL (self->messages)); i++) {
    g_autoptr (LcbMessage) cmp = g_list_model_get_item (G_LIST_MODEL (self->messages), i);

    if (!is_same_operator (msg, cmp))
      continue;

    if (!is_timestamp_close (msg, cmp))
      continue;

    /* 3GPP 23.041 8.2.1 */
    if (!has_same_cbm_params (msg, cmp))
      continue;

    /* 3GPP 23.041 8.2.2 */
   if (!has_same_text (msg, cmp))
      continue;

    return FALSE;
  }

  return TRUE;
}

static int
msg_timestamp_cmp (LcbMessage *a,
                   LcbMessage *b,
                   gpointer    unused)
{
  gint64 ts_a = lcb_message_get_timestamp (a);
  gint64 ts_b = lcb_message_get_timestamp (b);

  /* sort descending, not ascending */
  return (ts_a - ts_b) * -1;
}


static gboolean
add_msg_to_store (CbdManager *self, CbdMessage *msg)
{
  if (!cbd_manager_msg_is_recent (self, LCB_MESSAGE (msg))) {
    g_debug ("message '%d/%d/%d' with timestamp %" G_GINT64_FORMAT " already exists.",
             lcb_message_get_channel (LCB_MESSAGE (msg)),
             lcb_message_get_msg_code (LCB_MESSAGE (msg)),
             lcb_message_get_update (LCB_MESSAGE (msg)),
             lcb_message_get_timestamp (LCB_MESSAGE (msg)));
    return FALSE;
  }

  g_debug ("Adding new message %d/%d/%d",
           lcb_message_get_channel (LCB_MESSAGE (msg)),
           lcb_message_get_msg_code (LCB_MESSAGE (msg)),
           lcb_message_get_update (LCB_MESSAGE (msg)));
  /* TODO needs design + tests, but this is the rough idea.
  if (update > 0) {
    g_debug ("CBM '%s' [%p]: marking old CBMs as updated", msg_index, msg);

    for (guint i = 0; i < update; i++) {
      g_autofree char *old_index = triple_to_index (channel, msg_code, i);
      CbdMessage *old = g_hash_table_lookup (self->lookup_recent, old_index);

      if (!old) {
        g_debug ("Did not find expected CBM '%s'..", old_index);
        continue;
      }
      cbd_message_mark_updated (old);
    }
  }
  */

  g_list_store_insert_sorted (self->messages,
                              msg,
                              (GCompareDataFunc) msg_timestamp_cmp,
                              NULL);
  self->n_messages++;

  return TRUE;
}

static gboolean
save_to_db (CbdManager *self)
{
  g_autoptr (GError) error = NULL;
  gboolean exists;
  gboolean ok;

  g_return_val_if_fail (self->db_path, FALSE);

  exists = g_file_test (self->db_path, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_REGULAR);

  g_debug ("Saving to %s database '%s'",
           exists ? "existing" : "new",
           self->db_path);

  if (exists) {
    g_autoptr (GFile) file_src = NULL;
    g_autoptr (GFile) file_dest = NULL;
    g_autofree char *path = g_strdup_printf ("%s.new", self->db_path);

    ok = lcb_util_save_messages_to_file (G_LIST_MODEL (self->messages), path, &error);
    if (!ok) {
      g_warning ("Error saving messages to '%s': %s",
                 path, error->message);
      return FALSE;
    }

    g_debug ("Saved new database '%s', now overwriting old database '%s'",
             path, self->db_path);

    file_src = g_file_new_for_path (path);
    file_dest = g_file_new_for_path (self->db_path);

    ok = g_file_move (file_src,
                      file_dest,
                      G_FILE_COPY_OVERWRITE,
                      self->cancellable,
                      NULL, NULL, &error);
    if (!ok)
      g_warning ("Error overwriting old database '%s': %s",
                 self->db_path, error->message);

  } else {
    ok = lcb_util_save_messages_to_file (G_LIST_MODEL (self->messages), self->db_path, &error);

    if (!ok)
      g_warning ("Error saving messages to '%s': %s",
                 self->db_path, error->message);
  }

  return ok;
}

static gboolean
load_from_db (CbdManager *self)
{
  g_autoptr (GPtrArray) msg_array = NULL;
  g_autoptr (GError) error = NULL;

  g_assert (self->db_path);

  g_debug ("Loading messages from file ''%s'", self->db_path);
  msg_array = lcb_util_load_raw_messages_from_file (self->db_path, &error);
  if (!msg_array) {
    if (!g_error_matches (error, G_IO_ERROR, G_IO_ERROR_NOT_FOUND))
      g_warning ("Could not load messages from file '%s': %s",
                 self->db_path, error->message);
    return FALSE;
  }

  for (guint i = 0; i < msg_array->len; i++) {
    g_autoptr (CbdMessage) msg = NULL;
    GVariant *var_text;
    GVariant *var_channel;
    GVariant *var_msg_code;
    GVariant *var_update;
    GVariant *var_timestamp;
    GVariant *var_severity;
    GVariant *var_severity_subject;
    GVariant *var_operator_code;
    GHashTable *msg_table = msg_array->pdata[i];
    const char *operator_code = NULL;

    var_text = g_hash_table_lookup (msg_table, "text");
    var_channel = g_hash_table_lookup (msg_table, "channel");
    var_msg_code = g_hash_table_lookup (msg_table, "msg-code");
    var_update = g_hash_table_lookup (msg_table, "update");
    var_timestamp = g_hash_table_lookup (msg_table, "timestamp");
    var_severity = g_hash_table_lookup (msg_table, "severity");
    var_severity_subject = g_hash_table_lookup (msg_table, "severity-subject");
    var_operator_code = g_hash_table_lookup (msg_table, "operator-code");
    if (var_operator_code)
      operator_code = g_variant_get_string (var_operator_code, NULL);

    msg = cbd_message_new (g_variant_get_string (var_text, NULL),
                           g_variant_get_uint16 (var_channel),
                           g_variant_get_uint16 (var_msg_code),
                           g_variant_get_uint16 (var_update),
                           g_variant_get_int64 (var_timestamp),
                           g_variant_get_uint32 (var_severity),
                           var_severity_subject ? g_variant_get_string (var_severity_subject, NULL) : NULL,
                           operator_code);

    add_msg_to_store (self, msg);
  }

  return TRUE;
}


static const char *
severity_to_string (LcbSeverityLevel level)
{
  switch (level) {
  case LCB_SEVERITY_LEVEL_PRESIDENTIAL:
    return "Nation Wide Alert";
  case LCB_SEVERITY_LEVEL_EXTREME:
    return "Extreme Danger Alert";
  case LCB_SEVERITY_LEVEL_SEVERE:
    return "Severe Danger Alert";
  case LCB_SEVERITY_LEVEL_PUBLIC_SAFETY:
    return "Public Savety Alert";
  case LCB_SEVERITY_LEVEL_AMBER:
    return "Amber Alert";
  case LCB_SEVERITY_LEVEL_TEST:
    return "Test Alert";
  case LCB_SEVERITY_LEVEL_UNKNOWN:
  default:
    return "Cellular Broadcast";
  }
}


static const char *
severity_to_category (LcbSeverityLevel level)
{
  /* xdg-desktop-portal opted for an incomplete set of severitities
   * https://github.com/flatpak/xdg-desktop-portal/pull/1738
   * so fnor now we try our best to map reality to what the portal spec gives us */
  /* We prefix with `x-phosh.` as the cellbroadcast.* categories aren't in the xdg-spec yet:
   * See https://gitlab.freedesktop.org/xdg/xdg-specs/-/merge_requests/91 */
  switch (level) {
  case LCB_SEVERITY_LEVEL_PRESIDENTIAL:
    return "x-phosh-cellbroadcast.extreme";
  case LCB_SEVERITY_LEVEL_EXTREME:
    return "x-phosh-cellbroadcast.extreme";
  case LCB_SEVERITY_LEVEL_SEVERE:
    return "x-phosh-cellbroadcast.severe";
  case LCB_SEVERITY_LEVEL_PUBLIC_SAFETY:
    return "x-phosh-cellbroadcast.severe";
  case LCB_SEVERITY_LEVEL_AMBER:
    return "x-phosh-cellbroadcast.amber-alert";
  case LCB_SEVERITY_LEVEL_TEST:
    return "x-phosh-cellbroadcast.test";
  case LCB_SEVERITY_LEVEL_UNKNOWN:
  default:
    return "x-phosh-cellbroadcast.severe";
  }
}


static const char *
severity_to_icon_name (LcbSeverityLevel level)
{
  switch (level) {
  case LCB_SEVERITY_LEVEL_PRESIDENTIAL:
  case LCB_SEVERITY_LEVEL_EXTREME:
  case LCB_SEVERITY_LEVEL_SEVERE:
  case LCB_SEVERITY_LEVEL_PUBLIC_SAFETY:
  case LCB_SEVERITY_LEVEL_AMBER:
  case LCB_SEVERITY_LEVEL_TEST:
    return "dialog-warning-symbolic";
  default:
    return "application-x-executable-symbolic";
  }
}


static gboolean
check_send_notification (CbdManager *self, CbdMessage *msg)
{
  LcbSeverityLevel level;

  if (!g_settings_get_boolean (self->settings, "send-notifications"))
    return FALSE;

  level = g_settings_get_flags (self->settings, "send-notifications-filter");
  if (!level)
    return TRUE;

  return level & lcb_message_get_severity (LCB_MESSAGE (msg));
}


static void
send_notification (CbdManager *self, CbdMessage *msg)
{
  GNotification *noti = NULL;
  const char *title, *category;
  LcbSeverityLevel severity;
  g_autoptr (GIcon) icon = NULL;
  gboolean send_notification;

  send_notification = check_send_notification (self, msg);
  if (!send_notification)
    return;

  severity = lcb_message_get_severity (LCB_MESSAGE (msg));
  title = lcb_message_get_severity_subject (LCB_MESSAGE (msg));
  category = severity_to_category (severity);

  noti = g_notification_new (title);
  g_notification_set_category (noti, category);

  icon = g_themed_icon_new (severity_to_icon_name (severity));
  g_notification_set_icon (noti, icon);

  g_notification_set_body (noti, lcb_message_get_text (LCB_MESSAGE (msg)));

  /* We don't replace or withdraw cell broadcast notifications ever */
  g_application_send_notification (g_application_get_default (),
                                   NULL,
                                   noti);
}


static void
on_new_cbm (CbdMmManager *mm_manager,
            const char   *text,
            guint16       channel,
            guint16       msg_code,
            guint16       update,
            const char   *operator_code,
            CbdManager   *self)
{
  g_autoptr (GDateTime) now = g_date_time_new_now_utc ();
  g_autoptr (CbdMessage) msg = NULL;
  gint64 timestamp = g_date_time_to_unix (now);
  LcbSeverityLevel severity;
  const char *severity_subject;
  gboolean recent;
  CbdChannelManager *channel_manager;

  g_debug ("New CBM (%d): %s", channel, text);
  channel_manager = cbd_mm_manager_get_channel_manager (mm_manager);
  severity = cbd_channel_manager_lookup_level (channel_manager, channel);

  severity_subject = severity_to_string (severity);
  msg = cbd_message_new (text,
                         channel,
                         msg_code,
                         update,
                         timestamp,
                         severity,
                         severity_subject,
                         operator_code);
  recent = cbd_manager_add_message (self, msg);

  if (recent)
    send_notification (self, msg);
}

static void
cbd_manager_dispose (GObject *object)
{
  CbdManager *self = CBD_MANAGER (object);

  g_clear_object (&self->messages);

  g_cancellable_cancel (self->cancellable);
  g_clear_object (&self->cancellable);
  g_clear_object (&self->mm_manager);
  g_clear_object (&self->settings);

  G_OBJECT_CLASS (cbd_manager_parent_class)->dispose (object);
}

static void
cbd_manager_class_init (CbdManagerClass *klass)
{
  GObjectClass *object_class = G_OBJECT_CLASS (klass);

  object_class->dispose = cbd_manager_dispose;
}

static void
cbd_manager_init (CbdManager *self)
{
  g_autofree char *dir = NULL;
  int err;

  self->settings = g_settings_new ("org.freedesktop.cbd");
  self->messages = g_list_store_new (CBD_TYPE_MESSAGE);
  self->mm_manager = cbd_mm_manager_new ();
  g_signal_connect (self->mm_manager,
                    "new-cbm",
                    G_CALLBACK (on_new_cbm),
                    self);

  self->db_path = g_build_filename (g_get_user_data_dir (),
                                    "cellbroadcastd",
                                    "database.gvdb",
                                    NULL);
  dir = g_path_get_dirname (self->db_path);
  err = g_mkdir_with_parents (dir, 0755);
  if (err) {
    g_warning ("Could not create cellbroadcastd data directory '%s': %s",
               dir, g_strerror (errno));
    self->db_path = NULL;
  }

  if (self->db_path) {
    if (g_file_test (self->db_path, G_FILE_TEST_EXISTS | G_FILE_TEST_IS_REGULAR))
      load_from_db (self);
    else
      save_to_db (self); /* create an empty db */
  }

  g_object_bind_property (self->mm_manager,
                          "has-cellbroadcast",
                          self,
                          "cbs-supported",
                          G_BINDING_SYNC_CREATE);
}

CbdManager *
cbd_manager_new (void)
{
  return g_object_new (CBD_TYPE_MANAGER, NULL);
}

/**
 * cbd_manager_add_message:
 * @self: The manager
 * @msg: The message
 *
 * Possibly add a message to the db. If the message is a duplicate it won't be added again.
 *
 * Returns: `TRUE` if the message is new and not a duplicate.
 */
gboolean
cbd_manager_add_message (CbdManager *self,
                         CbdMessage *msg)
{
  g_return_val_if_fail (CBD_IS_MANAGER (self), FALSE);
  g_return_val_if_fail (CBD_IS_MESSAGE (msg), FALSE);

  if (!add_msg_to_store (self, msg))
    return FALSE;

  save_to_db (self);
  return TRUE;
}
