// Copyright ©2016 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 community

import (
	"errors"
	"fmt"
	"math"
	"math/rand/v2"

	"gonum.org/v1/gonum/graph"
)

// Interval is an interval of resolutions with a common score.
type Interval struct {
	// Low and High delimit the interval
	// such that the interval is [low, high).
	Low, High float64

	// Score is the score of the interval.
	Score float64

	// Reduced is the best scoring
	// community membership found for the
	// interval.
	Reduced
}

// Reduced is a graph reduction.
type Reduced interface {
	// Communities returns the community
	// structure of the reduction.
	Communities() [][]graph.Node
}

// Size is a score function that is the reciprocal of the number of communities.
func Size(g ReducedGraph) float64 { return 1 / float64(len(g.Structure())) }

// Weight is a score function that is the sum of community weights. The concrete
// type of g must be a pointer to a ReducedUndirected or a ReducedDirected, otherwise
// Weight will panic.
func Weight(g ReducedGraph) float64 {
	var w float64
	switch g := g.(type) {
	case *ReducedUndirected:
		for _, n := range g.nodes {
			w += n.weight
		}
	case *ReducedDirected:
		for _, n := range g.nodes {
			w += n.weight
		}
	default:
		panic(fmt.Sprintf("community: invalid graph type: %T", g))
	}
	return w
}

// ModularScore returns a modularized scoring function for Profile based on the
// graph g and the given score function. The effort parameter determines how
// many attempts will be made to get an improved score for any given resolution.
func ModularScore(g graph.Graph, score func(ReducedGraph) float64, effort int, src rand.Source) func(float64) (float64, Reduced) {
	return func(resolution float64) (float64, Reduced) {
		max := math.Inf(-1)
		var best Reduced
		for i := 0; i < effort; i++ {
			r := Modularize(g, resolution, src)
			s := score(r)
			if s > max {
				max = s
				best = r
			}
		}
		return max, best
	}
}

// SizeMultiplex is a score function that is the reciprocal of the number of communities.
func SizeMultiplex(g ReducedMultiplex) float64 { return 1 / float64(len(g.Structure())) }

// WeightMultiplex is a score function that is the sum of community weights. The concrete
// type of g must be pointer to a ReducedUndirectedMultiplex or a ReducedDirectedMultiplex,
// otherwise WeightMultiplex will panic.
func WeightMultiplex(g ReducedMultiplex) float64 {
	var w float64
	switch g := g.(type) {
	case *ReducedUndirectedMultiplex:
		for _, n := range g.nodes {
			for _, lw := range n.weights {
				w += lw
			}
		}
	case *ReducedDirectedMultiplex:
		for _, n := range g.nodes {
			for _, lw := range n.weights {
				w += lw
			}
		}
	default:
		panic(fmt.Sprintf("community: invalid graph type: %T", g))
	}
	return w
}

// ModularMultiplexScore returns a modularized scoring function for Profile based
// on the graph g and the given score function. The effort parameter determines how
// many attempts will be made to get an improved score for any given resolution.
func ModularMultiplexScore(g Multiplex, weights []float64, all bool, score func(ReducedMultiplex) float64, effort int, src rand.Source) func(float64) (float64, Reduced) {
	return func(resolution float64) (float64, Reduced) {
		max := math.Inf(-1)
		var best Reduced
		for i := 0; i < effort; i++ {
			r := ModularizeMultiplex(g, weights, []float64{resolution}, all, src)
			s := score(r)
			if s > max {
				max = s
				best = r
			}
		}
		return max, best
	}
}

// Profile returns an approximate profile of score values in the resolution domain [low,high)
// at the given granularity. The score is calculated by bisecting calls to fn. If log is true,
// log space bisection is used, otherwise bisection is linear. The function fn should be
// monotonically decreasing in at least 1/grain evaluations. Profile will attempt to detect
// non-monotonicity during the bisection.
//
// Since exact modularity optimization is known to be NP-hard and Profile calls modularization
// routines repeatedly, it is unlikely to return the exact resolution profile.
func Profile(fn func(float64) (float64, Reduced), log bool, grain, low, high float64) (profile []Interval, err error) {
	if low >= high {
		return nil, errors.New("community: zero or negative width domain")
	}

	defer func() {
		r := recover()
		e, ok := r.(nonDecreasing)
		if ok {
			err = e
			return
		}
		if r != nil {
			panic(r)
		}
	}()
	left, comm := fn(low)
	right, _ := fn(high)
	for i := 1; i < int(1/grain); i++ {
		rt, _ := fn(high)
		right = math.Max(right, rt)
	}
	profile = bisect(fn, log, grain, low, left, high, right, comm)

	// We may have missed some non-monotonicity,
	// so merge low score discordant domains into
	// their lower resolution neighbours.
	return fixUp(profile), nil
}

type nonDecreasing int

func (n nonDecreasing) Error() string {
	return fmt.Sprintf("community: profile does not reliably monotonically decrease: tried %d times", n)
}

func bisect(fn func(float64) (float64, Reduced), log bool, grain, low, scoreLow, high, scoreHigh float64, comm Reduced) []Interval {
	if low >= high {
		panic("community: zero or negative width domain")
	}
	if math.IsNaN(scoreLow) || math.IsNaN(scoreHigh) {
		return nil
	}

	// Heuristically determine a reasonable number
	// of times to try to get a higher value.
	maxIter := int(1 / grain)

	lowComm := comm
	for n := 0; scoreLow < scoreHigh; n++ {
		if n > maxIter {
			panic(nonDecreasing(n))
		}
		scoreLow, lowComm = fn(low)
	}

	if scoreLow == scoreHigh || tooSmall(low, high, grain, log) {
		return []Interval{{Low: low, High: high, Score: scoreLow, Reduced: lowComm}}
	}

	var mid float64
	if log {
		mid = math.Sqrt(low * high)
	} else {
		mid = (low + high) / 2
	}

	scoreMid := math.Inf(-1)
	var midComm Reduced
	for n := 0; scoreMid < scoreHigh; n++ {
		if n > maxIter {
			panic(nonDecreasing(n))
		}
		scoreMid, midComm = fn(mid)
	}

	lower := bisect(fn, log, grain, low, scoreLow, mid, scoreMid, lowComm)
	higher := bisect(fn, log, grain, mid, scoreMid, high, scoreHigh, midComm)
	for n := 0; lower[len(lower)-1].Score < higher[0].Score; n++ {
		if n > maxIter {
			panic(nonDecreasing(n))
		}
		lower[len(lower)-1].Score, lower[len(lower)-1].Reduced = fn(low)
	}

	if lower[len(lower)-1].Score == higher[0].Score {
		higher[0].Low = lower[len(lower)-1].Low
		lower = lower[:len(lower)-1]
		if len(lower) == 0 {
			return higher
		}
	}
	return append(lower, higher...)
}

// fixUp non-monotonically decreasing interval scores.
func fixUp(profile []Interval) []Interval {
	max := profile[len(profile)-1].Score
	for i := len(profile) - 2; i >= 0; i-- {
		if profile[i].Score > max {
			max = profile[i].Score
			continue
		}
		profile[i+1].Low = profile[i].Low
		profile = append(profile[:i], profile[i+1:]...)
	}
	return profile
}

func tooSmall(low, high, grain float64, log bool) bool {
	if log {
		return math.Log(high/low) < grain
	}
	return high-low < grain
}
