/**
 * Copyright (C) 2019 Patrik Dufresne Service Logiciel <info@patrikdufresne.com>
 *
 * This program is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 3 of the License, or (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with this program; if not, write to the Free Software Foundation,
 * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
 */
package com.planimod.core.planif;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.patrikdufresne.ilp.ILPLogger;
import com.patrikdufresne.ilp.ILPPolicy;
import com.patrikdufresne.ilp.Linear;
import com.patrikdufresne.ilp.LinearProblem;
import com.patrikdufresne.ilp.Solver;
import com.patrikdufresne.ilp.SolverFactory;
import com.patrikdufresne.ilp.Status;
import com.patrikdufresne.ilp.Variable;
import com.patrikdufresne.ilp.glpk.GLPKSolverFactory;
import com.patrikdufresne.ilp.util.ValueSnapshot;
import com.patrikdufresne.util.BidiMultiMap;
import com.planimod.core.Employee;
import com.planimod.core.EmployeePreference;
import com.planimod.core.Shift;
import com.planimod.core.Task;
import com.planimod.core.Team;
import com.planimod.core.comparators.EmployeeComparators;

/**
 * This class is used to create the problem structure and then solve it using a Integer Linear Problem solver, like
 * GLPK. To encapsulate each step of the solving process, an abstract class {@link SolveRunnable} is used. Each
 * subclasses is then specialized to optimize one part of the problem and maintain constrains when required.
 * 
 * @see SolveRunnable
 * @see LinearProblem
 * @author Patrik Dufresne
 */
@SuppressWarnings("unqualified-field-access")
class ProblemBuilder {

    static final transient Logger LOGGER = LoggerFactory.getLogger(ProblemBuilder.class);

    /**
     * Unit of work.
     */
    static final int UNIT = 1;

    private static final Double ONE = Double.valueOf(1);

    private static final Double ZERO = Double.valueOf(0);

    /**
     * Create binary variable with the name xik[i,k].
     * 
     * @param lp
     *            the linear problem
     * @param i
     *            the i value
     * @param k
     *            the k value
     * @return the variable
     */
    protected static Variable createXijkVariable(LinearProblem lp, int i, int j, int k) {
        String name = String.format("xijk[%d,%d,%d]", i, j, k);
        return lp.addBinaryVariable(name);
    }

    /**
     * Create a binary variable with the name xij[i, j].
     * 
     * @param lp
     *            the linear problem
     * @param i
     *            the i value
     * @param j
     *            the j value
     * @return the variable
     */
    protected static Variable createXijVariable(LinearProblem lp, int i, int j) {
        String name = String.format("xij[%d,%d]", i, j);
        return lp.addBinaryVariable(name);
    }

    /**
     * Create a binary variable with the name xi[i].
     * 
     * @param lp
     *            the linear problem
     * @param i
     *            the i value
     * @return the variable
     */
    protected static Variable createXiVariable(LinearProblem lp, int i) {
        String name = String.format("xi[%d]", i);
        return lp.addBinaryVariable(name);
    }

    /**
     * True if the given variable is fixed
     * 
     * @param var
     * @return
     */
    static boolean isFixedToZero(Variable var) {
        return var.getLowerBound().equals(var.getUpperBound()) && var.getLowerBound().equals(SolveRunnable.ZERO);
    }

    /**
     * Create an array of preferences for fast access.
     * 
     * @param preferences
     *            the preferences received by the problem builder
     * @param employees
     *            the employees
     * @throws GeneratePlanifException
     */
    private static List<EmployeePreference> sortPreferences(Collection<EmployeePreference> preferences, List<Employee> employees) {
        // Build an array of preference
        EmployeePreference[] prefs = new EmployeePreference[employees.size()];
        // Fill the array with preference object
        for (EmployeePreference pref : preferences) {
            // Check if the employee associated to the preference exists in our
            // list.
            int index = employees.indexOf(pref.getEmployee());
            if (!(index < 0)) {
                prefs[index] = pref;
            }
        }
        // Return array
        return Arrays.asList(prefs);
    }

