/*
 * Licensed to The Apereo Foundation under one or more contributor license
 * agreements. See the NOTICE file distributed with this work for
 * additional information regarding copyright ownership.
 *
 * The Apereo Foundation licenses this file to you under the Apache License,
 * Version 2.0 (the "License"); you may not use this file except in
 * compliance with the License. You may obtain a copy of the License at:
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *
 * See the License for the specific language governing permissions and
 * limitations under the License.
 * 
*/
package org.unitime.timetable.solver.curricula;

import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Queue;
import java.util.Set;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.cpsolver.ifs.assignment.Assignment;
import org.cpsolver.ifs.assignment.DefaultSingleAssignment;
import org.cpsolver.ifs.solution.Solution;
import org.cpsolver.ifs.util.DataProperties;
import org.cpsolver.ifs.util.IdGenerator;
import org.cpsolver.ifs.util.Progress;
import org.dom4j.Document;
import org.dom4j.DocumentHelper;
import org.dom4j.Element;
import org.unitime.timetable.model.CourseOffering;
import org.unitime.timetable.model.Curriculum;
import org.unitime.timetable.model.CurriculumClassification;
import org.unitime.timetable.model.CurriculumCourse;
import org.unitime.timetable.model.CurriculumCourseGroup;
import org.unitime.timetable.model.InstructionalOffering;
import org.unitime.timetable.model.PosMajor;
import org.unitime.timetable.model.Session;
import org.unitime.timetable.solver.curricula.StudentCourseDemands.NeedsStudentIdGenerator;
import org.unitime.timetable.solver.curricula.students.CurCourse;
import org.unitime.timetable.solver.curricula.students.CurModel;
import org.unitime.timetable.solver.curricula.students.CurStudent;
import org.unitime.timetable.solver.curricula.students.CurValue;
import org.unitime.timetable.solver.curricula.students.CurVariable;


/**
 * @author Tomas Muller
 */
public class CurriculaCourseDemands implements StudentCourseDemands, NeedsStudentIdGenerator {
	private static Log sLog = LogFactory.getLog(CurriculaCourseDemands.class);
	private Hashtable<Long, Set<WeightedStudentId>> iDemands = new Hashtable<Long, Set<WeightedStudentId>>();
	private Hashtable<Long, Set<WeightedCourseOffering>> iStudentRequests = new Hashtable<Long, Set<WeightedCourseOffering>>();
	private Hashtable<Long, Hashtable<Long, Double>> iEnrollmentPriorities = new Hashtable<Long, Hashtable<Long, Double>>();
	private IdGenerator iLastStudentId = null;
	protected ProjectedStudentCourseDemands iFallback;
	private Hashtable<Long, Hashtable<String, Set<String>>> iLoadedCurricula = new Hashtable<Long,Hashtable<String, Set<String>>>();
	private HashSet<Long> iCheckedCourses = new HashSet<Long>();
	private boolean iIncludeOtherStudents = true;
	private boolean iSetStudentCourseLimits = false;
	private boolean iCreateStudentGroups = true;
	private CurriculumEnrollmentPriorityProvider iEnrollmentPriorityProvider = null;
	private DataProperties iProperties = null;

	public CurriculaCourseDemands(DataProperties properties) {
		iProperties = properties;
		if (properties != null)
			iFallback = new ProjectedStudentCourseDemands(properties);
		iIncludeOtherStudents = properties.getPropertyBoolean("CurriculaCourseDemands.IncludeOtherStudents", iIncludeOtherStudents);
		iSetStudentCourseLimits = properties.getPropertyBoolean("CurriculaCourseDemands.SetStudentCourseLimits", iSetStudentCourseLimits);
		iCreateStudentGroups = properties.getPropertyBoolean("CurriculaCourseDemands.CreateStudentGroups", iCreateStudentGroups);
		iEnrollmentPriorityProvider = new DefaultCurriculumEnrollmentPriorityProvider(properties);
		if (properties.getProperty("CurriculaCourseDemands.CurriculumEnrollmentPriorityProvider") != null) {
			try {
				iEnrollmentPriorityProvider = (CurriculumEnrollmentPriorityProvider)Class.forName(
						properties.getProperty("CurriculaCourseDemands.CurriculumEnrollmentPriorityProvider"))
						.getConstructor(DataProperties.class).newInstance(properties);
			} catch (Exception e) {
				sLog.error("Failed to use custom enrollment priority provider: " + e.getMessage(), e);
			}
		}
	}
	
