/*
 * Copyright 2016 Red Hat, Inc. and/or its affiliates
 * and other contributors as indicated by the @author tags.
 *
 * 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.
 */

package org.keycloak.testsuite.model;

import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.ClassRule;
import org.junit.Rule;
import org.junit.Test;
import org.keycloak.common.util.Time;
import org.keycloak.models.AuthenticatedClientSessionModel;
import org.keycloak.models.ClientModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.UserSessionModel;
import org.keycloak.models.session.UserSessionPersisterProvider;
import org.keycloak.protocol.oidc.OIDCLoginProtocol;
import org.keycloak.services.managers.ClientManager;
import org.keycloak.services.managers.RealmManager;
import org.keycloak.models.UserManager;
import org.keycloak.services.managers.UserSessionManager;
import org.keycloak.testsuite.rule.KeycloakRule;
import org.keycloak.testsuite.rule.LoggingRule;

import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
 */
public class UserSessionProviderOfflineTest {

    @ClassRule
    public static KeycloakRule kc = new KeycloakRule();

    @Rule
    public LoggingRule loggingRule = new LoggingRule(this);

    private KeycloakSession session;
    private RealmModel realm;
    private UserSessionManager sessionManager;
    private UserSessionPersisterProvider persister;

    @Before
    public void before() {
        session = kc.startSession();
        realm = session.realms().getRealm("test");
        session.users().addUser(realm, "user1").setEmail("user1@localhost");
        session.users().addUser(realm, "user2").setEmail("user2@localhost");
        sessionManager = new UserSessionManager(session);
        persister = session.getProvider(UserSessionPersisterProvider.class);
    }

    @After
    public void after() {
        resetSession();
        session.sessions().removeUserSessions(realm);
        UserModel user1 = session.users().getUserByUsername("user1", realm);
        UserModel user2 = session.users().getUserByUsername("user2", realm);

        UserManager um = new UserManager(session);
        um.removeUser(realm, user1);
        um.removeUser(realm, user2);
        kc.stopSession(session, true);
    }


    @Test
    public void testOfflineSessionsCrud() {
        // Create some online sessions in infinispan
        int started = Time.currentTime();
        UserSessionModel[] origSessions = createSessions();

        resetSession();

        // Key is userSession ID, values are client UUIDS
        Map<String, Set<String>> offlineSessions = new HashMap<>();

        // Persist 3 created userSessions and clientSessions as offline
        ClientModel testApp = realm.getClientByClientId("test-app");
        List<UserSessionModel> userSessions = session.sessions().getUserSessions(realm, testApp);
        for (UserSessionModel userSession : userSessions) {
            offlineSessions.put(userSession.getId(), createOfflineSessionIncludeClientSessions(userSession));
        }

        resetSession();

        // Assert all previously saved offline sessions found
        for (Map.Entry<String, Set<String>> entry : offlineSessions.entrySet()) {
            UserSessionModel offlineSession = sessionManager.findOfflineUserSession(realm, entry.getKey());
            Assert.assertNotNull(offlineSession);
            Assert.assertEquals(offlineSession.getAuthenticatedClientSessions().keySet(), entry.getValue());
        }

        // Find clients with offline token
        UserModel user1 = session.users().getUserByUsername("user1", realm);
        Set<ClientModel> clients = sessionManager.findClientsWithOfflineToken(realm, user1);
        Assert.assertEquals(clients.size(), 2);
        for (ClientModel client : clients) {
            Assert.assertTrue(client.getClientId().equals("test-app") || client.getClientId().equals("third-party"));
        }

        UserModel user2 = session.users().getUserByUsername("user2", realm);
        clients = sessionManager.findClientsWithOfflineToken(realm, user2);
        Assert.assertEquals(clients.size(), 1);
        Assert.assertTrue(clients.iterator().next().getClientId().equals("test-app"));

        // Test count
        testApp = realm.getClientByClientId("test-app");
        ClientModel thirdparty = realm.getClientByClientId("third-party");
        Assert.assertEquals(3, session.sessions().getOfflineSessionsCount(realm, testApp));
        Assert.assertEquals(1, session.sessions().getOfflineSessionsCount(realm, thirdparty));

        // Revoke "test-app" for user1
        sessionManager.revokeOfflineToken(user1, testApp);

        resetSession();

        // Assert userSession revoked
        testApp = realm.getClientByClientId("test-app");
        thirdparty = realm.getClientByClientId("third-party");

        // Still 2 sessions. The count of sessions by client may not be accurate after revoke due the
        // performance optimizations (the "127.0.0.1" session still has another client "thirdparty" in it)
        Assert.assertEquals(2, session.sessions().getOfflineSessionsCount(realm, testApp));
        Assert.assertEquals(1, session.sessions().getOfflineSessionsCount(realm, thirdparty));

        List<UserSessionModel> thirdpartySessions = session.sessions().getOfflineUserSessions(realm, thirdparty, 0, 10);
        Assert.assertEquals(1, thirdpartySessions.size());
        Assert.assertEquals("127.0.0.1", thirdpartySessions.get(0).getIpAddress());
        Assert.assertEquals("user1", thirdpartySessions.get(0).getUser().getUsername());

        user1 = session.users().getUserByUsername("user1", realm);
        user2 = session.users().getUserByUsername("user2", realm);
        clients = sessionManager.findClientsWithOfflineToken(realm, user1);
        Assert.assertEquals(1, clients.size());
        Assert.assertEquals("third-party", clients.iterator().next().getClientId());
        clients = sessionManager.findClientsWithOfflineToken(realm, user2);
        Assert.assertEquals(1, clients.size());
        Assert.assertEquals("test-app", clients.iterator().next().getClientId());

        // Revoke the second session for user1 too.
        sessionManager.revokeOfflineToken(user1, thirdparty);

        resetSession();

        testApp = realm.getClientByClientId("test-app");
        thirdparty = realm.getClientByClientId("third-party");

        // Accurate count now. All sessions of user1 cleared
        Assert.assertEquals(1, session.sessions().getOfflineSessionsCount(realm, testApp));
        Assert.assertEquals(0, session.sessions().getOfflineSessionsCount(realm, thirdparty));

        List<UserSessionModel> testAppSessions = session.sessions().getOfflineUserSessions(realm, testApp, 0, 10);

        Assert.assertEquals(1, testAppSessions.size());
        Assert.assertEquals("127.0.0.3", testAppSessions.get(0).getIpAddress());
        Assert.assertEquals("user2", testAppSessions.get(0).getUser().getUsername());

        clients = sessionManager.findClientsWithOfflineToken(realm, user1);
        Assert.assertEquals(0, clients.size());
    }

