/* -*- Mode: C; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 8 -*- */
/*
 * Copyright 2022 Igalia S.L.
 */

#ifdef HAVE_CONFIG_H
#include <config.h>
#endif

#include "soup-connection-manager.h"
#include "soup-message-private.h"
#include "soup-misc.h"
#include "soup-session-private.h"
#include "soup-uri-utils-private.h"
#include "soup.h"

struct _SoupConnectionManager {
        SoupSession *session;

        GMutex mutex;
        GCond cond;
        GSocketConnectable *remote_connectable;
        guint max_conns;
        guint max_conns_per_host;
        guint num_conns;

        GHashTable *http_hosts;
        GHashTable *https_hosts;
        GHashTable *conns;

        guint64 last_connection_id;
};

typedef struct {
        GUri *uri;
        GHashTable *owner_map;
        GNetworkAddress *addr;

        GList *conns;
        guint  num_conns;

        GSource *keep_alive_src;
        SoupConnectionManager *conn_manager;
} SoupHost;

#define HOST_KEEP_ALIVE 5 * 60 * 1000 /* 5 min in msecs */

static SoupHost *
soup_host_new (GUri       *uri,
               GHashTable *owner_map)
{
        SoupHost *host;
        const char *scheme = g_uri_get_scheme (uri);

        host = g_new0 (SoupHost, 1);
        host->owner_map = owner_map;
        if (g_strcmp0 (scheme, "http") != 0 && g_strcmp0 (scheme, "https") != 0) {
                host->uri = soup_uri_copy (uri,
                                           SOUP_URI_SCHEME, soup_uri_is_https (uri) ? "https" : "http",
                                           SOUP_URI_NONE);
        } else
                host->uri = g_uri_ref (uri);

        host->addr = g_object_new (G_TYPE_NETWORK_ADDRESS,
                                   "hostname", g_uri_get_host (host->uri),
                                   "port", g_uri_get_port (host->uri),
                                   "scheme", g_uri_get_scheme (host->uri),
                                   NULL);

        g_hash_table_insert (host->owner_map, host->uri, host);

        return host;
}

static void
soup_host_free (SoupHost *host)
{
        g_warn_if_fail (host->conns == NULL);

        if (host->keep_alive_src) {
                g_source_destroy (host->keep_alive_src);
                g_source_unref (host->keep_alive_src);
        }

        g_uri_unref (host->uri);
        g_object_unref (host->addr);
        g_free (host);
}

/* Note that we can't use soup_uri_host_hash() and soup_uri_host_equal()
 * because we want to ignore the protocol; http://example.com and
 * webcal://example.com are the same host.
 */
static guint
soup_host_uri_hash (gconstpointer key)
{
        GUri *uri = (GUri*)key;

        g_warn_if_fail (uri != NULL && g_uri_get_host (uri) != NULL);

        return g_uri_get_port (uri) + soup_str_case_hash (g_uri_get_host (uri));
}

static gboolean
soup_host_uri_equal (gconstpointer v1, gconstpointer v2)
{
        GUri *one = (GUri*)v1;
        GUri *two = (GUri*)v2;

        g_warn_if_fail (one != NULL && two != NULL);

        const char *one_host = g_uri_get_host (one);
        const char *two_host = g_uri_get_host (two);
        g_warn_if_fail (one_host != NULL && two_host != NULL);

        if (g_uri_get_port (one) != g_uri_get_port (two))
                return FALSE;

        return g_ascii_strcasecmp (one_host, two_host) == 0;
}

static gboolean
free_unused_host (gpointer user_data)
{
        SoupHost *host = (SoupHost *)user_data;

        if (host->conns)
                return FALSE;

        /* This will free the host in addition to removing it from the hash table */
        g_hash_table_remove (host->owner_map, host->uri);

        return FALSE;
}

static void
soup_host_add_connection (SoupHost       *host,
                          SoupConnection *conn)
{
        host->conns = g_list_prepend (host->conns, conn);
        host->num_conns++;

        if (host->keep_alive_src) {
                g_source_destroy (host->keep_alive_src);
                g_source_unref (host->keep_alive_src);
                host->keep_alive_src = NULL;
        }
}

