/*
 * 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.privilege.handler;

import li.strolch.privilege.base.*;
import li.strolch.privilege.model.*;
import li.strolch.privilege.model.internal.*;
import li.strolch.privilege.policy.PrivilegePolicy;
import li.strolch.privilege.xml.CertificateStubsSaxReader;
import li.strolch.privilege.xml.CertificateStubsSaxReader.CertificateStub;
import li.strolch.privilege.xml.CertificateStubsSaxWriter;
import li.strolch.utils.concurrent.ElementLockingHandler;
import li.strolch.utils.dbc.DBC;
import li.strolch.utils.helper.AesCryptoHelper;
import li.strolch.utils.helper.AesCryptoHelper.SecretKeys;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.xml.sax.SAXParseException;

import javax.crypto.SecretKey;
import javax.xml.stream.XMLStreamException;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.time.ZonedDateTime;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Stream;

import static java.text.MessageFormat.format;
import static java.util.stream.Collectors.toList;
import static li.strolch.privilege.handler.PrivilegeCrudHandler.clearPassword;
import static li.strolch.utils.helper.ExceptionHelper.getRootCause;
import static li.strolch.utils.helper.StringHelper.isEmpty;
import static li.strolch.utils.helper.StringHelper.trimOrEmpty;

/**
 * <p>
 * This is default implementation of the {@link PrivilegeHandler}
 * </p>
 * <p>
 * The following list describes implementation details:
 * <ul>
 * <li>any methods which change the model are first validated by checking if the certificate has the appropriate
 * privilege</li>
 * <li>all model requests are delegated to the configured {@link PrivilegeHandler}, except for the session id to
 * {@link Certificate} map, no model data is kept in this implementation. This also means that to return the
 * representation objects, for every new model query, a new representation object is created</li>
 * <li>when creating new users, or editing users then a null password is understood as no password set</li>
 * <li>Password requirements are simple: Non null and non empty/length 0</li>
 * </ul>
 *
 * @author Robert von Burg <eitch@eitchnet.ch>
 */
public class DefaultPrivilegeHandler implements PrivilegeHandler {

	protected static final Logger logger = LoggerFactory.getLogger(DefaultPrivilegeHandler.class);
	public static final String SOURCE_UNKNOWN = "unknown";
	private PrivilegeCrudHandler crudHandler;

	/**
	 * Reference to all active sessions
	 */
	protected Map<String, PrivilegeContext> privilegeContextMap;

	/**
	 * Map of {@link PrivilegePolicy} classes
	 */
	protected Map<String, Class<PrivilegePolicy>> policyMap;

	/**
	 * The persistence handler is used for getting objects and saving changes
	 */
	protected PersistenceHandler persistenceHandler;

	/**
	 * The encryption handler is used for generating hashes and tokens
	 */
	protected EncryptionHandler encryptionHandler;

	/**
	 * The password strength handler is used for validating the strength of a password when being set
	 */
	protected PasswordStrengthHandler passwordStrengthHandler;

	/**
	 * The Single Sign On Handler
	 */
	protected SingleSignOnHandler ssoHandler;

	/**
	 * The {@link UserChallengeHandler} is used to challenge a user which tries to authenticate and/or change their
	 * password
	 */
	protected UserChallengeHandler userChallengeHandler;

	/**
	 * flag to define if already initialized
	 */
	protected boolean initialized;

	/**
	 * flag to define if a persist should be performed after a user changes their own data
	 */
	protected boolean autoPersistOnUserChangesData;

	/**
	 * flag to define if sessions should be persisted
	 */
	protected boolean persistSessions;

	/**
	 * Path to sessions file for persistence
	 */
	protected File persistSessionsPath;

	/**
	 * Secret key
	 */
	protected SecretKeys secretKey;

	/**
	 * flag if session refreshing is allowed
	 */
	protected boolean allowSessionRefresh;
	protected boolean disallowSourceChange;

	protected PrivilegeConflictResolution privilegeConflictResolution;

	private Map<String, String> parameterMap;

	private ElementLockingHandler<String> lockingHandler;
	private ScheduledExecutorService executorService;
	private Future<?> persistSessionsTask;
	private Future<?> persistModelTask;

	@Override
	public SingleSignOnHandler getSsoHandler() {
		return this.ssoHandler;
	}

	@Override
	public UserChallengeHandler getUserChallengeHandler() {
		return this.userChallengeHandler;
	}

	@Override
	public PersistenceHandler getPersistenceHandler() {
		return this.persistenceHandler;
	}

	@Override
	public Map<String, String> getParameterMap() {
		return this.parameterMap;
	}

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

	@Override
	public boolean isPersistOnUserDataChanged() {
		return this.autoPersistOnUserChangesData;
	}

	@Override
	public EncryptionHandler getEncryptionHandler() throws PrivilegeException {
		return this.encryptionHandler;
	}

	@Override
	public RoleRep getRole(Certificate certificate, String roleName) {
		return crudHandler.getRole(certificate, roleName);
	}

	@Override
	public UserRep getUser(Certificate certificate, String username) {
		return crudHandler.getUser(certificate, username);
	}

	@Override
	public Map<String, String> getPolicyDefs(Certificate certificate) {
		return crudHandler.getPolicyDefs(certificate);
	}

	@Override
	public List<Certificate> getCertificates(Certificate certificate) {

		// validate user actually has this type of privilege
		PrivilegeContext prvCtx = validate(certificate);
		prvCtx.validateAction(new SimpleRestrictable(PRIVILEGE_ACTION, PRIVILEGE_ACTION_GET_CERTIFICATES));

		return this.privilegeContextMap.values().stream().map(PrivilegeContext::getCertificate).collect(toList());
	}