    @Test
    public void testOnRealmRemoved() {
        RealmModel fooRealm = session.realms().createRealm("foo", "foo");
        fooRealm.addClient("foo-app");
        session.users().addUser(fooRealm, "user3");

        UserSessionModel userSession = session.sessions().createUserSession(fooRealm, session.users().getUserByUsername("user3", fooRealm), "user3", "127.0.0.1", "form", true, null, null);
        AuthenticatedClientSessionModel clientSession = createClientSession(fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state");

        resetSession();

        // Persist offline session
        fooRealm = session.realms().getRealm("foo");
        userSession = session.sessions().getUserSession(fooRealm, userSession.getId());
        createOfflineSessionIncludeClientSessions(userSession);

        resetSession();

        UserSessionModel offlineUserSession = sessionManager.findOfflineUserSession(fooRealm, userSession.getId());
        Assert.assertEquals(offlineUserSession.getAuthenticatedClientSessions().size(), 1);
        AuthenticatedClientSessionModel offlineClientSession = offlineUserSession.getAuthenticatedClientSessions().values().iterator().next();
        Assert.assertEquals("foo-app", offlineClientSession.getClient().getClientId());
        Assert.assertEquals("user3", offlineClientSession.getUserSession().getUser().getUsername());

        // Remove realm
        RealmManager realmMgr = new RealmManager(session);
        realmMgr.removeRealm(realmMgr.getRealm("foo"));

        resetSession();

        fooRealm = session.realms().createRealm("foo", "foo");
        fooRealm.addClient("foo-app");
        session.users().addUser(fooRealm, "user3");

        resetSession();

        // Assert nothing loaded
        fooRealm = session.realms().getRealm("foo");
        Assert.assertNull(sessionManager.findOfflineUserSession(fooRealm, userSession.getId()));
        Assert.assertEquals(0, session.sessions().getOfflineSessionsCount(fooRealm, fooRealm.getClientByClientId("foo-app")));

        // Cleanup
        realmMgr = new RealmManager(session);
        realmMgr.removeRealm(realmMgr.getRealm("foo"));
    }

    @Test
    public void testOnClientRemoved() {
        int started = Time.currentTime();

        RealmModel fooRealm = session.realms().createRealm("foo", "foo");
        fooRealm.addClient("foo-app");
        fooRealm.addClient("bar-app");
        session.users().addUser(fooRealm, "user3");

        UserSessionModel userSession = session.sessions().createUserSession(fooRealm, session.users().getUserByUsername("user3", fooRealm), "user3", "127.0.0.1", "form", true, null, null);
        createClientSession(fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state");
        createClientSession(fooRealm.getClientByClientId("bar-app"), userSession, "http://redirect", "state");

        resetSession();

        // Create offline session
        fooRealm = session.realms().getRealm("foo");
        userSession = session.sessions().getUserSession(fooRealm, userSession.getId());
        createOfflineSessionIncludeClientSessions(userSession);

        resetSession();

        RealmManager realmMgr = new RealmManager(session);
        ClientManager clientMgr = new ClientManager(realmMgr);
        fooRealm = realmMgr.getRealm("foo");

        // Assert session was persisted with both clientSessions
        UserSessionModel offlineSession = session.sessions().getOfflineUserSession(fooRealm, userSession.getId());
        UserSessionProviderTest.assertSession(offlineSession, session.users().getUserByUsername("user3", fooRealm), "127.0.0.1", started, started, "foo-app", "bar-app");

        // Remove foo-app client
        ClientModel client = fooRealm.getClientByClientId("foo-app");
        clientMgr.removeClient(fooRealm, client);

        resetSession();

        realmMgr = new RealmManager(session);
        clientMgr = new ClientManager(realmMgr);
        fooRealm = realmMgr.getRealm("foo");

        // Assert just one bar-app clientSession persisted now
        offlineSession = session.sessions().getOfflineUserSession(fooRealm, userSession.getId());
        Assert.assertEquals(1, offlineSession.getAuthenticatedClientSessions().size());
        Assert.assertEquals("bar-app", offlineSession.getAuthenticatedClientSessions().values().iterator().next().getClient().getClientId());

        // Remove bar-app client
        client = fooRealm.getClientByClientId("bar-app");
        clientMgr.removeClient(fooRealm, client);

        resetSession();

        // Assert nothing loaded - userSession was removed as well because it was last userSession
        realmMgr = new RealmManager(session);
        fooRealm = realmMgr.getRealm("foo");
        offlineSession = session.sessions().getOfflineUserSession(fooRealm, userSession.getId());
        Assert.assertEquals(0, offlineSession.getAuthenticatedClientSessions().size());

        // Cleanup
        realmMgr = new RealmManager(session);
        realmMgr.removeRealm(realmMgr.getRealm("foo"));
    }

    @Test
    public void testOnUserRemoved() {
        int started = Time.currentTime();

        RealmModel fooRealm = session.realms().createRealm("foo", "foo");
        fooRealm.addClient("foo-app");
        session.users().addUser(fooRealm, "user3");

        UserSessionModel userSession = session.sessions().createUserSession(fooRealm, session.users().getUserByUsername("user3", fooRealm), "user3", "127.0.0.1", "form", true, null, null);
        AuthenticatedClientSessionModel clientSession = createClientSession(fooRealm.getClientByClientId("foo-app"), userSession, "http://redirect", "state");

        resetSession();

        // Create offline session
        fooRealm = session.realms().getRealm("foo");
        userSession = session.sessions().getUserSession(fooRealm, userSession.getId());
        createOfflineSessionIncludeClientSessions(userSession);

        resetSession();

        RealmManager realmMgr = new RealmManager(session);
        fooRealm = realmMgr.getRealm("foo");
        UserModel user3 = session.users().getUserByUsername("user3", fooRealm);

        // Assert session was persisted with both clientSessions
        UserSessionModel offlineSession = session.sessions().getOfflineUserSession(fooRealm, userSession.getId());
        UserSessionProviderTest.assertSession(offlineSession, user3, "127.0.0.1", started, started, "foo-app");

        // Remove user3
        new UserManager(session).removeUser(fooRealm, user3);

        resetSession();

        // Assert userSession removed as well
        Assert.assertNull(session.sessions().getOfflineUserSession(fooRealm, userSession.getId()));

        // Cleanup
        realmMgr = new RealmManager(session);
        realmMgr.removeRealm(realmMgr.getRealm("foo"));

    }

    @Test
    public void testExpired() {
        // Create some online sessions in infinispan
        int started = Time.currentTime();
        UserSessionModel[] origSessions = createSessions();

        resetSession();

        // Key is userSessionId, value is set of client UUIDS
        Map<String, Set<String>> offlineSessions = new HashMap<>();

        // Persist 3 created userSessions and clientSessions as offline
        ClientModel testApp = realm.getClientByClientId("test-app");
        List<UserSessionModel> userSessions = session.sessions().getUserSessions(realm, testApp);
        for (UserSessionModel userSession : userSessions) {
            offlineSessions.put(userSession.getId(), createOfflineSessionIncludeClientSessions(userSession));
        }

        resetSession();

        // Assert all previously saved offline sessions found
        for (Map.Entry<String, Set<String>> entry : offlineSessions.entrySet()) {
            UserSessionModel foundSession = sessionManager.findOfflineUserSession(realm, entry.getKey());
            Assert.assertEquals(foundSession.getAuthenticatedClientSessions().keySet(), entry.getValue());
        }

        UserSessionModel session0 = session.sessions().getOfflineUserSession(realm, origSessions[0].getId());
        Assert.assertNotNull(session0);

        // sessions are in persister too
        Assert.assertEquals(3, persister.getUserSessionsCount(true));

        // Increase timeOffset - 5 minutes
        Time.setOffset(300);
        try {

            // Update lastSessionRefresh of session0. This will update lastSessionRefresh of all the sessions to DB as they were not yet updated to DB
            session0.setLastSessionRefresh(Time.currentTime());

            resetSession();

            // Increase timeOffset - 20 days
            Time.setOffset(1728000);

            session0 = session.sessions().getOfflineUserSession(realm, origSessions[0].getId());
            session0.setLastSessionRefresh(Time.currentTime());

            resetSession();

            // Increase timeOffset - 40 days
            Time.setOffset(3456000);

            // Expire and ensure that all sessions despite session0 were removed

            session.sessions().removeExpired(realm);
            persister.removeExpired(realm);

            resetSession();

            // assert session0 is the only session found
            Assert.assertNotNull(session.sessions().getOfflineUserSession(realm, origSessions[0].getId()));
            Assert.assertNull(session.sessions().getOfflineUserSession(realm, origSessions[1].getId()));
            Assert.assertNull(session.sessions().getOfflineUserSession(realm, origSessions[2].getId()));

            Assert.assertEquals(1, persister.getUserSessionsCount(true));

            // Expire everything and assert nothing found
            Time.setOffset(6000000);

            session.sessions().removeExpired(realm);
            persister.removeExpired(realm);

            resetSession();

            for (String userSessionId : offlineSessions.keySet()) {
                Assert.assertNull(sessionManager.findOfflineUserSession(realm, userSessionId));
            }
            Assert.assertEquals(0, persister.getUserSessionsCount(true));

        } finally {
            Time.setOffset(0);
        }
    }

    private Set<String> createOfflineSessionIncludeClientSessions(UserSessionModel userSession) {
        Set<String> offlineSessions = new HashSet<>();

        for (AuthenticatedClientSessionModel clientSession : userSession.getAuthenticatedClientSessions().values()) {
            sessionManager.createOrUpdateOfflineSession(clientSession, userSession);
            offlineSessions.add(clientSession.getClient().getId());
        }
        return offlineSessions;
    }


    private void resetSession() {
        kc.stopSession(session, true);
        session = kc.startSession();
        realm = session.realms().getRealm("test");
        sessionManager = new UserSessionManager(session);
        persister = session.getProvider(UserSessionPersisterProvider.class);
    }

    private AuthenticatedClientSessionModel createClientSession(ClientModel client, UserSessionModel userSession, String redirect, String state) {
        AuthenticatedClientSessionModel clientSession = session.sessions().createClientSession(client.getRealm(), client, userSession);
        clientSession.setRedirectUri(redirect);
        if (state != null) clientSession.setNote(OIDCLoginProtocol.STATE_PARAM, state);
        return clientSession;
    }

    private UserSessionModel[] createSessions() {
        UserSessionModel[] sessions = new UserSessionModel[3];
        sessions[0] = session.sessions().createUserSession(realm, session.users().getUserByUsername("user1", realm), "user1", "127.0.0.1", "form", true, null, null);

        Set<String> roles = new HashSet<String>();
        roles.add("one");
        roles.add("two");

        Set<String> protocolMappers = new HashSet<String>();
        protocolMappers.add("mapper-one");
        protocolMappers.add("mapper-two");

        createClientSession(realm.getClientByClientId("test-app"), sessions[0], "http://redirect", "state");
        createClientSession(realm.getClientByClientId("third-party"), sessions[0], "http://redirect", "state");

        sessions[1] = session.sessions().createUserSession(realm, session.users().getUserByUsername("user1", realm), "user1", "127.0.0.2", "form", true, null, null);
        createClientSession(realm.getClientByClientId("test-app"), sessions[1], "http://redirect", "state");

        sessions[2] = session.sessions().createUserSession(realm, session.users().getUserByUsername("user2", realm), "user2", "127.0.0.3", "form", true, null, null);
        createClientSession(realm.getClientByClientId("test-app"), sessions[2], "http://redirect", "state");

        return sessions;
    }
}