	public CurriculaCourseDemands() {
		this(null);
	}
	
	public boolean isMakingUpStudents() { return true; }
	
	public boolean canUseStudentClassEnrollmentsAsSolution() { return false; }
	
	public boolean isWeightStudentsToFillUpOffering() { return false; }

	public void init(org.hibernate.Session hibSession, Progress progress, Session session, Collection<InstructionalOffering> offerings) {
		iFallback.init(hibSession, progress, session, offerings);
		
		List<Curriculum> curricula = null;
		if (offerings != null && offerings.size() <= 1000) {
			String courses = "";
			int nrCourses = 0;
			for (InstructionalOffering offering: offerings)
				for (CourseOffering course: offering.getCourseOfferings()) {
					if (!courses.isEmpty()) courses += ",";
					courses += course.getUniqueId();
					nrCourses++;
				}
			if (nrCourses > 0 && nrCourses <= 1000) {
				Set<Curriculum> curriculaSet = new HashSet<Curriculum>(hibSession.createQuery(
						"select distinct c from CurriculumCourse cc inner join cc.classification.curriculum c where " +
						"c.academicArea.session.uniqueId = :sessionId and cc.course.uniqueId in (" + courses + ")")
						.setLong("sessionId", session.getUniqueId()).list());
				// include children curricula
				curriculaSet.addAll(
						hibSession.createQuery(
							"select distinct d from CurriculumCourse cc inner join cc.classification.curriculum c, Curriculum d " +
							"where c.academicArea = d.academicArea and d.multipleMajors = true and size(c.majors) <= 1 and size(c.majors) < size(d.majors) and " +
							"(select count(m) from Curriculum x inner join x.majors m where x.uniqueId = c.uniqueId and m not in elements(d.majors)) = 0 and " +
							"c.academicArea.session.uniqueId = :sessionId and cc.course.uniqueId in (" + courses + ")")
							.setLong("sessionId", session.getUniqueId()).list()
						);
				// include parent curricula
				curriculaSet.addAll(
						hibSession.createQuery(
							"select distinct d from CurriculumCourse cc inner join cc.classification.curriculum c, Curriculum d " +
							"where c.multipleMajors = true and size(c.majors) >= 1 and size(c.majors) > size(d.majors) and c.academicArea = d.academicArea and " +
							"(select count(m) from Curriculum x inner join x.majors m where x.uniqueId = d.uniqueId and m not in elements(c.majors)) = 0 and " +
							"c.academicArea.session.uniqueId = :sessionId and cc.course.uniqueId in (" + courses + ")")
							.setLong("sessionId", session.getUniqueId()).list());
				curricula = new ArrayList<Curriculum>(curriculaSet);
			}
		}
		
		if (curricula == null) {
			curricula = hibSession.createQuery(
					"select c from Curriculum c where c.academicArea.session.uniqueId = :sessionId")
					.setLong("sessionId", session.getUniqueId()).list();
		}

		List<Initialization> inits = new ArrayList<Initialization>();
		for (Curriculum curriculum: curricula) {
			for (CurriculumClassification clasf: curriculum.getClassifications()) {
				if (clasf.getNrStudents() > 0) {
					List<CurriculumClassification> templates = new ArrayList<CurriculumClassification>();
					if (curriculum.isMultipleMajors())
						for (Curriculum parent: curricula)
							if (parent.isTemplateFor(curriculum)) {
								for (CurriculumClassification parentClasf: parent.getClassifications()) {
									if (parentClasf.getAcademicClassification().equals(clasf.getAcademicClassification()))
										templates.add(parentClasf);
								}
							}
					inits.add(new Initialization(clasf, templates));
				}
			}
		}
		new ParallelInitialization("Loading curricula",
				iProperties.getPropertyInt("CurriculaCourseDemands.NrThreads", 1),
				inits).execute(hibSession, progress);
		
		if (iDemands.isEmpty()) {
			progress.warn("There are no curricula, using projected course demands instead.");
		}
	}
	
