/*
 * Ibis - IRCv3 Library
 * Copyright (C) 2022-2024 Ibis Developers
 *
 * Ibis is the legal property of its developers, whose names are too numerous
 * to list here.  Please refer to the COPYRIGHT file distributed with this
 * source distribution.
 *
 * This library is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 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 General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this library; if not, see <https://www.gnu.org/licenses/>.
 */

#include "ibistags.h"

#include "ibisstring.h"

struct _IbisTags {
	GObject parent;

	GHashTable *tags;
	GQueue *keys;
};

/******************************************************************************
 * Helpers
 *****************************************************************************/
static char *
ibis_tag_unescape_value(const char *escaped) {
	GString *unescaped = NULL;
	gboolean escaping = FALSE;

	g_return_val_if_fail(escaped != NULL, NULL);

	unescaped = g_string_new("");

	/* Walk the string and replace escaped values according to
	 * https://ircv3.net/specs/extensions/message-tags.html#escaping-values
	 */
	for(int i = 0; escaped[i] != '\0'; i++) {
		if(escaping) {
			/* Set the replacement to the current character which will fall
			 * through for everything, including '\\'.
			 */
			char replacement = escaped[i];

			if(escaped[i] == ':') {
				replacement = ';';
			} else if(escaped[i] == 's') {
				replacement = ' ';
			} else if(escaped[i] == 'r') {
				replacement = '\r';
			} else if(escaped[i] == 'n') {
				replacement = '\n';
			}

			g_string_append_c(unescaped, replacement);
			escaping = FALSE;
		} else {
			if(escaped[i] == '\\') {
				escaping = TRUE;
			} else {
				g_string_append_c(unescaped, escaped[i]);
			}
		}
	}

	return g_string_free_and_steal(unescaped);
}

static char *
ibis_tag_escape_value(const char *value) {
	GString *escaped = NULL;

	g_return_val_if_fail(value != NULL, NULL);

	escaped = g_string_new("");

	for(int i = 0; value[i] != '\0'; i++) {
		if(value[i] == ';') {
			g_string_append(escaped, "\\:");
		} else if(value[i] == ' ') {
			g_string_append(escaped, "\\s");
		} else if(value[i] == '\\') {
			g_string_append(escaped, "\\\\");
		} else if(value[i] == '\r') {
			g_string_append(escaped, "\\r");
		} else if(value[i] == '\n') {
			g_string_append(escaped, "\\n");
		} else {
			g_string_append_c(escaped, value[i]);
		}
	}

	return g_string_free_and_steal(escaped);
}

/******************************************************************************
 * GObject Implementation
 *****************************************************************************/
G_DEFINE_FINAL_TYPE(IbisTags, ibis_tags, G_TYPE_OBJECT)

static void
ibis_tags_finalize(GObject *obj) {
	IbisTags *tags = IBIS_TAGS(obj);

	g_clear_pointer(&tags->tags, g_hash_table_unref);
	g_clear_pointer(&tags->keys, g_queue_free);

	G_OBJECT_CLASS(ibis_tags_parent_class)->finalize(obj);
}

static void
ibis_tags_init(IbisTags *tags) {
	tags->tags = g_hash_table_new_full(g_str_hash, g_str_equal, g_free,
	                                   g_free);
	tags->keys = g_queue_new();
}

static void
ibis_tags_class_init(IbisTagsClass *klass) {
	GObjectClass *obj_class = G_OBJECT_CLASS(klass);

	obj_class->finalize = ibis_tags_finalize;
}

/******************************************************************************
 * Public API
 *****************************************************************************/
void
ibis_tags_add(IbisTags *tags, const char *key, const char *value) {
	char *new_key = NULL;
	char *normalized_value = NULL;
	gboolean inserted = FALSE;

	g_return_if_fail(IBIS_IS_TAGS(tags));
	g_return_if_fail(!ibis_str_is_empty(key));

	/* Per the spec, empty tag values (`foo=`) must be treated like missing
	 * tag values (`foo`). So if a tag is not missing or empty, duplicate it,
	 * otherwise use the NULL value it was initialized with to make it have a
	 * missing value.
	 */
	if(!ibis_str_is_empty(value)) {
		normalized_value = g_strdup(value);
	}

	new_key = g_strdup(key);
	inserted = g_hash_table_insert(tags->tags, new_key, normalized_value);
	if(inserted) {
		g_queue_push_tail(tags->keys, new_key);
	}
}

void
ibis_tags_clear(IbisTags *tags) {
	g_return_if_fail(IBIS_IS_TAGS(tags));

	g_hash_table_remove_all(tags->tags);
	g_queue_clear(tags->keys);
}

gboolean
ibis_tags_contains(IbisTags *tags1, IbisTags *tags2) {
	GHashTableIter iter;
	gpointer expected_key = NULL;
	gpointer expected_value = NULL;

	g_return_val_if_fail(IBIS_IS_TAGS(tags1), FALSE);
	g_return_val_if_fail(IBIS_IS_TAGS(tags2), FALSE);

	/* We walk tags2 as that's our needle, and check if the keys and values
	 * exist in tags1.
	 */
	g_hash_table_iter_init(&iter, tags2->tags);
	while(g_hash_table_iter_next(&iter, &expected_key, &expected_value)) {
		gpointer actual_value = NULL;
		gboolean found = FALSE;

		found = g_hash_table_lookup_extended(tags1->tags, expected_key, NULL,
		                                     &actual_value);
		if(!found) {
			return FALSE;
		}

		if(!ibis_str_equal(expected_value, actual_value)) {
			return FALSE;
		}
	}

	return TRUE;
}