	@Override
	public List<RoleRep> getRoles(Certificate certificate) {
		return crudHandler.getRoles(certificate);
	}

	@Override
	public List<UserRep> getUsers(Certificate certificate) {
		return crudHandler.getUsers(certificate);
	}

	@Override
	public List<UserRep> queryUsers(Certificate certificate, UserRep selectorRep) {
		return crudHandler.queryUsers(certificate, selectorRep);
	}

	@Override
	public UserRep addUser(Certificate certificate, UserRep userRepParam, char[] password) {
		return this.lockingHandler.lockedExecuteWithResult(userRepParam.getUsername(),
				() -> crudHandler.addUser(certificate, userRepParam, password));
	}

	@Override
	public void addOrUpdateUsers(Certificate certificate, List<UserRep> userReps) throws PrivilegeException {
		this.lockingHandler.lockedExecute(PrivilegeHandler.class.getSimpleName(),
				() -> crudHandler.addOrUpdateUsers(certificate, userReps));
	}

	@Override
	public UserRep updateUser(Certificate certificate, UserRep userRep, char[] password) throws PrivilegeException {
		return this.lockingHandler.lockedExecuteWithResult(userRep.getUsername(),
				() -> crudHandler.updateUser(certificate, userRep, password));
	}

	@Override
	public UserRep removeUser(Certificate certificate, String username) {
		return this.lockingHandler.lockedExecuteWithResult(username,
				() -> crudHandler.removeUser(certificate, username));
	}

	@Override
	public UserRep setUserLocale(Certificate certificate, String username, Locale locale) {
		return this.lockingHandler.lockedExecuteWithResult(username,
				() -> crudHandler.setUserLocale(certificate, username, locale));
	}

	@Override
	public void requirePasswordChange(Certificate certificate, String username) throws PrivilegeException {
		this.lockingHandler.lockedExecute(username, () -> crudHandler.requirePasswordChange(certificate, username));
	}

	@Override
	public void setUserPassword(Certificate certificate, String username, char[] password) {
		this.lockingHandler.lockedExecute(username, () -> crudHandler.setUserPassword(certificate, username, password));
	}

	@Override
	public UserRep setUserState(Certificate certificate, String username, UserState state) {
		return this.lockingHandler.lockedExecuteWithResult(username,
				() -> crudHandler.setUserState(certificate, username, state));
	}

	@Override
	public RoleRep addRole(Certificate certificate, RoleRep roleRep) {
		return this.lockingHandler.lockedExecuteWithResult(roleRep.getName(),
				() -> crudHandler.addRole(certificate, roleRep));
	}

	@Override
	public RoleRep replaceRole(Certificate certificate, RoleRep roleRep) {
		return this.lockingHandler.lockedExecuteWithResult(roleRep.getName(),
				() -> crudHandler.replaceRole(certificate, roleRep));
	}

	@Override
	public RoleRep removeRole(Certificate certificate, String roleName) {
		return this.lockingHandler.lockedExecuteWithResult(roleName,
				() -> crudHandler.removeRole(certificate, roleName));
	}

	void invalidSessionsFor(User user) {
		List<PrivilegeContext> contexts = new ArrayList<>(this.privilegeContextMap.values());
		for (PrivilegeContext ctx : contexts) {
			if (ctx.getUserRep().getUsername().equals(user.getUsername()))
				invalidate(ctx.getCertificate());
		}
	}

	@Override
	public void initiateChallengeFor(Usage usage, String username) {
		initiateChallengeFor(usage, username, SOURCE_UNKNOWN);
	}

	@Override
	public void initiateChallengeFor(Usage usage, String username, String source) {
		this.lockingHandler.lockedExecute(username, () -> internalInitiateChallengeFor(usage, username, source));
	}

	private void internalInitiateChallengeFor(Usage usage, String username, String source) {
		DBC.PRE.assertNotEmpty("source must not be empty!", source);

		// get User
		User user = this.persistenceHandler.getUser(username);
		if (user == null) {
			throw new PrivilegeModelException(format("User {0} does not exist!", username));
		}

		// initiate the challenge
		this.userChallengeHandler.initiateChallengeFor(usage, user, source);

		logger.info(format("Initiated Challenge for {0} with usage {1}", username, usage));
	}

	@Override
	public Certificate validateChallenge(String username, String challenge) throws PrivilegeException {
		return validateChallenge(username, challenge, "unknown");
	}

	@Override
	public Certificate validateChallenge(String username, String challenge, String source) throws PrivilegeException {
		return this.lockingHandler.lockedExecuteWithResult(username,
				() -> internalValidateChallenge(username, challenge, source));
	}

	private Certificate internalValidateChallenge(String username, String challenge, String source)
			throws PrivilegeException {
		DBC.PRE.assertNotEmpty("source must not be empty!", source);

		// get User
		User user = this.persistenceHandler.getUser(username);
		if (user == null) {
			throw new PrivilegeModelException(format("User {0} does not exist!", username));
		}

		// validate the response
		UserChallenge userChallenge = this.userChallengeHandler.validateResponse(user, challenge);

		// initialize a new privilege context
		Usage usage = userChallenge.getUsage();
		Certificate certificate = buildPrivilegeContext(usage, user, userChallenge.getSource(), ZonedDateTime.now(),
				false).getCertificate();

		if (!source.equals("unknown") && !source.equals(userChallenge.getSource())) {
			logger.warn("Challenge request and response source's are different: request: " + userChallenge.getSource() +
					" to " + source);
		}

		persistSessionsAsync();

		logger.info(format("Challenge validated for user {0} with usage {1}", username, usage));
		return certificate;
	}

