// Package scheduler implements a simple scheduler that triggers the next item when its due time is
// reached based on the list of upcoming items.
package scheduler

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/kopia/kopia/internal/clock"
	"github.com/kopia/kopia/internal/sleepable"
	"github.com/kopia/kopia/repo/logging"
)

var log = logging.Module("scheduler")

// GetItemsFunc is a callback that returns items for the scheduler to consider.
type GetItemsFunc func(ctx context.Context, now time.Time) []Item

// Item describes an item that can be scheduled with a function that is invoked the next time the item is due.
type Item struct {
	Description string
	NextTime    time.Time
	Trigger     func()
}

// Scheduler manages triggering of arbitrary events by periodically determining the first
// of a set of upcoming events and waiting until it's due and invoking the trigger function.
type Scheduler struct {
	TimeNow func() time.Time
	Debug   bool

	refreshRequested chan string
	getItems         GetItemsFunc
	closed           chan struct{}
	wg               sync.WaitGroup
}

// Options the scheduler.
type Options struct {
	TimeNow        func() time.Time
	Debug          bool
	RefreshChannel chan string
}

// Start runs a new scheduler that will call getItems() to get the list of items to schedule.
func Start(ctx context.Context, getItems GetItemsFunc, opts Options) *Scheduler {
	timeNow := opts.TimeNow

	if timeNow == nil {
		timeNow = clock.Now
	}

	s := &Scheduler{
		TimeNow:          timeNow,
		refreshRequested: opts.RefreshChannel,
		closed:           make(chan struct{}),
		getItems:         getItems,
		Debug:            opts.Debug,
	}

	s.wg.Go(func() {
		s.run(context.WithoutCancel(ctx))
	})

	return s
}

const sleepTimeWhenNoUpcomingSnapshots = 8 * time.Hour

func (s *Scheduler) upcomingItems(ctx context.Context, now time.Time) (nextTriggerTime time.Time, toTrigger []Item) {
	allsm := s.getItems(ctx, now)

	for _, t := range allsm {
		if nextTriggerTime.IsZero() || t.NextTime.Before(nextTriggerTime) {
			nextTriggerTime = t.NextTime
			toTrigger = nil
		}

		if t.NextTime.Equal(nextTriggerTime) {
			toTrigger = append(toTrigger, t)
		}
	}

	if s.Debug {
		log(ctx).Debugf(">>>> scheduling %v items (now %v)", len(allsm), now.Format(time.RFC3339))

		for _, it := range allsm {
			log(ctx).Debugf("  %v %v", it.NextTime.Format(time.RFC3339), it.Description)
		}
	}

	return nextTriggerTime, toTrigger
}

// Stop stops the scheduler.
func (s *Scheduler) Stop() {
	close(s.closed)
	s.wg.Wait()
}

func (s *Scheduler) run(ctx context.Context) {
	var timer *sleepable.Timer

	for {
		now := s.TimeNow()
		nextTriggerTime, toTrigger := s.upcomingItems(ctx, now)

		targetTime := nextTriggerTime
		if targetTime.IsZero() {
			targetTime = now.Add(sleepTimeWhenNoUpcomingSnapshots)
		}

		if s.Debug {
			log(ctx).Debugf("sleeping until %v (%v)",
				targetTime.Format(time.RFC3339),
				TriggerNames(toTrigger),
			)
		}

		// stop previous timer, if any
		if timer != nil {
			timer.Stop()
		}

		timer = sleepable.NewTimer(s.TimeNow, targetTime)

		select {
		case <-s.closed:
			// stopping, just exit
			timer.Stop()

			return

		case <-timer.C:
			for _, sm := range toTrigger {
				log(ctx).Debugf("triggering %v", sm.Description)

				sm.Trigger()
			}

		case reason := <-s.refreshRequested:
			if s.Debug {
				log(ctx).Debugw("schedule re-evaluation requested", "reason", reason)
			}
		}
	}
}

// TriggerNames returns a human-readable description of the items that are about to be triggered.
func TriggerNames(toTrigger []Item) string {
	var result []string

	for _, t := range toTrigger {
		result = append(result, t.Description)
	}

	if len(result) == 0 {
		return "no triggers"
	}

	if len(result) == 1 {
		return result[0]
	}

	s := fmt.Sprintf("%v triggers: ", len(result))

	var suffix string

	const maxItems = 5

	if len(result) > maxItems {
		suffix = " [...]"
		result = result[:maxItems]
	}

	return s + strings.Join(result, ", ") + suffix
}