    /**
     * This function always throw an exception.
     * 
     * @param employee
     * @param task
     * @throws GeneratePlanifException
     */
    private static void throwUnavailableEmployeeLocked(Employee employee, Task task) throws GeneratePlanifException {
        // Can't locked an employee on unqualified position,
        // throw exception
        throw new GeneratePlanifException(employee + " not avaiable for" + task, GeneratePlanifException.EMPLOYEE_LOCKED_UNAVAIL_OR_UNQUALIFY, employee, task);
    }

    int count = 0;

    /**
     * Employees array ordered by seniority starting by the most senior employee.
     */
    List<Employee> employees;

    /**
     * The linear problem.
     */
    transient LinearProblem lp;

    private MaxEmployeePreferences maxEmployeePreferences;

    private MaxPreferredSeniorities maxPreferredSeniorities;

    MaxSeniorEmployee maxSeniorEmployee;

    MaxTaskAssigned maxTaskAssigned;

    MinEmployeeAssigned minEmployee;

    MinSwappableTask minSwappable;

    final IGeneratePlanifProgressMonitor monitor;

    BidiMultiMap<Employee, Task> nonAvailabilityTable;

    /**
     * Employee's preferences ordered by seniority. The index matches the {@link #employees} index.
     */
    List<EmployeePreference> preferences;

    BidiMultiMap<Employee, Task> qualificationTable;

    /**
     * Section table.
     */
    transient SiTable siTable;

    /**
     * Used to keep track of the last known feasible solution.
     */
    transient ValueSnapshot snapshot;

    /**
     * Linear problem solver.
     */
    transient Solver solver;

    /**
     * Coefficients table for the team.
     * <p>
     * This object represent T_j, TS_j, TNS_j.
     */
    Table table;

    /**
     * Tasks array.
     */
    List<Task> tasks;

    transient Variable[] xi;

    transient Variable[][] xij;

    transient Variable[][][] xijk;

    /**
     * Create a new problem builder with the task and the employee specified.
     * 
     * @param events
     *            the production events
     * @param tasks
     *            the tasks associated to the specified production events
     * @param qualificationTable
     *            the qualification table for each tasks
     * @param nonAvailabilityTable
     *            the non availability table
     * @param preferences
     *            the employee's preferences for each employee in the qualification table.
     */
    public ProblemBuilder(
            IGeneratePlanifProgressMonitor monitor,
            Collection<Shift> shifts,
            BidiMultiMap<Employee, Task> qualificationTable,
            BidiMultiMap<Employee, Task> nonAvailabilityTable,
            Collection<EmployeePreference> preferences) {
        this.monitor = monitor;
        this.qualificationTable = qualificationTable;
        this.nonAvailabilityTable = nonAvailabilityTable;
        // Copy the tasks into an unmodifable list
        this.tasks = new ArrayList<Task>();
        for (Task t : qualificationTable.valueSet()) {
            if (!(t.getLocked() && t.getEmployee() == null)) {
                this.tasks.add(t);
            }
        }
        this.tasks = Collections.unmodifiableList(this.tasks);
        // Copy the employees list and sort it by seniority
        // The qualificationTable contains employees that are not available
        // during the week. We need to remove them. See #59.
        this.employees = new ArrayList<Employee>();
        for (Employee e : qualificationTable.keySet()) {
            if (isAvailable(e)) {
                this.employees.add(e);
            }
        }
        Collections.sort(this.employees, EmployeeComparators.bySeniority());
        this.employees = Collections.unmodifiableList(this.employees);
        // Sort the preference in the same order as the employees.
        this.preferences = Collections.unmodifiableList(sortPreferences(preferences, this.employees));
        // Compute the tables
        this.table = Table.create(Collections.unmodifiableCollection(shifts), this.tasks);
        this.siTable = SiTable.create(this.tasks);
        /**
         * Create a ILogger to handle logs from the ILP engine.
         */
        ILPPolicy.setLog(new ILPLogger() {

            final transient Logger ilpLogger = LoggerFactory.getLogger(ILPLogger.class);

            /**
             * This implementation return the log level of the ilp logger.
             */
            @Override
            public int getLevel() {
                if (ilpLogger.isDebugEnabled()) {
                    return ILPLogger.DEBUG;
                } else if (ilpLogger.isWarnEnabled()) {
                    return ILPLogger.WARNING;
                } else if (ilpLogger.isInfoEnabled()) {
                    return ILPLogger.INFO;
                }
                return ILPLogger.ERROR;
            }

            @Override
            public void log(int severity, String message) {
                // Phase out the info and debug log to avoid generating to much
                // log in debug mode.
                switch (severity) {
                case ILPLogger.DEBUG:
                    this.ilpLogger.debug(message);
                    break;
                case ILPLogger.INFO:
                    this.ilpLogger.info(message);
                    break;
                case ILPLogger.WARNING:
                    this.ilpLogger.warn(message);
                    break;
                case ILPLogger.ERROR:
                    this.ilpLogger.error(message);
                    break;
                }
            }
        });
    }