	@Override
	public Certificate authenticate(String username, char[] password, boolean keepAlive) {
		return authenticate(username, password, "unknown", Usage.ANY, keepAlive);
	}

	@Override
	public Certificate authenticate(String username, char[] password, String source, Usage usage, boolean keepAlive) {
		return this.lockingHandler.lockedExecuteWithResult(username,
				() -> internalAuthenticate(username, password, source, usage, keepAlive));
	}

	private Certificate internalAuthenticate(String username, char[] password, String source, Usage usage,
			boolean keepAlive) {
		DBC.PRE.assertNotEmpty("source must not be empty!", source);

		// we don't want the user to worry about whitespace
		username = trimOrEmpty(username);

		try {
			// username must be at least 2 characters in length
			if (username.length() < 2) {
				String msg = format("The given username ''{0}'' is shorter than 2 characters", username);
				throw new InvalidCredentialsException(msg);
			}

			// check the password
			User user = checkCredentialsAndUserState(username, password);

			// validate user has at least one role
			if (streamAllRolesForUser(this.persistenceHandler, user).findAny().isEmpty())
				throw new InvalidCredentialsException(
						format("User {0} does not have any groups or roles defined!", username));

			if (user.isPasswordChangeRequested()) {
				if (usage == Usage.SINGLE)
					throw new IllegalStateException("Password change requested!");
				usage = Usage.SET_PASSWORD;
			}

			// initialize a new privilege context
			Certificate certificate = buildPrivilegeContext(usage, user, source, ZonedDateTime.now(),
					keepAlive).getCertificate();

			persistSessionsAsync();

			// save last login
			UserHistory history = user.getHistory();
			if (history.isFirstLoginEmpty())
				history = history.withFirstLogin(ZonedDateTime.now());
			history = history.withLastLogin(ZonedDateTime.now());
			this.persistenceHandler.replaceUser(user.withHistory(history));
			persistModelAsync();

			// log
			logger.info(format("User {0} authenticated: {1}", username, certificate));

			// return the certificate
			return certificate;

		} catch (PrivilegeException e) {
			throw e;
		} catch (RuntimeException e) {
			logger.error(e.getMessage(), e);
			String msg = "User {0} failed to authenticate: {1}";
			msg = format(msg, username, e.getMessage());
			throw new PrivilegeException(msg, e);
		} finally {
			clearPassword(password);
		}
	}

	/**
	 * Returns a {@link Stream} of all roles of the given user. This includes the roles referenced by the user's groups
	 *
	 * @param user the user for which to stream the roles
	 *
	 * @return a stream of role names
	 */
	public static Stream<String> streamAllRolesForUser(PersistenceHandler persistenceHandler, User user) {
		return Stream.concat(user.getRoles().stream(),
				user.groups().stream().map(persistenceHandler::getGroup).filter(Objects::nonNull)
						.flatMap(g -> g.roles().stream()));
	}

	@Override
	public Certificate authenticateSingleSignOn(Object data, boolean keepAlive) throws PrivilegeException {
		return authenticateSingleSignOn(data, "unknown", keepAlive);
	}

	@Override
	public Certificate authenticateSingleSignOn(Object data, String source, boolean keepAlive) {
		DBC.PRE.assertNotEmpty("source must not be empty!", source);
		if (this.ssoHandler == null)
			throw new IllegalStateException("The SSO Handler is not configured!");

		User user = this.ssoHandler.authenticateSingleSignOn(data);

		return this.lockingHandler.lockedExecuteWithResult(user.getUsername(),
				() -> internalAuthenticateSingleSignOn(user, source, keepAlive));
	}

	private Certificate internalAuthenticateSingleSignOn(User user, String source, boolean keepAlive)
			throws PrivilegeException {

		DBC.PRE.assertEquals("SSO Users must have UserState.REMOTE!", UserState.REMOTE, user.getUserState());
		UserHistory history = user.getHistory();
		history = history.withLastLogin(ZonedDateTime.now());

		// persist this user
		User internalUser = this.persistenceHandler.getUser(user.getUsername());
		if (internalUser == null) {
			history = history.withFirstLogin(ZonedDateTime.now());
			this.persistenceHandler.addUser(user.withHistory(history));
		} else {
			history = history.withFirstLogin(internalUser.getHistory().getFirstLogin());
			this.persistenceHandler.replaceUser(user.withHistory(history));
		}
		persistModelAsync();

		// initialize a new privilege context
		Certificate certificate = buildPrivilegeContext(Usage.ANY, user, source, ZonedDateTime.now(),
				keepAlive).getCertificate();

		persistSessionsAsync();

		// log
		logger.info(format("User {0} authenticated: {1}", user.getUsername(), certificate));

		return certificate;
	}

	@Override
	public Certificate refresh(Certificate certificate, String source) throws AccessDeniedException {
		return this.lockingHandler.lockedExecuteWithResult(certificate.getUsername(),
				() -> internalRefresh(certificate, source));
	}

