/*
 * Copyright 2013 Robert von Burg <eitch@eitchnet.ch>
 *
 * 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 li.strolch.runtime.sessions;

import li.strolch.agent.api.ComponentContainer;
import li.strolch.agent.api.StrolchComponent;
import li.strolch.exception.StrolchNotAuthenticatedException;
import li.strolch.privilege.base.AccessDeniedException;
import li.strolch.privilege.base.PrivilegeException;
import li.strolch.privilege.model.Certificate;
import li.strolch.privilege.model.PrivilegeContext;
import li.strolch.privilege.model.SimpleRestrictable;
import li.strolch.privilege.model.Usage;
import li.strolch.runtime.configuration.ComponentConfiguration;
import li.strolch.runtime.privilege.PrivilegeHandler;
import li.strolch.utils.dbc.DBC;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.text.MessageFormat;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

import static java.util.function.Function.identity;
import static li.strolch.runtime.StrolchConstants.StrolchPrivilegeConstants.PRIVILEGE_GET_SESSION;
import static li.strolch.runtime.StrolchConstants.StrolchPrivilegeConstants.PRIVILEGE_INVALIDATE_SESSION;

/**
 * @author Robert von Burg <eitch@eitchnet.ch>
 */
public class DefaultStrolchSessionHandler extends StrolchComponent implements StrolchSessionHandler {

	public static final String PARAM_SESSION_TTL_MINUTES = "session.ttl.minutes";
	public static final String PARAM_SESSION_MAX_KEEP_ALIVE_MINUTES = "session.maxKeepAlive.minutes";

	private static final Logger logger = LoggerFactory.getLogger(DefaultStrolchSessionHandler.class);
	private PrivilegeHandler privilegeHandler;
	private final Map<String, Certificate> certificateMap;
	private int sessionTtlMinutes;
	private int maxKeepAliveMinutes;

	private ScheduledFuture<?> validateSessionsTask;
	private Future<?> persistSessionsTask;

	public DefaultStrolchSessionHandler(ComponentContainer container, String componentName) {
		super(container, componentName);
		this.certificateMap = new ConcurrentHashMap<>();
	}

	@Override
	public int getSessionTtlMinutes() {
		return this.sessionTtlMinutes;
	}

	@Override
	public void refreshSessions() {
		Map<String, Certificate> certificates;
		try {
			certificates = runAsAgentWithResult(ctx -> {
				Certificate cert = ctx.getCertificate();
				return this.privilegeHandler.getPrivilegeHandler().getCertificates(cert).stream()
						.filter(c -> !c.getUserState().isSystem())
						.collect(Collectors.toMap(Certificate::getAuthToken, identity()));
			});
		} catch (Exception e) {
			throw new IllegalStateException("Failed to refresh sessions!", e);
		}

		synchronized (this.certificateMap) {
			this.certificateMap.clear();
			this.certificateMap.putAll(certificates);
		}
		checkSessionsForTimeout();
		logger.info("Restored " + certificates.size() + " sessions of which " +
				(certificates.size() - this.certificateMap.size()) + " had timed out and were removed.");
	}

	@Override
	public int getSessionMaxKeepAliveMinutes() {
		return this.maxKeepAliveMinutes;
	}

	@Override
	public boolean isRefreshAllowed() {
		return this.privilegeHandler.isRefreshAllowed();
	}

	@Override
	public void initialize(ComponentConfiguration configuration) throws Exception {
		this.sessionTtlMinutes = configuration.getInt(PARAM_SESSION_TTL_MINUTES, 30);
		this.maxKeepAliveMinutes = configuration.getInt(PARAM_SESSION_MAX_KEEP_ALIVE_MINUTES,
				Math.max(this.sessionTtlMinutes, 30));
		super.initialize(configuration);
	}

	@Override
	public void start() throws Exception {
		this.privilegeHandler = getContainer().getComponent(PrivilegeHandler.class);

		refreshSessions();

		this.validateSessionsTask = getScheduledExecutor("SessionHandler").scheduleWithFixedDelay(
				this::checkSessionsForTimeout, 5, 1, TimeUnit.MINUTES);

		super.start();
	}

	@Override
	public void stop() throws Exception {

		if (this.validateSessionsTask != null)
			this.validateSessionsTask.cancel(true);

		this.privilegeHandler = null;
		super.stop();
	}

	@Override
	public void destroy() throws Exception {
		this.certificateMap.clear();
		super.destroy();
	}

	@Override
	public Certificate authenticate(String username, char[] password, String source, Usage usage, boolean keepAlive) {
		DBC.PRE.assertNotEmpty("Username must be set!", username);
		DBC.PRE.assertNotNull("Passwort must be set", password);

		Certificate certificate = this.privilegeHandler.authenticate(username, password, source, usage, keepAlive);

		this.certificateMap.put(certificate.getAuthToken(), certificate);
		logger.info(MessageFormat.format("{0} sessions currently active.", this.certificateMap.size()));

		return certificate;
	}