    /**
     * Call the {@link IGeneratePlanifProgressMonitor#beginSearch(int)} with the total number of work.
     * 
     * @param monitor
     *            the progress monitor
     */
    private void beginSearch() {
        int totalWork = 0;
        // One unit for createEmployeeTaskVariables
        totalWork += UNIT;
        // One unit for createConstraintEmployeeAssignToAtMostOneTaskWithinGl
        totalWork += UNIT;
        // One unit for createConstraintEmployeeAssignToAtMostOneTeam
        totalWork += UNIT;
        // One unit for MaxPreferredSeniorities
        totalWork += UNIT;
        // One unit for maximizeNbTaskAssigned
        totalWork += UNIT;
        // One unit for MinEmployeeAssigned
        totalWork += UNIT;
        // One unit for MinSwappableTask
        totalWork += UNIT;
        // One unit for MaxSeniorEmployee
        totalWork += UNIT;
        // One two units for each employee :
        // maximizeEmployeesPreferredPositionAndPreferredTeam
        totalWork += (UNIT + UNIT) * this.employees.size();
        this.monitor.beginSearch(totalWork);
    }

    /**
     * Create the problen and solve it
     * 
     * @return True if the problem is feasible
     * @throws GeneratePlanifException
     * @throws InterruptedException
     */
    public void createAndSolveProblem() throws GeneratePlanifException {

        /*
         * Create linear problem
         */
        LOGGER.info("Create linear problem");
        SolverFactory solverFactory = GLPKSolverFactory.instance();
        this.solver = solverFactory.createSolver();
        this.lp = this.solver.createLinearProblem();
        // Update the monitor
        beginSearch();
        /*
         * Create variables
         */
        try {
            setStep(GeneratePlanifProgressMonitorStep.CREATE_VARIABLES);
            LOGGER.info("Create variables");
            xi = new Variable[this.employees.size()];
            xij = new Variable[this.employees.size()][this.table.tj().size()];
            xijk = new Variable[this.employees.size()][this.table.tj().size()][this.tasks.size()];
            createEmployeeTaskVariables();
            worked(UNIT);
            createConstraintEmployeeAssignToAtMostOneTaskWithinGl();
            worked(UNIT);
            createConstraintEmployeeAssignToAtMostOneTeam();
            worked(UNIT);
            // Check if the problem is feasible.
            setStep(GeneratePlanifProgressMonitorStep.CHECK_FEASIBLE);
            if (!(new CheckFeasible(this)).run()) {
                LOGGER.warn("Problem can't be solved.");
                throw new GeneratePlanifException("Starting condition is unfeasible.", GeneratePlanifException.STARTING_UNFEASIBLE);
            }
            worked(UNIT);
            // Maximize preferred seniorities
            setStep(GeneratePlanifProgressMonitorStep.MAXIMIZE_PREFERRED_SENIORITIES);
            this.maxPreferredSeniorities = new MaxPreferredSeniorities(this);
            this.maxPreferredSeniorities.run();
            worked(UNIT);
            // Find the maximum number of tasks
            setStep(GeneratePlanifProgressMonitorStep.MAXIMIZE_ASSIGNED_TASK);
            this.maxTaskAssigned = new MaxTaskAssigned(this);
            this.maxTaskAssigned.run();
            worked(UNIT);
            // Find the minimum number of employee required
            setStep(GeneratePlanifProgressMonitorStep.MINIMIZE_EMPLOYEE);
            this.minEmployee = new MinEmployeeAssigned(this);
            this.minEmployee.run();
            worked(UNIT);
            // Find the minimum number of swappable task to be assigned.
            setStep(GeneratePlanifProgressMonitorStep.MINIMIZE_SWAPPABLE_TASK);
            this.minSwappable = new MinSwappableTask(this);
            this.minSwappable.run();
            worked(UNIT);
            // Find the most senior employees
            setStep(GeneratePlanifProgressMonitorStep.MAXIMIZE_SENIOR);
            this.maxSeniorEmployee = new MaxSeniorEmployee(this);
            this.maxSeniorEmployee.run();
            worked(UNIT);
            // Maximize the employee's preferences
            setStep(GeneratePlanifProgressMonitorStep.MAXIMIZE_PREFERENCES);
            this.maxEmployeePreferences = new MaxEmployeePreferences(this);
            this.maxEmployeePreferences.run();
            // This solve is not always required, need to determine if the
            // problem is dirty and require a solve.
            if (!Status.OPTIMAL.equals(lp.getStatus()) || !Status.FEASIBLE.equals(lp.getStatus())) {
                if (!(new CheckFeasible(this)).run()) {
                    LOGGER.warn("Problem can't be solved.");
                    throw new GeneratePlanifException("Problem can't be solved.", GeneratePlanifException.UNFEASIBLE);
                }
            }
            // Sets the employee value for each task.
            persistResult();
        } finally {
            this.tasks = null;
            this.preferences = null;
            this.xi = null;
            this.xij = null;
            this.xijk = null;
            // Free resources
            lp.dispose();
            solver.dispose();
        }
    }

