// Copyright ©2017 The Gonum Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

package optimize

import (
	"errors"
	"math"
	"testing"

	"golang.org/x/exp/rand"

	"gonum.org/v1/gonum/floats"
	"gonum.org/v1/gonum/mat"
	"gonum.org/v1/gonum/optimize/functions"
)

type functionThresholdConverger struct {
	Threshold float64
}

func (functionThresholdConverger) Init(dim int) {}

func (f functionThresholdConverger) Converged(loc *Location) Status {
	if loc.F < f.Threshold {
		return FunctionThreshold
	}
	return NotTerminated
}

type cmaTestCase struct {
	dim      int
	problem  Problem
	method   *CmaEsChol
	initX    []float64
	settings *Settings
	good     func(result *Result, err error, concurrent int) error
}

func cmaTestCases() []cmaTestCase {
	localMinMean := []float64{2.2, -2.2}
	s := mat.NewSymDense(2, []float64{0.01, 0, 0, 0.01})
	var localMinChol mat.Cholesky
	localMinChol.Factorize(s)
	return []cmaTestCase{
		{
			// Test that can find a small value.
			dim: 10,
			problem: Problem{
				Func: functions.ExtendedRosenbrock{}.Func,
			},
			method: &CmaEsChol{
				StopLogDet: math.NaN(),
			},
			settings: &Settings{
				Converger: functionThresholdConverger{0.01},
			},
			good: func(result *Result, err error, concurrent int) error {
				if result.Status != FunctionThreshold {
					return errors.New("result not function threshold")
				}
				if result.F > 0.01 {
					return errors.New("result not sufficiently small")
				}
				return nil
			},
		},
		{
			// Test that can stop when the covariance gets small.
			// For this case, also test that it is really at a minimum.
			dim: 2,
			problem: Problem{
				Func: functions.ExtendedRosenbrock{}.Func,
			},
			method: &CmaEsChol{},
			settings: &Settings{
				Converger: NeverTerminate{},
			},
			good: func(result *Result, err error, concurrent int) error {
				if result.Status != MethodConverge {
					return errors.New("result not method converge")
				}
				if result.F > 1e-12 {
					return errors.New("minimum not found")
				}
				return nil
			},
		},
		{
			// Test that population works properly and it stops after a certain
			// number of iterations.
			dim: 3,
			problem: Problem{
				Func: functions.ExtendedRosenbrock{}.Func,
			},
			method: &CmaEsChol{
				Population: 100,
				ForgetBest: true, // Otherwise may get an update at the end.
			},
			settings: &Settings{
				MajorIterations: 10,
				Converger:       NeverTerminate{},
			},
			good: func(result *Result, err error, concurrent int) error {
				if result.Status != IterationLimit {
					return errors.New("result not iteration limit")
				}
				threshLower := 10
				threshUpper := 10
				if concurrent != 0 {
					// Could have one more from final update.
					threshUpper++
				}
				if result.MajorIterations < threshLower || result.MajorIterations > threshUpper {
					return errors.New("wrong number of iterations")
				}
				return nil
			},
		},
		{
			// Test that work stops with some number of function evaluations.
			dim: 5,
			problem: Problem{
				Func: functions.ExtendedRosenbrock{}.Func,
			},
			method: &CmaEsChol{
				Population: 100,
			},
			settings: &Settings{
				FuncEvaluations: 250, // Somewhere in the middle of an iteration.
				Converger:       NeverTerminate{},
			},
			good: func(result *Result, err error, concurrent int) error {
				if result.Status != FunctionEvaluationLimit {
					return errors.New("result not function evaluations")
				}
				threshLower := 250
				threshUpper := 251
				if concurrent != 0 {
					threshUpper = threshLower + concurrent
				}
				if result.FuncEvaluations < threshLower {
					return errors.New("too few function evaluations")
				}
				if result.FuncEvaluations > threshUpper {
					return errors.New("too many function evaluations")
				}
				return nil
			},
		},
		{
			// Test that the global minimum is found with the right initialization.
			dim: 2,
			problem: Problem{
				Func: functions.Rastrigin{}.Func,
			},
			method: &CmaEsChol{
				Population: 100, // Increase the population size to reduce noise.
			},
			settings: &Settings{
				Converger: NeverTerminate{},
			},
			good: func(result *Result, err error, concurrent int) error {
				if result.Status != MethodConverge {
					return errors.New("result not method converge")
				}
				if !floats.EqualApprox(result.X, []float64{0, 0}, 1e-6) {
					return errors.New("global minimum not found")
				}
				return nil
			},
		},
		{
			// Test that a local minimum is found (with a different initialization).
			dim: 2,
			problem: Problem{
				Func: functions.Rastrigin{}.Func,
			},
			initX: localMinMean,
			method: &CmaEsChol{
				Population:   100, // Increase the population size to reduce noise.
				InitCholesky: &localMinChol,
				ForgetBest:   true, // So that if it accidentally finds a better place we still converge to the minimum.
			},
			settings: &Settings{
				Converger: NeverTerminate{},
			},
			good: func(result *Result, err error, concurrent int) error {
				if result.Status != MethodConverge {
					return errors.New("result not method converge")
				}
				if !floats.EqualApprox(result.X, []float64{2, -2}, 3e-2) {
					return errors.New("local minimum not found")
				}
				return nil
			},
		},
	}
}

func TestCmaEsChol(t *testing.T) {
	t.Parallel()
	for i, test := range cmaTestCases() {
		src := rand.New(rand.NewSource(1))
		method := test.method
		method.Src = src
		initX := test.initX
		if initX == nil {
			initX = make([]float64, test.dim)
		}
		// Run and check that the expected termination occurs.
		result, err := Minimize(test.problem, initX, test.settings, method)
		if testErr := test.good(result, err, test.settings.Concurrent); testErr != nil {
			t.Errorf("cas %d: %v", i, testErr)
		}

		// Run a second time to make sure there are no residual effects
		result, err = Minimize(test.problem, initX, test.settings, method)
		if testErr := test.good(result, err, test.settings.Concurrent); testErr != nil {
			t.Errorf("cas %d second: %v", i, testErr)
		}

		// Test the problem in parallel.
		test.settings.Concurrent = 5
		result, err = Minimize(test.problem, initX, test.settings, method)
		if testErr := test.good(result, err, test.settings.Concurrent); testErr != nil {
			t.Errorf("cas %d concurrent: %v", i, testErr)
		}
		test.settings.Concurrent = 0
	}
}