static void
soup_host_remove_connection (SoupHost       *host,
                             SoupConnection *conn)
{
        host->conns = g_list_remove (host->conns, conn);
        host->num_conns--;

        /* Free the SoupHost (and its GNetworkAddress) if there
         * has not been any new connection to the host during
         * the last HOST_KEEP_ALIVE msecs.
         */
        if (host->num_conns == 0) {
                g_assert (host->keep_alive_src == NULL);
                host->keep_alive_src = soup_add_timeout (g_main_context_get_thread_default (),
                                                         HOST_KEEP_ALIVE,
                                                         free_unused_host,
                                                         host);
        }
}

static SoupHost *
soup_connection_manager_get_host_for_message (SoupConnectionManager *manager,
                                              SoupMessage           *msg)
{
        GUri *uri = soup_message_get_uri (msg);
        SoupHost *host;
        GHashTable *map;

        map = soup_uri_is_https (uri) ?  manager->https_hosts : manager->http_hosts;
        host = g_hash_table_lookup (map, uri);
        if (!host)
                host = soup_host_new (uri, map);

        return host;
}

SoupConnectionManager *
soup_connection_manager_new (SoupSession *session,
                             guint        max_conns,
                             guint        max_conns_per_host)
{
        SoupConnectionManager *manager;

        manager = g_new0 (SoupConnectionManager, 1);
        manager->session = session;
        manager->max_conns = max_conns;
        manager->max_conns_per_host = max_conns_per_host;
        manager->http_hosts = g_hash_table_new_full (soup_host_uri_hash,
                                                     soup_host_uri_equal,
                                                     NULL,
                                                     (GDestroyNotify)soup_host_free);
        manager->https_hosts = g_hash_table_new_full (soup_host_uri_hash,
                                                      soup_host_uri_equal,
                                                      NULL,
                                                      (GDestroyNotify)soup_host_free);
        manager->conns = g_hash_table_new (NULL, NULL);
        g_mutex_init (&manager->mutex);
        g_cond_init (&manager->cond);

        return manager;
}

void
soup_connection_manager_free (SoupConnectionManager *manager)
{
        g_clear_object (&manager->remote_connectable);
        g_hash_table_destroy (manager->http_hosts);
        g_hash_table_destroy (manager->https_hosts);
        g_hash_table_destroy (manager->conns);
        g_mutex_clear (&manager->mutex);
        g_cond_clear (&manager->cond);

        g_free (manager);
}

void
soup_connection_manager_set_max_conns (SoupConnectionManager *manager,
                                       guint                  max_conns)
{
        g_assert (manager->num_conns == 0);
        manager->max_conns = max_conns;
}

guint
soup_connection_manager_get_max_conns (SoupConnectionManager *manager)
{
        return manager->max_conns;
}

void
soup_connection_manager_set_max_conns_per_host (SoupConnectionManager *manager,
                                                guint                  max_conns_per_host)
{
        g_assert (manager->num_conns == 0);
        manager->max_conns_per_host = max_conns_per_host;
}

guint
soup_connection_manager_get_max_conns_per_host (SoupConnectionManager *manager)
{
        return manager->max_conns_per_host;
}

void
soup_connection_manager_set_remote_connectable (SoupConnectionManager *manager,
                                                GSocketConnectable    *connectable)
{
        g_assert (manager->num_conns == 0);
        manager->remote_connectable = connectable ? g_object_ref (connectable) : NULL;
}

GSocketConnectable *
soup_connection_manager_get_remote_connectable (SoupConnectionManager *manager)
{
        return manager->remote_connectable;
}

guint
soup_connection_manager_get_num_conns (SoupConnectionManager *manager)
{
        return manager->num_conns;
}

static void
soup_connection_manager_drop_connection (SoupConnectionManager *manager,
                                         SoupConnection        *conn)
{
        g_signal_handlers_disconnect_by_data (conn, manager);
        manager->num_conns--;
        g_object_unref (conn);

        g_cond_broadcast (&manager->cond);
}

static void
soup_connection_list_disconnect_all (GList *conns)
{
        GList *c;

        for (c = conns; c; c = g_list_next (c)) {
                SoupConnection *conn = (SoupConnection *)c->data;

                soup_connection_disconnect (conn);
                g_object_unref (conn);
	}
        g_list_free (conns);
}

