/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF 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.apache.wayang.profiler.log;

import org.apache.wayang.core.api.Configuration;
import org.apache.wayang.core.optimizer.costs.EstimationContext;
import org.apache.wayang.core.optimizer.costs.LoadProfileEstimator;
import org.apache.wayang.core.optimizer.costs.TimeEstimate;
import org.apache.wayang.core.platform.AtomicExecutionGroup;
import org.apache.wayang.core.platform.PartialExecution;
import org.apache.wayang.core.platform.Platform;
import org.apache.wayang.core.util.Bitmask;

import java.util.Comparator;
import java.util.Map;
import java.util.Random;
import java.util.function.ToDoubleFunction;
import java.util.stream.DoubleStream;

/**
 * Context for the optimization of {@link LoadProfileEstimator}s.
 */
public class Individual {

    /**
     * Orders {@link Individual}s by their fitness descendingly.
     */
    public static Comparator<Individual> fitnessComparator =
            (i1, i2) -> Double.compare(i2.getFitness(), i1.getFitness());

    private final double[] genome;

    private final double[] maturity;

    private double minMaturity = Double.NaN, maxMaturity = Double.NaN;

    private double fitness = Double.NaN;

    Individual(int genomeSize) {
        this.genome = new double[genomeSize];
        this.maturity = new double[genomeSize];
    }

    public double[] getGenome() {
        return this.genome;
    }

    public void setGene(int index, double value, double maturity) {
        this.genome[index] = value;
        this.updateMaturity(index, maturity);
    }

    private void updateMaturity(int index, double maturity) {
        maturity = 1d;
        this.maturity[index] = maturity;
        if (Double.isNaN(this.minMaturity) || this.minMaturity > maturity) {
            this.minMaturity = maturity;
        }
        if (Double.isNaN(this.maxMaturity) || this.maxMaturity < maturity) {
            this.maxMaturity = maturity;
        }
    }

    public Individual mutate(Random random,
                             Bitmask activatedGenes,
                             OptimizationSpace optimizationSpace,
                             double mutationProb,
                             double resetProb) {

        // Make at least one mutation more likely.
        if (mutationProb > 0d) mutationProb = Math.max(mutationProb, 1 / activatedGenes.cardinality());

        final double smoothing = 1d;
        int numActivatedGenes = activatedGenes.cardinality();
        double logGainProduct = 0d;
        for (int i = activatedGenes.nextSetBit(0); i != -1; i = activatedGenes.nextSetBit(i + 1)) {
            final double gain = this.maturity[i] - this.minMaturity;
            logGainProduct += Math.log(gain + smoothing);
        }
        double meanGain = Math.exp((logGainProduct / numActivatedGenes)) - smoothing;

        Individual mutant = new Individual(this.genome.length);
        for (int i = 0; i < this.genome.length; i++) {
            if (!activatedGenes.get(i)) {
                mutant.setGene(i, this.genome[i], this.maturity[i]);
                continue;
            }
            final double gain = this.maturity[i] - this.minMaturity;
            double boost = (meanGain + smoothing) / (gain + smoothing);
            final double uniform = random.nextDouble() * boost;
            if (uniform <= mutationProb) {
                final double mutatedGene = optimizationSpace.getVariable(i).mutate(this.genome[i], random);
                mutant.setGene(i, mutatedGene, Double.NaN);
            } else if (uniform <= mutationProb + resetProb) {
                mutant.setGene(i, optimizationSpace.getVariable(i).createRandomValue(random), Double.NaN);
            } else {
                mutant.setGene(i, this.genome[i], this.maturity[i]);
            }
        }
        return mutant;
    }