gboolean
ibis_tags_equal(IbisTags *tags1, IbisTags *tags2) {
	guint size1 = 0;
	guint size2 = 0;

	if(tags1 == NULL && tags2 == NULL) {
		return TRUE;
	}

	if(tags1 == NULL || tags2 == NULL) {
		return FALSE;
	}

	size1 = g_hash_table_size(tags1->tags);
	size2 = g_hash_table_size(tags2->tags);

	if(size1 != size2) {
		return FALSE;
	}

	return ibis_tags_contains(tags1, tags2);
}

gboolean
ibis_tags_exists(IbisTags *tags, const char *key) {
	g_return_val_if_fail(IBIS_IS_TAGS(tags), FALSE);
	g_return_val_if_fail(!ibis_str_is_empty(key), FALSE);

	return g_hash_table_contains(tags->tags, key);
}

guint
ibis_tags_get_size(IbisTags *tags) {
	g_return_val_if_fail(IBIS_IS_TAGS(tags), 0);

	return g_hash_table_size(tags->tags);
}

const char *
ibis_tags_lookup(IbisTags *tags, const char *key) {
	g_return_val_if_fail(IBIS_IS_TAGS(tags), FALSE);
	g_return_val_if_fail(!ibis_str_is_empty(key), FALSE);

	return g_hash_table_lookup(tags->tags, key);
}

IbisTags *
ibis_tags_new(void) {
	return g_object_new(IBIS_TYPE_TAGS, NULL);
}

IbisTags *
ibis_tags_parse(const char *tags_string, GError **error) {
	IbisTags *tags = NULL;

	g_return_val_if_fail(tags_string != NULL, NULL);

	tags = ibis_tags_new();
	ibis_tags_parse_string(tags, tags_string, error);

	return tags;
}

void
ibis_tags_parse_string(IbisTags *tags, const char *tags_string,
                       GError **error)
{
	GError *local_error = NULL;
	GMatchInfo *info = NULL;
	GRegex *regex = NULL;
	gboolean matches = FALSE;

	g_return_if_fail(IBIS_IS_TAGS(tags));
	g_return_if_fail(tags_string != NULL);

	ibis_tags_clear(tags);

	/* tags_string can never be NULL, because g_match_info_fetch_named always
	 * returns a string. So if we were passed an empty string, just return the
	 * empty hash table.
	 */
	if(*tags_string == '\0') {
		return;
	}

	regex = g_regex_new("(?<key>(?<client_prefix>\\+?)"
	                           "(?:(?<vendor>[A-Za-z0-9-\\.]+)\\/)?"
	                           "(?<key_name>[A-Za-z0-9-]+)"
	                    ")"
	                    "(?:=(?<value>[^\r\n;]*))?(?:;|$)",
	                    0, 0, NULL);
	g_assert(regex != NULL);

	matches = g_regex_match_full(regex, tags_string, -1, 0, 0, &info,
	                             &local_error);

	g_clear_pointer(&regex, g_regex_unref);

	if(local_error != NULL) {
		g_propagate_error(error, local_error);

		g_clear_pointer(&info, g_match_info_unref);

		return;
	}

	if(!matches) {
		g_set_error_literal(error, IBIS_TAGS_ERROR, 0,
		                    "failed to parse tags: unknown error");

		g_clear_pointer(&info, g_match_info_unref);

		return;
	}

	while(g_match_info_matches(info)) {
		char *key = NULL;
		char *value = NULL;
		char *unescaped = NULL;

		key = g_match_info_fetch_named(info, "key");
		value = g_match_info_fetch_named(info, "value");

		unescaped = ibis_tag_unescape_value(value);
		g_free(value);

		ibis_tags_add(tags, key, unescaped);

		g_free(key);
		g_free(unescaped);

		g_match_info_next(info, &local_error);

		if(local_error != NULL) {
			g_propagate_error(error, local_error);

			break;
		}
	}

	g_clear_pointer(&info, g_match_info_unref);
}

gboolean
ibis_tags_remove(IbisTags *tags, const char *key) {
	gpointer original_key = NULL;

	g_return_val_if_fail(IBIS_IS_TAGS(tags), FALSE);
	g_return_val_if_fail(!ibis_str_is_empty(key), FALSE);

	if(g_hash_table_lookup_extended(tags->tags, key, &original_key, NULL)) {
		g_queue_remove(tags->keys, original_key);
	}

	return g_hash_table_remove(tags->tags, key);
}

char *
ibis_tags_serialize(IbisTags *tags) {
	GString *str = NULL;
	gboolean first = TRUE;

	g_return_val_if_fail(IBIS_IS_TAGS(tags), NULL);

	str = g_string_new("");

	for(GList *l = tags->keys->head; l != NULL; l = l->next) {
		char *key = l->data;
		char *value = NULL;

		value = g_hash_table_lookup(tags->tags, key);

		if(!first) {
			g_string_append_printf(str, ";%s", (char *)key);
		} else {
			g_string_append_printf(str, "@%s", (char *)key);
			first = FALSE;
		}

		/* Normalize missing and empty values to missing. */
		if(!ibis_str_is_empty(value)) {
			char *escaped = ibis_tag_escape_value(value);

			g_string_append_printf(str, "=%s", escaped);
			g_free(escaped);
		}
	}

	return g_string_free_and_steal(str);
}