    /**
     * Create the following constraint to enforce an employee to be assign only to task within the same team.
     * 
     * <pre>
     * SUM from{k ∈ Gl} xijk <= x_ij, for each i and each l
     * </pre>
     */
    private void createConstraintEmployeeAssignToAtMostOneTaskWithinGl() {
        LOGGER.info("Create constraints: employee assign " + "to at most one task whitin a Gl.");
        // Loop on each Gl
        List<List<Double>> gl = table.gl();
        for (int l = 0; l < gl.size(); l++) {
            // Considering their is only one team for a Gl, get the team
            // associated with the gl.
            int j = this.table.teamForGl(l);
            List<Double> coefs = gl.get(l);
            // Loop on employees
            for (int i = 0; i < this.employees.size(); i++) {
                // Create the following constraints
                // SUM from{k in Gl} xijk <= xij
                Linear linear = lp.createLinear(coefs, Arrays.asList(xijk[i][j]));
                // Check if there is any term in the linear expression
                if (linear.size() > 0) {
                    if (xij[i][j] == null) {
                        xij[i][j] = createXijVariable(lp, i, j);
                    }
                    linear.add(lp.createTerm(-1, xij[i][j]));
                    String name = String.format("SUM from{k ∈ Gl} xijk <= x_ij, for i=%d, j=%d, l=%d", i, j, l);
                    lp.addConstraint(name, linear, null, SolveRunnable.ZERO);
                }
            }
        }
    }