	protected String getCacheName() {
		return "curriculum-demands";
	}
	
	protected void computeTargetShare(int nrStudents, Collection<CurriculumCourse> courses, CurriculumCourseGroupsProvider course2groups, CurModel model) {
		for (CurriculumCourse c1: courses) {
			float x1 = c1.getPercShare() * nrStudents;
			Set<CurriculumCourse>[] group = new HashSet[] { new HashSet<CurriculumCourse>(), new HashSet<CurriculumCourse>()};
			Queue<CurriculumCourse> queue = new LinkedList<CurriculumCourse>();
			queue.add(c1);
			Set<CurriculumCourseGroup> done = new HashSet<CurriculumCourseGroup>();
			while (!queue.isEmpty()) {
				CurriculumCourse c = queue.poll();
				for (CurriculumCourseGroup g: course2groups.getGroups(c))
					if (done.add(g))
						for (CurriculumCourse x: courses)
							if (!x.equals(c) && !x.equals(c1) && course2groups.getGroups(x).contains(g) && group[group[0].contains(c) ? 0 : g.getType()].add(x))
								queue.add(x);
			}
			for (CurriculumCourse c2: courses) {
				float x2 = c2.getPercShare() * nrStudents;
				if (c1.getUniqueId() >= c2.getUniqueId()) continue;
				float share = c1.getPercShare() * c2.getPercShare() * nrStudents;
				boolean opt = group[0].contains(c2);
				boolean req = !opt && group[1].contains(c2);
				model.setTargetShare(c1.getUniqueId(), c2.getUniqueId(), opt ? 0.0 : req ? Math.min(x1, x2) : share, true);
			}
		}
	}
	
	public Set<WeightedStudentId> getDemands(CourseOffering course) {
		if (iDemands.isEmpty()) return iFallback.getDemands(course);
		Set<WeightedStudentId> demands = iDemands.get(course.getUniqueId());
		if (!iIncludeOtherStudents) return demands;
		if (demands == null) {
			demands = new HashSet<WeightedStudentId>();
			iDemands.put(course.getUniqueId(), demands);
		}
		if (iCheckedCourses.add(course.getUniqueId())) {
			int was = demands.size();
			Hashtable<String,Set<String>> curricula = iLoadedCurricula.get(course.getUniqueId());
			Set<WeightedStudentId> other = iFallback.getDemands(course);
			if (curricula == null || curricula.isEmpty()) {
				demands.addAll(other);
			} else {
				other: for (WeightedStudentId student: other) {
					// if (student.getMajors().isEmpty()) continue; // ignore students w/o academic area
					for (AreaClasfMajor acm: student.getMajors()) {
						Set<String> majors = curricula.get(acm.getArea());
						if (majors != null && (majors.contains("") || student.match(acm.getArea(), majors))) continue other; // all majors or match
					}
					demands.add(student);
				}
			}
			if (demands.size() > was)
				sLog.info(course.getCourseName() + " has " + (demands.size() - was) + " other students (besides of the " + was + " curriculum students).");
		}
		return demands;
	}
	
	public Set<WeightedCourseOffering> getCourses(Long studentId) {
		if (iIncludeOtherStudents && studentId >= 0) return iFallback.getCourses(studentId);
		return iStudentRequests.get(studentId);
	}

	@Override
	public Double getEnrollmentPriority(Long studentId, Long courseId) {
		Hashtable<Long, Double> priorities = iEnrollmentPriorities.get(studentId);
		return (priorities == null ? null : priorities.get(courseId));
	}
	