	private Certificate internalRefresh(Certificate certificate, String source) throws AccessDeniedException {
		DBC.PRE.assertNotNull("certificate must not be null!", certificate);

		try {
			// username must be at least 2 characters in length
			if (!this.allowSessionRefresh)
				throw new AccessDeniedException("Refreshing of sessions not allowed!");

			validate(certificate);

			if (!certificate.isKeepAlive())
				throw new AccessDeniedException("Refreshing of session not allowed!");

			if (!certificate.getSource().equals(source)) {
				logger.error("Source of existing session {} is not the same as the refresh request's source {}",
						certificate.getSource(), source);
			}

			// check the password
			User user = this.persistenceHandler.getUser(certificate.getUsername());

			// initialize a new privilege context
			PrivilegeContext refreshedContext = buildPrivilegeContext(certificate.getUsage(), user, source,
					ZonedDateTime.now(), true);

			// invalidate the previous session
			invalidate(certificate);

			// log
			Certificate refreshedCertificate = refreshedContext.getCertificate();
			logger.info(format("User {0} refreshed session: {1}", user.getUsername(), refreshedCertificate));

			// return the certificate
			return refreshedCertificate;

		} catch (PrivilegeException e) {
			throw e;
		} catch (RuntimeException e) {
			logger.error(e.getMessage(), e);
			String msg = "User {0} failed to refresh session: {1}";
			msg = format(msg, certificate.getUsername(), e.getMessage());
			throw new PrivilegeException(msg, e);
		}
	}

	private synchronized boolean persistSessionsAsync() {
		if (!this.persistSessions)
			return false;

		// async execution, max. once per second
		if (this.persistSessionsTask != null)
			this.persistSessionsTask.cancel(true);
		this.persistSessionsTask = this.executorService.schedule(() -> {

			// get sessions reference
			AtomicReference<List<Certificate>> sessions = new AtomicReference<>();
			this.lockingHandler.lockedExecute("persist-sessions", () -> sessions.set(
					new ArrayList<>(this.privilegeContextMap.values()).stream().map(PrivilegeContext::getCertificate)
							.filter(c -> !c.getUserState().isSystem()).collect(toList())));

			// write the sessions
			try (OutputStream out = Files.newOutputStream(this.persistSessionsPath.toPath());
				 OutputStream outputStream = AesCryptoHelper.wrapEncrypt(this.secretKey, out)) {

				CertificateStubsSaxWriter writer = new CertificateStubsSaxWriter(sessions.get(), outputStream);
				writer.write();
				outputStream.flush();

			} catch (Exception e) {
				logger.error("Failed to persist sessions!", e);
				if (this.persistSessionsPath.exists() && !this.persistSessionsPath.delete()) {
					logger.error("Failed to delete sessions file after failing to write to it, at " +
							this.persistSessionsPath.getAbsolutePath());
				}
			}
		}, 1, TimeUnit.SECONDS);

		return true;
	}

	private void loadSessions() {
		if (!this.persistSessions) {
			logger.info("Persisting of sessions not enabled, so not loading!.");
			return;
		}

		if (!this.persistSessionsPath.exists()) {
			logger.info("Sessions file does not exist");
			return;
		}

		if (!this.persistSessionsPath.isFile())
			throw new PrivilegeModelException(
					"Sessions data file is not a file but exists at " + this.persistSessionsPath.getAbsolutePath());

		List<CertificateStub> certificateStubs;
		try (InputStream fin = Files.newInputStream(this.persistSessionsPath.toPath());
			 InputStream inputStream = AesCryptoHelper.wrapDecrypt(this.secretKey, fin)) {

			CertificateStubsSaxReader reader = new CertificateStubsSaxReader(inputStream);
			certificateStubs = reader.read();

		} catch (Exception e) {
			if (getRootCause(e) instanceof SAXParseException)
				logger.error("Failed to load sessions: " + getRootCause(e).getMessage());
			else
				logger.error("Failed to load sessions!", e);
			if (!this.persistSessionsPath.delete())
				logger.error("Failed to delete session file at " + this.persistSessionsPath.getAbsolutePath());
			return;
		}

		if (certificateStubs.isEmpty()) {
			logger.info("No persisted sessions exist to be loaded.");
			return;
		}

		for (CertificateStub stub : certificateStubs) {
			String username = stub.getUsername();
			User user = this.persistenceHandler.getUser(username);
			if (user == null) {
				logger.error("Ignoring session data for missing user " + username);
				continue;
			}

			if (user.getUserState() == UserState.DISABLED || user.getUserState() == UserState.EXPIRED) {
				logger.error("Ignoring session data for disabled/expired user " + username);
				continue;
			}

			if (streamAllRolesForUser(this.persistenceHandler, user).findAny().isEmpty()) {
				logger.error("Ignoring session data for user " + username + " which has no roles or groups defined!");
				continue;
			}

			// initialize a new privilege context
			buildPrivilegeContext(user, stub);
		}

		logger.info("Loaded " + this.privilegeContextMap.size() + " sessions.");
	}

