package fleeting

import (
	"context"
	"errors"
	"fmt"
	"sync"
	"time"

	"github.com/hashicorp/go-hclog"

	"gitlab.com/gitlab-org/fleeting/fleeting/metrics"
	"gitlab.com/gitlab-org/fleeting/fleeting/provider"
)

type Provisioner struct {
	log   hclog.Logger
	group provider.InstanceGroup
	opts  options
	info  provider.ProviderInfo

	mu        sync.Mutex
	request   []request            // requests for new instances
	pending   []request            // inflight requests
	updated   []Instance           // instances updated in the last cycle
	instances map[string]*instance // current instances, reconciled with instance group

	closed   chan struct{}
	shutdown func()

	mc metrics.Collector
}

var (
	ErrMaxSizeExceedsInstanceGroupMax = errors.New("max size option exceeds instance group's max size")
)

type request struct {
	requestedAt time.Time
}

func Init(ctx context.Context, log hclog.Logger, group provider.InstanceGroup, opts ...Option) (*Provisioner, error) {
	if log == nil {
		log = hclog.Default()
	}

	a := &Provisioner{
		log:       log,
		group:     group,
		closed:    make(chan struct{}),
		instances: make(map[string]*instance),
	}

	err := loadOptions(&a.opts, opts)
	if err != nil {
		return nil, fmt.Errorf("option: %w", err)
	}

	if err := validateConnectorConfig(&a.opts.settings.ConnectorConfig); err != nil {
		return nil, err
	}

	a.info, err = group.Init(ctx, log, a.opts.settings)
	if err != nil {
		return nil, fmt.Errorf("instance group init: %w", err)
	}

	log.Info("plugin initialized", "version", a.info.Version, "build info", a.info.BuildInfo)

	if a.opts.maxSize > a.info.MaxSize {
		return nil, fmt.Errorf("%w: %d > %d", ErrMaxSizeExceedsInstanceGroupMax, a.opts.maxSize, a.info.MaxSize)
	}

	if a.opts.maxSize > 0 {
		a.info.MaxSize = a.opts.maxSize
	}

	a.log = a.log.With("group", a.info.ID)

	a.mc = metrics.Init(a.opts.metricsCollector, a.info.MaxSize)

	// attempt to reconcile
	for i := 0; i < 5; i++ {
		err = a.reconcile(ctx, true)
		if err == nil {
			break
		}
		time.Sleep(a.opts.updateIntervalWhenExpecting)
	}
	if err != nil {
		return nil, fmt.Errorf("reconciling with instance group: %w", err)
	}
	if ctx.Err() != nil {
		return nil, ctx.Err()
	}

	ctx, cancel := context.WithCancel(context.Background())
	a.shutdown = cancel

	go func() {
		defer cancel()

		for {
			start := time.Now()

			a.removal(ctx)
			err := a.reconcile(ctx, false)
			// only provision if we're up-to-date
			if err == nil {
				a.provision(ctx)
			} else {
				log.Error("reconcile", "err", err)
			}

			a.onMC(func(mc metrics.Collector) {
				c := a.Capacity()

				mc.MaxInstancesSet(c.Max)
				mc.InstancesCountSet(metrics.InstanceCountRequested, c.Requested-c.Pending)
				mc.InstancesCountSet(metrics.InstanceCountPending, c.Pending)
				mc.InstancesCountSet(metrics.InstanceCountCreating, c.Creating)
				mc.InstancesCountSet(metrics.InstanceCountRunning, c.Running)
				mc.InstancesCountSet(metrics.InstanceCountDeleting, c.Deleting)
			})

			if ctx.Err() != nil {
				return
			}

			// wait until we can update next
			a.wait(start)
		}
	}()

	return a, nil
}

