/*
 * Copyright 2023 Vsevolod Stakhov
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
#include "config.h"
#include "learn_cache.h"
#include "rspamd.h"
#include "stat_api.h"
#include "stat_internal.h"
#include "cryptobox.h"
#include "ucl.h"
#include "hiredis.h"
#include "adapters/libev.h"
#include "lua/lua_common.h"
#include "libmime/message.h"

#define REDIS_DEFAULT_TIMEOUT 0.5
#define REDIS_STAT_TIMEOUT 30
#define REDIS_DEFAULT_PORT 6379
#define DEFAULT_REDIS_KEY "learned_ids"

static const gchar *M = "redis learn cache";

struct rspamd_redis_cache_ctx {
	lua_State *L;
	struct rspamd_statfile_config *stcf;
	const gchar *username;
	const gchar *password;
	const gchar *dbname;
	const gchar *redis_object;
	gdouble timeout;
	gint conf_ref;
};

struct rspamd_redis_cache_runtime {
	struct rspamd_redis_cache_ctx *ctx;
	struct rspamd_task *task;
	struct upstream *selected;
	ev_timer timer_ev;
	redisAsyncContext *redis;
	gboolean has_event;
};

static GQuark
rspamd_stat_cache_redis_quark(void)
{
	return g_quark_from_static_string(M);
}

static inline struct upstream_list *
rspamd_redis_get_servers(struct rspamd_redis_cache_ctx *ctx,
						 const gchar *what)
{
	lua_State *L = ctx->L;
	struct upstream_list *res;

	lua_rawgeti(L, LUA_REGISTRYINDEX, ctx->conf_ref);
	lua_pushstring(L, what);
	lua_gettable(L, -2);
	res = *((struct upstream_list **) lua_touserdata(L, -1));
	lua_settop(L, 0);

	return res;
}

static void
rspamd_redis_cache_maybe_auth(struct rspamd_redis_cache_ctx *ctx,
							  redisAsyncContext *redis)
{
	if (ctx->username) {
		if (ctx->password) {
			redisAsyncCommand(redis, NULL, NULL, "AUTH %s %s", ctx->username, ctx->password);
		}
		else {
			msg_warn("Redis requires a password when username is supplied");
		}
	}
	else if (ctx->password) {
		redisAsyncCommand(redis, NULL, NULL, "AUTH %s", ctx->password);
	}
	if (ctx->dbname) {
		redisAsyncCommand(redis, NULL, NULL, "SELECT %s", ctx->dbname);
	}
}

/* Called on connection termination */
static void
rspamd_redis_cache_fin(gpointer data)
{
	struct rspamd_redis_cache_runtime *rt = data;
	redisAsyncContext *redis;

	rt->has_event = FALSE;
	ev_timer_stop(rt->task->event_loop, &rt->timer_ev);

	if (rt->redis) {
		redis = rt->redis;
		rt->redis = NULL;
		/* This calls for all callbacks pending */
		redisAsyncFree(redis);
	}
}

static void
rspamd_redis_cache_timeout(EV_P_ ev_timer *w, int revents)
{
	struct rspamd_redis_cache_runtime *rt =
		(struct rspamd_redis_cache_runtime *) w->data;
	struct rspamd_task *task;

	task = rt->task;

	msg_err_task("connection to redis server %s timed out",
				 rspamd_upstream_name(rt->selected));
	rspamd_upstream_fail(rt->selected, FALSE, "timeout");

	if (rt->has_event) {
		rspamd_session_remove_event(task->s, rspamd_redis_cache_fin, rt);
	}
}

