/* -*- Mode: C; tab-width: 8; indent-tabs-mode: t; c-basic-offset: 8 -*-
 *
 * Copyright (C) 2016-2024 Matthias Klumpp <matthias@tenstral.net>
 *
 * Licensed under the GNU Lesser General Public License Version 2.1
 *
 * 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, see <http://www.gnu.org/licenses/>.
 */

/**
 * SECTION:asc-utils-metainfo
 * @short_description: Helper functions for working with MetaInfo data in appstream-compose.
 * @include: appstream-compose.h
 */

#include "config.h"
#include "asc-utils-metainfo.h"

#include "as-utils-private.h"
#include "as-spdx.h"
#include "as-desktop-entry.h"

/* Maximum amount of releases present in output data */
#define MAX_RELEASE_INFO_COUNT 4

/**
 * asc_parse_metainfo_data:
 * @cres: an #AscResult instance.
 * @mdata: an #AsMetadata instance used for parsing.
 * @bytes: the data to parse.
 * @mi_basename: the basename of the MetaInfo file we are loading.
 *
 * Parses metaInfo XML into a new #AsComponent and records any issues in
 * an #AscResult.
 *
 * Returns: (transfer full): A new #AsComponent or %NULL if we refused to accept this data.
 **/
AsComponent *
asc_parse_metainfo_data (AscResult *cres,
			 AsMetadata *mdata,
			 GBytes *bytes,
			 const gchar *mi_basename)
{
	g_autoptr(GError) error = NULL;
	AsComponent *cpt;

	g_return_val_if_fail (mi_basename != NULL, NULL);

	if (!as_metadata_parse_bytes (mdata, bytes, AS_FORMAT_KIND_XML, &error)) {
		g_autofree gchar *cid_guess = g_strdup (mi_basename);

		/* try to guess the component-ID */
		if (g_str_has_suffix (cid_guess, ".metainfo.xml"))
			cid_guess[strlen (cid_guess) - 13] = '\0';
		else if (g_str_has_suffix (cid_guess, ".appdata.xml"))
			cid_guess[strlen (cid_guess) - 12] = '\0';
		else if (g_str_has_suffix (cid_guess, ".xml"))
			cid_guess[strlen (cid_guess) - 4] = '\0';

		asc_result_add_hint_by_cid (cres,
					    cid_guess,
					    "metainfo-parsing-error",
					    "fname",
					    mi_basename,
					    "error",
					    error->message,
					    NULL);
		return NULL;
	}

	cpt = as_metadata_get_component (mdata);
	if (cpt == NULL)
		return NULL;

	/* check if we have a component-id, a component without ID is invalid */
	if (as_is_empty (as_component_get_id (cpt))) {
		asc_result_add_hint (cres, NULL, "metainfo-no-id", "fname", mi_basename, NULL);
		return NULL;
	}

	/* we at least read enough data to consider this component */
	if (!asc_result_add_component (cres, cpt, bytes, NULL))
		return NULL;

	/* check if we can actually legally use this metadata */
	if (!as_license_is_metadata_license (as_component_get_metadata_license (cpt))) {
		asc_result_add_hint (cres,
				     cpt,
				     "metainfo-license-invalid",
				     "license",
				     as_component_get_metadata_license (cpt),
				     NULL);
		return NULL;
	}

	/* quit immediately if we have an unknown component type */
	if (as_component_get_kind (cpt) == AS_COMPONENT_KIND_UNKNOWN) {
		asc_result_add_hint_simple (cres, cpt, "metainfo-unknown-type");
		return NULL;
	}

	return g_object_ref (cpt);
}

/**
 * asc_parse_metainfo_data_simple:
 * @cres: an #AscResult instance.
 * @bytes: the data to parse.
 * @mi_basename: the basename of the MetaInfo file we are loading.
 *
 * Parses metaInfo XML into a new #AsComponent and records any issues in
 * an #AscResult.
 *
 * Returns: (transfer full): A new #AsComponent or %NULL if we refused to accept this data.
 **/
AsComponent *
asc_parse_metainfo_data_simple (AscResult *cres, GBytes *bytes, const gchar *mi_basename)
{
	g_autoptr(AsMetadata) mdata = as_metadata_new ();

	as_metadata_set_locale (mdata, "ALL");
	as_metadata_set_format_style (mdata, AS_FORMAT_STYLE_METAINFO);

	return asc_parse_metainfo_data (cres, mdata, bytes, mi_basename);
}

