/**
* OLAT - Online Learning and Training<br>
* http://www.olat.org
* <p>
* Licensed under the Apache License, Version 2.0 (the "License"); <br>
* you may not use this file except in compliance with the License.<br>
* You may obtain a copy of the License at
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* Unless required by applicable law or agreed to in writing,<br>
* software distributed under the License is distributed on an "AS IS" BASIS, <br>
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. <br>
* See the License for the specific language governing permissions and <br>
* limitations under the License.
* <p>
* Copyright (c) since 2004 at Multimedia- & E-Learning Services (MELS),<br>
* University of Zurich, Switzerland.
* <hr>
* <a href="http://www.openolat.org">
* OpenOLAT - Online Learning and Training</a><br>
* This file has been modified by the OpenOLAT community. Changes are licensed
* under the Apache 2.0 license as the original file.
*/

package org.olat.course.assessment;

import java.io.Serializable;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.persistence.TypedQuery;

import org.olat.core.commons.persistence.DBFactory;
import org.olat.core.commons.persistence.PersistenceHelper;
import org.olat.core.helpers.Settings;
import org.olat.core.id.Identity;
import org.olat.core.id.OLATResourceable;
import org.olat.core.logging.OLog;
import org.olat.core.logging.Tracing;
import org.olat.core.logging.activity.ILoggingAction;
import org.olat.core.logging.activity.StringResourceableType;
import org.olat.core.logging.activity.ThreadLocalUserActivityLogger;
import org.olat.core.manager.BasicManager;
import org.olat.core.util.StringHelper;
import org.olat.core.util.cache.n.CacheWrapper;
import org.olat.core.util.coordinate.CoordinatorManager;
import org.olat.core.util.coordinate.SyncerCallback;
import org.olat.core.util.coordinate.SyncerExecutor;
import org.olat.core.util.event.GenericEventListener;
import org.olat.core.util.resource.OresHelper;
import org.olat.course.CourseFactory;
import org.olat.course.ICourse;
import org.olat.course.auditing.UserNodeAuditManager;
import org.olat.course.nodes.AssessableCourseNode;
import org.olat.course.nodes.CourseNode;
import org.olat.course.properties.CoursePropertyManager;
import org.olat.course.run.scoring.ScoreEvaluation;
import org.olat.course.run.userview.UserCourseEnvironment;
import org.olat.properties.Property;
import org.olat.testutils.codepoints.server.Codepoint;
import org.olat.util.logging.activity.LoggingResourceable;


/**
 * Description:<BR>
 * The assessment manager is used by the assessable course nodes to store and
 * retrieve user assessment data from the database. The assessment Manager
 * should not be used directly from the controllers but only via the assessable
 * course nodes interface.<BR>
 * Exception are nodes that want to save or get node attempts variables for
 * nodes that are not assessable nodes (e.g. questionnaire) <BR>
 * This implementation will store its values using the property manager and has
 * a cache built in for frequently used assessment data like score, passed and
 * attempts variables.
 * <P>
 * 
 * the underlying cache is segmented as follows:
 * 1.) by this class (=owner in singlevm, coowner in cluster mode)
 * 2.) by course (so that e.g. deletion of a course removes all caches)
 * 3.) by identity, for preloading and invalidating (e.g. a user entering a course will cause the identity's cache to be loaded)
 * 
 * each cache only has -one- key, which is a hashmap with all the information (score,passed, etc) for the given user/course.
 * the reason for this is that it must be possible to see a difference between a null value (key expired) and a value which corresponds to
 * e.g. "this user has never attempted this test in this course". since only the concrete set, but not the possible set is known. (at least
 * not in the database). so all keys of a given user/course will therefore expire together which also makes sense from a use point of view.
 * 
 * Cache usage with e.g. the wiki: wikipages should be saved as separate keys, since no batch updates are needed for perf. reasons.
 * 
 * reason for 3: preloading all data of all users of a course lasts up to 5 seconds and will waste memory.
 * a user in a course only needs its own data. only when a tutor enters the assessment functionality, all data of all users is needed ->
 * do a full load only then.
 * 
 * TODO: e.g. IQTEST.onDelete(..) cleans all data without going over the assessmentmanager here. meaning that the cache has stale data in it.
 * since coursenode.getIdent (partial key of this cache) is forever unique, then this doesn't really matter. - but it is rather unintended...
 * point is that a node can save lots of data that have nothing to do with assessments
 * 
 * 
 * @author Felix Jost
 */
public class NewCachePersistingAssessmentManager extends BasicManager implements AssessmentManager {
	
	private static final OLog log = Tracing.createLoggerFor(NewCachePersistingAssessmentManager.class);

	/**
	 * the key under which a hashmap is stored in a cachewrapper. we only use one key so that either all values of a user are there or none are there.
	 * (otherwise we cannot know whether a null value means expiration of cache or no-such-property-yet-for-user)
	 */
	private static final String FULLUSERSET = "FULLUSERSET";
	private static final String LAST_MODIFIED = "LAST_MODIFIED";
	
	
	// Float and Integer are immutable objects, we can reuse them.
	private static final Float FLOAT_ZERO = new Float(0);
	private static final Integer INTEGER_ZERO = new Integer(0);
	