    /**
     * Create the following constraints to enforce the employee to work on at most one team.
     * 
     * <pre>
     * SUM from{j=0} to{m} xij <= xi, for each employee i
     * </pre>
     */
    private void createConstraintEmployeeAssignToAtMostOneTeam() {
        LOGGER.info("Create constraints: employee assign to at " + "most one team.");
        for (int i = 0; i < this.employees.size(); i++) {
            // Create the linear
            Linear linear = lp.createLinear();
            for (int j = 0; j < this.xij[i].length; j++) {
                if (xij[i] != null && xij[i][j] != null) {
                    linear.add(lp.createTerm(SolveRunnable.ONE, xij[i][j]));
                }
            }
            // Create the constraint if the linear is not empty.
            if (linear.size() > 0) {
                // Create the variable if needed
                if (xi[i] == null) {
                    xi[i] = createXiVariable(lp, i);
                }
                linear.add(lp.createTerm(-1, xi[i]));
                String name = String.format("SUM from{j=0} to{m} xij <= xi, for i=%d", i);
                lp.addConstraint(name, linear, null, SolveRunnable.ZERO);
            }
        }
    }

    /**
     * This function create every xijk variables if required. It also create the following constraint:
     * 
     * <pre>
     * SUM from{i=0} to{n} SUM from{j=0} to {m} x_ijk <= 1, for each k
     * </pre>
     * 
     * A task is performed by at most one employee.
     * 
     * @throws
     */
    private void createEmployeeTaskVariables() throws GeneratePlanifException {
        // Loop on tasks
        for (int k = 0; k < tasks.size(); k++) {
            Task task = tasks.get(k);
            Linear linear = lp.createLinear();
            // Check if the tasks id locked.
            if (task.getLocked()) {
                Employee employee = task.getEmployee();
                // Check if the locked employee is available, if not throw an
                // exception.
                int i = employees.indexOf(employee);
                if (!isAvailable(employee, task) || i < 0) {
                    throwUnavailableEmployeeLocked(employee, task);
                }
                // Create a fixed variable for locked task whatever the employee
                // is qualify or not. See #122.
                Linear lockLinear = lp.createLinear();
                for (int j = 0; j < table.tj().size(); j++) {
                    if (table.kInTj(k, j)) {
                        xijk[i][j][k] = createXijkVariable(lp, i, j, k);
                        lockLinear.add(lp.createTerm(SolveRunnable.ONE, xijk[i][j][k]));
                        linear.add(lp.createTerm(SolveRunnable.ONE, xijk[i][j][k]));
                    }
                }
                if (lockLinear.size() > 0) {
                    String name = String.format("SUM from{j=0} to{p} xijk >= 1, for i=%d, k=%d", i, k);
                    lp.addConstraint(name, lockLinear, SolveRunnable.ONE, null);
                }
            } else {
                // Loop on each employee
                for (int i = 0; i < employees.size(); i++) {
                    // Avoid creation of variables for which the employee is not
                    // qualify or available.
                    boolean isQualify = isQualify(employees.get(i), task);
                    if (isQualify) {
                        // Need to create the variables
                        for (int j = 0; j < table.tj().size(); j++) {
                            if (table.kInTj(k, j)) {
                                xijk[i][j][k] = createXijkVariable(lp, i, j, k);
                                linear.add(lp.createTerm(SolveRunnable.ONE, xijk[i][j][k]));
                            }
                        }
                    }
                }
            }
            // Add the constraint if the linear is not empty.
            if (linear.size() > 0) {
                String name = String.format("SUM from{i=0} to{n} SUM from{j=0} to{p} xijk <= 1, for k=%d", k);
                lp.addConstraint(name, linear, null, SolveRunnable.ONE);
            }
        }
    }

    /**
     * Return a string representation of the employee i
     * 
     * @param i
     *            the employee
     * @return the string
     */
    String employeeToString(int i) {
        StringBuilder buf = new StringBuilder();
        buf.append(i);
        Employee employee = this.employees.get(i);
        String firstname = employee.getFirstname();
        String lastname = employee.getLastname();
        if (firstname != null || lastname != null) {
            buf.append(" (");
            if (firstname != null) {
                buf.append(firstname);
                buf.append(" ");
            }
            if (lastname != null) {
                buf.append(lastname);
            }
            buf.append(")");
        }
        return buf.toString();
    }