/* Called when we have checked the specified message id */
static void
rspamd_stat_cache_redis_get(redisAsyncContext *c, gpointer r, gpointer priv)
{
	struct rspamd_redis_cache_runtime *rt = priv;
	redisReply *reply = r;
	struct rspamd_task *task;
	glong val = 0;

	task = rt->task;

	if (c->err == 0) {
		if (reply) {
			if (G_LIKELY(reply->type == REDIS_REPLY_INTEGER)) {
				val = reply->integer;
			}
			else if (reply->type == REDIS_REPLY_STRING) {
				rspamd_strtol(reply->str, reply->len, &val);
			}
			else {
				if (reply->type == REDIS_REPLY_ERROR) {
					msg_err_task("cannot learn %s: redis error: \"%s\"",
								 rt->ctx->stcf->symbol, reply->str);
				}
				else if (reply->type != REDIS_REPLY_NIL) {
					msg_err_task("bad learned type for %s: %d",
								 rt->ctx->stcf->symbol, reply->type);
				}

				val = 0;
			}
		}

		if ((val > 0 && (task->flags & RSPAMD_TASK_FLAG_LEARN_SPAM)) ||
			(val < 0 && (task->flags & RSPAMD_TASK_FLAG_LEARN_HAM))) {
			/* Already learned */
			msg_info_task("<%s> has been already "
						  "learned as %s, ignore it",
						  MESSAGE_FIELD(task, message_id),
						  (task->flags & RSPAMD_TASK_FLAG_LEARN_SPAM) ? "spam" : "ham");
			task->flags |= RSPAMD_TASK_FLAG_ALREADY_LEARNED;
		}
		else if (val != 0) {
			/* Unlearn flag */
			task->flags |= RSPAMD_TASK_FLAG_UNLEARN;
		}

		rspamd_upstream_ok(rt->selected);
	}
	else {
		rspamd_upstream_fail(rt->selected, FALSE, c->errstr);
	}

	if (rt->has_event) {
		rspamd_session_remove_event(task->s, rspamd_redis_cache_fin, rt);
	}
}

/* Called when we have learned the specified message id */
static void
rspamd_stat_cache_redis_set(redisAsyncContext *c, gpointer r, gpointer priv)
{
	struct rspamd_redis_cache_runtime *rt = priv;
	struct rspamd_task *task;

	task = rt->task;

	if (c->err == 0) {
		/* XXX: we ignore results here */
		rspamd_upstream_ok(rt->selected);
	}
	else {
		rspamd_upstream_fail(rt->selected, FALSE, c->errstr);
	}

	if (rt->has_event) {
		rspamd_session_remove_event(task->s, rspamd_redis_cache_fin, rt);
	}
}

static void
rspamd_stat_cache_redis_generate_id(struct rspamd_task *task)
{
	rspamd_cryptobox_hash_state_t st;
	rspamd_token_t *tok;
	guint i;
	guchar out[rspamd_cryptobox_HASHBYTES];
	gchar *b32out;
	gchar *user = NULL;

	rspamd_cryptobox_hash_init(&st, NULL, 0);

	user = rspamd_mempool_get_variable(task->task_pool, "stat_user");
	/* Use dedicated hash space for per users cache */
	if (user != NULL) {
		rspamd_cryptobox_hash_update(&st, user, strlen(user));
	}

	for (i = 0; i < task->tokens->len; i++) {
		tok = g_ptr_array_index(task->tokens, i);
		rspamd_cryptobox_hash_update(&st, (guchar *) &tok->data,
									 sizeof(tok->data));
	}

	rspamd_cryptobox_hash_final(&st, out);

	b32out = rspamd_mempool_alloc(task->task_pool,
								  sizeof(out) * 8 / 5 + 3);
	i = rspamd_encode_base32_buf(out, sizeof(out), b32out,
								 sizeof(out) * 8 / 5 + 2, RSPAMD_BASE32_DEFAULT);

	if (i > 0) {
		/* Zero terminate */
		b32out[i] = '\0';
	}

	rspamd_mempool_set_variable(task->task_pool, "words_hash", b32out, NULL);
}