	/**
	 * Checks the credentials and validates that the user may log in.
	 *
	 * @param username the username of the {@link User} to check against
	 * @param password the password of this user
	 *
	 * @return the {@link User} if the credentials are valid and the user may login
	 *
	 * @throws AccessDeniedException       if anything is wrong with the credentials or the user state
	 * @throws InvalidCredentialsException if the given credentials are invalid, the user does not exist, or has no
	 *                                     password set
	 */
	protected User checkCredentialsAndUserState(String username, char[] password)
			throws InvalidCredentialsException, AccessDeniedException {

		// and validate the password
		if (password == null || password.length < 3)
			throw new InvalidCredentialsException("Password is invalid!");

		// get user object
		User user = this.persistenceHandler.getUser(username);
		// no user means no authentication
		if (user == null) {
			String msg = format("There is no user defined with the username {0}", username);
			throw new InvalidCredentialsException(msg);
		}

		// make sure not a system user - they may not login
		if (user.getUserState() == UserState.SYSTEM) {
			String msg = "User {0} is a system user and may not login!";
			msg = format(msg, username);
			throw new InvalidCredentialsException(msg);
		}

		// validate if user is allowed to login
		// this also capture the trying to login of SYSTEM user
		if (user.getUserState() != UserState.ENABLED) {
			String msg = "User {0} does not have state {1} and can not login!";
			msg = format(msg, username, UserState.ENABLED);
			throw new AccessDeniedException(msg);
		}

		PasswordCrypt userPasswordCrypt = user.getPasswordCrypt();
		if (userPasswordCrypt == null || userPasswordCrypt.password() == null)
			throw new InvalidCredentialsException(format("User {0} has no password and may not login!", username));

		// we only work with hashed passwords
		PasswordCrypt requestPasswordCrypt;
		if (userPasswordCrypt.salt() == null) {
			requestPasswordCrypt = this.encryptionHandler.hashPasswordWithoutSalt(password);
		} else if (userPasswordCrypt.hashAlgorithm() == null || userPasswordCrypt.hashIterations() == -1 ||
				userPasswordCrypt.hashKeyLength() == -1) {
			requestPasswordCrypt = this.encryptionHandler.hashPassword(password, userPasswordCrypt.salt());
		} else {
			requestPasswordCrypt = this.encryptionHandler.hashPassword(password, userPasswordCrypt.salt(),
					userPasswordCrypt.hashAlgorithm(), userPasswordCrypt.hashIterations(),
					userPasswordCrypt.hashKeyLength());
		}

		// validate password
		if (!Arrays.equals(requestPasswordCrypt.password(), userPasswordCrypt.password()))
			throw new InvalidCredentialsException(format("Password is incorrect for {0}", username));

		// see if we need to update the hash
		if (this.encryptionHandler.isPasswordCryptOutdated(userPasswordCrypt)) {

			logger.warn("Updating user " + username + " due to change in hashing algorithm properties ");

			// get new salt for user
			byte[] salt = this.encryptionHandler.nextSalt();

			// hash password
			PasswordCrypt newPasswordCrypt = this.encryptionHandler.hashPassword(password, salt);

			// create new user
			user = new User(user.getUserId(), user.getUsername(), newPasswordCrypt, user.getFirstname(),
					user.getLastname(), user.getUserState(), user.getGroups(), user.getRoles(), user.getLocale(),
					user.getProperties(), user.isPasswordChangeRequested(), user.getHistory());

			// delegate user replacement to persistence handler
			this.persistenceHandler.replaceUser(user);
			persistModelAsync();

			logger.info("Updated password for " + user.getUsername());
		}

		return user;
	}

	@Override
	public boolean invalidate(Certificate certificate) {

		// remove registration
		PrivilegeContext privilegeContext = this.privilegeContextMap.remove(certificate.getSessionId());

		// persist sessions
		if (privilegeContext != null)
			persistSessionsAsync();

		// return true if object was really removed
		boolean loggedOut = privilegeContext != null;
		if (loggedOut)
			logger.info(format("User {0} logged out.", certificate.getUsername()));
		else
			logger.warn("User already logged out!");

		return loggedOut;
	}

	@Override
	public PrivilegeContext validate(Certificate certificate) throws PrivilegeException {
		return validate(certificate, "unknown");
	}

	@Override
	public void validateSystemSession(PrivilegeContext ctx) throws PrivilegeException {
		// ctx  must not be null
		if (ctx == null)
			throw new PrivilegeException("PrivilegeContext may not be null!");

		// validate user state is system
		if (ctx.getUserRep().getUserState() != UserState.SYSTEM) {
			String msg = "The PrivilegeContext user {0} does not have expected user state {1}";
			msg = format(msg, ctx.getUserRep().getUsername(), UserState.SYSTEM);
			throw new PrivilegeException(msg);
		}

		// see if a session exists for this certificate
		Certificate certificate = ctx.getCertificate();
		PrivilegeContext privilegeContext = this.privilegeContextMap.get(certificate.getSessionId());
		if (privilegeContext == null) {
			String msg = format("There is no session information for {0}", certificate);
			throw new NotAuthenticatedException(msg);
		}

		// validate same privilege contexts
		if (ctx != privilegeContext) {
			String msg = format("The given PrivilegeContext {0} is not the same as registered under the sessionId {1}",
					ctx.getCertificate().getSessionId(), privilegeContext.getCertificate().getSessionId());
			throw new PrivilegeException(msg);
		}

		certificate.setLastAccess(ZonedDateTime.now());
	}

	@Override
	public PrivilegeContext validate(Certificate certificate, String source) throws PrivilegeException {
		DBC.PRE.assertNotEmpty("source must not be empty!", source);

		// certificate  must not be null
		if (certificate == null)
			throw new PrivilegeException("Certificate may not be null!");

		// first see if a session exists for this certificate
		PrivilegeContext privilegeContext = this.privilegeContextMap.get(certificate.getSessionId());
		if (privilegeContext == null) {
			String msg = format("There is no session information for {0}", certificate);
			throw new NotAuthenticatedException(msg);
		}

		// validate certificate has not been tampered with
		Certificate sessionCertificate = privilegeContext.getCertificate();
		if (!sessionCertificate.equals(certificate)) {
			String msg = "Received illegal certificate for session id {0}";
			msg = format(msg, certificate.getSessionId());
			throw new PrivilegeException(msg);
		}

		// validate that challenge certificate is not expired (1 hour only) 
		if (sessionCertificate.getUsage() != Usage.ANY) {
			ZonedDateTime dateTime = sessionCertificate.getLoginTime();
			if (dateTime.plusHours(1).isBefore(ZonedDateTime.now())) {
				invalidate(sessionCertificate);
				throw new NotAuthenticatedException("Certificate has already expired!");
			}
		}

		certificate.setLastAccess(ZonedDateTime.now());

		// assert source did not change
		if (this.disallowSourceChange && !source.equals(SOURCE_UNKNOWN) && !certificate.getSource().equals(source)) {
			invalidate(certificate);
			throw new NotAuthenticatedException("Source has changed for certificate " + certificate + " to " + source);
		}

		return privilegeContext;
	}