    public boolean fixNonAvailabilitiesVariables(int i) {
        boolean result = false;
        Employee employee = employees.get(i);
        for (int j1 = 0; j1 < xijk[i].length; j1++) {
            for (int k = 0; k < xijk[i][j1].length; k++) {
                if (xijk[i][j1][k] != null && !isAvailable(employee, tasks.get(k))) {
                    // Need to sets the variables to zero.
                    xijk[i][j1][k].setLowerBound(ZERO);
                    xijk[i][j1][k].setUpperBound(ZERO);
                    result = true;
                }
            }
        }
        return result;
    }

    /**
     * Check if the employee is available.
     * 
     * @param employee
     *            the employee
     * @return True if the employee is not available for this planif.
     */
    private boolean isAvailable(Employee employee) {
        int count = this.nonAvailabilityTable.valueSet(employee).size();
        return count < this.tasks.size();
    }

    /**
     * Check if the employee is available to work on the given tasks using the non-availability table.
     * 
     * @param employee
     *            the employee
     * @param task
     *            the task
     * @return True if the employee is available.
     */
    boolean isAvailable(Employee employee, Task task) {
        return !nonAvailabilityTable.containsEntry(employee, task);
    }

    /**
     * This function is used to check if an employee is quality to be assigned to the given task.
     * 
     * @return True if the employee is qualify.
     */
    private boolean isQualify(Employee employee, Task task) {
        return this.qualificationTable.containsEntry(employee, task);
    }

    /**
     * Save the result of the search into the planif event objects.
     * 
     * @throws GeneratePlanifException
     */
    private void persistResult() throws GeneratePlanifException {
        for (int k = 0; k < this.tasks.size(); k++) {
            // By default, initialize the employee to null
            this.tasks.get(k).setEmployee(null);
            for (int i = 0; i < xijk.length; i++) {
                for (int j = 0; j < xijk[i].length; j++) {
                    if (xijk[i][j][k] != null && SolveRunnable.ONE.equals(xijk[i][j][k].getValue())) {
                        this.tasks.get(k).setEmployee(this.employees.get(i));
                    }
                }
            }
        }
    }

    /**
     * Sets the new step to the progress monitor. Should be a one of the constant define in
     * {@link IGeneratePlanifProgressMonitor}.
     * 
     * @param step
     *            the new step id.
     */
    private void setStep(GeneratePlanifProgressMonitorStep step) {
        if (this.monitor != null) {
            this.monitor.setStep(step);
        }
    }

    /**
     * Return a string representation of the team j
     * 
     * @param j
     *            the team
     * @return the string
     */
    String teamToString(int j) {
        StringBuilder buf = new StringBuilder();
        buf.append(j);
        Team team = this.table.getTeam(j);
        String name = team.getName();
        if (name != null) {
            buf.append(" (");
            buf.append(team.getName());
            buf.append(")");
        }
        return buf.toString();
    }

    /**
     * Return a collection of variables related to employee <code>i</code>. The collection return each non-null xi, xij,
     * xijk variables.
     * 
     * @param i
     *            the employee.
     * @return the collection of variables.
     */
    public Collection<Variable> variablesForEmployee(int i) {
        ArrayList<Variable> list = new ArrayList<Variable>();
        list.add(xi[i]);
        for (int j = 0; j < xij[i].length; j++) {
            if (xij[i][j] != null) {
                list.add(xij[i][j]);
            }
            for (int k = 0; k < xijk[i][j].length; k++) {
                if (xijk[i][j][k] != null) {
                    list.add(xijk[i][j][k]);
                }
            }
        }
        return list;
    }

    /**
     * Used to update the progress of the monitor and it's also check the cancel state of the monitor.
     * 
     * @param monitor
     *            the progress monitor
     * @param unit
     *            unit of work
     * @throws InterruptedException
     *             if {@link IGeneratePlanifProgressMonitor#isCanceled()} return true
     */
    void worked(int unit) throws GeneratePlanifException {
        if (this.monitor != null) {
            if (this.monitor.isCanceled()) {
                throw new GeneratePlanifException("Cancel by user", GeneratePlanifException.CANCEL_BY_USER);
            }
            this.monitor.worked(UNIT);
        }
    }
}