// Shutdown shuts down the provisioner.
//
// This function blocks until either all instances marked for deletion enter
// their delation state, the context is canceled or ShutdownDeletionRetries is
// exceeded.
func (a *Provisioner) Shutdown(ctx context.Context) {
	select {
	case <-a.closed:
		return
	default:
		close(a.closed)
	}

	a.log.Info("Received shutdown signal; stopping Provisioner")
	a.shutdown()

	// request deletions
	for i := 0; i < a.opts.shutdownDeletionRetries && a.removal(ctx) > 0 && ctx.Err() == nil; i++ {
		time.Sleep(a.opts.shutdownDeletionInterval)

		a.log.With("loop", i).Info("Running provisioner's shutdown reconcile")
		a.reconcile(ctx, false)
	}

	logger := a.log
	if err := a.group.Shutdown(ctx); err != nil {
		logger = logger.With("err", err)
	}

	logger.Info("provisioner shutdown completed")
}

// Request requests new instances to be created. Multiple calls to Request will
// be coalesced and if the Instance Group supports it, will be performed in a
// single request.
//
// Any requests that exceed Instance Group capacity will be queued.
func (a *Provisioner) Request(n int) {
	select {
	case <-a.closed:
		return
	default:
	}

	req := make([]request, 0, n)
	for i := 0; i < n; i++ {
		req = append(req, request{requestedAt: time.Now()})
	}

	a.mu.Lock()
	defer a.mu.Unlock()

	a.request = append(a.request, req...)
}

// Instances returns active (creating, running, deleting) instances the
// provisioner is aware of.
//
// It does not return pending requests for a new instance or pending requests
// for deletions of instances. Use Capacity() to get inflight request data.
//
// The information returned can be out-of-date if an Instance Group update
// cannot or has not yet updated.
func (a *Provisioner) Instances() []Instance {
	a.mu.Lock()
	defer a.mu.Unlock()

	instances := make([]Instance, 0, len(a.instances))
	for _, inst := range a.instances {
		instances = append(instances, inst)
	}

	return instances
}

type Capacity struct {
	Creating  int // number of instances being created
	Running   int // number of instances running
	Deleting  int // number of instances being deleted
	Requested int // number of instances that will or have been requested
	Pending   int // number of instances that have been requested but are pending response
	Max       int // maximum number of instances allowed by the instance group
}

// Capacity returns the current count of instances, those requested and
// max capacity.
func (a *Provisioner) Capacity() Capacity {
	a.mu.Lock()
	defer a.mu.Unlock()

	c := Capacity{
		Requested: len(a.request) + len(a.pending),
		Pending:   len(a.pending),
		Max:       a.info.MaxSize,
	}

	for _, inst := range a.instances {
		if !inst.deletedAt.IsZero() {
			c.Deleting++
			continue
		}

		switch inst.state {
		case provider.StateCreating:
			c.Creating++
		case provider.StateRunning:
			c.Running++
		case provider.StateDeleting:
			c.Deleting++
		}
	}

	return c
}

func (a *Provisioner) reconcile(ctx context.Context, init bool) error {
	a.log.With("init", init).Debug("running reconcile()")

	a.onMC(func(mc metrics.Collector) {
		mc.InternalOperationInc(metrics.InternalOperationReconcile)
	})

	updateStart := time.Now()

	err := a.group.Update(ctx, func(id string, state provider.State) {
		a.mu.Lock()
		defer a.mu.Unlock()

		inst, ok := a.instances[id]
		if !ok {
			switch state {
			// never add newly added deleted/timeout instance
			case provider.StateDeleted, provider.StateTimeout:
				return
			}

			a.instances[id] = &instance{
				id:            id,
				provisioner:   a,
				state:         state,
				ready:         make(chan struct{}),
				provisionedAt: time.Now(),
				updatedAt:     time.Now(),
			}
			inst = a.instances[id]
			a.update(inst)

			switch {
			case init:
				inst.cause = CausePreexisted
			default:
				if len(a.pending) == 0 {
					inst.cause = CauseUnexpected
				} else {
					inst.cause = CauseRequested
				}
			}

			a.log.Info("instance discovery", "id", id, "state", state, "cause", inst.Cause())

			if len(a.pending) > 0 {
				inst.requestedAt = a.pending[0].requestedAt
				a.pending = a.pending[1:]
			}

			a.onMC(func(mc metrics.Collector) {
				mc.InstanceOperationInc(metrics.InstanceOperationCreate)
				mc.InstanceCreationTimeObserve(inst.provisionedAt.Sub(inst.requestedAt))
			})

			if state == provider.StateRunning {
				inst.signalReady()

				a.onMC(func(mc metrics.Collector) {
					mc.InstanceIsRunningTimeObserve(inst.provisionedAt.Sub(inst.requestedAt))
				})
			}

			return
		}

		if state != inst.state {
			a.log.Info("instance update", "id", id, "state", state)
			if state == provider.StateRunning {
				inst.signalReady()

				a.onMC(func(mc metrics.Collector) {
					mc.InstanceIsRunningTimeObserve(inst.provisionedAt.Sub(inst.requestedAt))
				})
			}
			a.update(inst)
		}
		inst.state = state
		inst.updatedAt = time.Now()
		inst.missedUpdates = 0
	})

	if err != nil {
		return err
	}

	a.prune(updateStart)
	a.notify()

	return nil
}

