// Copyright 2023 wanderer <a_mirre at utb dot cz>
// SPDX-License-Identifier: GPL-3.0-or-later

package de

import (
	"log"
	"os"
	"sort"
	"time"

	"golang.org/x/exp/rand"

	"git.dotya.ml/wanderer/math-optim/bench"
	"git.dotya.ml/wanderer/math-optim/stats"
	"gonum.org/v1/gonum/stat/distuv"
)

// JDE is a holder for the settings of an instance of a self-adapting
// differential evolution (jDE) algorithm.
type JDE struct {
	// Generations denotes the number of generations the population evolves
	// for. Special value -1 disables limiting the number of generations.
	Generations int
	// BenchMinIters is the number of iterations the bench function will be re-run (for statistical purposes).
	BenchMinIters int
	// Dimensions to solve the problem for.
	Dimensions []int
	// F is the differential weight (mutation/weighting factor).
	F float64
	// CR is the crossover probability constant.
	CR float64
	// MutationStrategy selects the mutation strategy, i.e. the variant of the
	// jDE algorithm (0..17), see mutationStrategies.go for more details.
	MutationStrategy int
	// AdptScheme is the parameter self-adaptation scheme (0..1).
	AdptScheme int
	// NP is the initial population size.
	NP int
	// BenchName is a name of the problem to optimise.
	BenchName string
	// ch is a channel for writing back computed results.
	ch chan []stats.Stats
	// chAlgoMeans is a channel for writing back algo means.
	chAlgoMeans chan *stats.AlgoBenchMean
	// initialised denotes the initialisation state of the struct.
	initialised bool
}

const (
	// jDEMinNP is the minimum size of the initial population for jDE.
	jDEMinNP = 4
	// fMin is the minimum allowed value of the differential weight.
	fMin = 0.5
	// fMax is the maximum allowed value of the differential weight.
	fMax = 2.0
	// crMin is the minimum allowed value of the crossover probability constant.
	crMin = 0.2
	// crMax is the maximum allowed value of the crossover probability constant.
	crMax = 0.9
)

// jDELogger declares and initialises a "custom" jDE logger.
var jDELogger = log.New(os.Stderr, " *** δ jDE:", log.Ldate|log.Ltime|log.Lshortfile)

// Init initialises the jDE algorithm, performs sanity checks on the inputs.
func (j *JDE) Init(generations, benchMinIters, mutStrategy, adptScheme, np int, f, cr float64, dimensions []int, bench string, ch chan []stats.Stats, chAlgoMeans chan *stats.AlgoBenchMean) {
	if j == nil {
		jDELogger.Fatalln("jDE needs to be initialised before calling RunjDE, exiting...")
	}

	// check input parameters.
	switch {
	case generations == 0:
		jDELogger.Fatalln("Generations cannot be 0, got", generations)

	case generations == -1:
		jDELogger.Println("Generations is '-1', disabling generation limits..")

	case benchMinIters < 1:
		jDELogger.Fatalln("Minimum bench iterations cannot be less than 1, got:", benchMinIters)

	case mutStrategy < 0 || mutStrategy > 17:
		jDELogger.Fatalln("Mutation strategy needs to be from the interval <0; 17>, got", mutStrategy)

	case adptScheme < 0 || adptScheme > 1:
		jDELogger.Fatalln("Parameter self-adaptation scheme needs to be from the interval <0; 1>, got", adptScheme)

	case np < jDEMinNP:
		jDELogger.Fatalf("NP cannot be less than %d, got: %d\n.", jDEMinNP, np)

	case f < fMin || f > fMax:
		jDELogger.Fatalf("F needs to be from the interval <%f;%f>, got: %f\n.", fMin, fMax, f)

	case cr < crMin || cr > crMax:
		jDELogger.Fatalf("CR needs to be from the interval <%f;>%f, got: %f\n.", crMin, crMax, cr)

	case len(dimensions) == 0:
		jDELogger.Fatalf("Dimensions cannot be empty, got: %+v\n", dimensions)

	case bench == "":
		jDELogger.Fatalln("Bench cannot be empty, got:", bench)
	}

	j.Generations = generations
	j.BenchMinIters = benchMinIters
	j.MutationStrategy = mutStrategy
	j.AdptScheme = adptScheme
	j.NP = np
	j.F = f
	j.CR = cr
	j.Dimensions = dimensions
	j.BenchName = bench
	j.ch = ch
	j.chAlgoMeans = chAlgoMeans

	j.initialised = true
}

