/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
 * vi:set noexpandtab tabstop=8 shiftwidth=8:
 *
 * Copyright (C) 2021 Endless OS Foundation, Inc
 *
 * Author: Philip Withnall <pwithnall@endlessos.org>
 *
 * SPDX-License-Identifier: GPL-2.0-or-later
 */

/**
 * SECTION:gs-remote-icon
 * @short_description: A #GIcon implementation for remote icons
 *
 * #GsRemoteIcon is a #GIcon implementation which represents remote icons —
 * icons which have an HTTP or HTTPS URI. It provides a well-known local filename
 * for a cached copy of the icon, accessible as #GFileIcon:file, and a method
 * to download the icon to the cache, gs_remote_icon_ensure_cached().
 *
 * Constructing a #GsRemoteIcon does not guarantee that the icon is cached. Call
 * gs_remote_icon_ensure_cached() for that.
 *
 * #GsRemoteIcon is immutable after construction and hence is entirely thread
 * safe.
 *
 * FIXME: Currently does no cache invalidation.
 *
 * Since: 40
 */

#include "config.h"

#include <gio/gio.h>
#include <glib.h>
#include <glib-object.h>
#include <glib/gstdio.h>
#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <libsoup/soup.h>

#include "gs-icon.h"
#include "gs-remote-icon.h"
#include "gs-utils.h"

/* FIXME: Work around the fact that GFileIcon is not derivable, by deriving from
 * it anyway by copying its `struct GFileIcon` definition inline here. This will
 * work as long as the size of `struct GFileIcon` doesn’t change within GIO.
 * There’s no way of knowing if that’s the case.
 *
 * See https://gitlab.gnome.org/GNOME/glib/-/issues/2345 for why this is
 * necessary. */
struct _GsRemoteIcon
{
	/* struct GFileIcon { */
	GObject		 grandparent;
	GFile		*file;
	/* } */

	gchar		*uri;  /* (owned), immutable after construction */
};

G_DEFINE_TYPE (GsRemoteIcon, gs_remote_icon, G_TYPE_FILE_ICON)

typedef enum {
	PROP_URI = 1,
} GsRemoteIconProperty;

static GParamSpec *obj_props[PROP_URI + 1] = { NULL, };

static void
gs_remote_icon_get_property (GObject    *object,
                             guint       prop_id,
                             GValue     *value,
                             GParamSpec *pspec)
{
	GsRemoteIcon *self = GS_REMOTE_ICON (object);

	switch ((GsRemoteIconProperty) prop_id) {
	case PROP_URI:
		g_value_set_string (value, gs_remote_icon_get_uri (self));
		break;
	default:
		G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
		break;
	}
}

static void
gs_remote_icon_set_property (GObject      *object,
                             guint         prop_id,
                             const GValue *value,
                             GParamSpec   *pspec)
{
	GsRemoteIcon *self = GS_REMOTE_ICON (object);

	switch ((GsRemoteIconProperty) prop_id) {
	case PROP_URI:
		/* Construct only */
		g_assert (self->uri == NULL);
		self->uri = g_value_dup_string (value);
		g_assert (g_str_has_prefix (self->uri, "http:") ||
			  g_str_has_prefix (self->uri, "https:"));
		break;
	default:
		G_OBJECT_WARN_INVALID_PROPERTY_ID (object, prop_id, pspec);
		break;
	}
}

static void
gs_remote_icon_finalize (GObject *object)
{
	GsRemoteIcon *self = GS_REMOTE_ICON (object);

	g_free (self->uri);

	G_OBJECT_CLASS (gs_remote_icon_parent_class)->finalize (object);
}

static void
gs_remote_icon_class_init (GsRemoteIconClass *klass)
{
	GObjectClass *object_class = G_OBJECT_CLASS (klass);

	object_class->get_property = gs_remote_icon_get_property;
	object_class->set_property = gs_remote_icon_set_property;
	object_class->finalize = gs_remote_icon_finalize;

	/**
	 * GsRemoteIcon:uri: (not nullable)
	 *
	 * Remote URI of the icon. This must be an HTTP or HTTPS URI; it is a
	 * programmer error to provide other URI schemes.
	 *
	 * Since: 40
	 */
	obj_props[PROP_URI] =
		g_param_spec_string ("uri", NULL, NULL,
				     NULL,
				     G_PARAM_READWRITE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_EXPLICIT_NOTIFY | G_PARAM_STATIC_STRINGS);

	g_object_class_install_properties (object_class, G_N_ELEMENTS (obj_props), obj_props);
}

static void
gs_remote_icon_init (GsRemoteIcon *self)
{
}

/* Use a hash-prefixed filename to avoid cache clashes.
 * This can only fail if @create_directory is %TRUE. */