	public class Initialization implements ParallelInitialization.Task {
		private CurriculumClassification iClassification;
		private List<CurriculumClassification> iTemplates;
		private boolean iUpdateClassification = false;
		private CurModel iModel;
		private Hashtable<Long, CourseOffering> iCourses;
		private Assignment<CurVariable, CurValue> iAssignment;
		
		public Initialization(CurriculumClassification classification, List<CurriculumClassification> templates) {
			iClassification = classification;
			iTemplates = templates;
		}
		
		@Override
		public void setup(org.hibernate.Session hibSession) {
			sLog.debug("Processing " + iClassification.getCurriculum().getAbbv() + " " + iClassification.getName() + " ... (" + iClassification.getNrStudents() + " students, " + iClassification.getCourses().size() + " courses)");
			
			// Create model
			List<CurStudent> students = new ArrayList<CurStudent>();
			for (long i = 0; i < iClassification.getNrStudents(); i++)
				students.add(new CurStudent(- (1 + i), 1f));
			iModel = new CurModel(students);
			iCourses = new Hashtable<Long, CourseOffering>();
			
			Collection<CurriculumCourse> courses = null;
			CurriculumCourseGroupsProvider course2groups = null;
			if (iTemplates == null || iTemplates.isEmpty()) {
				courses = iClassification.getCourses();
				course2groups = new DefaultCurriculumCourseGroupsProvider();
			} else {
				Map<Long, CurriculumCourse> curriculumCourses = new HashMap<Long, CurriculumCourse>();
				course2groups = new TableCurriculumCourseGroupsProvider();
				// Populate with templates (if a course is present two or more times, maximize percent share
				for (CurriculumClassification template: iTemplates) {
					for (CurriculumCourse course: template.getCourses()) {
						CurriculumCourse prev = curriculumCourses.get(course.getCourse().getUniqueId());
						if (prev == null || prev.getPercShare() < course.getPercShare())
							curriculumCourses.put(course.getCourse().getUniqueId(), course);
						((TableCurriculumCourseGroupsProvider)course2groups).add(course);
					}
				}
				// Override with courses on the curriculum
				for (CurriculumCourse course: iClassification.getCourses()) {
					curriculumCourses.put(course.getCourse().getUniqueId(), course);
					((TableCurriculumCourseGroupsProvider)course2groups).add(course);
				}
				courses = curriculumCourses.values();
			}
			
			for (CurriculumCourse course: courses) {
				iModel.addCourse(course.getUniqueId(), course.getCourse().getCourseName(), course.getPercShare() * iClassification.getNrStudents(), iEnrollmentPriorityProvider.getEnrollmentPriority(course, course2groups));
				iCourses.put(course.getUniqueId(), course.getCourse());
				
				Hashtable<String,Set<String>> curricula = iLoadedCurricula.get(course.getCourse().getUniqueId());
				if (curricula == null) {
					curricula = new Hashtable<String, Set<String>>();
					iLoadedCurricula.put(course.getCourse().getUniqueId(), curricula);
				}
				Set<String> majors = curricula.get(iClassification.getCurriculum().getAcademicArea().getAcademicAreaAbbreviation());
				if (majors == null) {
					majors = new HashSet<String>();
					curricula.put(iClassification.getCurriculum().getAcademicArea().getAcademicAreaAbbreviation(), majors);
				}
				if (iClassification.getCurriculum().getMajors().isEmpty()) {
					majors.add("");
				} else {
					for (PosMajor mj: iClassification.getCurriculum().getMajors())
						majors.add(mj.getCode());
				}

			}
			computeTargetShare(iClassification.getNrStudents(), courses, course2groups, iModel);
			if (iSetStudentCourseLimits)
				iModel.setStudentLimits();
			
			// Load model from cache (if exists)
			Solution<CurVariable, CurValue> cachedSolution = null;
			iAssignment = new DefaultSingleAssignment<CurVariable, CurValue>();
			Document cachedXml = iClassification.getStudentsDocument();
			Element cache = (cachedXml == null ? null : cachedXml.getRootElement());
			if (cache != null && cache.getName().equals(getCacheName())) {
				cachedSolution = CurModel.loadFromXml(cache);
				if (iSetStudentCourseLimits)
					((CurModel)cachedSolution.getModel()).setStudentLimits();
			}

			// Check the cached model
			if (cachedSolution != null && ((CurModel)cachedSolution.getModel()).isSameModel(iModel)) {
				// Reuse
				sLog.debug("  using cached model...");
				iModel = ((CurModel)cachedSolution.getModel());
				iAssignment = cachedSolution.getAssignment();
			} else {
				iUpdateClassification = true;
			}			
		}
		