static GList *
soup_connection_manager_cleanup_locked (SoupConnectionManager *manager,
                                        gboolean               cleanup_idle)
{
        GList *conns = NULL;
        GHashTableIter iter;
        SoupConnection *conn;
        SoupHost *host;

        g_hash_table_iter_init (&iter, manager->conns);
        while (g_hash_table_iter_next (&iter, (gpointer *)&conn, (gpointer *)&host)) {
                SoupConnectionState state;

                state = soup_connection_get_state (conn);
                if (state == SOUP_CONNECTION_IDLE && (cleanup_idle || !soup_connection_is_idle_open (conn))) {
                        conns = g_list_prepend (conns, g_object_ref (conn));
                        g_hash_table_iter_remove (&iter);
                        soup_host_remove_connection (host, conn);
                        soup_connection_manager_drop_connection (manager, conn);
                }
        }

        return conns;
}

static void
connection_disconnected (SoupConnection        *conn,
                         SoupConnectionManager *manager)
{
        SoupHost *host = NULL;

        g_mutex_lock (&manager->mutex);
        g_hash_table_steal_extended (manager->conns, conn, NULL, (gpointer *)&host);
        if (host)
                soup_host_remove_connection (host, conn);
        soup_connection_manager_drop_connection (manager, conn);
        g_mutex_unlock (&manager->mutex);

        soup_session_kick_queue (manager->session);
}

static void
connection_state_changed (SoupConnection        *conn,
                          GParamSpec            *param,
                          SoupConnectionManager *manager)
{
        if (soup_connection_get_state (conn) != SOUP_CONNECTION_IDLE)
                return;

        g_mutex_lock (&manager->mutex);
        g_cond_broadcast (&manager->cond);
        g_mutex_unlock (&manager->mutex);

        soup_session_kick_queue (manager->session);
}

static SoupConnection *
soup_connection_manager_get_connection_locked (SoupConnectionManager *manager,
                                               SoupMessageQueueItem  *item)
{
        SoupMessage *msg = item->msg;
        gboolean need_new_connection;
        SoupConnection *conn;
        SoupSocketProperties *socket_props;
        SoupHost *host;
        guint8 force_http_version;
        GList *l;
        GSocketConnectable *remote_connectable;
        gboolean try_cleanup = TRUE;

        need_new_connection =
                (soup_message_query_flags (msg, SOUP_MESSAGE_NEW_CONNECTION)) ||
                (soup_message_is_misdirected_retry (msg)) ||
                (!soup_message_query_flags (msg, SOUP_MESSAGE_IDEMPOTENT) &&
                 !SOUP_METHOD_IS_IDEMPOTENT (soup_message_get_method (msg)));

        host = soup_connection_manager_get_host_for_message (manager, msg);

        force_http_version = g_getenv ("SOUP_FORCE_HTTP1") ? SOUP_HTTP_1_1 : soup_message_get_force_http_version (msg);
        while (TRUE) {
                for (l = host->conns; l && l->data; l = g_list_next (l)) {
                        SoupHTTPVersion http_version;

                        conn = (SoupConnection *)l->data;

                        http_version = soup_connection_get_negotiated_protocol (conn);
                        if (force_http_version <= SOUP_HTTP_2_0 && http_version != force_http_version)
                                continue;

                        switch (soup_connection_get_state (conn)) {
                        case SOUP_CONNECTION_IN_USE:
                                if (!need_new_connection && http_version == SOUP_HTTP_2_0 && soup_connection_get_owner (conn) == g_thread_self () && soup_connection_is_reusable (conn))
                                        return conn;
                                break;
                        case SOUP_CONNECTION_IDLE:
                                if (!need_new_connection && soup_connection_is_idle_open (conn))
                                        return conn;
                                break;
                        case SOUP_CONNECTION_CONNECTING:
                                if (soup_session_steal_preconnection (item->session, item, conn))
                                        return conn;

                                /* Always wait if we have a pending connection as it may be
                                 * an h2 connection which will be shared. http/1.x connections
                                 * will only be slightly delayed. */
                                if (force_http_version > SOUP_HTTP_1_1 && !need_new_connection && !item->connect_only && item->async && soup_connection_get_owner (conn) == g_thread_self ())
                                        return NULL;
                        default:
                                break;
                        }
                }

                if (host->num_conns >= manager->max_conns_per_host) {
                        if (need_new_connection && try_cleanup) {
                                GList *conns;

                                try_cleanup = FALSE;
                                conns = soup_connection_manager_cleanup_locked (manager, TRUE);
                                if (conns) {
                                        /* The connection has already been removed and the signals disconnected so,
                                         * it's ok to disconnect with the mutex locked.
                                         */
                                        soup_connection_list_disconnect_all (conns);
                                        continue;
                                }
                        }

                        if (item->async)
                                return NULL;

                        g_cond_wait (&manager->cond, &manager->mutex);
                        try_cleanup = TRUE;
                        continue;
                }

                if (manager->num_conns >= manager->max_conns) {
                        if (try_cleanup) {
                                GList *conns;

                                try_cleanup = FALSE;
                                conns = soup_connection_manager_cleanup_locked (manager, TRUE);
                                if (conns) {
                                        /* The connection has already been removed and the signals disconnected so,
                                         * it's ok to disconnect with the mutex locked.
                                         */
                                        soup_connection_list_disconnect_all (conns);
                                        continue;
                                }
                        }

                        if (item->async)
                                return NULL;

                        g_cond_wait (&manager->cond, &manager->mutex);
                        try_cleanup = TRUE;
                        continue;
                }

                break;
        }

        /* Create a new connection */
        remote_connectable = manager->remote_connectable ? manager->remote_connectable : G_SOCKET_CONNECTABLE (host->addr);
        socket_props = soup_session_ensure_socket_props (item->session);
        conn = g_object_new (SOUP_TYPE_CONNECTION,
                             "id", ++manager->last_connection_id,
                             "context", soup_session_get_context (item->session),
                             "remote-connectable", remote_connectable,
                             "ssl", soup_uri_is_https (host->uri),
                             "socket-properties", socket_props,
                             "force-http-version", force_http_version,
                             NULL);

        g_signal_connect (conn, "disconnected",
                          G_CALLBACK (connection_disconnected),
                          manager);
        g_signal_connect (conn, "notify::state",
                          G_CALLBACK (connection_state_changed),
                          manager);

        g_hash_table_insert (manager->conns, conn, host);

        manager->num_conns++;
        soup_host_add_connection (host, conn);

        return conn;
}

