/*
 *  Java Information Dynamics Toolkit (JIDT)
 *  Copyright (C) 2024, Joseph T. Lizier
 *  
 *  This program is free software: you can redistribute it and/or modify
 *  it under the terms of the GNU 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 General Public License for more details.
 *  
 *  You should have received a copy of the GNU General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */
package infodynamics.measures.continuous;

import java.util.Random;
import java.util.Vector;

import infodynamics.utils.MatrixUtils;

/**
 * Implements {@link EntropyCalculatorMultiVariate} to provide a base
 * class with common functionality for child class implementations of
 * {@link EntropyCalculatorMultiVariate}
 * via various estimators. 
 * 
 * <p>These various estimators include: e.g. box-kernel estimation, Kozachenko, etc
 * (see the child classes linked above).
 * </p>
 * 
 * <p>Usage is as outlined in {@link EntropyCalculatorMultiVariate}.</p>
 * 
  * @author Joseph Lizier (<a href="joseph.lizier at gmail.com">email</a>,
 * <a href="http://lizier.me/joseph/">www</a>)
 */
public abstract class EntropyCalculatorMultiVariateCommon implements EntropyCalculatorMultiVariate {
	/**
	 * Whether we're in debug mode
	 */
	protected boolean debug = false;
	/**
	 * Number of observations supplied
	 */
	protected int totalObservations = 0;
	/**
	 * Number of joint variables/dimensions
	 */
	protected int dimensions = 1;
	/**
	 * Track whether we've computed the average for the supplied
	 *  observations yet
	 */
	protected boolean isComputed = false;
	/**
	 * Last computed average entropy
	 */
	protected double lastAverage = 0.0;
	/**
	 * Whether we normalise the incoming observations to mean 0,
	 * standard deviation 1.
	 */
	protected boolean normalise = true;
	/**
	 * Whether to add an amount of random noise to the incoming data
	 */
	protected boolean addNoise = false;
	/**
	 * Amount of random Gaussian noise to add to the incoming data
	 */
	protected double noiseLevel = (double) 0.0;
	/**
	 * Has the user set a seed for the random noise
	 */
	protected boolean noiseSeedSet = false;
	/**
	 * Seed that the user set for the random noise
	 */
	protected long noiseSeed = 0;
	/**
	 * Storage for observations supplied via {@link #addObservations(double[][])}
	 * type calls
	 */
	protected Vector<double[][]> vectorOfObservations;
	/**
	 * The set of observations, retained in case the user wants to retrieve the local
	 *  entropy values of these.
	 */
	protected double[][] observations;

	/**
	 * Default constructor
	 */
	public EntropyCalculatorMultiVariateCommon() {
		// Nothing to do
	}
	
	@Override
	public void initialise() throws Exception {
		initialise(dimensions);
	}

	@Override
	public void initialise(int dimensions) {
		this.dimensions = dimensions;
		observations = null;
		totalObservations = 0;
		isComputed = false;
		lastAverage = 0;
	}

	@Override
	public void setProperty(String propertyName, String propertyValue) throws Exception {

		boolean propertySet = true;

		if (propertyName.equalsIgnoreCase(NUM_DIMENSIONS_PROP_NAME)) {
			dimensions = Integer.parseInt(propertyValue);
		} else if (propertyName.equalsIgnoreCase(NORMALISE_PROP_NAME)) {
			normalise = Boolean.parseBoolean(propertyValue);
		} else if (propertyName.equalsIgnoreCase(PROP_ADD_NOISE)) {
			if (propertyValue.equals("0") ||
					propertyValue.equalsIgnoreCase("false")) {
				addNoise = false;
				noiseLevel = 0;
			} else {
				addNoise = true;
				noiseLevel = Double.parseDouble(propertyValue);
			}
	    } else if (propertyName.equalsIgnoreCase(PROP_NOISE_SEED)) {
	    	if (propertyValue.equals(NOISE_NO_SEED_VALUE)) {
	    		noiseSeedSet = false;
	    	} else {
	    		noiseSeedSet = true;
	    		noiseSeed = Long.parseLong(propertyValue);
	    	}
		} else {
			// No property was set
			propertySet = false;
		}
		if (debug && propertySet) {
			System.out.println(this.getClass().getSimpleName() + ": Set property " + propertyName +
					" to " + propertyValue);
		}
	}
	
	@Override
	public String getProperty(String propertyName) throws Exception {
		if (propertyName.equalsIgnoreCase(NUM_DIMENSIONS_PROP_NAME)) {
			return Integer.toString(dimensions);
		} else if (propertyName.equalsIgnoreCase(NORMALISE_PROP_NAME)) {
			return Boolean.toString(normalise);
	    } else if (propertyName.equalsIgnoreCase(PROP_ADD_NOISE)) {
	        return Double.toString(noiseLevel);
	    } else if (propertyName.equalsIgnoreCase(PROP_NOISE_SEED)) {
	    	if (noiseSeedSet) {
	    		return Long.toString(noiseSeed);
	    	} else {
	    		return NOISE_NO_SEED_VALUE;
	    	}
		} else {
			// No property was set, and no superclass to call:
			return null;
		}
	}

	@Override
	public void startAddObservations() {
		isComputed = false;
		totalObservations = 0;
		observations = null;
		vectorOfObservations = new Vector<double[][]>();
	}

