package tim.prune.function.estimate;

import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.FlowLayout;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.util.ArrayList;

import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JDialog;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollBar;

import tim.prune.App;
import tim.prune.GenericFunction;
import tim.prune.I18nManager;
import tim.prune.config.Config;
import tim.prune.data.DataPoint;
import tim.prune.data.Distance;
import tim.prune.data.RangeStatsWithGradients;
import tim.prune.data.Track;
import tim.prune.data.Unit;
import tim.prune.data.UnitSetLibrary;
import tim.prune.function.estimate.jama.Matrix;
import tim.prune.gui.ProgressDialog;

/**
 * Function to learn the estimation parameters from the current track
 */
public class LearnParameters extends GenericFunction implements Runnable
{
	/** Progress dialog */
	ProgressDialog _progress = null;
	/** Results dialog */
	JDialog _dialog = null;
	/** Calculated parameters */
	private ParametersPanel _calculatedParamPanel = null;
	private EstimationParameters _calculatedParams = null;
	/** Slider for weighted average */
	private JScrollBar _weightSlider = null;
	/** Label to describe position of slider */
	private JLabel _sliderDescLabel = null;
	/** Combined parameters */
	private ParametersPanel _combinedParamPanel = null;
	/** Combine button */
	private JButton _combineButton = null;


	/**
	 * Inner class used to hold the results of the matrix solving
	 */
	static class MatrixResults
	{
		public EstimationParameters _parameters = null;
		public double _averageErrorPc = 0.0; // percentage
	}


	/**
	 * Constructor
	 * @param inApp App object
	 */
	public LearnParameters(App inApp)
	{
		super(inApp);
	}

	/** @return key for function name */
	public String getNameKey() {
		return "function.learnestimationparams";
	}

	/**
	 * Begin the function
	 */
	public void begin()
	{
		// Show progress bar
		if (_progress == null) {
			_progress = new ProgressDialog(_parentFrame, getNameKey());
		}
		_progress.show();
		// Start new thread for the calculations
		new Thread(this).start();
	}

	/**
	 * Run method in separate thread
	 */
	public void run()
	{
		_progress.setMaximumValue(100);
		// Go through the track and collect the range stats for each sample
		ArrayList<RangeStatsWithGradients> statsList = new ArrayList<RangeStatsWithGradients>(20);
		Track track = _app.getTrackInfo().getTrack();
		final int numPoints = track.getNumPoints();
		final int sampleSize = numPoints / 30;
		int prevStartIndex = -1;
		for (int i=0; i<30; i++)
		{
			int startIndex = i * sampleSize;
			RangeStatsWithGradients stats = getRangeStats(track, startIndex, startIndex + sampleSize, prevStartIndex);
			if (stats != null && stats.getMovingDistanceKilometres() > 1.0
				&& !stats.getTimestampsIncomplete() && !stats.getTimestampsOutOfSequence()
				&& stats.getTotalDurationInSeconds() > 100
				&& startIndex > prevStartIndex)
			{
				// System.out.println("Got stats for " + stats.getStartIndex() + " to " + stats.getEndIndex());
				statsList.add(stats);
				prevStartIndex = startIndex;
			}
			_progress.setValue(i);
		}

		// Check if we've got enough samples
		if (statsList.size() < 10)
		{
			_progress.close();
			// Show error message, not enough samples
			_app.showErrorMessage(getNameKey(), "error.learnestimationparams.failed");
			return;
		}
		// Loop around, solving the matrices and removing the highest-error sample
		MatrixResults results = reduceSamples(statsList);
		_progress.close();
		if (results == null)
		{
			_app.showErrorMessage(getNameKey(), "error.learnestimationparams.failed");
			return;
		}

		// Create the dialog if necessary
		if (_dialog == null)
		{
			_dialog = new JDialog(_parentFrame, getName(), true);
			_dialog.setLocationRelativeTo(_parentFrame);
			// Create Gui and show it
			_dialog.getContentPane().add(makeDialogComponents());
			_dialog.pack();
		}

		// Populate the values in the dialog
		populateCalculatedValues(results);
		updateCombinedLabels(calculateCombinedParameters());
		_dialog.setVisible(true);
	}