    public Individual crossOver(Individual that, Random random) {
        Individual offspring = new Individual(this.genome.length);
        double minMaturity = Math.min(this.minMaturity, that.minMaturity);
        for (int i = 0; i < this.genome.length; i++) {
            double thisProb = GeneticOptimizer.getSelectionProbability(this.maturity[i], that.maturity[i], minMaturity);
            if (random.nextDouble() < thisProb) {
                offspring.setGene(i, this.genome[i], this.maturity[i]);
            } else {
                offspring.setGene(i, that.genome[i], that.maturity[i]);
            }
        }
        return offspring;
    }

//    /**
//     * Calculate the fitness as the arithmetic mean the individual fitnesses of the estimation subjects.
//     */
//    double calcluateSubjectbasedFitness(GeneticOptimizer geneticOptimizer) {
//
//        Map<Object, FitnessAggregator> subjectAggregators = new HashMap<>();
//        for (PartialExecution partialExecution : geneticOptimizer.getData()) {
//            // Calculate values for the given partialExecution
//            double timeEstimate = this.estimateTime(
//                    partialExecution,
//                    geneticOptimizer.getEstimators(),
//                    geneticOptimizer.getPlatformOverheads(),
//                    geneticOptimizer.getConfiguration()
//            );
//            double partialFitness = this.calculateRelativeDelta(timeEstimate, partialExecution.getMeasuredExecutionTime());
//            double weight = Math.log(Math.max(timeEstimate, partialExecution.getMeasuredExecutionTime()) + 2d) / Math.log(2);
////            double weight = Math.max(timeEstimate.getGeometricMeanEstimate(), partialExecution.getMeasuredExecutionTime()) + 1;
//
//            // Attribute the fitness to all involved subjects.
//            for (PartialExecution.OperatorExecution operatorExecution : partialExecution.getOperatorExecutions()) {
//                Object subject = operatorExecution.getOperator().getClass();
//                final FitnessAggregator aggregator = subjectAggregators.computeIfAbsent(subject, k -> new FitnessAggregator(0, 0));
//                aggregator.fitnessAccumulator += weight * partialFitness;
//                aggregator.weightAccumulator += weight;
//                aggregator.numObservations++;
//            }
//            for (Platform subject : partialExecution.getInitializedPlatforms()) {
//                final FitnessAggregator aggregator = subjectAggregators.computeIfAbsent(subject, k -> new FitnessAggregator(0, 0));
//                aggregator.fitnessAccumulator += weight * partialFitness;
//                aggregator.weightAccumulator += weight;
//                aggregator.numObservations++;
//            }
//        }
//
//        // Aggregate the fitness values of the different subjects.
//        FitnessAggregator aggregator = new FitnessAggregator(0, 0);
//        for (FitnessAggregator subjectAggregator : subjectAggregators.values()) {
//            double subjectFitness = subjectAggregator.fitnessAccumulator / subjectAggregator.weightAccumulator;
//            double subjectWeight = 1;//Math.log(1 + subjectAggregator.numObservations);
//            aggregator.fitnessAccumulator += subjectWeight * subjectFitness;
//            aggregator.weightAccumulator += subjectWeight;
//            aggregator.numObservations++;
//        }
//
//        return aggregator.fitnessAccumulator / aggregator.weightAccumulator;
//
//    }

//    private static class FitnessAggregator {
//
//        private double fitnessAccumulator;
//
//        private double weightAccumulator;
//
//        private int numObservations = 0;
//
//        public FitnessAggregator(double fitnessAccumulator, double weightAccumulator) {
//            this.fitnessAccumulator = fitnessAccumulator;
//            this.weightAccumulator = weightAccumulator;
//        }
//    }

    public void updateMaturity(Bitmask activatedGenes) {
        final double newMaturity = this.getFitness();
        for (int activatedGene = activatedGenes.nextSetBit(0);
             activatedGene != -1;
             activatedGene = activatedGenes.nextSetBit(activatedGene + 1)) {
            double currentMaturity = this.maturity[activatedGene];
            if (Double.isNaN(currentMaturity) || newMaturity > currentMaturity) {
                this.updateMaturity(activatedGene, newMaturity);
            }
        }
    }

    /**
     * Update the fitness of this instance.
     *
     * @param fitnessFunction calculates the fitness for this instance
     * @return the new fitness
     */
    public double updateFitness(ToDoubleFunction<Individual> fitnessFunction) {
        return this.fitness = fitnessFunction.applyAsDouble(this);
    }

    public double getFitness() {
        if (Double.isNaN(this.fitness)) {
            throw new IllegalStateException("The fitness of the individual has not yet been calculated.");
        }
        return this.fitness;
    }