SoupConnection *
soup_connection_manager_get_connection (SoupConnectionManager *manager,
                                        SoupMessageQueueItem  *item)
{
        SoupConnection *conn;

        soup_connection_manager_cleanup (manager, FALSE);

        conn = soup_message_get_connection (item->msg);
        if (conn) {
                g_warn_if_fail (soup_connection_get_state (conn) != SOUP_CONNECTION_DISCONNECTED);
                g_object_unref (conn);
                return conn;
        }

        g_mutex_lock (&manager->mutex);
        conn = soup_connection_manager_get_connection_locked (manager, item);
        if (conn)
                soup_message_set_connection (item->msg, conn);
        g_mutex_unlock (&manager->mutex);

        return conn;
}

gboolean
soup_connection_manager_cleanup (SoupConnectionManager *manager,
                                 gboolean               cleanup_idle)
{
        GList *conns;

        g_mutex_lock (&manager->mutex);
        conns = soup_connection_manager_cleanup_locked (manager, cleanup_idle);
        g_mutex_unlock (&manager->mutex);

        if (conns) {
                soup_connection_list_disconnect_all (conns);

                return TRUE;
        }

        return FALSE;
}

GIOStream *
soup_connection_manager_steal_connection (SoupConnectionManager *manager,
                                          SoupMessage           *msg)
{
        SoupConnection *conn;
        SoupHost *host;
        GIOStream *stream;

        conn = soup_message_get_connection (msg);
        if (!conn)
                return NULL;

        if (soup_connection_get_state (conn) != SOUP_CONNECTION_IN_USE) {
                g_object_unref (conn);
                return NULL;
        }

        g_mutex_lock (&manager->mutex);
        host = soup_connection_manager_get_host_for_message (manager, msg);
        g_hash_table_remove (manager->conns, conn);
        soup_host_remove_connection (host, conn);
        soup_connection_manager_drop_connection (manager, conn);
        g_mutex_unlock (&manager->mutex);

        stream = soup_connection_steal_iostream (conn);
        soup_message_set_connection (msg, NULL);
        g_object_unref (conn);

        return stream;
}