	// one cache entry point to generate subcaches for all assessmentmanager instances
	private static CacheWrapper assessmentMainCache = CoordinatorManager.getInstance().getCoordinator().getCacher().getOrCreateCache(NewCachePersistingAssessmentManager.class, null);

	// the cache per assessment manager instance (=per course)
	private CacheWrapper courseCache;
	private OLATResourceable ores;
	
	// we cannot store the ref to cpm here, since at the time where the assessmentManager is initialized, the given course is not fully initialized yet.
	//does not work: final CoursePropertyManager cpm; 
		
	/**
	 * Get an instance of the persisting assessment manager. This will use the
	 * course property manager to persist assessment data. THIS METHOD MUST ONLY
	 * BE USED WITHIN THE COURSE CONSTRUCTOR. Use course.getAssessmentManager() to
	 * use the assessment manager during runtime!
	 * 
	 * @param course
	 * @return The assessment manager for this course
	 */
	public static AssessmentManager getInstance(ICourse course) {
		return new NewCachePersistingAssessmentManager(course);
	}

	/**
	 * Private since singleton
	 */
	private NewCachePersistingAssessmentManager(ICourse course) {
		this.ores = course;
		courseCache = assessmentMainCache.getOrCreateChildCacheWrapper(course);
	}
	/**
	 * @param identity the identity for which to properties are to be loaded. 
	 * if null, the properties of all identities (=all properties of this course)
	 * are loaded.
	 * @return
	 */
	private List<Property> loadPropertiesFor(List<Identity> identities) {
		if(identities == null || identities.isEmpty()) return Collections.emptyList();
		
		ICourse course = CourseFactory.loadCourse(ores);
		StringBuilder sb = new StringBuilder();
		sb.append("from org.olat.properties.Property as p")
		  .append(" inner join fetch p.identity as ident ")
		  .append(" inner join fetch ident.user as user ")
		  .append(" where p.resourceTypeId = :restypeid and p.resourceTypeName = :restypename")
		  .append(" and p.name in ('")
		  .append(ATTEMPTS).append("','")
		  .append(SCORE).append("','")
		  .append(PASSED).append("','")
		  .append(ASSESSMENT_ID).append("','")
		  .append(COMMENT).append("','")
		  .append(COACH_COMMENT)
		  .append("')");
		if (identities != null) {
			sb.append(" and p.identity.key in (:id)");
		}
		TypedQuery<Property> query = DBFactory.getInstance().getCurrentEntityManager()
				.createQuery(sb.toString(), Property.class)
				.setParameter("restypename", course.getResourceableTypeName())
				.setParameter("restypeid", course.getResourceableId());
		if (identities != null) {
			query.setParameter("id", PersistenceHelper.toKeys(identities));
		}
		return query.getResultList();	
	}
	
	/**
	 * @see org.olat.course.assessment.AssessmentManager#preloadCache(org.olat.core.id.Identity)
	 */
	@Override
	public void preloadCache(Identity identity) {
		// triggers loading of data of the given user.
		getOrLoadScorePassedAttemptsMap(identity, null, false);
	}

	@Override
	public void preloadCache(List<Identity> identities) {
		int count = 0;
		int batch = 200;

		Map<Identity, List<Property>> map = new HashMap<Identity, List<Property>>(201); 
		do {
			int toIndex = Math.min(count + batch, identities.size());
			List<Identity> toLoad = identities.subList(count, toIndex);
			List<Property> allProperties = loadPropertiesFor(toLoad);
			
			map.clear();
			for(Property prop:allProperties) {
				if(!map.containsKey(prop.getIdentity())) {
					map.put(prop.getIdentity(), new ArrayList<Property>());
				}
				map.get(prop.getIdentity()).add(prop);
			}
			
			for(Identity id:toLoad) {
				List<Property> props = map.get(id);
				if(props == null) {
					props = new ArrayList<Property>(1);
				}
				getOrLoadScorePassedAttemptsMap(id, props, false);
			}
			count += batch;
		} while(count < identities.size());
	}
	