func (a *Provisioner) removal(ctx context.Context) int {
	a.log.Debug("running removal()")

	a.onMC(func(mc metrics.Collector) {
		mc.InternalOperationInc(metrics.InternalOperationRemoval)
	})

	var deletion []string

	// only instances that are "active" (in a.instances) are continued to be
	// scheduled for deletion. This prevents us from continuously trying to
	// remove an instance that has been pruned.
	a.mu.Lock()
	for _, inst := range a.instances {
		switch inst.state {
		case provider.StateDeleting, provider.StateDeleted, provider.StateTimeout:
			continue
		}

		// retry deletions if request appears to have had no affect.
		if inst.removing && time.Since(inst.deletedAt) > a.opts.deletionRetryInterval {
			deletion = append(deletion, inst.ID())
		}
	}
	a.mu.Unlock()

	if len(deletion) > 0 {
		a.log.With("amount", len(deletion)).Info("decreasing instances")

		succeeded, err := a.group.Decrease(ctx, deletion)
		if err != nil {
			a.log.Error("decreasing instances", "err", err)
		}

		a.mu.Lock()
		for _, id := range succeeded {
			a.instances[id].deletedAt = time.Now()
		}
		a.mu.Unlock()
	}

	return len(deletion)
}

func (a *Provisioner) provision(ctx context.Context) {
	a.log.Debug("running provision()")

	a.onMC(func(mc metrics.Collector) {
		mc.InternalOperationInc(metrics.InternalOperationProvision)
	})

	var demand int

	a.mu.Lock()
	{
		demand = len(a.request)
		capacity := a.info.MaxSize - len(a.pending) - len(a.instances)
		if demand > capacity {
			demand = capacity
		}
	}
	a.mu.Unlock()

	if demand <= 0 {
		return
	}

	a.log.With("amount", demand).Info("increasing instances")

	added, err := a.group.Increase(ctx, demand)

	increaseLog := a.log.With("num_requested", demand, "num_successful", added)
	increaseLog.Info("increasing instances response")

	if err != nil {
		increaseLog.Error("increasing instances failure", "err", err)
	}
	if added > demand {
		increaseLog.Error("provider increased instances beyond those requested")
	}

	if added <= 0 {
		return
	}

	a.mu.Lock()
	defer a.mu.Unlock()

	a.pending = append(a.pending, a.request[:added]...)
	a.request = a.request[added:]

	a.log.Info("increase update", "pending", added, "total_pending", len(a.pending), "requesting", len(a.request))
}