	/**
	 * Make the dialog components
	 * @return the GUI components for the dialog
	 */
	private Component makeDialogComponents()
	{
		JPanel dialogPanel = new JPanel();
		dialogPanel.setLayout(new BorderLayout());

		// main panel with a box layout
		JPanel mainPanel = new JPanel();
		mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
		// Label at top
		JLabel introLabel = new JLabel(I18nManager.getText("dialog.learnestimationparams.intro") + ":");
		introLabel.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
		introLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
		mainPanel.add(introLabel);

		// Panel for the calculated results
		_calculatedParamPanel = new ParametersPanel("dialog.estimatetime.results", true, getConfig());
		_calculatedParamPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
		mainPanel.add(_calculatedParamPanel);
		mainPanel.add(Box.createVerticalStrut(14));

		mainPanel.add(new JLabel(I18nManager.getText("dialog.learnestimationparams.combine") + ":"));
		mainPanel.add(Box.createVerticalStrut(4));
		_weightSlider = new JScrollBar(JScrollBar.HORIZONTAL, 5, 1, 0, 11);
		_weightSlider.addAdjustmentListener(e -> {
			if (!e.getValueIsAdjusting()) {
				updateCombinedLabels(calculateCombinedParameters());
			}
		});
		mainPanel.add(_weightSlider);
		_sliderDescLabel = new JLabel(" ");
		_sliderDescLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
		mainPanel.add(_sliderDescLabel);
		mainPanel.add(Box.createVerticalStrut(12));

		// Results panel
		_combinedParamPanel = new ParametersPanel("dialog.learnestimationparams.combinedresults", getConfig());
		_combinedParamPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
		mainPanel.add(_combinedParamPanel);

		dialogPanel.add(mainPanel, BorderLayout.NORTH);

		// button panel at bottom
		JPanel buttonPanel = new JPanel();
		buttonPanel.setLayout(new FlowLayout(FlowLayout.RIGHT));

		// Combine
		_combineButton = new JButton(I18nManager.getText("button.combine"));
		_combineButton.addActionListener(e -> combineAndFinish());
		buttonPanel.add(_combineButton);

		// Cancel
		JButton cancelButton = new JButton(I18nManager.getText("button.cancel"));
		cancelButton.addActionListener(e -> _dialog.dispose());
		KeyAdapter escapeListener = new KeyAdapter() {
			public void keyPressed(KeyEvent inE) {
				if (inE.getKeyCode() == KeyEvent.VK_ESCAPE) {_dialog.dispose();}
			}
		};
		_combineButton.addKeyListener(escapeListener);
		cancelButton.addKeyListener(escapeListener);
		buttonPanel.add(cancelButton);
		dialogPanel.add(buttonPanel, BorderLayout.SOUTH);
		return dialogPanel;
	}

	/**
	 * Construct a rangestats object for the selected range
	 * @param inTrack track object
	 * @param inStartIndex start index
	 * @param inEndIndex end index
	 * @param inPreviousStartIndex the previously used start index, or -1
	 * @return range stats object or null if required information missing from this bit of the track
	 */
	private RangeStatsWithGradients getRangeStats(Track inTrack, int inStartIndex,
		int inEndIndex, int inPreviousStartIndex)
	{
		// Check parameters
		if (inTrack == null || inStartIndex < 0 || inEndIndex <= inStartIndex || inStartIndex > inTrack.getNumPoints()) {
			return null;
		}
		final int numPoints = inTrack.getNumPoints();
		int start = inStartIndex;

		// Search forward until a decent track point found for the start
		DataPoint p = inTrack.getPoint(start);
		while (start < numPoints && (p == null || p.isWaypoint() || !p.hasTimestamp() || !p.hasAltitude()))
		{
			start++;
			p = inTrack.getPoint(start);
		}
		if (inPreviousStartIndex >= 0 && start <= (inPreviousStartIndex + 10) // overlapping too much with previous range
			|| (start >= (numPoints - 10))) // starting too late in the track
		{
			return null;
		}

		// Search forward (counting the radians) until a decent end point found
		double movingRads = 0.0;
		final double minimumRads = Distance.convertDistanceToRadians(1.0, UnitSetLibrary.UNITS_KILOMETRES);
		DataPoint prevPoint = inTrack.getPoint(start);
		int endIndex = start;
		boolean shouldStop = false;
		do
		{
			endIndex++;
			p = inTrack.getPoint(endIndex);
			if (p != null && !p.isWaypoint())
			{
				if (!p.hasAltitude() || !p.hasTimestamp()) {return null;} // abort if no time/altitude
				if (prevPoint != null && !p.getSegmentStart()) {
					movingRads += DataPoint.calculateRadiansBetween(prevPoint, p);
				}
			}
			prevPoint = p;
			if (endIndex >= numPoints) {
				shouldStop = true; // reached the end of the track
			}
			else if (movingRads >= minimumRads && endIndex >= inEndIndex) {
				shouldStop = true; // got at least a kilometre
			}
		}
		while (!shouldStop);

		// Check moving distance
		if (movingRads >= minimumRads)
		{
			final int altitudeTolerance = getConfig().getConfigInt(Config.KEY_ALTITUDE_TOLERANCE) / 100;
			return new RangeStatsWithGradients(inTrack, start, endIndex, altitudeTolerance);
		}
		return null;
	}