    /**
     * Calculate the fitness as weighted harmonic mean of the relative prediction accuracies.
     */
    double calculateRelativeFitness(GeneticOptimizer geneticOptimizer) {
        // Some settings.
        double harmonicSmoothing = .1d;
        double weightSum = 0d;
        double fitnessSum = 0d;

        // Calculate the arithmetic mean of the partial fitnesses for each data point.
        for (PartialExecution partialExecution : geneticOptimizer.getData()) {
            // Estimate the time with the current variables.
            double timeEstimate = this.estimateTime(
                    partialExecution,
                    geneticOptimizer.getPlatformOverheads(),
                    geneticOptimizer.getConfiguration()
            );

            // Calculate the weight.
//            double weight = Math.log(partialExecution.getMeasuredExecutionTime() + 2d) / Math.log(2);
//            double weight = Math.sqrt(Math.max(timeEstimate, partialExecution.getMeasuredExecutionTime())) + 1;
            double weight = geneticOptimizer.calculateObservationBasedWeight(partialExecution);
//                    + geneticOptimizer.calculateRuntimeBasedWeight(partialExecution);

            // Calculate the partial fitness.
            double relativeDelta = this.calculateRelativeDelta(timeEstimate, partialExecution.getMeasuredExecutionTime());

            // Prepare mean calculation.
//            fitnessSum += weight / (partialFitness + harmonicSmoothing);
            fitnessSum += weight * (relativeDelta * relativeDelta);
            weightSum += weight;
        }
//        return (weightSum / fitnessSum) - harmonicSmoothing;
        return -Math.sqrt(fitnessSum) / weightSum;
    }

    private double calculateRelativeDelta(double timeEstimate, long actualTime) {
        // Get important values.
        final double smoothing = 1000d;
        final long meanEstimate = Math.round(timeEstimate);
        final long delta = Math.abs(meanEstimate - actualTime);
        return (delta + smoothing) / (actualTime + smoothing);
    }

    /**
     * Calculate the fitness as weighted arithmetic mean of the absolute prediction accuracies.
     */
    double calculateAbsoluteFitness(GeneticOptimizer geneticOptimizer) {
        double weightSum = 0d;
        double fitnessSum = 0d;
        for (PartialExecution partialExecution : geneticOptimizer.getData()) {
            double timeEstimate = this.estimateTime(
                    partialExecution,
                    geneticOptimizer.getPlatformOverheads(),
                    geneticOptimizer.getConfiguration()
            );
            double weight = geneticOptimizer.calculateObservationBasedWeight(partialExecution)
                    + 3 * geneticOptimizer.calculateRuntimeBasedWeight(partialExecution);
            double partialFitness = this.calculateAbsolutePartialFitness(timeEstimate, partialExecution.getMeasuredExecutionTime());
            weightSum += weight;
            fitnessSum += weight * -(partialFitness * partialFitness);
        }
        return fitnessSum / weightSum;
    }

    private double calculateAbsolutePartialFitness(double timeEstimate, long actualTime) {
        final long delta = Math.abs(Math.round(timeEstimate) - actualTime);
        return -delta;
    }

    double estimateTime(PartialExecution partialExecution,
                        Map<Platform, Variable> platformOverheads,
                        Configuration configuration) {
        final DoubleStream operatorEstimates = partialExecution.getAtomicExecutionGroups().stream()
                .map(atomicExecutionGroup -> this.estimateTime(atomicExecutionGroup, configuration))
                .mapToDouble(TimeEstimate::getGeometricMeanEstimate);
        final DoubleStream platformEstimates = partialExecution.getInitializedPlatforms().stream()
                .mapToDouble(p -> {
                    final Variable variable = platformOverheads.get(p);
                    return variable == null ? 0d : variable.getValue(this);
                });
        return DoubleStream.concat(operatorEstimates, platformEstimates).sum();
    }

    /**
     * Estimates the execution time for the given {@link AtomicExecutionGroup} with the genome of this instance.
     *
     * @param executionGroup the {@link AtomicExecutionGroup}
     * @param configuration  provides estimation context
     * @return the {@link TimeEstimate}
     */
    private TimeEstimate estimateTime(AtomicExecutionGroup executionGroup,
                                      Configuration configuration) {
        final EstimationContext estimationContext = executionGroup.getEstimationContext();
        return executionGroup.estimateExecutionTime(new DynamicEstimationContext(this, estimationContext));
    }
}