/**
 * asc_process_metainfo_releases:
 * @cres: an #AscResult instance.
 * @unit: an #AscUnit where release information could be read from.
 * @cpt: the #AsComponent to read release data for.
 * @mi_filename: the metadata filename for the component, as found in the passed unit.
 * @allow_net: set to %TRUE if network access should be allowed.
 * @acurl: the #AsCurl to use for network access.
 * @used_reldata: (out) (optional): Receive the release data that was used, if set.
 *
 * Reads an external release description file, either from a network location or from
 * the given unit. Also performs further processing on the release information, like sorting
 * releases or pruning old ones.
 **/
void
asc_process_metainfo_releases (AscResult *cres,
			       AscUnit *unit,
			       AsComponent *cpt,
			       const gchar *mi_filename,
			       gboolean allow_net,
			       AsCurl *acurl,
			       GBytes **used_reldata)
{
	g_autoptr(GError) local_error = NULL;
	g_autoptr(GBytes) relmd_bytes = NULL;
	AsReleaseList *releases = as_component_get_releases_plain (cpt);

	/* download external release metadata or fetch local release data */
	if (as_release_list_get_kind (releases) == AS_RELEASE_LIST_KIND_EXTERNAL) {
		g_autofree gchar *relmd_uri = NULL;

		as_release_list_clear (releases);
		if (allow_net && as_release_list_get_url (releases) != NULL) {
			/* get the release data from a network location */
			const gchar *releases_url = as_release_list_get_url (releases);

			relmd_bytes = as_curl_download_bytes (acurl, releases_url, &local_error);
			if (relmd_bytes == NULL) {
				asc_result_add_hint (cres,
						     NULL,
						     "metainfo-releases-download-failed",
						     "url",
						     releases_url,
						     "msg",
						     local_error->message,
						     NULL);
				goto out;
			}
			relmd_uri = g_strdup (releases_url);
		} else {
			/* we need to find local release information */
			g_autofree gchar *relfile_path = NULL;
			g_autofree gchar *relfile_name = NULL;
			g_autofree gchar *tmp = NULL;

			relfile_name = g_strconcat (as_component_get_id (cpt),
						    ".releases.xml",
						    NULL);
			tmp = g_path_get_dirname (mi_filename);
			relfile_path = g_build_filename (tmp, "releases", relfile_name, NULL);

			relmd_bytes = asc_unit_read_data (unit, relfile_path, &local_error);
			if (relmd_bytes == NULL) {
				asc_result_add_hint (cres,
						     NULL,
						     "file-read-error",
						     "fname",
						     relfile_path,
						     "msg",
						     local_error->message,
						     NULL);
				goto out;
			}
			relmd_uri = g_steal_pointer (&relfile_path);
		}

		if (!as_release_list_load_from_bytes (releases, NULL, relmd_bytes, &local_error)) {
			asc_result_add_hint (cres,
					     NULL,
					     "metainfo-releases-read-failed",
					     "path",
					     relmd_uri,
					     "msg",
					     local_error->message,
					     NULL);
			goto out;
		}
	}

	/* limit the amount of releases that we add to the output metadata.
	 * since releases are sorted with the newest one at the top, we will only
	 * remove the older ones. */
	if (as_component_get_kind (cpt) != AS_COMPONENT_KIND_OPERATING_SYSTEM) {
		if (as_release_list_len (releases) > MAX_RELEASE_INFO_COUNT)
			as_release_list_set_size (releases, MAX_RELEASE_INFO_COUNT);
	}

out:
	if (used_reldata != NULL)
		*used_reldata = g_steal_pointer (&relmd_bytes);
}

/**
 * asc_validate_metainfo_data_for_component:
 * @cres: an #AscResult instance.
 * @validator: an #AsValidator to validate with.
 * @cpt: the loaded #AsComponent which we are validating
 * @bytes: the data @cpt was constructed from.
 * @mi_basename: the basename of the MetaInfo file we are analyzing.
 * @relmd_bytes: (nullable): the release metadata for this component.
 *
 * Validates MetaInfo data for the given component and stores the validation result as issue hints
 * in the given #AscResult.
 * Both the result as well as the validator's state may be modified by this function.
 **/