	/**
	 * retrieves the Map which contains all data for this course and the given user. 
	 * if the cache evicted the map in the meantime, then it is recreated
	 * by querying the database and fetching all that data in one query, and then reput into the cache.
	 * <br>
	 * this method is threadsafe.
	 * 
	 * @param identity the identity 
	 * @param notify if true, then the
	 * @return a Map containing nodeident+"_"+ e.g. PASSED as key, Boolean (for PASSED), Float (for SCORE), or Integer (for ATTEMPTS) as values
	 */
	private Map<String, Serializable> getOrLoadScorePassedAttemptsMap(Identity identity, List<Property> properties, boolean prepareForNewData) {
		CacheWrapper cw = getCacheWrapperFor(identity);
		synchronized(cw) {  // o_clusterOK by:fj : we sync on the cache to protect access within the monitor "one user in a course".
			// a user is only active on one node at the same time.
			Map<String, Serializable> m = (Map<String, Serializable>) cw.get(FULLUSERSET);
			if (m == null) {
				// cache entry (=all data of the given identity in this course) has expired or has never been stored yet into the cache.
				// or has been invalidated (in cluster mode when puts occurred from an other node for the same cache)
				m = new HashMap<String, Serializable>();
				// load data
				List<Property> loadedProperties = properties == null ? loadPropertiesFor(Collections.singletonList(identity)) : properties;
				for (Property property:loadedProperties) {
					addPropertyToCache(m, property);
				}
				
				//If property not found, prefill with default value.
				if(!m.containsKey(ATTEMPTS)) {
					m.put(ATTEMPTS, INTEGER_ZERO);
				}
				if(!m.containsKey(SCORE)) {
					m.put(SCORE, FLOAT_ZERO);
				}
				if(!m.containsKey(LAST_MODIFIED)) {
					m.put(LAST_MODIFIED, null);
				}
				
				// we use a putSilent here (no invalidation notifications to other cluster nodes), since
				// we did not generate new data, but simply asked to reload it. 
				if (prepareForNewData) {
					cw.update(FULLUSERSET, (Serializable) m);
				} else {
					cw.put(FULLUSERSET, (Serializable) m);
				}
			} else {
				// still in cache. 
				if (prepareForNewData) { // but we need to notify that data has changed: we reput the data into the cache - a little hacky yes
					cw.update(FULLUSERSET, (Serializable) m);
				}
			}
			return m;
		}
	}
	
