package events

import (
	"context"
	"sync"

	"go.opencensus.io/trace"
	"golang.org/x/xerrors"

	"github.com/filecoin-project/go-state-types/abi"

	"github.com/filecoin-project/lotus/chain/types"
)

type heightHandler struct {
	ts     *types.TipSet
	height abi.ChainEpoch
	called bool

	handle HeightHandler
	revert RevertHandler
}

type heightEvents struct {
	api          EventAPI
	gcConfidence abi.ChainEpoch

	lk                        sync.Mutex
	head                      *types.TipSet
	tsHeights, triggerHeights map[abi.ChainEpoch][]*heightHandler
	lastGc                    abi.ChainEpoch //nolint:structcheck
}

func newHeightEvents(api EventAPI, obs *observer, gcConfidence abi.ChainEpoch) *heightEvents {
	he := &heightEvents{
		api:            api,
		gcConfidence:   gcConfidence,
		tsHeights:      map[abi.ChainEpoch][]*heightHandler{},
		triggerHeights: map[abi.ChainEpoch][]*heightHandler{},
	}
	he.lk.Lock()
	he.head = obs.Observe((*heightEventsObserver)(he))
	he.lk.Unlock()
	return he
}

// ChainAt invokes the specified `HeightHandler` when the chain reaches the
// specified height+confidence threshold. If the chain is rolled-back under the
// specified height, `RevertHandler` will be called.
//
// ts passed to handlers is the tipset at the specified epoch, or above if lower tipsets were null.
//
// The context governs cancellations of this call, it won't cancel the event handler.
func (e *heightEvents) ChainAt(ctx context.Context, hnd HeightHandler, rev RevertHandler, confidence int, h abi.ChainEpoch) error {
	if abi.ChainEpoch(confidence) > e.gcConfidence {
		// Need this to be able to GC effectively.
		return xerrors.Errorf("confidence cannot be greater than gcConfidence: %d > %d", confidence, e.gcConfidence)
	}
	handler := &heightHandler{
		height: h,
		handle: hnd,
		revert: rev,
	}
	triggerAt := h + abi.ChainEpoch(confidence)

	// Here we try to jump onto a moving train. To avoid stopping the train, we release the lock
	// while calling the API and/or the trigger functions. Unfortunately, it's entirely possible
	// (although unlikely) to go back and forth across the trigger heights, so we need to keep
	// going back and forth here till we're synced.
	//
	// TODO: Consider using a worker goroutine so we can just drop the handler in a channel? The
	// downside is that we'd either need a tipset cache, or we'd need to potentially fetch
	// tipsets in-line inside the event loop.
	e.lk.Lock()
	for {
		head := e.head
		if head.Height() >= h {
			// Head is past the handler height. We at least need to stash the tipset to
			// avoid doing this from the main event loop.
			e.lk.Unlock()

			var ts *types.TipSet
			if head.Height() == h {
				ts = head
			} else {
				var err error
				ts, err = e.api.ChainGetTipSetAfterHeight(ctx, handler.height, head.Key())
				if err != nil {
					return xerrors.Errorf("events.ChainAt: failed to get tipset: %s", err)
				}
			}

			// If we've applied the handler on the wrong tipset, revert.
			if handler.called && !ts.Equals(handler.ts) {
				ctx, span := trace.StartSpan(ctx, "events.HeightRevert")
				span.AddAttributes(trace.BoolAttribute("immediate", true))
				err := handler.revert(ctx, handler.ts)
				span.End()
				if err != nil {
					return err
				}
				handler.called = false
			}

			// Save the tipset.
			handler.ts = ts

			// If we've reached confidence and haven't called, call.
			if !handler.called && head.Height() >= triggerAt {
				ctx, span := trace.StartSpan(ctx, "events.HeightApply")
				span.AddAttributes(trace.BoolAttribute("immediate", true))
				err := handler.handle(ctx, handler.ts, head.Height())
				span.End()
				if err != nil {
					return err
				}

				handler.called = true

				// If we've reached gcConfidence, return without saving anything.
				if head.Height() >= h+e.gcConfidence {
					return nil
				}
			}

			e.lk.Lock()
		} else if handler.called {
			// We're not passed the head (anymore) but have applied the handler. Revert, try again.
			e.lk.Unlock()
			ctx, span := trace.StartSpan(ctx, "events.HeightRevert")
			span.AddAttributes(trace.BoolAttribute("immediate", true))
			err := handler.revert(ctx, handler.ts)
			span.End()
			if err != nil {
				return err
			}
			handler.called = false
			e.lk.Lock()
		} // otherwise, we changed heads but the change didn't matter.

		// If we managed to get through this without the head changing, we're finally done.
		if head.Equals(e.head) {
			e.triggerHeights[triggerAt] = append(e.triggerHeights[triggerAt], handler)
			e.tsHeights[h] = append(e.tsHeights[h], handler)
			e.lk.Unlock()
			return nil
		}
	}
}