static gchar *
gs_remote_icon_get_cache_filename (const gchar  *uri,
                                   gboolean      create_directory,
                                   GError      **error)
{
	g_autofree gchar *uri_checksum = NULL;
	g_autofree gchar *uri_basename = NULL;
	g_autofree gchar *cache_basename = NULL;
	GsUtilsCacheFlags flags;

	uri_checksum = g_compute_checksum_for_string (G_CHECKSUM_SHA1,
						      uri,
						      -1);
	uri_basename = g_path_get_basename (uri);

	/* convert filename from jpg to png, as we always convert to PNG on
	 * download */
	if (g_str_has_suffix (uri_basename, ".jpg"))
		memcpy (uri_basename + strlen (uri_basename) - 4, ".png", 4);

	cache_basename = g_strdup_printf ("%s-%s", uri_checksum, uri_basename);

	flags = GS_UTILS_CACHE_FLAG_WRITEABLE;
	if (create_directory)
		flags |= GS_UTILS_CACHE_FLAG_CREATE_DIRECTORY;

	return gs_utils_get_cache_filename ("icons",
					    cache_basename,
					    flags,
					    error);
}

/**
 * gs_remote_icon_new:
 * @uri: remote URI of the icon
 *
 * Create a new #GsRemoteIcon representing @uri. The #GFileIcon:file of the
 * resulting icon will represent the local cache location for the icon.
 *
 * Returns: (transfer full): a new remote icon
 * Since: 40
 */
GIcon *
gs_remote_icon_new (const gchar *uri)
{
	g_autofree gchar *cache_filename = NULL;
	g_autoptr(GFile) file = NULL;

	g_return_val_if_fail (uri != NULL, NULL);

	/* The file is the expected cached location of the icon, once it’s
	 * downloaded. By setting it as the #GFileIcon:file property, existing
	 * code (particularly in GTK) which operates on #GFileIcons will work
	 * transparently with this.
	 *
	 * Ideally, #GFileIcon would be an interface rather than a class, which
	 * would make this implementation cleaner, but this is what we’re stuck
	 * with.
	 *
	 * See https://gitlab.gnome.org/GNOME/glib/-/issues/2345 */
	cache_filename = gs_remote_icon_get_cache_filename (uri, FALSE, NULL);
	g_assert (cache_filename != NULL);
	file = g_file_new_for_path (cache_filename);

	return g_object_new (GS_TYPE_REMOTE_ICON,
			     "file", file,
			     "uri", uri,
			     NULL);
}

/**
 * gs_remote_icon_get_uri:
 * @self: a #GsRemoteIcon
 *
 * Gets the value of #GsRemoteIcon:uri.
 *
 * Returns: (not nullable): remote URI of the icon
 * Since: 40
 */
const gchar *
gs_remote_icon_get_uri (GsRemoteIcon *self)
{
	g_return_val_if_fail (GS_IS_REMOTE_ICON (self), NULL);

	return self->uri;
}

static GdkPixbuf *
gs_icon_download (SoupSession   *session,
                  const gchar   *uri,
                  const gchar   *destination_path,
                  guint          max_size,
                  GCancellable  *cancellable,
                  GError       **error)
{
	guint status_code;
	g_autoptr(SoupMessage) msg = NULL;
	g_autoptr(GInputStream) stream = NULL;
	g_autoptr(GdkPixbuf) pixbuf = NULL;
	g_autoptr(GdkPixbuf) scaled_pixbuf = NULL;

	/* Create the request */
	msg = soup_message_new (SOUP_METHOD_GET, uri);
	if (msg == NULL) {
		g_set_error_literal (error,
				     G_IO_ERROR,
				     G_IO_ERROR_INVALID_DATA,
				     "Icon has an invalid URL");
		return NULL;
	}

	/* Send request synchronously and start reading the response. */
	stream = soup_session_send (session, msg, cancellable, error);

	status_code = soup_message_get_status (msg);
	if (stream == NULL) {
		return NULL;
	} else if (status_code != SOUP_STATUS_OK) {
		g_set_error (error,
			     G_IO_ERROR,
			     G_IO_ERROR_FAILED,
			     "Failed to download icon %s: %s",
			     uri, soup_status_get_phrase (status_code));
		return NULL;
	}

	/* Typically these icons are 64x64px PNG files. If not, resize down
	 * so it’s at most @max_size square, to minimise the size of the on-disk
	 * cache.*/
	pixbuf = gdk_pixbuf_new_from_stream (stream, cancellable, error);
	if (pixbuf == NULL)
		return NULL;

	if ((guint) gdk_pixbuf_get_height (pixbuf) <= max_size &&
	    (guint) gdk_pixbuf_get_width (pixbuf) <= max_size) {
		scaled_pixbuf = g_object_ref (pixbuf);
	} else {
		scaled_pixbuf = gdk_pixbuf_scale_simple (pixbuf, max_size, max_size,
							 GDK_INTERP_BILINEAR);
	}

	/* write file */
	if (!gdk_pixbuf_save (scaled_pixbuf, destination_path, "png", error, NULL))
		return NULL;

	return g_steal_pointer (&scaled_pixbuf);
}