	/**
	 * Build an A matrix for the given list of RangeStats objects
	 * @param inStatsList list of (non-null) RangeStats objects
	 * @return A matrix with n rows and 5 columns
	 */
	private static Matrix buildAMatrix(ArrayList<RangeStatsWithGradients> inStatsList)
	{
		final Unit METRES = UnitSetLibrary.UNITS_METRES;
		Matrix result = new Matrix(inStatsList.size(), 5);
		int row = 0;
		for (RangeStatsWithGradients stats : inStatsList)
		{
			result.setValue(row, 0, stats.getMovingDistanceKilometres());
			result.setValue(row, 1, stats.getGentleAltitudeRange().getClimb(METRES));
			result.setValue(row, 2, stats.getSteepAltitudeRange().getClimb(METRES));
			result.setValue(row, 3, stats.getGentleAltitudeRange().getDescent(METRES));
			result.setValue(row, 4, stats.getSteepAltitudeRange().getDescent(METRES));
			row++;
		}
		return result;
	}

	/**
	 * Build a B matrix containing the observations (moving times)
	 * @param inStatsList list of (non-null) RangeStats objects
	 * @return B matrix with single column of n rows
	 */
	private static Matrix buildBMatrix(ArrayList<RangeStatsWithGradients> inStatsList)
	{
		Matrix result = new Matrix(inStatsList.size(), 1);
		int row = 0;
		for (RangeStatsWithGradients stats : inStatsList)
		{
			result.setValue(row, 0, stats.getMovingDurationInSeconds() / 60.0); // convert seconds to minutes
			row++;
		}
		return result;
	}

	/**
	 * Look for the maximum absolute value in the given column matrix
	 * @param inMatrix matrix with only one column
	 * @return row index of cell with greatest absolute value, or -1 if not valid
	 */
	private static int getIndexOfMaxValue(Matrix inMatrix)
	{
		if (inMatrix == null || inMatrix.getNumColumns() > 1) {
			return -1;
		}
		int index = 0;
		double maxValue = 0.0;
		// Loop over the first column looking for the maximum absolute value
		for (int i=0; i<inMatrix.getNumRows(); i++)
		{
			final double currValue = Math.abs(inMatrix.get(i, 0));
			if (currValue > maxValue)
			{
				maxValue = currValue;
				index = i;
			}
		}
		return index;
	}

	/**
	 * See if the given set of samples is sufficient for getting a descent solution (at least 3 nonzero values)
	 * @param inRangeSet list of RangeStats objects
	 * @param inRowToIgnore row index to ignore, or -1 to use them all
	 * @return true if the samples look ok
	 */
	private static boolean isRangeSetSufficient(ArrayList<RangeStatsWithGradients> inRangeSet, int inRowToIgnore)
	{
		// number of samples with gentle/steep climb/descent values > 0
		int numGC = 0, numSC = 0, numGD = 0, numSD = 0;
		final Unit METRES = UnitSetLibrary.UNITS_METRES;
		int i = 0;
		for (RangeStatsWithGradients stats : inRangeSet)
		{
			if (i != inRowToIgnore)
			{
				if (stats.getGentleAltitudeRange().getClimb(METRES) > 0) {numGC++;}
				if (stats.getSteepAltitudeRange().getClimb(METRES) > 0)  {numSC++;}
				if (stats.getGentleAltitudeRange().getDescent(METRES) > 0) {numGD++;}
				if (stats.getSteepAltitudeRange().getDescent(METRES) > 0)  {numSD++;}
			}
			i++;
		}
		return numGC > 3 && numSC > 3 && numGD > 3 && numSD > 3;
	}