gpointer
rspamd_stat_cache_redis_init(struct rspamd_stat_ctx *ctx,
							 struct rspamd_config *cfg,
							 struct rspamd_statfile *st,
							 const ucl_object_t *cf)
{
	struct rspamd_redis_cache_ctx *cache_ctx;
	struct rspamd_statfile_config *stf = st->stcf;
	const ucl_object_t *obj;
	gboolean ret = FALSE;
	lua_State *L = (lua_State *) cfg->lua_state;
	gint conf_ref = -1;

	cache_ctx = g_malloc0(sizeof(*cache_ctx));
	cache_ctx->timeout = REDIS_DEFAULT_TIMEOUT;
	cache_ctx->L = L;

	/* First search in backend configuration */
	obj = ucl_object_lookup(st->classifier->cfg->opts, "backend");
	if (obj != NULL && ucl_object_type(obj) == UCL_OBJECT) {
		ret = rspamd_lua_try_load_redis(L, obj, cfg, &conf_ref);
	}

	/* Now try statfiles config */
	if (!ret && stf->opts) {
		ret = rspamd_lua_try_load_redis(L, stf->opts, cfg, &conf_ref);
	}

	/* Now try classifier config */
	if (!ret && st->classifier->cfg->opts) {
		ret = rspamd_lua_try_load_redis(L, st->classifier->cfg->opts, cfg, &conf_ref);
	}

	/* Now try global redis settings */
	if (!ret) {
		obj = ucl_object_lookup(cfg->cfg_ucl_obj, "redis");

		if (obj) {
			const ucl_object_t *specific_obj;

			specific_obj = ucl_object_lookup(obj, "statistics");

			if (specific_obj) {
				ret = rspamd_lua_try_load_redis(L,
												specific_obj, cfg, &conf_ref);
			}
			else {
				ret = rspamd_lua_try_load_redis(L,
												obj, cfg, &conf_ref);
			}
		}
	}

	if (!ret) {
		msg_err_config("cannot init redis cache for %s", stf->symbol);
		g_free(cache_ctx);
		return NULL;
	}

	obj = ucl_object_lookup(st->classifier->cfg->opts, "cache_key");

	if (obj) {
		cache_ctx->redis_object = ucl_object_tostring(obj);
	}
	else {
		cache_ctx->redis_object = DEFAULT_REDIS_KEY;
	}

	cache_ctx->conf_ref = conf_ref;

	/* Check some common table values */
	lua_rawgeti(L, LUA_REGISTRYINDEX, conf_ref);

	lua_pushstring(L, "timeout");
	lua_gettable(L, -2);
	if (lua_type(L, -1) == LUA_TNUMBER) {
		cache_ctx->timeout = lua_tonumber(L, -1);
	}
	lua_pop(L, 1);

	lua_pushstring(L, "db");
	lua_gettable(L, -2);
	if (lua_type(L, -1) == LUA_TSTRING) {
		cache_ctx->dbname = rspamd_mempool_strdup(cfg->cfg_pool,
												  lua_tostring(L, -1));
	}
	lua_pop(L, 1);

	lua_pushstring(L, "username");
	lua_gettable(L, -2);
	if (lua_type(L, -1) == LUA_TSTRING) {
		cache_ctx->username = rspamd_mempool_strdup(cfg->cfg_pool,
													lua_tostring(L, -1));
	}
	lua_pop(L, 1);

	lua_pushstring(L, "password");
	lua_gettable(L, -2);
	if (lua_type(L, -1) == LUA_TSTRING) {
		cache_ctx->password = rspamd_mempool_strdup(cfg->cfg_pool,
													lua_tostring(L, -1));
	}
	lua_pop(L, 1);

	lua_settop(L, 0);

	cache_ctx->stcf = stf;

	return (gpointer) cache_ctx;
}