func (a *Provisioner) prune(updateStart time.Time) {
	a.mu.Lock()
	defer a.mu.Unlock()

	timeNow := time.Now()

	for id := range a.instances {
		inst := a.instances[id]

		switch inst.state {
		// if a provider sets an instance to deleted
		// or timeout, it's removed from the list
		case provider.StateDeleted, provider.StateTimeout:
			lifetime := timeNow.Sub(inst.provisionedAt)

			a.log.Info("instance pruned", "id", id, "lifetime", lifetime)

			delete(a.instances, id)

			a.onMC(func(mc metrics.Collector) {
				mc.InstanceOperationInc(metrics.InstanceOperationDelete)
				mc.InstanceDeletionTimeObserve(timeNow.Sub(inst.deletedAt))
				mc.InstanceLifeDurationObserve(lifetime)
			})

			continue
		}

		// We detect if an instance has "disappeared" from the latest update we received
		// by checking if its updatedAt time is older than the last update we had.
		//
		// If the instance's state was already "deleting" or we'd requested it to be removed,
		// we move this to the "deleted" state and assume it was successfully removed.
		//
		// If the instance's state was anything else, we increment a "missed updated"
		// counter, and move the state to "timeout" after it's been missed 5 times
		// and remove the instance from the instances list.
		if inst.updatedAt.Before(updateStart) {
			if inst.state == provider.StateDeleting || inst.removing {
				lifetime := timeNow.Sub(inst.provisionedAt)

				a.log.Info("instance pruned", "id", id, "lifetime", lifetime)

				inst.state = provider.StateDeleted
				inst.updatedAt = time.Now()

				delete(a.instances, id)

				a.onMC(func(mc metrics.Collector) {
					mc.InstanceOperationInc(metrics.InstanceOperationDelete)
					mc.InstanceDeletionTimeObserve(timeNow.Sub(inst.deletedAt))
					mc.InstanceLifeDurationObserve(lifetime)
				})

				a.update(inst)
				continue
			}

			inst.missedUpdates++

			a.onMC(func(mc metrics.Collector) {
				mc.MissedUpdateInc()
			})

			if inst.missedUpdates > 5 {
				lifetime := timeNow.Sub(inst.provisionedAt)

				a.log.Warn("instance pruned", "id", id, "missed_updates", inst.missedUpdates, "last_updated_at", inst.updatedAt, "lifetime", lifetime)

				inst.state = provider.StateTimeout
				delete(a.instances, id)

				a.onMC(func(mc metrics.Collector) {
					mc.InstanceOperationInc(metrics.InstanceOperationDelete)
					mc.InstanceDeletionTimeObserve(timeNow.Sub(inst.deletedAt))
					mc.InstanceLifeDurationObserve(lifetime)
				})

				a.update(inst)
			}
		}
	}
}

func (a *Provisioner) update(inst Instance) {
	if a.opts.subscriber == nil {
		return
	}

	a.updated = append(a.updated, inst)
}

func (a *Provisioner) notify() {
	if a.opts.subscriber == nil {
		return
	}

	a.mu.Lock()
	updated := make([]Instance, len(a.updated))
	copy(updated, a.updated)
	a.updated = a.updated[:0]
	a.mu.Unlock()

	a.opts.subscriber(updated)
}

// wait waits until we've exceeded the optimal update interval.
func (a *Provisioner) wait(start time.Time) {
	// we sleep at a small interval because the optimal update
	// interval can fluctuate as we move from not expecting state
	// changes to suddenly expecting them.
	for time.Since(start) < a.optimalUpdateInterval() {
		max := a.opts.updateIntervalWhenExpecting
		if max > time.Second {
			max = time.Second
		}
		time.Sleep(max)
	}
}

func (a *Provisioner) optimalUpdateInterval() time.Duration {
	a.mu.Lock()
	defer a.mu.Unlock()

	if len(a.request) > 0 || len(a.pending) > 0 {
		return a.opts.updateIntervalWhenExpecting
	}

	for _, inst := range a.instances {
		if inst.removing || inst.state != provider.StateRunning {
			return a.opts.updateIntervalWhenExpecting
		}
	}

	return a.opts.updateInterval
}

func (a *Provisioner) onMC(mFn func(mc metrics.Collector)) {
	if a.mc != nil {
		mFn(a.mc)
	}
}