	@Override
	public void validatePassword(Locale locale, char[] password) throws PasswordStrengthException {
		if (!this.passwordStrengthHandler.validateStrength(password))
			throw new PasswordStrengthException(this.passwordStrengthHandler.getDescription(locale));
	}

	@Override
	public boolean persist(Certificate certificate) {

		// validate who is doing this
		PrivilegeContext prvCtx = validate(certificate);
		prvCtx.validateAction(new SimpleRestrictable(PRIVILEGE_ACTION, PRIVILEGE_ACTION_PERSIST));

		// persist non async
		try {
			return this.persistenceHandler.persist();
		} catch (XMLStreamException | IOException e) {
			throw new IllegalStateException("Failed to persist model", e);
		}
	}

	protected synchronized void persistModelAsync() {
		if (!this.autoPersistOnUserChangesData)
			return;

		// async execution, max. once per second
		if (this.persistModelTask != null)
			this.persistModelTask.cancel(true);
		this.persistModelTask = this.executorService.schedule(
				() -> this.lockingHandler.lockedExecute("persist-model", () -> {
					try {
						this.persistenceHandler.persist();
					} catch (Exception e) {
						logger.error("Failed to persist model!", e);
					}
				}), 1, TimeUnit.SECONDS);
	}

	@Override
	public boolean persistSessions(Certificate certificate, String source) {

		// validate who is doing this
		PrivilegeContext prvCtx = validate(certificate);
		prvCtx.validateAction(new SimpleRestrictable(PRIVILEGE_ACTION, PRIVILEGE_ACTION_PERSIST_SESSIONS));

		return persistSessionsAsync();
	}

	@Override
	public void start() {
		this.lockingHandler.start();
	}

	@Override
	public void stop() {
		this.lockingHandler.stop();
	}

	@Override
	public boolean reload(Certificate certificate, String source) {

		// validate who is doing this
		PrivilegeContext prvCtx = validate(certificate, source);
		prvCtx.validateAction(new SimpleRestrictable(PRIVILEGE_ACTION, PRIVILEGE_ACTION_RELOAD));

		return this.persistenceHandler.reload();
	}

	/**
	 * Initializes the concrete {@link PrivilegeHandler}. The passed parameter map contains any configuration this
	 * {@link PrivilegeHandler} might need. This method may only be called once and this must be enforced by the
	 * concrete implementation
	 *
	 * @param parameterMap            a map containing configuration properties
	 * @param encryptionHandler       the {@link EncryptionHandler} instance for this {@link PrivilegeHandler}
	 * @param passwordStrengthHandler the {@link PasswordStrengthHandler} instance for this {@link PrivilegeHandler}
	 * @param persistenceHandler      the {@link PersistenceHandler} instance for this {@link PrivilegeHandler}
	 * @param userChallengeHandler    the handler to challenge a user's actions e.g. password change or authentication
	 * @param ssoHandler              the {@link SingleSignOnHandler}
	 * @param policyMap               map of {@link PrivilegePolicy} classes
	 *
	 * @throws PrivilegeException if this method is called multiple times or an initialization exception occurs
	 */
	public void initialize(ScheduledExecutorService executorService, Map<String, String> parameterMap,
			EncryptionHandler encryptionHandler, PasswordStrengthHandler passwordStrengthHandler,
			PersistenceHandler persistenceHandler, UserChallengeHandler userChallengeHandler,
			SingleSignOnHandler ssoHandler, Map<String, Class<PrivilegePolicy>> policyMap) {

		if (this.initialized)
			throw new PrivilegeModelException("Already initialized!");

		this.executorService = executorService;
		this.lockingHandler = new ElementLockingHandler<>(executorService, TimeUnit.SECONDS, 10L);
		this.policyMap = Map.copyOf(policyMap);
		this.encryptionHandler = encryptionHandler;
		this.passwordStrengthHandler = passwordStrengthHandler;
		this.persistenceHandler = persistenceHandler;
		this.userChallengeHandler = userChallengeHandler;
		this.ssoHandler = ssoHandler;

		handleAutoPersistOnUserDataChange(parameterMap);
		handlePersistSessionsParam(parameterMap);
		handleConflictResolutionParam(parameterMap);
		handleSecretParams(parameterMap);

		this.allowSessionRefresh = Boolean.parseBoolean(parameterMap.get(PARAM_ALLOW_SESSION_REFRESH));
		this.disallowSourceChange = Boolean.parseBoolean(parameterMap.get(PARAM_DISALLOW_SOURCE_CHANGE));

		this.crudHandler = new PrivilegeCrudHandler(this, this.policyMap, this.privilegeConflictResolution);

		// validate policies on privileges of Roles
		for (Role role : persistenceHandler.getAllRoles()) {
			this.crudHandler.validatePolicies(role);
		}

		// validate privilege conflicts
		validatePrivilegeConflicts();

		this.privilegeContextMap = new ConcurrentHashMap<>();

		loadSessions();

		this.parameterMap = parameterMap;
		this.initialized = true;
	}