	/**
	 * Reduce the number of samples in the given list by eliminating the ones with highest errors
	 * @param inStatsList list of stats
	 * @return results in an object
	 */
	private MatrixResults reduceSamples(ArrayList<RangeStatsWithGradients> inStatsList)
	{
		int statsIndexToRemove = -1;
		Matrix answer = null;
		boolean finished = false;
		double averageErrorPc = 0.0;
		while (!finished)
		{
			// Remove the marked stats object, if any
			if (statsIndexToRemove >= 0) {
				inStatsList.remove(statsIndexToRemove);
			}

			// Build up the matrices
			Matrix A = buildAMatrix(inStatsList);
			Matrix B = buildBMatrix(inStatsList);
			// System.out.println("Times in minutes are:\n" + B.toString());

			// Solve (if possible)
			try
			{
				answer = A.solve(B);
				// System.out.println("Solved matrix with " + A.getNumRows() + " rows:\n" + answer.toString());
				// Work out the percentage error for each estimate
				Matrix estimates = A.times(answer);
				Matrix errors = estimates.minus(B).divideEach(B);
				// System.out.println("Errors: " + errors.toString());
				averageErrorPc = errors.getAverageAbsValue();
				// find biggest percentage error, remove it from list
				statsIndexToRemove = getIndexOfMaxValue(errors);
				if (statsIndexToRemove < 0)
				{
					System.err.println("Something wrong - index is " + statsIndexToRemove);
					throw new Exception();
				}
				// Check whether removing this element would make the range set insufficient
				finished = inStatsList.size() <= 25 || !isRangeSetSufficient(inStatsList, statsIndexToRemove);
			}
			catch (Exception e)
			{
				// Couldn't solve at all
				System.out.println("Failed to reduce: " + e.getClass().getName() + " - " + e.getMessage());
				return null;
			}
			_progress.setValue(20 + 80 * (30 - inStatsList.size())/5); // Counting from 30 to 25
		}
		// Copy results to an EstimationParameters object
		MatrixResults result = new MatrixResults();
		result._parameters = EstimationParameters.fromMetricUnits(answer.get(0, 0) * 5, // convert from 1km to 5km
			answer.get(1, 0) * 100.0, answer.get(2, 0) * 100.0,                         // convert from m to 100m
			answer.get(3, 0) * 100.0, answer.get(4, 0) * 100.0);
		result._averageErrorPc = averageErrorPc;
		return result;
	}


	/**
	 * Populate the dialog's labels with the calculated values
	 * @param inResults results of the calculations
	 */
	private void populateCalculatedValues(MatrixResults inResults)
	{
		if (inResults == null || inResults._parameters == null)
		{
			_calculatedParams = null;
			_calculatedParamPanel.updateParameters(null, 0.0);
		}
		else
		{
			_calculatedParams = inResults._parameters;
			_calculatedParamPanel.updateParameters(_calculatedParams, inResults._averageErrorPc);
		}
	}

	/**
	 * Combine the calculated parameters with the existing ones
	 * according to the value of the slider
	 * @return combined parameters
	 */
	private EstimationParameters calculateCombinedParameters()
	{
		final double fraction1 = 1 - 0.1 * _weightSlider.getValue(); // slider left = value 0 = fraction 1 = keep current
		EstimationParameters oldParams = EstimationParameters.fromConfigString(getConfig().getConfigString(Config.KEY_ESTIMATION_PARAMS));
		if (oldParams == null) {
			return _calculatedParams;
		}
		return oldParams.combine(_calculatedParams, fraction1);
	}

	/**
	 * Update the labels to show the combined parameters
	 * @param inCombinedParams combined estimation parameters
	 */
	private void updateCombinedLabels(EstimationParameters inCombinedParams)
	{
		// Update the slider description label
		String sliderDesc = null;
		final int sliderVal = _weightSlider.getValue();
		switch (sliderVal)
		{
			case 0:  sliderDesc = I18nManager.getText("dialog.learnestimationparams.weight.100pccurrent"); break;
			case 5:  sliderDesc = I18nManager.getText("dialog.learnestimationparams.weight.50pc"); break;
			case 10: sliderDesc = I18nManager.getText("dialog.learnestimationparams.weight.100pccalculated"); break;
			default:
				final int currTenths = 10 - sliderVal, calcTenths = sliderVal;
				sliderDesc = "" + currTenths + "0% " + I18nManager.getText("dialog.learnestimationparams.weight.current")
					+ " + " + calcTenths + "0% " + I18nManager.getText("dialog.learnestimationparams.weight.calculated");
		}
		_sliderDescLabel.setText(sliderDesc);
		// And update all the combined params labels
		_combinedParamPanel.updateParameters(inCombinedParams);
		_combineButton.setEnabled(sliderVal > 0);
	}

	/**
	 * React to the combine button, by saving the combined parameters in the config
	 */
	private void combineAndFinish()
	{
		EstimationParameters params = calculateCombinedParameters();
		if (params == null) {
			params = EstimationParameters.DEFAULT_PARAMS;
		}
		getConfig().setConfigString(Config.KEY_ESTIMATION_PARAMS, params.toConfigString());
		_dialog.dispose();
	}
}