	@Override
	public Certificate authenticateSingleSignOn(Object data) {
		Certificate certificate = this.privilegeHandler.authenticateSingleSignOn(data);

		this.certificateMap.put(certificate.getAuthToken(), certificate);
		logger.info(MessageFormat.format("{0} sessions currently active.", this.certificateMap.size()));

		return certificate;
	}

	@Override
	public Certificate authenticateSingleSignOn(Object data, String source) {
		Certificate certificate = this.privilegeHandler.authenticateSingleSignOn(data, source);

		this.certificateMap.put(certificate.getAuthToken(), certificate);
		logger.info(MessageFormat.format("{0} sessions currently active.", this.certificateMap.size()));

		return certificate;
	}

	@Override
	public Certificate refreshSession(Certificate certificate, String source) {
		Certificate refreshedSession = this.privilegeHandler.refreshSession(certificate, source);

		invalidate(certificate);
		this.certificateMap.put(refreshedSession.getAuthToken(), refreshedSession);
		logger.info(MessageFormat.format("{0} sessions currently active.", this.certificateMap.size()));

		return refreshedSession;
	}

	@Override
	public boolean isSessionKnown(String authToken) {
		DBC.PRE.assertNotEmpty("authToken must be set!", authToken);
		return this.certificateMap.containsKey(authToken);
	}

	@Override
	public Certificate validate(String authToken) throws StrolchNotAuthenticatedException {
		DBC.PRE.assertNotEmpty("authToken must be set!", authToken);

		Certificate certificate = this.certificateMap.get(authToken);
		if (certificate == null)
			throw new StrolchNotAuthenticatedException(
					MessageFormat.format("No certificate exists for sessionId {0}", authToken));

		return validate(certificate).getCertificate();
	}

	@Override
	public Certificate validate(String authToken, String source) throws StrolchNotAuthenticatedException {
		DBC.PRE.assertNotEmpty("authToken must be set!", authToken);

		Certificate certificate = this.certificateMap.get(authToken);
		if (certificate == null)
			throw new StrolchNotAuthenticatedException(
					MessageFormat.format("No certificate exists for sessionId {0}", authToken));

		return validate(certificate, source).getCertificate();
	}

	@Override
	public PrivilegeContext validate(Certificate certificate) throws StrolchNotAuthenticatedException {
		try {
			PrivilegeContext privilegeContext = this.privilegeHandler.validate(certificate);

			if (this.persistSessionsTask != null)
				this.persistSessionsTask = getScheduledExecutor("SessionHandler").schedule(this::persistSessions, 5,
						TimeUnit.SECONDS);

			return privilegeContext;
		} catch (PrivilegeException e) {
			throw new StrolchNotAuthenticatedException(e.getMessage(), e);
		}
	}

	@Override
	public PrivilegeContext validate(Certificate certificate, String source) throws StrolchNotAuthenticatedException {
		try {
			PrivilegeContext privilegeContext = this.privilegeHandler.validate(certificate, source);

			if (this.persistSessionsTask != null)
				this.persistSessionsTask = getScheduledExecutor("SessionHandler").schedule(this::persistSessions, 5,
						TimeUnit.SECONDS);

			return privilegeContext;
		} catch (PrivilegeException e) {
			throw new StrolchNotAuthenticatedException(e.getMessage(), e);
		}
	}

	private void persistSessions() {
		try {
			runAsAgent(ctx -> this.privilegeHandler.getPrivilegeHandler()
					.persistSessions(ctx.getCertificate(), ctx.getCertificate().getSource()));
		} catch (Exception e) {
			logger.error("Failed to persist sessions", e);
		} finally {
			this.persistSessionsTask = null;
		}
	}

	@Override
	public void invalidate(Certificate certificate) {
		DBC.PRE.assertNotNull("Certificate must be given!", certificate);

		Certificate removedCert = this.certificateMap.remove(certificate.getAuthToken());
		if (removedCert == null)
			logger.error(MessageFormat.format("No session was registered with token {0}", certificate.getAuthToken()));

		this.privilegeHandler.invalidate(certificate);
	}

	@Override
	public void initiateChallengeFor(Usage usage, String username) {
		this.privilegeHandler.getPrivilegeHandler().initiateChallengeFor(usage, username);
	}

	@Override
	public void initiateChallengeFor(Usage usage, String username, String source) {
		this.privilegeHandler.getPrivilegeHandler().initiateChallengeFor(usage, username, source);
	}

	@Override
	public Certificate validateChallenge(String username, String challenge) throws PrivilegeException {
		DBC.PRE.assertNotEmpty("username must be set!", username);
		DBC.PRE.assertNotEmpty("challenge must be set", challenge);

		Certificate certificate = this.privilegeHandler.getPrivilegeHandler().validateChallenge(username, challenge);

		this.certificateMap.put(certificate.getAuthToken(), certificate);
		logger.info(MessageFormat.format("{0} sessions currently active.", this.certificateMap.size()));

		return certificate;
	}