	@Override
	public void finaliseAddObservations() throws Exception {

		observations = new double[totalObservations][dimensions];
		
		// Construct the joint vectors from the given observations
		int startObservation = 0;
		for (double[][] obs : vectorOfObservations) {
			if ((obs == null) || (obs.length < 1)) {
				continue;
			}
			// Dimension was checked earlier by addObservations.
			// Copy the data from these given observations into our master array
			MatrixUtils.arrayCopy(obs, 0, 0,
					observations, startObservation, 0,
					obs.length, dimensions);
			startObservation += obs.length;
		}

		// We don't need to keep the vector of observation sets anymore:
		vectorOfObservations = null;

		if (addNoise) {
			Random random = new Random();
			if (noiseSeedSet) {
				random.setSeed(noiseSeed);
			}
			// Add Gaussian noise of std dev noiseLevel to the data
			for (int r = 0; r < totalObservations; r++) {
				for (int c = 0; c < dimensions; c++) {
					observations[r][c] += random.nextGaussian()*noiseLevel;
				}
			}
		}
	}

	@Override
	public void setObservations(double[][] observations) throws Exception {
		startAddObservations();
		addObservations(observations);
		finaliseAddObservations();
	}

	/*
	 * (non-Javadoc)
	 * @see infodynamics.measures.continuous.EntropyCalculator#setObservations(double[])
	 * 
	 * This method here to ensure we make compatibility with the 
	 *  EntropyCalculator interface; only valid if dimension > 1
	 */
	@Override
	public void setObservations(double[] observations) throws Exception {
		startAddObservations();
		addObservations(observations);
		finaliseAddObservations();
	}

	/**
	 * Shortcut method to join two sets of samples into a joint multivariate 
	 * 
	 * Each row of the data is an observation; each column of
	 *  the row is a new variable in the multivariate observation.
	 * This method signature allows the user to call setObservations for
	 *  joint time series without combining them into a single joint time
	 *  series (we do the combining for them).
	 * 
	 * @param data1 observations1 few variables in the joint data
	 * @param observations2 the other variables in the joint data
	 * @throws Exception When the length of the two arrays of observations do not match.
	 * @see #setObservations(double[][])
	 */
	@Deprecated
	public void setObservations(double[][] observations1, double[][] observations2)
			throws Exception {
		startAddObservations();
		addObservations(observations1, observations2);
		finaliseAddObservations();
	}

	@Override
	public void addObservations(double[][] observations) throws Exception {
		if (vectorOfObservations == null) {
		// startAddObservations was not called first
			throw new RuntimeException("User did not call startAddObservations before addObservations");
		}
		if ((observations != null) && (observations.length > 0)) {
			// Check the dimension:
			if (observations[0].length != dimensions) {
				throw new Exception(String.format("Cannot supply observations with dimension %d when expected dimension = %d",
						observations[0].length, dimensions));
			}
			totalObservations += observations.length;
		}
		// Add the observations whether empty or else valid:
		vectorOfObservations.add(observations);
	}

	/*
	 * (non-Javadoc)
	 * @see infodynamics.measures.continuous.EntropyCalculator#addObservations(double[])
	 * 
	 * This method here to ensure we make compatibility with the 
	 *  EntropyCalculator interface; only valid if dimension > 1
	 */
	@Override
	public void addObservations(double[] observations) throws Exception {
		if (dimensions != 1) {
			throw new Exception(String.format("Cannot set univariate observations when expected dimension = %d", dimensions));
		}
		double[][] observations2D = MatrixUtils.reshape(observations, observations.length, 1);
		addObservations(observations2D);
	}
	
	/**
	 * Shortcut method to join two sets of samples into a joint multivariate. 
	 * 
	 * Each row of the data is an observation; each column of
	 *  the row is a new variable in the multivariate observation.
	 * This method signature allows the user to call addObservations for
	 *  joint time series without combining them into a single joint time
	 *  series (we do the combining for them).
	 * 
	 * @param data1 first few variables in the joint data
	 * @param data2 the other variables in the joint data
	 * @throws Exception When the length of the two arrays of observations do not match.
	 * @see #addObservations(double[][])
	 */
	@Deprecated
	public void addObservations(double[][] data1,
			double[][] data2) throws Exception {
		int timeSteps = data1.length;
		if ((data1 == null) || (data2 == null)) {
			throw new Exception("Cannot have null data arguments");
		}
		if (data1.length != data2.length) {
			throw new Exception("Length of data1 (" + data1.length + ") is not equal to the length of data2 (" +
					data2.length + ")");
		}
		int data1Variables = data1[0].length;
		int data2Variables = data2[0].length;
		double[][] data = new double[timeSteps][data1Variables + data2Variables];
		for (int t = 0; t < timeSteps; t++) {
			System.arraycopy(data1[t], 0, data[t], 0, data1Variables);
			System.arraycopy(data2[t], 0, data[t], data1Variables, data2Variables);
		}
		// Now defer to the normal setObservations method
		addObservations(data);
	}

	@Override
	public int getNumObservations() throws Exception {
		return totalObservations;
	}

	@Override
	public double getLastAverage() {
		return lastAverage;
	}
	
	@Override
	public void setDebug(boolean debug) {
		this.debug = debug;
	}

}