// InitAndRun initialises the jDE algorithm, performs sanity checks on the
// inputs and calls the Run method.
func (j *JDE) InitAndRun(generations, benchMinIters, mutStrategy, adptScheme, np int, f, cr float64, dimensions []int, bench string, ch chan []stats.Stats, chAlgoMeans chan *stats.AlgoBenchMean) {
	if j == nil {
		jDELogger.Fatalln("jDE is nil, NewjDE() needs to be called first. exiting...")
	}

	j.Init(generations, benchMinIters, mutStrategy, adptScheme, np, f, cr, dimensions, bench, ch, chAlgoMeans)

	j.Run()
}

// Run self-adapting differential evolution algorithm.
func (j *JDE) Run() {
	if j == nil {
		jDELogger.Fatalln("jDE is nil, NewjDE() needs to be called first. exiting...")
	}

	if !j.initialised {
		jDELogger.Fatalln("jDE needs to be initialised before calling Run(), exiting...")
	}

	var jDEStats []stats.Stats

	jDEMeans := &stats.AlgoBenchMean{
		Algo:       "jDE",
		BenchMeans: make([]stats.BenchMean, 0, len(j.Dimensions)),
	}

	// run evolve for for all dimensions.
	for _, dim := range j.Dimensions {
		maxFES := bench.GetGAMaxFES(dim)
		fesPerIter := int(float64(maxFES / j.NP))
		jDEStatDimX := &stats.Stats{
			Algo:        "jDE",
			Dimens:      dim,
			Iterations:  j.BenchMinIters,
			Generations: maxFES,
			NP:          j.NP,
			F:           j.F,
			CR:          j.CR,
		}
		funcStats := &stats.FuncStats{BenchName: j.BenchName}
		dimXMean := &stats.BenchMean{
			Bench:       j.BenchName,
			Dimens:      dim,
			Iterations:  j.BenchMinIters,
			Generations: maxFES,
			Neighbours:  -1,
		}
		benchFuncParams := bench.FunctionParams[j.BenchName]
		uniDist := distuv.Uniform{
			Min: benchFuncParams.Min(),
			Max: benchFuncParams.Max(),
		}

		// jDELogger.Printf("running bench \"%s\" for %dD, maxFES: %d\n",
		// j.BenchName, dim, maxFES,
		// )

		funcStats.BenchResults = make([]stats.BenchRound, j.BenchMinIters)

		// rand.Seed(uint64(time.Now().UnixNano()))
		// create and seed a source of preudo-randomness
		src := rand.NewSource(uint64(rand.Int63()))

		// track execution duration.
		start := time.Now()

		for iter := 0; iter < j.BenchMinIters; iter++ {
			jDELogger.Printf("run: %d, bench: %s, %dD, started at %s",
				iter, j.BenchName, dim, start,
			)

			funcStats.BenchResults[iter].Iteration = iter

			uniDist.Src = src

			// create a population with known params.
			pop := newPopulation(j.BenchName, j.NP, dim)

			// set population seed.
			pop.Seed = uint64(time.Now().UnixNano())
			// initialise the population.
			pop.Init()
			// jDELogger.Printf("%+v\n", pop.Population)

			var bestResult float64

			// the core.
			for i := 0; i < fesPerIter; i++ {
				r := j.evaluate(pop)

				// distinguish the first or any of the subsequent iterations.
				switch i {
				case 0:
					bestResult = r

				default:
					// call evolve where jDE runs.
					j.evolve(pop, &uniDist)

					// take measurements of current population fitness.
					r = j.evaluate(pop)

					// save if better.
					if r < bestResult {
						bestResult = r
					}
				}

				funcStats.BenchResults[iter].Results = append(
					funcStats.BenchResults[iter].Results,
					bestResult,
				)
				// this block makes sure we properly count func evaluations for
				// the purpose of correctly comparable plot comparison. i.e.
				// append the winning (current best) value NP-1 (the first best
				// is already saved at this point) times to represent the fact
				// that while evaluating (and comparing) other population
				// individuals to the current best value is taking place in the
				// background, the current best value itself is kept around and
				// symbolically saved as the best of the Generation.
				for x := 0; x < j.NP-1; x++ {
					funcStats.BenchResults[iter].Results = append(
						funcStats.BenchResults[iter].Results,
						bestResult,
					)
				}
			}
		}

		elapsed := time.Since(start)

		jDELogger.Printf("completed: bench: %s, %dD, computing took %s\n",
			j.BenchName, dim, elapsed,
		)

		// get mean vals.
		dimXMean.MeanVals = stats.GetMeanVals(funcStats.BenchResults, maxFES)
		funcStats.MeanVals = dimXMean.MeanVals

		jDEStatDimX.BenchFuncStats = append(
			jDEStatDimX.BenchFuncStats, *funcStats,
		)

		jDEStats = append(jDEStats, *jDEStatDimX)

		jDEMeans.BenchMeans = append(jDEMeans.BenchMeans, *dimXMean)
	}

	sort.Sort(jDEMeans)

	j.chAlgoMeans <- jDEMeans
	j.ch <- jDEStats
}