gpointer
rspamd_stat_cache_redis_runtime(struct rspamd_task *task,
								gpointer c, gboolean learn)
{
	struct rspamd_redis_cache_ctx *ctx = c;
	struct rspamd_redis_cache_runtime *rt;
	struct upstream *up;
	struct upstream_list *ups;
	rspamd_inet_addr_t *addr;

	g_assert(ctx != NULL);

	if (task->tokens == NULL || task->tokens->len == 0) {
		return NULL;
	}

	if (learn) {
		ups = rspamd_redis_get_servers(ctx, "write_servers");

		if (!ups) {
			msg_err_task("no write servers defined for %s, cannot learn",
						 ctx->stcf->symbol);
			return NULL;
		}

		up = rspamd_upstream_get(ups,
								 RSPAMD_UPSTREAM_MASTER_SLAVE,
								 NULL,
								 0);
	}
	else {
		ups = rspamd_redis_get_servers(ctx, "read_servers");

		if (!ups) {
			msg_err_task("no read servers defined for %s, cannot check",
						 ctx->stcf->symbol);
			return NULL;
		}

		up = rspamd_upstream_get(ups,
								 RSPAMD_UPSTREAM_ROUND_ROBIN,
								 NULL,
								 0);
	}

	if (up == NULL) {
		msg_err_task("no upstreams reachable");
		return NULL;
	}

	rt = rspamd_mempool_alloc0(task->task_pool, sizeof(*rt));
	rt->selected = up;
	rt->task = task;
	rt->ctx = ctx;

	addr = rspamd_upstream_addr_next(up);
	g_assert(addr != NULL);

	if (rspamd_inet_address_get_af(addr) == AF_UNIX) {
		rt->redis = redisAsyncConnectUnix(rspamd_inet_address_to_string(addr));
	}
	else {
		rt->redis = redisAsyncConnect(rspamd_inet_address_to_string(addr),
									  rspamd_inet_address_get_port(addr));
	}

	if (rt->redis == NULL) {
		msg_warn_task("cannot connect to redis server %s: %s",
					  rspamd_inet_address_to_string_pretty(addr),
					  strerror(errno));

		return NULL;
	}
	else if (rt->redis->err != REDIS_OK) {
		msg_warn_task("cannot connect to redis server %s: %s",
					  rspamd_inet_address_to_string_pretty(addr),
					  rt->redis->errstr);
		redisAsyncFree(rt->redis);
		rt->redis = NULL;

		return NULL;
	}

	redisLibevAttach(task->event_loop, rt->redis);

	/* Now check stats */
	rt->timer_ev.data = rt;
	ev_timer_init(&rt->timer_ev, rspamd_redis_cache_timeout,
				  rt->ctx->timeout, 0.0);
	rspamd_redis_cache_maybe_auth(ctx, rt->redis);

	if (!learn) {
		rspamd_stat_cache_redis_generate_id(task);
	}

	return rt;
}

gint rspamd_stat_cache_redis_check(struct rspamd_task *task,
								   gboolean is_spam,
								   gpointer runtime)
{
	struct rspamd_redis_cache_runtime *rt = runtime;
	gchar *h;

	if (rspamd_session_blocked(task->s)) {
		return RSPAMD_LEARN_IGNORE;
	}

	h = rspamd_mempool_get_variable(task->task_pool, "words_hash");

	if (h == NULL) {
		return RSPAMD_LEARN_IGNORE;
	}

	if (redisAsyncCommand(rt->redis, rspamd_stat_cache_redis_get, rt,
						  "HGET %s %s",
						  rt->ctx->redis_object, h) == REDIS_OK) {
		rspamd_session_add_event(task->s,
								 rspamd_redis_cache_fin,
								 rt,
								 M);
		ev_timer_start(rt->task->event_loop, &rt->timer_ev);
		rt->has_event = TRUE;
	}

	/* We need to return OK every time */
	return RSPAMD_LEARN_OK;
}

gint rspamd_stat_cache_redis_learn(struct rspamd_task *task,
								   gboolean is_spam,
								   gpointer runtime)
{
	struct rspamd_redis_cache_runtime *rt = runtime;
	gchar *h;
	gint flag;

	if (rt == NULL || rt->ctx == NULL || rspamd_session_blocked(task->s)) {
		return RSPAMD_LEARN_IGNORE;
	}

	h = rspamd_mempool_get_variable(task->task_pool, "words_hash");
	g_assert(h != NULL);

	flag = (task->flags & RSPAMD_TASK_FLAG_LEARN_SPAM) ? 1 : -1;

	if (redisAsyncCommand(rt->redis, rspamd_stat_cache_redis_set, rt,
						  "HSET %s %s %d",
						  rt->ctx->redis_object, h, flag) == REDIS_OK) {
		rspamd_session_add_event(task->s,
								 rspamd_redis_cache_fin, rt, M);
		ev_timer_start(rt->task->event_loop, &rt->timer_ev);
		rt->has_event = TRUE;
	}

	/* We need to return OK every time */
	return RSPAMD_LEARN_OK;
}

void rspamd_stat_cache_redis_close(gpointer c)
{
	struct rspamd_redis_cache_ctx *ctx = (struct rspamd_redis_cache_ctx *) c;
	lua_State *L;

	L = ctx->L;

	if (ctx->conf_ref) {
		luaL_unref(L, LUA_REGISTRYINDEX, ctx->conf_ref);
	}

	g_free(ctx);
}