void
asc_validate_metainfo_data_for_component (AscResult *cres,
					  AsValidator *validator,
					  AsComponent *cpt,
					  GBytes *bytes,
					  const gchar *mi_basename,
					  GBytes *relmd_bytes)
{
	GHashTable *issues_files;
	GHashTableIter hiter;
	gpointer hkey, hvalue;

	/* don't check web URLs for validity, we catch those issues differently */
	as_validator_set_allow_net (validator, FALSE);

	/* remove release data from a potential previous use of this validator */
	as_validator_clear_release_data (validator);

	/* add release data if we have any */
	if (relmd_bytes != NULL) {
		g_autoptr(GError) tmp_error = NULL;
		g_autofree gchar *release_name = g_strconcat (as_component_get_id (cpt),
							      ".releases.xml",
							      NULL);
		if (!as_validator_add_release_bytes (validator,
						     release_name,
						     relmd_bytes,
						     &tmp_error))
			g_warning ("Failed to add release metadata for %s: %s",
				   as_component_get_id (cpt),
				   tmp_error->message);
	}

	/* validate */
	as_validator_validate_bytes (validator, bytes);

	/* convert & register found issues */
	issues_files = as_validator_get_issues_per_file (validator);
	g_hash_table_iter_init (&hiter, issues_files);
	while (g_hash_table_iter_next (&hiter, &hkey, &hvalue)) {
		const gchar *filename = (const gchar *) hkey;
		const GPtrArray *issues = (const GPtrArray *) hvalue;

		if (as_is_empty (filename))
			filename = mi_basename;

		for (guint i = 0; i < issues->len; i++) {
			g_autofree gchar *asv_tag = NULL;
			g_autofree gchar *location = NULL;
			glong line;
			const gchar *issue_hint;
			AsValidatorIssue *issue = AS_VALIDATOR_ISSUE (
			    g_ptr_array_index (issues, i));

			/* we have a special hint tag for legacy metadata,
			 * with its proper "error" priority */
			if (g_strcmp0 (as_validator_issue_get_tag (issue), "metainfo-ancient") ==
			    0) {
				asc_result_add_hint_simple (cres, cpt, "ancient-metadata");
				continue;
			}

			/* create a tag for asgen out of the AppStream validator tag by prefixing it */
			asv_tag = g_strconcat ("asv-", as_validator_issue_get_tag (issue), NULL);

			line = as_validator_issue_get_line (issue);
			if (line >= 0)
				location = g_strdup_printf ("%s:%ld", filename, line);
			else
				location = g_strdup (filename);

			/* we don't need to do much here, with the tag generated here,
			* the hint registry will automatically assign the right explanation
			* text and severity to the issue. */
			issue_hint = as_validator_issue_get_hint (issue);
			if (issue_hint == NULL)
				issue_hint = "";
			asc_result_add_hint (cres,
					     cpt,
					     asv_tag,
					     "location",
					     location,
					     "hint",
					     issue_hint,
					     NULL);
		}
	}
}

/**
 * asc_parse_desktop_entry_data:
 * @cres: an #AscResult instance.
 * @cpt: (nullable): an existing #AsComponent which we are adding data from this desktop file to, or NULL to create a new one
 * @bytes: the data @cpt was constructed from.
 * @de_basename: the basename of the desktop-entry file we are parsing.
 * @ignore_nodisplay: set %TRUE if fields which cause the desktop-entry file to be purposefully hidden should be ignored (useful when passing an existing #AsComponent).
 * @fversion: AppStream format version
 * @de_l10n_fn: (scope call): callback for custom desktop-entry field localization functions.
 * @user_data: user data for @de_l10n_fn
 *
 * Parse a XDG desktop-entry file into either a new component or for extending an existing component.
 *
 * Returns: (nullable) (transfer full): A new #AsComponent or reference to the existing #AsComponent passed as parameter in case
 *                                      we parsed anything. %NULL if we refused to accept this data.
 */