		@Override
		public void execute() {
			if (iUpdateClassification) {
				// Solve model
				iModel.solve(iProperties, iAssignment);
			}
		}
		
		@Override
		public void teardown(org.hibernate.Session hibSession) {
			if (iUpdateClassification) {
				// Save into the cache
				Document doc = DocumentHelper.createDocument();
				iModel.saveAsXml(doc.addElement(getCacheName()), iAssignment);
				// sLog.debug("Model:\n" + doc.asXML());
				iClassification.setStudentsDocument(doc);

				hibSession.update(iClassification);
			}

			// Save results
			String majors = "";
			for (PosMajor major: iClassification.getCurriculum().getMajors()) {
				if (!majors.isEmpty()) majors += ",";
				majors += major.getCode();
			}
			for (CurStudent s: iModel.getStudents()) {
				WeightedStudentId student = new WeightedStudentId(- iLastStudentId.newId(), iClassification);
				if (iCreateStudentGroups)
					student.getGroups().add(new Group(-iClassification.getUniqueId(), iClassification.getCurriculum().getAbbv() + " " + iClassification.getAcademicClassification().getCode()));
				Set<WeightedCourseOffering> studentCourses = new HashSet<WeightedCourseOffering>();
				iStudentRequests.put(student.getStudentId(), studentCourses);
				Hashtable<Long, Double> priorities = new Hashtable<Long, Double>(); iEnrollmentPriorities.put(student.getStudentId(), priorities);
				for (CurCourse course: s.getCourses(iAssignment)) {
					CourseOffering co = iCourses.get(course.getCourseId());
					if (course.getPriority() != null) priorities.put(co.getUniqueId(), course.getPriority());
					Set<WeightedStudentId> courseStudents = iDemands.get(co.getUniqueId());
					if (courseStudents == null) {
						courseStudents = new HashSet<WeightedStudentId>();
						iDemands.put(co.getUniqueId(), courseStudents);
					}
					courseStudents.add(student);
					studentCourses.add(new WeightedCourseOffering(co, student.getWeight()));
				}
			}
		}
	}
	
	public static interface CurriculumCourseGroupsProvider {
		public Set<CurriculumCourseGroup> getGroups(CurriculumCourse course);
	}
	
	public static class DefaultCurriculumCourseGroupsProvider implements CurriculumCourseGroupsProvider {
		@Override
		public Set<CurriculumCourseGroup> getGroups(CurriculumCourse course) {
			return course.getGroups();
		}
	}
	
	public static class TableCurriculumCourseGroupsProvider implements CurriculumCourseGroupsProvider {
		private Map<Long, Set<CurriculumCourseGroup>> iTable = new HashMap<Long, Set<CurriculumCourseGroup>>();
		
		public TableCurriculumCourseGroupsProvider() {
		}
		
		public void add(CurriculumCourse course) {
			Set<CurriculumCourseGroup> groups = iTable.get(course.getCourse().getUniqueId());
			if (groups == null) {
				groups = new HashSet<CurriculumCourseGroup>();
				iTable.put(course.getCourse().getUniqueId(), groups);
			}
			groups.addAll(course.getGroups());
		}

		@Override
		public Set<CurriculumCourseGroup> getGroups(CurriculumCourse course) {
			Set<CurriculumCourseGroup> groups = iTable.get(course.getCourse().getUniqueId());
			return (groups == null ? course.getGroups() : groups);
		}
		
	}

	@Override
	public void setStudentIdGenerator(IdGenerator generator) {
		iLastStudentId = generator;
	}
}