// evaluate evaluates the fitness of current population.
func (j *JDE) evaluate(pop *Population) float64 {
	f := bench.Functions[pop.Problem]

	bestIndividual := pop.Population[pop.GetBestIdx()].CurX
	bestSolution := f(bestIndividual)

	return bestSolution
}

// evolve evolves a population by running the jDE (self-adapting Differential
// Evolution) algorithm on the passed population until termination conditions
// are met.
// nolint: gocognit
func (j *JDE) evolve(pop *Population, uniDist *distuv.Uniform) {
	popCount := len(pop.Population)

	for i, currentIndividual := range pop.Population {
		idcs := make([]int, 0, popCount-1)

		// gather indices.
		for k := 0; k < popCount; k++ {
			if k != i {
				idcs = append(idcs, k)
			}
		}

		// randomly choose 3 of those idcs.
		selectedIdcs := make([]int, 0)

		selectedA := false
		selectedB := false
		selectedC := false

		for !selectedA {
			candidateA := rand.Intn(len(idcs)) % len(idcs)

			if candidateA != i {
				selectedIdcs = append(selectedIdcs, candidateA)
				selectedA = true
			}
		}

		for !selectedB {
			a := selectedIdcs[0]
			candidateB := rand.Intn(len(idcs)) % len(idcs)

			if candidateB != i && candidateB != a {
				selectedIdcs = append(selectedIdcs, candidateB)
				selectedB = true
			}
		}

		for !selectedC {
			a := selectedIdcs[0]
			b := selectedIdcs[1]
			candidateC := rand.Intn(len(idcs)) % len(idcs)

			if candidateC != i && candidateC != a && candidateC != b {
				selectedIdcs = append(selectedIdcs, candidateC)
				selectedC = true
			}
		}

		// selected contains the selected population individuals.
		selected := make([]PopulationIndividual, 0)

		// select individuals for rand/1/bin
		for _, idx := range selectedIdcs {
			for k := 0; k < popCount; k++ {
				if k == idx {
					selected = append(selected, pop.Population[idx])
				}
			}
		}

		mutant := make([]float64, pop.Dimen)

		// mutate.
		for k := 0; k < pop.Dimen; k++ {
			// mutant = a + mut * (b - c)
			mutant[k] = selected[0].CurX[k] + (j.F * (selected[1].CurX[k] - selected[2].CurX[k]))

			// clip values to <0;1>.
			switch {
			case mutant[k] < 0:
				mutant[k] = 0

			case mutant[k] > 1:
				mutant[k] = 1
			}
		}

		crossPoints := make([]bool, pop.Dimen)

		// prepare crossover points (binomial crossover).
		for k := 0; k < pop.Dimen; k++ {
			if v := uniDist.Rand(); v < j.CR {
				crossPoints[k] = true
			} else {
				crossPoints[k] = false
			}
		}

		trial := make([]float64, pop.Dimen)

		// recombine using crossover points.
		for k := 0; k < pop.Dimen; k++ {
			if crossPoints[k] {
				trial[k] = currentIndividual.CurX[k]
			}
		}

		f := bench.Functions[pop.Problem]
		currentFitness := f(currentIndividual.CurX)
		trialFitness := f(trial)

		// replace if better.
		if trialFitness < currentFitness {
			pop.Population[i].CurX = trial
		}
	}
}

// NewjDE returns a pointer to a new, uninitialised jDE instance.
func NewjDE() *JDE {
	return &JDE{}
}

// LogPrintln wraps the jDE logger's Println func.
func LogPrintln(v ...any) {
	jDELogger.Println(v...)
}

// LogPrintf wraps the jDE logger's Printf func.
func LogPrintf(s string, v ...any) {
	jDELogger.Printf(s, v...)
}

// LogFatalln wraps the jDE logger's Fatalln func.
func LogFatalln(s string) {
	jDELogger.Fatalln(s)
}

// LogFatalf wraps the jDE logger's Fatalf func.
func LogFatalf(s string, v ...any) {
	jDELogger.Fatalf(s, v...)
}