AsComponent *
asc_parse_desktop_entry_data (AscResult *cres,
			      AsComponent *cpt,
			      GBytes *bytes,
			      const gchar *de_basename,
			      gboolean ignore_nodisplay,
			      AsFormatVersion fversion,
			      AscTranslateDesktopTextFn de_l10n_fn,
			      gpointer user_data)
{
	g_autoptr(AsComponent) ncpt = NULL;
	g_autoptr(GPtrArray) issues = NULL;
	g_autoptr(GError) error = NULL;
	g_autofree gchar *prev_cid = NULL;
	gboolean cpt_is_new = FALSE;
	const gchar *data;
	gsize data_len;
	gboolean ret;

	/* the component-ID is derived from the desktop-entry filename, so a name that is not
	 * valid UTF-8 can not be used at all - not even to report an issue for it */
	if (!g_utf8_validate (de_basename, -1, NULL)) {
		g_autofree gchar *de_basename_safe = as_path_segment_sanitize (de_basename);
		asc_result_add_hint_by_cid (cres,
					    de_basename_safe,
					    "desktop-file-error",
					    "msg",
					    "The filename of this desktop-entry file is not valid "
					    "UTF-8, so no component-ID can be derived from it.",
					    NULL);
		return NULL;
	}

	/* NOTE: The cid needs to be the exact desktop-entry name briefly, as as_desktop_entry_parse_data
	 * uses it to synthesize a Launchable. We can reset it later. */
	if (cpt != NULL) {
		ncpt = g_object_ref (cpt);
		prev_cid = g_strdup (as_component_get_id (cpt));
		as_component_set_id (ncpt, de_basename);
		cpt_is_new = FALSE;
	} else {
		ncpt = as_component_new ();
		as_component_set_id (ncpt, de_basename);
		cpt_is_new = TRUE;
	}
	/* we shouldn't even try to use cpt from this point forward */
	cpt = NULL;

	/* prepare */
	issues = g_ptr_array_new_with_free_func (g_object_unref);
	data = g_bytes_get_data (bytes, &data_len);

	/* sanity check */
	if (data_len == 0) {
		asc_result_add_hint_by_cid (cres,
					    de_basename,
					    "desktop-file-error",
					    "msg",
					    "Desktop file was empty or nonexistent. "
					    "Please ensure that it isn't a symbolic link to "
					    "contents of a different package.",
					    NULL);
		return NULL;
	}

	/* actually parse the desktop-entry data now */
	ret = as_desktop_entry_parse_data (ncpt,
					   data,
					   data_len,
					   fversion,
					   ignore_nodisplay,
					   issues,
					   de_l10n_fn,
					   user_data,
					   &error);
	if (error != NULL) {
		asc_result_add_hint_by_cid (cres,
					    de_basename,
					    "desktop-file-error",
					    "msg",
					    error->message,
					    NULL);
		return NULL;
	}
	if (!ret)
		return NULL;

	if (!ignore_nodisplay && cpt == NULL) {
		/* If we shouldn't ignore NoDisplay & Co. and have no preexisting component, we
		 * will also skip any kind of settings or console app.
		 * Otherwise we would sythesize a component with usually extremely low-quality
		 * metadata for it (and additionally flood the software catalog with tons of settings apps
		 * for various DEs). This means any kind of settings app needs a metainfo file in order to be
		 * added to the software catalogs. */
		GPtrArray *categories = as_component_get_categories (ncpt);
		for (guint i = 0; i < categories->len; i++) {
			const gchar *tmp = g_ptr_array_index (categories, i);
			if (g_strcmp0 (tmp, "Settings") == 0)
				return NULL;
			if (g_strcmp0 (tmp, "DesktopSettings") == 0)
				return NULL;
			if (g_strcmp0 (tmp, "ConsoleOnly") == 0)
				return NULL;
		}
	}

	/* the desktop-entry parsing function may have overridden this, so we reset the original value if we had one */
	if (prev_cid != NULL)
		as_component_set_id (ncpt, prev_cid);

	/* reset component priority to neutral (may be lowest) */
	as_component_set_priority (ncpt, 0);

	/* add new component to the results set */
	if (cpt_is_new && !asc_result_add_component (cres, ncpt, bytes, NULL))
		return NULL;

	/* register all the hints we may have found */
	for (guint i = 0; i < issues->len; i++) {
		AsValidatorIssue *issue = AS_VALIDATOR_ISSUE (g_ptr_array_index (issues, i));
		g_autofree gchar *asv_tag = NULL;
		const gchar *issue_hint = NULL;
		const gchar *orig_tag = NULL;

		orig_tag = as_validator_issue_get_tag (issue);
		if ((g_strcmp0 (orig_tag, "desktop-entry-hidden-set") == 0) ||
		    (g_strcmp0 (orig_tag, "desktop-entry-empty-onlyshowin") == 0)) {
			/* these tags get special treatment with links & co */
			asv_tag = g_strdup (orig_tag);
		} else {
			asv_tag = g_strconcat ("asv-", orig_tag, NULL);
		}
		issue_hint = as_validator_issue_get_hint (issue);
		if (issue_hint == NULL)
			issue_hint = "";
		asc_result_add_hint (cres,
				     ncpt,
				     asv_tag,
				     "location",
				     de_basename,
				     "hint",
				     issue_hint,
				     NULL);
	}

	return g_steal_pointer (&ncpt);
}