// Updates the head and garbage collects if we're 2x over our garbage collection confidence period.
func (e *heightEventsObserver) updateHead(h *types.TipSet) {
	e.lk.Lock()
	defer e.lk.Unlock()
	e.head = h

	if e.head.Height() < e.lastGc+e.gcConfidence*2 {
		return
	}
	e.lastGc = h.Height()

	targetGcHeight := e.head.Height() - e.gcConfidence
	for h := range e.tsHeights {
		if h >= targetGcHeight {
			continue
		}
		delete(e.tsHeights, h)
	}
	for h := range e.triggerHeights {
		if h >= targetGcHeight {
			continue
		}
		delete(e.triggerHeights, h)
	}
}

type heightEventsObserver heightEvents

func (e *heightEventsObserver) Revert(ctx context.Context, from, to *types.TipSet) error {
	// Update the head first so we don't accidental skip reverting a concurrent call to ChainAt.
	e.updateHead(to)

	// Call revert on all hights between the two tipsets, handling empty tipsets.
	for h := from.Height(); h > to.Height(); h-- {
		e.lk.Lock()
		triggers := e.tsHeights[h]
		e.lk.Unlock()

		// 1. Triggers are only invoked from the global event loop, we don't need to hold the lock while calling.
		// 2. We only ever append to or replace the trigger slice, so it's safe to iterate over it without the lock.
		for _, handler := range triggers {
			handler.ts = nil // invalidate
			if !handler.called {
				// We haven't triggered this yet, or there has been a concurrent call to ChainAt.
				continue
			}
			ctx, span := trace.StartSpan(ctx, "events.HeightRevert")
			err := handler.revert(ctx, from)
			span.End()

			if err != nil {
				log.Errorf("reverting chain trigger (@H %d): %s", h, err)
			}
			handler.called = false
		}
	}
	return nil
}

func (e *heightEventsObserver) Apply(ctx context.Context, from, to *types.TipSet) error {
	// Update the head first so we don't accidental skip applying a concurrent call to ChainAt.
	e.updateHead(to)

	for h := from.Height() + 1; h <= to.Height(); h++ {
		e.lk.Lock()
		triggers := e.triggerHeights[h]
		tipsets := e.tsHeights[h]
		e.lk.Unlock()

		// Stash the tipset for future triggers.
		for _, handler := range tipsets {
			handler.ts = to
		}

		// Trigger the ready triggers.
		for _, handler := range triggers {
			if handler.called {
				// We may have reverted past the trigger point, but not past the call point.
				// Or there has been a concurrent call to ChainAt.
				continue
			}

			ctx, span := trace.StartSpan(ctx, "events.HeightApply")
			span.AddAttributes(trace.BoolAttribute("immediate", false))
			err := handler.handle(ctx, handler.ts, h)
			span.End()

			if err != nil {
				log.Errorf("chain trigger (@H %d, called @ %d) failed: %+v", h, to.Height(), err)
			}

			handler.called = true
		}
	}
	return nil
}