	private void handleAutoPersistOnUserDataChange(Map<String, String> parameterMap) {
		String autoPersistS = parameterMap.get(PARAM_AUTO_PERSIST_ON_USER_CHANGES_DATA);
		if (isEmpty(autoPersistS) || autoPersistS.equals(Boolean.FALSE.toString())) {
			this.autoPersistOnUserChangesData = false;
		} else if (autoPersistS.equals(Boolean.TRUE.toString())) {
			this.autoPersistOnUserChangesData = true;
			logger.info("Enabling automatic persistence when user changes their data.");
		} else {
			String msg = "Parameter {0} has illegal value {1}. Overriding with {2}";
			msg = format(msg, PARAM_AUTO_PERSIST_ON_USER_CHANGES_DATA, autoPersistS, Boolean.FALSE);
			logger.error(msg);
			this.autoPersistOnUserChangesData = false;
		}
	}

	private void handlePersistSessionsParam(Map<String, String> parameterMap) {
		String persistSessionsS = parameterMap.get(PARAM_PERSIST_SESSIONS);
		if (isEmpty(persistSessionsS) || persistSessionsS.equals(Boolean.FALSE.toString())) {
			this.persistSessions = false;
		} else if (persistSessionsS.equals(Boolean.TRUE.toString())) {
			this.persistSessions = true;

			String persistSessionsPathS = parameterMap.get(PARAM_PERSIST_SESSIONS_PATH);
			if (isEmpty(persistSessionsPathS)) {
				String msg = "Parameter {0} has illegal value {1}.";
				msg = format(msg, PARAM_PERSIST_SESSIONS_PATH, persistSessionsPathS);
				throw new PrivilegeModelException(msg);
			}

			this.persistSessionsPath = getPersistSessionFile(persistSessionsPathS);
			logger.info(format("Enabling persistence of sessions to {0}", this.persistSessionsPath.getAbsolutePath()));
		} else {
			String msg = "Parameter {0} has illegal value {1}. Overriding with {2}";
			msg = format(msg, PARAM_PERSIST_SESSIONS, persistSessionsS, Boolean.FALSE);
			logger.error(msg);
			this.persistSessions = false;
		}
	}

	private static File getPersistSessionFile(String persistSessionsPathS) {
		File persistSessionsPath = new File(persistSessionsPathS);
		if (!persistSessionsPath.getParentFile().isDirectory()) {
			String msg = "Path for param {0} is invalid as parent does not exist or is not a directory. Value: {1}";
			msg = format(msg, PARAM_PERSIST_SESSIONS_PATH, persistSessionsPath.getAbsolutePath());
			throw new PrivilegeModelException(msg);
		}

		if (persistSessionsPath.exists() && (!persistSessionsPath.isFile() || !persistSessionsPath.canWrite())) {
			String msg = "Path for param {0} is invalid as file exists but is not a file or not writeable. Value: {1}";
			msg = format(msg, PARAM_PERSIST_SESSIONS_PATH, persistSessionsPath.getAbsolutePath());
			throw new PrivilegeModelException(msg);
		}
		return persistSessionsPath;
	}

	private void handleConflictResolutionParam(Map<String, String> parameterMap) {
		String privilegeConflictResolutionS = parameterMap.get(PARAM_PRIVILEGE_CONFLICT_RESOLUTION);
		if (privilegeConflictResolutionS == null) {
			this.privilegeConflictResolution = PrivilegeConflictResolution.MERGE;
			String msg = "No {0} parameter defined. Using {1}";
			msg = format(msg, PARAM_PRIVILEGE_CONFLICT_RESOLUTION, this.privilegeConflictResolution);
			logger.info(msg);
		} else {
			try {
				this.privilegeConflictResolution = PrivilegeConflictResolution.valueOf(privilegeConflictResolutionS);
			} catch (Exception e) {
				String msg = "Parameter {0} has illegal value {1}.";
				msg = format(msg, PARAM_PRIVILEGE_CONFLICT_RESOLUTION, privilegeConflictResolutionS);
				throw new PrivilegeModelException(msg);
			}
		}
		logger.info("Privilege conflict resolution set to " + this.privilegeConflictResolution);
	}

	private void handleSecretParams(Map<String, String> parameterMap) {

		String secretKeyS = parameterMap.get(PARAM_SECRET_KEY);
		if (isEmpty(secretKeyS)) {
			String msg = "Parameter {0} may not be empty";
			msg = format(msg, PARAM_SECRET_KEY);
			throw new PrivilegeModelException(msg);
		}

		String secretSaltS = parameterMap.get(PARAM_SECRET_SALT);
		if (isEmpty(secretSaltS)) {
			String msg = "Parameter {0} may not be empty";
			msg = format(msg, PARAM_SECRET_SALT);
			throw new PrivilegeModelException(msg);
		}

		this.secretKey = AesCryptoHelper.buildSecret(secretKeyS.toCharArray(), secretSaltS.getBytes());

		// remove secrets
		parameterMap.remove(PARAM_SECRET_KEY);
		parameterMap.remove(PARAM_SECRET_SALT);
	}

	private void validatePrivilegeConflicts() {
		if (!this.privilegeConflictResolution.isStrict()) {
			return;
		}

		List<String> conflicts = new ArrayList<>();
		List<User> users = this.persistenceHandler.getAllUsers();
		for (User user : users) {
			Map<String, String> privilegeNames = new HashMap<>();
			conflicts.addAll(this.crudHandler.detectPrivilegeConflicts(privilegeNames, user));
		}

		if (!conflicts.isEmpty()) {
			for (String conflict : conflicts) {
				logger.error(conflict);
			}
			throw new PrivilegeModelException("There are " + conflicts.size() + " privilege conflicts!");
		}
	}