/**
 * gs_remote_icon_ensure_cached:
 * @self: a #GsRemoteIcon
 * @soup_session: a #SoupSession to use to download the icon
 * @maximum_icon_size: maximum size (in logical pixels) of the icon to save
 * @scale: scale the icon will be used at
 * @cancellable: (nullable): a #GCancellable, or %NULL
 * @error: return location for a #GError, or %NULL
 *
 * Ensure the given icon is present in the local cache, potentially downloading
 * it from its remote server if needed. This will do network and disk I/O.
 *
 * @maximum_icon_size specifies the maximum size (in logical pixels) of the icon
 * which should be saved to the cache. This is the maximum size that the icon
 * can ever be used at, as icons can be downscaled but never upscaled. Typically
 * this will be 160px. The device scale factor (`gtk_widget_get_scale_factor()`)
 * is provided separately as @scale.
 *
 * This can be called from any thread, as #GsRemoteIcon is immutable and hence
 * thread-safe.
 *
 * Returns: %TRUE on success, %FALSE otherwise
 * Since: 48
 */
gboolean
gs_remote_icon_ensure_cached (GsRemoteIcon  *self,
                              SoupSession   *soup_session,
                              guint          maximum_icon_size,
                              guint          scale,
                              GCancellable  *cancellable,
                              GError       **error)
{
	const gchar *uri;
	g_autofree gchar *cache_filename = NULL;
	GIcon *icon = NULL;
	GStatBuf stat_buf;
	int pixbuf_width = 0, pixbuf_height = 0;
	unsigned int icon_device_width, icon_device_height;

	g_return_val_if_fail (GS_IS_REMOTE_ICON (self), FALSE);
	g_return_val_if_fail (SOUP_IS_SESSION (soup_session), FALSE);
	g_return_val_if_fail (maximum_icon_size > 0, FALSE);
	g_return_val_if_fail (scale > 0, FALSE);
	g_return_val_if_fail (cancellable == NULL || G_IS_CANCELLABLE (cancellable), FALSE);
	g_return_val_if_fail (error == NULL || *error == NULL, FALSE);

	uri = gs_remote_icon_get_uri (self);

	/* Work out cache filename. */
	cache_filename = gs_remote_icon_get_cache_filename (uri, TRUE, error);
	if (cache_filename == NULL)
		return FALSE;

	icon = G_ICON (self);

	/* Already in cache and not older than 30 days */
	if (g_stat (cache_filename, &stat_buf) != -1 &&
	    S_ISREG (stat_buf.st_mode) &&
	    (g_get_real_time () / G_USEC_PER_SEC) - stat_buf.st_mtim.tv_sec < (60 * 60 * 24 * 30)) {
		/* Fallthrough and ensure the downloaded image dimensions are stored on the icon */
		gdk_pixbuf_get_file_info (cache_filename, &pixbuf_width, &pixbuf_height);
	} else {
		g_autoptr(GdkPixbuf) cached_pixbuf = NULL;

		cached_pixbuf = gs_icon_download (soup_session, uri, cache_filename, maximum_icon_size * gs_icon_get_scale (icon), cancellable, error);
		if (cached_pixbuf == NULL)
			return FALSE;

		pixbuf_width = gdk_pixbuf_get_width (cached_pixbuf);
		pixbuf_height = gdk_pixbuf_get_height (cached_pixbuf);
	}

	/* Ensure the dimensions are set correctly on the icon. We know the
	 * pixbuf’s (device) dimensions, so need to convert those to logical
	 * dimensions using the icon’s scale. The caller will have set the scale
	 * on the #GsIcon already, or it will default to 1. */
	scale = gs_icon_get_scale (icon);
	g_assert (scale > 0);

	icon_device_width = pixbuf_width / scale;
	icon_device_height = pixbuf_height / scale;

	if (gs_icon_get_width (icon) != 0 && gs_icon_get_height (icon) != 0 &&
	    (gs_icon_get_width (icon) != icon_device_width ||
	     gs_icon_get_height (icon) != icon_device_height)) {
		g_debug ("Icon downloaded from ‘%s’ has dimensions %ux%u@%u, "
		         "but was expected to have dimensions %ux%u@%u "
		         "according to metadata. Overriding with downloaded "
		         "dimensions.",
		         uri, icon_device_width, icon_device_height, scale,
		         gs_icon_get_width (icon), gs_icon_get_height (icon),
		         gs_icon_get_scale (icon));
		gs_icon_set_width (icon, icon_device_width);
		gs_icon_set_height (icon, icon_device_height);
	} else if (gs_icon_get_width (icon) == 0 || gs_icon_get_height (icon) == 0) {
		gs_icon_set_width (icon, icon_device_width);
		gs_icon_set_height (icon, icon_device_height);
	}

	return TRUE;
}