	@Override
	public Certificate validateChallenge(String username, String challenge, String source) throws PrivilegeException {
		DBC.PRE.assertNotEmpty("username must be set!", username);
		DBC.PRE.assertNotEmpty("challenge must be set", challenge);

		Certificate certificate = this.privilegeHandler.getPrivilegeHandler()
				.validateChallenge(username, challenge, source);

		this.certificateMap.put(certificate.getAuthToken(), certificate);
		logger.info(MessageFormat.format("{0} sessions currently active.", this.certificateMap.size()));

		return certificate;
	}

	protected void checkSessionsForTimeout() {
		ZonedDateTime maxKeepAliveTime = ZonedDateTime.now().minusMinutes(this.maxKeepAliveMinutes);
		ZonedDateTime timeOutTime = ZonedDateTime.now().minusMinutes(this.sessionTtlMinutes);

		Map<String, Certificate> certificateMap = getCertificateMapCopy();
		for (Certificate certificate : certificateMap.values()) {
			if (certificate.isKeepAlive()) {

				if (maxKeepAliveTime.isAfter(certificate.getLoginTime())) {
					String msg = "KeepAlive for session {0} for user {1} has expired, invalidating session...";
					logger.info(MessageFormat.format(msg, certificate.getSessionId(), certificate.getUsername()));
					sessionTimeout(certificate);
				}

			} else {
				if (timeOutTime.isAfter(certificate.getLastAccess())) {
					String msg = "Session {0} for user {1} has expired, invalidating session...";
					logger.info(MessageFormat.format(msg, certificate.getSessionId(), certificate.getUsername()));
					sessionTimeout(certificate);
				}
			}
		}
	}

	protected void sessionTimeout(Certificate certificate) {
		DBC.PRE.assertNotNull("Certificate must be given!", certificate);

		Certificate removedCert = this.certificateMap.remove(certificate.getAuthToken());
		if (removedCert == null)
			logger.error(MessageFormat.format("No session was registered with token {0}", certificate.getAuthToken()));

		this.privilegeHandler.sessionTimeout(certificate);
	}

	@Override
	public UserSession getSession(Certificate certificate, String source, String sessionId) throws PrivilegeException {
		PrivilegeContext ctx = this.privilegeHandler.validate(certificate, source);
		ctx.assertHasPrivilege(PRIVILEGE_GET_SESSION);

		Map<String, Certificate> certificateMap = getCertificateMapCopy();
		for (Certificate cert : certificateMap.values()) {
			if (cert.getSessionId().equals(sessionId)) {
				ctx.validateAction(new SimpleRestrictable(PRIVILEGE_GET_SESSION, cert));
				return new UserSession(cert);
			}
		}

		throw new PrivilegeException("No Session exists with the id " + sessionId);
	}

	@Override
	public List<UserSession> getSessions(Certificate certificate, String source) {
		PrivilegeContext ctx = this.privilegeHandler.validate(certificate, source);
		ctx.assertHasPrivilege(PRIVILEGE_GET_SESSION);
		List<UserSession> sessions = new ArrayList<>(this.certificateMap.size());

		Map<String, Certificate> certificateMap = getCertificateMapCopy();
		for (Certificate cert : certificateMap.values()) {
			try {
				ctx.validateAction(new SimpleRestrictable(PRIVILEGE_GET_SESSION, cert));
				sessions.add(new UserSession(cert));
			} catch (AccessDeniedException e) {
				// no, user may not get this session
			}
		}

		return sessions;
	}

	@Override
	public void invalidate(Certificate certificate, String sessionId) {
		PrivilegeContext ctx = this.privilegeHandler.validate(certificate);
		ctx.assertHasPrivilege(PRIVILEGE_INVALIDATE_SESSION);

		boolean ok = false;
		Map<String, Certificate> certificateMap = getCertificateMapCopy();
		for (Certificate cert : certificateMap.values()) {
			if (cert.getSessionId().equals(sessionId)) {
				ctx.validateAction(new SimpleRestrictable(PRIVILEGE_INVALIDATE_SESSION, cert));
				invalidate(cert);
				ok = true;
				break;
			}
		}

		if (!ok) {
			throw new PrivilegeException("Can not invalidate session as no session exists with the id " + sessionId);
		}
	}

	@Override
	public void setSessionLocale(Certificate certificate, String sessionId, Locale locale) {
		if (!certificate.getSessionId().equals(sessionId)) {
			String msg = "Users can only change their own session locale: {0} may not change locale of session {1}";
			throw new AccessDeniedException(MessageFormat.format(msg, certificate.getUsername(), sessionId));
		}

		Map<String, Certificate> certificateMap = getCertificateMapCopy();
		for (Certificate cert : certificateMap.values()) {
			if (cert.getSessionId().equals(sessionId)) {
				if (!cert.getLocale().equals(locale)) {
					cert.setLocale(locale);
					persistSessions();
				}
				break;
			}
		}
	}

	private Map<String, Certificate> getCertificateMapCopy() {
		synchronized (this.certificateMap) {
			return new HashMap<>(this.certificateMap);
		}
	}
}