	private CacheWrapper getCacheWrapperFor(Identity identity) {
		// the ores is only for within the cache
		OLATResourceable ores = OresHelper.createOLATResourceableInstanceWithoutCheck("Identity", identity.getKey());
		CacheWrapper cw = courseCache.getOrCreateChildCacheWrapper(ores);
		return cw;
	}
	
	
	// package local for perf. reasons, threadsafe.
	/**
	 * puts a property into the cache. 
	 * since it only puts data into a map which in turn is put under the FULLUSERSET key into the cache, we need to 
	 * explicitly reput that key from the cache first, so that the cache notices that that data has changed 
	 * (and can propagate to other nodes if applicable) 
	 * 
	 */
	void putPropertyIntoCache(Identity identity, Property property) { 
		// load the data, and indicate it to reput into the cache so that the cache knows it is something new.
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, true);
		addPropertyToCache(m, property);		
	}
	
	/**
	 * Removes a property from cache.
	 * @param identity
	 * @param property
	 */
	void removePropertyFromCache(Identity identity, Property property) { 
		// load the data, and indicate it to reput into the cache so that the cache knows it is something new.
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, true);
		this.removePropertyFromCache(m, property);
	}

	/**
	 * thread safe.
	 * @param property
	 * @throws AssertionError
	 */
	private void addPropertyToCache(Map<String, Serializable> acache, Property property) throws AssertionError {
		String propertyName = property.getName();
		Serializable value;
		if (propertyName.equals(ATTEMPTS)) {
			value = new Integer(property.getLongValue().intValue());
		} else if (propertyName.equals(SCORE)) {
			value = property.getFloatValue();
		} else if (propertyName.equals(PASSED)) {
			value = new Boolean(property.getStringValue());
		} else if (propertyName.equals(ASSESSMENT_ID)) {
			value = property.getLongValue();			
		} else if (propertyName.equals(COMMENT) || propertyName.equals(COACH_COMMENT)) {
			value = property.getTextValue();			
		}	else {
			throw new AssertionError("property in list that is not of type attempts, score, passed or ASSESSMENT_ID, COMMENT and COACH_COMMENT :: " + propertyName);
		}
		
		Date lastModified = property.getLastModified();
		// put in cache, maybe overriding old values		
		String cacheKey = getPropertyCacheKey(property);		
		synchronized(acache) {//cluster_ok acache is an element from the cacher
			acache.put(cacheKey, value);
			
			String lmCacheKey = getLastModifiedCacheKey(property);
			Long currentLastModifiedDate = (Long)acache.get(lmCacheKey);
			if(currentLastModifiedDate == null || currentLastModifiedDate.longValue() < lastModified.getTime()) {
				acache.put(lmCacheKey, new Long(lastModified.getTime()));
			}
		}
	}
	
	/**
	 * Removes property from cache
	 * @param acache
	 * @param property
	 * @throws AssertionError
	 */
	private void removePropertyFromCache(Map<String, Serializable> acache, Property property) throws AssertionError {
		String propertyName = property.getName();		
		if (!(propertyName.equals(ATTEMPTS) || propertyName.equals(SCORE) || propertyName.equals(PASSED))) {
			throw new AssertionError("property in list that is not of type attempts, score or passed ::" + propertyName);
		}
				
		String cacheKey = getPropertyCacheKey(property);		
		synchronized(acache) {//cluster_ok acache is an elment from the cacher
			acache.remove(cacheKey);			
		}
	}
	
	/**
	 * 
	 * @param courseNode
	 * @param identity
	 * @param assessedIdentity
	 * @param score
	 * @param coursePropManager
	 */
	void saveNodeScore(CourseNode courseNode, Identity identity, Identity assessedIdentity, Float score, CoursePropertyManager coursePropManager) {
    // olat:::: introduce a createOrUpdate method in the cpm and also if applicable in the general propertymanager
		if (score != null) {
			Property scoreProperty = coursePropManager.findCourseNodeProperty(courseNode, assessedIdentity, null, SCORE);
			if (scoreProperty == null) {
				scoreProperty = coursePropManager.createCourseNodePropertyInstance(courseNode, assessedIdentity, null, SCORE, score, null, null, null);
				coursePropManager.saveProperty(scoreProperty);
			} else {
				scoreProperty.setFloatValue(score);
				coursePropManager.updateProperty(scoreProperty);
			}
			// add to cache
			putPropertyIntoCache(assessedIdentity, scoreProperty);
		}
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#saveNodeAttempts(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity, org.olat.core.id.Identity,
	 *      java.lang.Integer)
	 */
	public void saveNodeAttempts(final CourseNode courseNode, final Identity identity, final Identity assessedIdentity, final Integer attempts) {
		//   A note on updating the EfficiencyStatement:
		// In the equivalent method incrementNodeAttempts() in this class, the following code is executed:
		//   // Update users efficiency statement
	  //   EfficiencyStatementManager esm =	EfficiencyStatementManager.getInstance();
	  //   esm.updateUserEfficiencyStatement(userCourseEnv);
		// One would expect that saveNodeAttempts would also have to update the EfficiencyStatement - or
		// the caller of this method would have to make sure that this happens in the same transaction.
		// While this is not explicitly so, implicitly it is: currently the only user this method is 
		// the AssessmentEditController - which as the 2nd last method calls into saveScoreEvaluation
		// - which in turn does update the EfficiencyStatement - at which point we're happy and everything works fine.
		// But it seems like this mechanism is a bit unobvious and might well be worth some refactoring...
		ICourse course = CourseFactory.loadCourse(ores);
		final CoursePropertyManager cpm = course.getCourseEnvironment().getCoursePropertyManager();
		CoordinatorManager.getInstance().getCoordinator().getSyncer().doInSync(createOLATResourceableForLocking(assessedIdentity), new SyncerExecutor(){
			public void execute() {
				Property attemptsProperty = cpm.findCourseNodeProperty(courseNode, assessedIdentity, null, ATTEMPTS);
				if (attemptsProperty == null) {
					attemptsProperty = cpm.createCourseNodePropertyInstance(courseNode, assessedIdentity, null, ATTEMPTS, 
							null, new Long(attempts.intValue()), null, null);
					cpm.saveProperty(attemptsProperty);
				} else {
					attemptsProperty.setLongValue(new Long(attempts.intValue()));
					cpm.updateProperty(attemptsProperty);
				}
				// add to cache
				putPropertyIntoCache(assessedIdentity, attemptsProperty);
			}
		});

		// node log
		UserNodeAuditManager am = course.getCourseEnvironment().getAuditManager();
		am.appendToUserNodeLog(courseNode, identity, assessedIdentity, ATTEMPTS + " set to: " + String.valueOf(attempts));

		// notify about changes
		AssessmentChangedEvent ace = new AssessmentChangedEvent(AssessmentChangedEvent.TYPE_ATTEMPTS_CHANGED, assessedIdentity);
		CoordinatorManager.getInstance().getCoordinator().getEventBus().fireEventToListenersOf(ace, course);

		// user activity logging
		ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_ATTEMPTS_UPDATED, 
				getClass(), 
				LoggingResourceable.wrap(assessedIdentity), 
				LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiAttempts, "", String.valueOf(attempts)));	
		}

	
	/**
	 * 
	 * @param courseNode
	 * @param identity
	 * @param assessedIdentity
	 * @param passed
	 * @param coursePropManager
	 */
	void saveNodePassed(CourseNode courseNode, Identity identity, Identity assessedIdentity, Boolean passed, CoursePropertyManager coursePropManager) {		
		  Property passedProperty = coursePropManager.findCourseNodeProperty(courseNode, assessedIdentity, null, PASSED);
		  if (passedProperty == null && passed!=null) {					
			  String pass = passed.toString();
			  passedProperty = coursePropManager.createCourseNodePropertyInstance(courseNode, assessedIdentity, null, PASSED, null, null, pass, null);
			  coursePropManager.saveProperty(passedProperty);					 
		  } else if (passedProperty!=null){
			  if (passed!=null) {
			  passedProperty.setStringValue(passed.toString());
			  coursePropManager.updateProperty(passedProperty);
			  } else {
			  	removePropertyFromCache(assessedIdentity,passedProperty);
				  coursePropManager.deleteProperty(passedProperty);
			  }
		  }
		  
		  //add to cache
		  if(passed!=null && passedProperty!=null) {
			  putPropertyIntoCache(assessedIdentity, passedProperty);
		  }		
	}
	
	
	/**
	 * @see org.olat.course.assessment.AssessmentManager#saveNodeComment(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity, org.olat.core.id.Identity,
	 *      java.lang.String)
	 */
	public void saveNodeComment(final CourseNode courseNode, final Identity identity, final Identity assessedIdentity, final String comment) {
		ICourse course = CourseFactory.loadCourse(ores);
		final CoursePropertyManager cpm = course.getCourseEnvironment().getCoursePropertyManager();
		CoordinatorManager.getInstance().getCoordinator().getSyncer().doInSync(createOLATResourceableForLocking(assessedIdentity), new SyncerExecutor(){
			public void execute() {
				Property commentProperty = cpm.findCourseNodeProperty(courseNode, assessedIdentity, null, COMMENT);
				if (commentProperty == null) {
					commentProperty = cpm.createCourseNodePropertyInstance(courseNode, assessedIdentity, null, COMMENT, null, null, null, comment);
					cpm.saveProperty(commentProperty);
				} else {
					commentProperty.setTextValue(comment);
					cpm.updateProperty(commentProperty);
				}
			  // add to cache
				putPropertyIntoCache(assessedIdentity, commentProperty);
			}
		});
		// node log
		UserNodeAuditManager am = course.getCourseEnvironment().getAuditManager();
		am.appendToUserNodeLog(courseNode, identity, assessedIdentity, COMMENT + " set to: " + comment);

		// notify about changes
		AssessmentChangedEvent ace = new AssessmentChangedEvent(AssessmentChangedEvent.TYPE_USER_COMMENT_CHANGED, assessedIdentity);
		CoordinatorManager.getInstance().getCoordinator().getEventBus().fireEventToListenersOf(ace, course);

		// user activity logging
		ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_USERCOMMENT_UPDATED, 
				getClass(), 
				LoggingResourceable.wrap(assessedIdentity), 
				LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiUserComment, "", StringHelper.stripLineBreaks(comment)));	
	}
	
	/**
	 * @see org.olat.course.assessment.AssessmentManager#saveNodeCoachComment(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity, java.lang.String)
	 */
	public void saveNodeCoachComment(final CourseNode courseNode, final Identity assessedIdentity, final String comment) {
		ICourse course = CourseFactory.loadCourse(ores);
		final CoursePropertyManager cpm = course.getCourseEnvironment().getCoursePropertyManager();
		CoordinatorManager.getInstance().getCoordinator().getSyncer().doInSync(createOLATResourceableForLocking(assessedIdentity), new SyncerExecutor(){
			public void execute() {
				Property commentProperty = cpm.findCourseNodeProperty(courseNode, assessedIdentity, null, COACH_COMMENT);
				if (commentProperty == null) {
					commentProperty = cpm.createCourseNodePropertyInstance(courseNode, assessedIdentity, null, COACH_COMMENT, null, null, null, comment);
					cpm.saveProperty(commentProperty);
				} else {
					commentProperty.setTextValue(comment);
					cpm.updateProperty(commentProperty);
				}
			  // add to cache
				putPropertyIntoCache(assessedIdentity, commentProperty);
			}
		});
		// olat::: no node log here? (because what we did above is a node log with custom text AND by a coach)?

		// notify about changes
		AssessmentChangedEvent ace = new AssessmentChangedEvent(AssessmentChangedEvent.TYPE_COACH_COMMENT_CHANGED, assessedIdentity);
		CoordinatorManager.getInstance().getCoordinator().getEventBus().fireEventToListenersOf(ace, course);

		// user activity logging
		ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_COACHCOMMENT_UPDATED, 
				getClass(), 
				LoggingResourceable.wrap(assessedIdentity), 
				LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiCoachComment, "", StringHelper.stripLineBreaks(comment)));	
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#incrementNodeAttempts(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity)
	 */
	public void incrementNodeAttempts(CourseNode courseNode, Identity identity, UserCourseEnvironment userCourseEnv) {
		incrementNodeAttempts(courseNode, identity, userCourseEnv, true);
	}
	
	/**
	 * @see org.olat.course.assessment.AssessmentManager#incrementNodeAttemptsInBackground(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity, org.olat.course.run.userview.UserCourseEnvironment)
	 */
	@Override
	public void incrementNodeAttemptsInBackground(CourseNode courseNode, Identity identity, UserCourseEnvironment userCourseEnv) {
		incrementNodeAttempts(courseNode, identity, userCourseEnv, false);
	}

	private void incrementNodeAttempts(final CourseNode courseNode, final Identity identity, final UserCourseEnvironment userCourseEnv, boolean logActivity) {
		ICourse course = CourseFactory.loadCourse(ores);
		final CoursePropertyManager cpm = course.getCourseEnvironment().getCoursePropertyManager();
		long attempts = CoordinatorManager.getInstance().getCoordinator().getSyncer().doInSync(createOLATResourceableForLocking(identity), new SyncerCallback<Long>(){
			public Long execute() {
				long attempts = incrementNodeAttemptsProperty(courseNode, identity, cpm);
				if(courseNode instanceof AssessableCourseNode) {
          // Update users efficiency statement
				  EfficiencyStatementManager esm =	EfficiencyStatementManager.getInstance();
				  esm.updateUserEfficiencyStatement(userCourseEnv);
				}
				return attempts;
			}
		});

		// notify about changes
		AssessmentChangedEvent ace = new AssessmentChangedEvent(AssessmentChangedEvent.TYPE_ATTEMPTS_CHANGED, identity);
		CoordinatorManager.getInstance().getCoordinator().getEventBus().fireEventToListenersOf(ace, course);

		if(logActivity) {
			// user activity logging
			ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_ATTEMPTS_UPDATED, 
					getClass(), 
					LoggingResourceable.wrap(identity), 
					LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiAttempts, "", String.valueOf(attempts)));
		}
	}
	
	/**
	 * Private method. Increments the attempts property.
	 * @param courseNode
	 * @param identity
	 * @param cpm
	 * @return the resulting new number of node attempts
	 */
	private long incrementNodeAttemptsProperty(CourseNode courseNode, Identity identity, CoursePropertyManager cpm) {		
		Long attempts;
		Property attemptsProperty = cpm.findCourseNodeProperty(courseNode, identity, null, ATTEMPTS);
		if (attemptsProperty == null) {
			attempts = new Long(1);
			attemptsProperty = cpm.createCourseNodePropertyInstance(courseNode, identity, null, ATTEMPTS, null, attempts, null, null);
			cpm.saveProperty(attemptsProperty);
		} else {
			attempts = new Long(attemptsProperty.getLongValue().longValue() + 1);
			attemptsProperty.setLongValue(attempts);
			cpm.updateProperty(attemptsProperty);
		}
		// add to cache
		putPropertyIntoCache(identity, attemptsProperty);
		
		return attempts;
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#getNodeScore(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity)
	 */
	public Float getNodeScore(CourseNode courseNode, Identity identity) {
		// Check if courseNode exist
		if (courseNode == null) {
			return FLOAT_ZERO; // return default value
		}
		
		String cacheKey = getCacheKey(courseNode, SCORE);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			Float result = (Float) m.get(cacheKey);
			return result;
		}	
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#getNodePassed(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity)
	 */
	public Boolean getNodePassed(CourseNode courseNode, Identity identity) {
		// Check if courseNode exist
		if (courseNode == null) {
			return Boolean.FALSE; // return default value
		}
		
		String cacheKey = getCacheKey(courseNode, PASSED);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			Boolean result = (Boolean) m.get(cacheKey);
			return result;
		}		
	}
	
	/**
	 * @see org.olat.course.assessment.AssessmentManager#getNodeAttempts(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity)
	 */
	public Integer getNodeAttempts(CourseNode courseNode, Identity identity) {
		// Check if courseNode exist
		if (courseNode == null) {
			return INTEGER_ZERO; // return default value
		}
		
		String cacheKey = getCacheKey(courseNode, ATTEMPTS);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			Integer result = (Integer) m.get(cacheKey);
			// see javadoc of org.olat.course.assessment.AssessmentManager#getNodeAttempts
			return result == null? INTEGER_ZERO : result;
		}				
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#getNodeComment(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity)
	 */
	public String getNodeComment(CourseNode courseNode, Identity identity) {		
		if (courseNode == null) {
			return null; // return default value
		}
				
		String cacheKey = getCacheKey(courseNode, COMMENT);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			String result = (String) m.get(cacheKey);			
			return result;
		}		
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#getNodeCoachComment(org.olat.course.nodes.CourseNode,
	 *      org.olat.core.id.Identity)
	 */
	public String getNodeCoachComment(CourseNode courseNode, Identity identity) {				
		if (courseNode == null) {
			return null; // return default value
		}
				
		String cacheKey = getCacheKey(courseNode, COACH_COMMENT);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			String result = (String) m.get(cacheKey);			
			return result;
		}		
	}
	
	/**
	 * Internal method to create a cache key for a given node, and property
	 * @param identity
	 * @param nodeIdent
	 * @param propertyName
	 * @return String the key
	 */
	private String getCacheKey(CourseNode courseNode, String propertyName) {
		String nodeIdent = courseNode.getIdent();
		return getCacheKey(nodeIdent, propertyName);
	}

	/**
	 * threadsafe.
	 * @param nodeIdent
	 * @param propertyName
	 * @return
	 */
	private String getCacheKey(String nodeIdent, String propertyName) {
		StringBuilder key = new StringBuilder(nodeIdent.length()+propertyName.length()+1);
		key.append(nodeIdent).append('_').append(propertyName);
		return key.toString();
	}
	
	/**
	 * Finds the cacheKey for the input property.
	 * @param property
	 * @return Returns the cacheKey
	 */
	private String getPropertyCacheKey(Property property) {
    //- node id is coded into property category like this: NID:ms::12345667
		// olat::: move the extract method below to the CoursePropertyManager - since the generation/concat method is also there.
		String propertyName = property.getName();
		String propertyCategory = property.getCategory();
		String nodeIdent = propertyCategory.substring(propertyCategory.indexOf("::") + 2);
		String cacheKey = getCacheKey(nodeIdent, propertyName);
    //cacheKey is now e.g. 12345667_PASSED
		return cacheKey;
	}
	
	private String getLastModifiedCacheKey(Property property) {;
		String propertyCategory = property.getCategory();
		String nodeIdent = propertyCategory.substring(propertyCategory.indexOf("::") + 2);
		String cacheKey = getCacheKey(nodeIdent, LAST_MODIFIED);
		return cacheKey;
	}
	
	/**
	 * @see org.olat.course.assessment.AssessmentManager#registerForAssessmentChangeEvents(org.olat.core.util.event.GenericEventListener,
	 *      org.olat.core.id.Identity)
	 */
	public void registerForAssessmentChangeEvents(GenericEventListener gel, Identity identity) {
		CoordinatorManager.getInstance().getCoordinator().getEventBus().registerFor(gel, identity, ores);
	}

	/**
	 * @see org.olat.course.assessment.AssessmentManager#deregisterFromAssessmentChangeEvents(org.olat.core.util.event.GenericEventListener)
	 */
	public void deregisterFromAssessmentChangeEvents(GenericEventListener gel) {
		CoordinatorManager.getInstance().getCoordinator().getEventBus().deregisterFor(gel, ores);
	}

	// package local for perf. reasons
	void courseLog(ILoggingAction action, CourseNode cn, LoggingResourceable... details) {
		if (Settings.isJUnitTest()) return;
		ICourse course = CourseFactory.loadCourse(ores);
		
		LoggingResourceable[] infos = new LoggingResourceable[2+details.length];
		infos[0] = LoggingResourceable.wrap(course);
		infos[1] = LoggingResourceable.wrap(cn);
		for (int i = 0; i < details.length; i++) {
			LoggingResourceable lri = details[i];
			infos[i+2] = lri;
		}
		
		ThreadLocalUserActivityLogger.log(action, getClass(), details);
	}
	  
  /**
   * 
   * @param courseNode
   * @param assessedIdentity
   * @param assessmentID
   * @param coursePropManager
   */
  void saveAssessmentID(CourseNode courseNode, Identity assessedIdentity, Long assessmentID, CoursePropertyManager coursePropManager) { 
  	if(assessmentID!=null) {
  	  Property assessmentIDProperty = coursePropManager.findCourseNodeProperty(courseNode, assessedIdentity, null, ASSESSMENT_ID);
		  if (assessmentIDProperty == null) {					
			  assessmentIDProperty = coursePropManager.createCourseNodePropertyInstance(courseNode, assessedIdentity, null, ASSESSMENT_ID, null, assessmentID, null, null);
			  coursePropManager.saveProperty(assessmentIDProperty);
		  } else {
			  assessmentIDProperty.setLongValue(assessmentID);
			  coursePropManager.updateProperty(assessmentIDProperty);
		  }	
		  // add to cache
			putPropertyIntoCache(assessedIdentity, assessmentIDProperty);
  	}
  }
	
	/**
	 * No caching for the assessmentID.
	 * @see org.olat.course.assessment.AssessmentManager#getAssessmentID(org.olat.course.nodes.CourseNode, org.olat.core.id.Identity)
	 */
	public Long getAssessmentID(CourseNode courseNode, Identity identity) {
		if (courseNode == null) {
			return Long.valueOf(0); // return default value
		}
				
		String cacheKey = getCacheKey(courseNode, ASSESSMENT_ID);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			Long result = (Long) m.get(cacheKey);			
			return result;
		}				
	}
	
	@Override
	public Date getScoreLastModifiedDate(CourseNode courseNode, Identity identity) {
		if (courseNode == null) {
			return null; // return default value
		}
		
		String cacheKey = getCacheKey(courseNode, LAST_MODIFIED);
		Map<String, Serializable> m = getOrLoadScorePassedAttemptsMap(identity, null, false);		
		synchronized(m) {//o_clusterOK by:fj is per vm only
			Long lastModified = (Long) m.get(cacheKey);
			if(lastModified != null) {
				Calendar cal = Calendar.getInstance();
				cal.setTimeInMillis(lastModified.longValue());
				return cal.getTime();
			}
		}
		return null;
	}

	/**
	 * 
	 * @see org.olat.course.assessment.AssessmentManager#saveScoreEvaluation(org.olat.course.nodes.CourseNode, org.olat.core.id.Identity, org.olat.core.id.Identity, org.olat.course.run.scoring.ScoreEvaluation)
	 */
	public void saveScoreEvaluation(final CourseNode courseNode, final Identity identity, final Identity assessedIdentity, final ScoreEvaluation scoreEvaluation, 
			final UserCourseEnvironment userCourseEnv, final boolean incrementUserAttempts) {
		ICourse course = CourseFactory.loadCourse(ores);
		final CoursePropertyManager cpm = course.getCourseEnvironment().getCoursePropertyManager();
		// o_clusterREVIEW we could sync on a element finer than course, e.g. the composite course+assessIdentity.
		// +: concurrency would be higher
		// -: many entries (num of courses * visitors of given course) in the locktable.
		// we could also sync on the assessedIdentity.
		
		Codepoint.codepoint(NewCachePersistingAssessmentManager.class, "beforeSyncUpdateUserEfficiencyStatement");
		Long attempts = CoordinatorManager.getInstance().getCoordinator().getSyncer().doInSync(createOLATResourceableForLocking(assessedIdentity), new SyncerCallback<Long>(){
			public Long execute() {
				Long attempts = null;
				Codepoint.codepoint(NewCachePersistingAssessmentManager.class, "doInSyncUpdateUserEfficiencyStatement");
				log.debug("codepoint reached: doInSyncUpdateUserEfficiencyStatement by identity: " + identity.getName());
				saveNodeScore(courseNode, identity, assessedIdentity, scoreEvaluation.getScore(), cpm);
				saveNodePassed(courseNode, identity, assessedIdentity, scoreEvaluation.getPassed(), cpm);
				saveAssessmentID(courseNode, assessedIdentity, scoreEvaluation.getAssessmentID(), cpm);				
				if(incrementUserAttempts) {
					attempts = incrementNodeAttemptsProperty(courseNode, assessedIdentity, cpm);
				}
				if(courseNode instanceof AssessableCourseNode) {
				  userCourseEnv.getScoreAccounting().scoreInfoChanged((AssessableCourseNode)courseNode, scoreEvaluation);
				  // Update users efficiency statement
				  EfficiencyStatementManager esm =	EfficiencyStatementManager.getInstance();
				  esm.updateUserEfficiencyStatement(userCourseEnv);
				}
				return attempts;
			}});
    // here used to be a codepoint which lead to error (OLAT-3570) in AssessmentWithCodepointsTest.
    // The reason for this error was that the AuditManager appendToUserNodeLog() is not synchronized, so could be called by several threads simultaneously.
    // The end effect of this error is data inconsistency: the score/passed info is stored but the userNodeLog info is not updated and the AssessmentChangedEvent is not fired.
    // This case is very seldom, but could be avoided if the test could be protected by a lock.
		
		
		// node log
		UserNodeAuditManager am = course.getCourseEnvironment().getAuditManager();
		am.appendToUserNodeLog(courseNode, identity, assessedIdentity, SCORE + " set to: " + String.valueOf(scoreEvaluation.getScore()));
		if(scoreEvaluation.getPassed()!=null) {
		  am.appendToUserNodeLog(courseNode, identity, assessedIdentity, PASSED + " set to: " + scoreEvaluation.getPassed().toString());
		} else {
			 am.appendToUserNodeLog(courseNode, identity, assessedIdentity, PASSED + " set to \"undefined\"");
		}
		if(scoreEvaluation.getAssessmentID()!=null) {
			am.appendToUserNodeLog(courseNode, assessedIdentity, assessedIdentity, ASSESSMENT_ID + " set to: " + scoreEvaluation.getAssessmentID().toString());
		}		

		Codepoint.codepoint(NewCachePersistingAssessmentManager.class, "afterSyncUpdateUserEfficiencyStatement");
		log.debug("codepoint reached: afterSyncUpdateUserEfficiencyStatement by identity: " + identity.getName());
		// notify about changes
		AssessmentChangedEvent ace = new AssessmentChangedEvent(AssessmentChangedEvent.TYPE_SCORE_EVAL_CHANGED, assessedIdentity);
		CoordinatorManager.getInstance().getCoordinator().getEventBus().fireEventToListenersOf(ace, course);

		// user activity logging
		if (scoreEvaluation.getScore()!=null) {
			ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_SCORE_UPDATED, 
					getClass(), 
					LoggingResourceable.wrap(assessedIdentity), 
					LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiScore, "", String.valueOf(scoreEvaluation.getScore())));
		}

		if (scoreEvaluation.getPassed()!=null) {
			ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_PASSED_UPDATED, 
					getClass(), 
					LoggingResourceable.wrap(assessedIdentity), 
					LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiPassed, "", String.valueOf(scoreEvaluation.getPassed())));
		} else {
			ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_PASSED_UPDATED, 
					getClass(), 
					LoggingResourceable.wrap(assessedIdentity), 
					LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiPassed, "", "undefined"));
		}

		if (incrementUserAttempts && attempts!=null) {
			ThreadLocalUserActivityLogger.log(AssessmentLoggingAction.ASSESSMENT_ATTEMPTS_UPDATED, 
					getClass(), 
					LoggingResourceable.wrap(identity), 
					LoggingResourceable.wrapNonOlatResource(StringResourceableType.qtiAttempts, "", String.valueOf(attempts)));	
		}
	}
	
	/**
	 * Always use this to get a OLATResourceable for doInSync locking!
	 * Uses the assessIdentity.
	 * 
	 * @param course
	 * @param assessedIdentity
	 * @param courseNode
	 * @return
	 */
	public OLATResourceable createOLATResourceableForLocking(Identity assessedIdentity) {				
		String type = "AssessmentManager::Identity";
		OLATResourceable oLATResourceable = OresHelper.createOLATResourceableInstance(type,assessedIdentity.getKey());
		return oLATResourceable;
	}
	
}