	/**
	 * Replaces any existing {@link PrivilegeContext} for the given user by updating with the new user object
	 *
	 * @param newUser the new user to update with
	 */
	void updateExistingSessionsForUser(User newUser) {
		List<PrivilegeContext> contexts = new ArrayList<>(this.privilegeContextMap.values());
		for (PrivilegeContext ctx : contexts) {
			if (!ctx.getUserRep().getUsername().equals(newUser.getUsername()))
				continue;
			replacePrivilegeContextForCert(newUser, ctx.getCertificate());
		}

		persistSessionsAsync();
	}

	/**
	 * Replaces any existing {@link PrivilegeContext} for users with the given role
	 *
	 * @param role the role to update with
	 */
	void updateExistingSessionsWithNewRole(Role role) {
		List<PrivilegeContext> contexts = new ArrayList<>(this.privilegeContextMap.values());
		for (PrivilegeContext ctx : contexts) {
			if (!ctx.getUserRep().hasRole(role.getName()))
				continue;
			User user = this.persistenceHandler.getUser(ctx.getUsername());
			if (user == null)
				continue;
			replacePrivilegeContextForCert(user, ctx.getCertificate());
		}

		persistSessionsAsync();
	}

	@Override
	public void runAs(String username, SystemAction action) throws Exception {
		PrivilegeContext systemUserPrivilegeContext = initiateSystemPrivilege(username, action);
		String sessionId = systemUserPrivilegeContext.getCertificate().getSessionId();
		try {
			// perform the action
			action.execute(systemUserPrivilegeContext);
		} finally {
			this.privilegeContextMap.remove(sessionId);
		}
	}

	@Override
	public <T> T runWithResult(String username, SystemActionWithResult<T> action) throws Exception {
		PrivilegeContext systemUserPrivilegeContext = initiateSystemPrivilege(username, action);
		String sessionId = systemUserPrivilegeContext.getCertificate().getSessionId();
		try {
			// perform the action
			return action.execute(systemUserPrivilegeContext);
		} finally {
			this.privilegeContextMap.remove(sessionId);
		}
	}

	@Override
	public PrivilegeContext openSystemUserContext(String username) throws PrivilegeException {
		return buildSystemUserPrivilegeContext(username);
	}

	private PrivilegeContext initiateSystemPrivilege(String username, Restrictable restrictable) {
		if (username == null)
			throw new PrivilegeException("systemUsername may not be null!");
		if (restrictable == null)
			throw new PrivilegeException("action may not be null!");

		PrivilegeContext systemUserPrivilegeContext = buildSystemUserPrivilegeContext(username);
		systemUserPrivilegeContext.validateAction(restrictable);
		return systemUserPrivilegeContext;
	}

	/**
	 * Returns the {@link Certificate} for the given system username. If it does not yet exist, then it is created by
	 * authenticating the system user
	 *
	 * @param systemUsername the name of the system user
	 *
	 * @return the {@link Certificate} for this system user
	 */
	private PrivilegeContext buildSystemUserPrivilegeContext(String systemUsername) {

		// get user object
		User user = this.persistenceHandler.getUser(systemUsername);

		// no user means no authentication
		if (user == null) {
			String msg = format("The system user with username {0} does not exist!", systemUsername);
			throw new AccessDeniedException(msg);
		}

		// validate password
		if (user.getPasswordCrypt() != null) {
			String msg = format("System users must not have a password: {0}", user.getUsername());
			throw new AccessDeniedException(msg);
		}

		// validate user state is system
		if (user.getUserState() != UserState.SYSTEM) {
			String msg = "The system {0} user does not have expected user state {1}";
			msg = format(msg, user.getUsername(), UserState.SYSTEM);
			throw new PrivilegeException(msg);
		}

		// validate user has at least one role
		if (user.getRoles().isEmpty()) {
			String msg = format("The system user {0} does not have any roles defined!", user.getUsername());
			throw new PrivilegeException(msg);
		}

		// initialize a new privilege context
		PrivilegeContext privilegeContext = buildPrivilegeContext(Usage.ANY, user, "internal", ZonedDateTime.now(),
				false);

		// log
		if (logger.isDebugEnabled()) {
			String msg = "The system user ''{0}'' is logged in with session {1}";
			msg = format(msg, user.getUsername(), privilegeContext.getCertificate().getSessionId());
			logger.info(msg);
		}

		return privilegeContext;
	}

	private void buildPrivilegeContext(User user, CertificateStub stub) {
		PrivilegeContext privilegeContext = new PrivilegeContextBuilder(this).buildPrivilegeContext(stub.getUsage(),
				user, stub.getAuthToken(), stub.getSessionId(), stub.getSource(), stub.getLoginTime(),
				stub.isKeepAlive());
		Certificate certificate = privilegeContext.getCertificate();
		certificate.setLocale(stub.getLocale());
		certificate.setLastAccess(stub.getLastAccess());
		this.privilegeContextMap.put(certificate.getSessionId(), privilegeContext);
	}

	private void replacePrivilegeContextForCert(User user, Certificate cert) {
		PrivilegeContext privilegeContext = new PrivilegeContextBuilder(this).buildPrivilegeContext(cert.getUsage(),
				user, cert.getAuthToken(), cert.getSessionId(), cert.getSource(), cert.getLoginTime(),
				cert.isKeepAlive());
		this.privilegeContextMap.put(privilegeContext.getCertificate().getSessionId(), privilegeContext);
	}

	public PrivilegeContext buildPrivilegeContext(Usage usage, User user, String source, ZonedDateTime loginTime,
			boolean keepAlive) {
		PrivilegeContext privilegeContext = new PrivilegeContextBuilder(this).buildPrivilegeContext(usage, user, source,
				loginTime, keepAlive);
		this.privilegeContextMap.put(privilegeContext.getCertificate().getSessionId(), privilegeContext);
		return privilegeContext;
	}
}
