package gateio

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

	"github.com/thrasher-corp/gocryptotrader/common/key"
	"github.com/thrasher-corp/gocryptotrader/currency"
	"github.com/thrasher-corp/gocryptotrader/exchanges/asset"
	"github.com/thrasher-corp/gocryptotrader/exchanges/orderbook"
	"github.com/thrasher-corp/gocryptotrader/exchanges/subscription"
)

const defaultWSSnapshotSyncDelay = 2 * time.Second

var errOrderbookSnapshotOutdated = errors.New("orderbook snapshot is outdated")

type wsOBUpdateManager struct {
	lookup            map[key.PairAsset]*updateCache
	snapshotSyncDelay time.Duration
	mtx               sync.RWMutex
}

type updateCache struct {
	updates  []pendingUpdate
	updating bool
	mtx      sync.Mutex
}

type pendingUpdate struct {
	update        *orderbook.Update
	firstUpdateID int64
}

func newWsOBUpdateManager(snapshotSyncDelay time.Duration) *wsOBUpdateManager {
	return &wsOBUpdateManager{lookup: make(map[key.PairAsset]*updateCache), snapshotSyncDelay: snapshotSyncDelay}
}

// ProcessOrderbookUpdate processes an orderbook update by syncing snapshot, caching updates and applying them
func (m *wsOBUpdateManager) ProcessOrderbookUpdate(ctx context.Context, g *Exchange, firstUpdateID int64, update *orderbook.Update) error {
	cache := m.LoadCache(update.Pair, update.Asset)
	cache.mtx.Lock()
	defer cache.mtx.Unlock()

	if cache.updating {
		cache.updates = append(cache.updates, pendingUpdate{update: update, firstUpdateID: firstUpdateID})
		return nil
	}

	lastUpdateID, err := g.Websocket.Orderbook.LastUpdateID(update.Pair, update.Asset)
	if err != nil && !errors.Is(err, orderbook.ErrDepthNotFound) {
		return err
	}

	if lastUpdateID+1 >= firstUpdateID {
		return applyOrderbookUpdate(g, update)
	}

	// Orderbook is behind notifications, therefore Invalidate store
	if err := g.Websocket.Orderbook.InvalidateOrderbook(update.Pair, update.Asset); err != nil && !errors.Is(err, orderbook.ErrDepthNotFound) {
		return err
	}

	cache.updating = true
	cache.updates = append(cache.updates, pendingUpdate{update: update, firstUpdateID: firstUpdateID})

	go func() {
		select {
		case <-ctx.Done():
			return
		case <-time.After(m.snapshotSyncDelay):
			if err := cache.SyncOrderbook(ctx, g, update.Pair, update.Asset); err != nil {
				g.Websocket.DataHandler <- fmt.Errorf("failed to sync orderbook for %v %v: %w", update.Pair, update.Asset, err)
			}
		}
	}()

	return nil
}

// LoadCache loads the cache for the given pair and asset. If the cache does not exist, it creates a new one.
func (m *wsOBUpdateManager) LoadCache(p currency.Pair, a asset.Item) *updateCache {
	m.mtx.RLock()
	cache, ok := m.lookup[key.PairAsset{Base: p.Base.Item, Quote: p.Quote.Item, Asset: a}]
	m.mtx.RUnlock()
	if !ok {
		m.mtx.Lock()
		cache = &updateCache{}
		m.lookup[key.PairAsset{Base: p.Base.Item, Quote: p.Quote.Item, Asset: a}] = cache
		m.mtx.Unlock()
	}
	return cache
}

// SyncOrderbook fetches and synchronises an orderbook snapshot to the limit size so that pending updates can be
// applied to the orderbook.
func (c *updateCache) SyncOrderbook(ctx context.Context, g *Exchange, pair currency.Pair, a asset.Item) error {
	// TODO: When subscription config is added for all assets update limits to use sub.Levels
	var limit uint64
	switch a {
	case asset.Spot:
		sub := g.Websocket.GetSubscription(spotOrderbookUpdateKey)
		if sub == nil {
			return fmt.Errorf("no subscription found for %q", spotOrderbookUpdateKey)
		}
		// There is no way to set levels when we subscribe for this specific subscription case.
		// Extract limit from interval e.g. 20ms == 20 limit book and 100ms == 100 limit book.
		limit = uint64(sub.Interval.Duration().Milliseconds()) //nolint:gosec // No overflow risk
	case asset.USDTMarginedFutures, asset.USDCMarginedFutures:
		limit = futuresOrderbookUpdateLimit
	case asset.DeliveryFutures:
		limit = deliveryFuturesUpdateLimit
	case asset.Options:
		limit = optionOrderbookUpdateLimit
	}

	book, err := g.UpdateOrderbookWithLimit(ctx, pair, a, limit)

	c.mtx.Lock() // lock here to prevent ws handle data interference with REST request above
	defer func() {
		c.updates = nil
		c.updating = false
		c.mtx.Unlock()
	}()

	if err != nil {
		return err
	}

	if a != asset.Spot {
		if err := g.Websocket.Orderbook.LoadSnapshot(book); err != nil {
			return err
		}
	} else {
		// Spot, Margin, and Cross Margin books are all classified as spot
		for i := range standardMarginAssetTypes {
			if enabled, _ := g.IsPairEnabled(pair, standardMarginAssetTypes[i]); !enabled {
				continue
			}
			book.Asset = standardMarginAssetTypes[i]
			if err := g.Websocket.Orderbook.LoadSnapshot(book); err != nil {
				return err
			}
		}
	}
	return c.applyPendingUpdates(g, a)
}

// ApplyPendingUpdates applies all pending updates to the orderbook
func (c *updateCache) applyPendingUpdates(g *Exchange, a asset.Item) error {
	for _, data := range c.updates {
		lastUpdateID, err := g.Websocket.Orderbook.LastUpdateID(data.update.Pair, a)
		if err != nil {
			return err
		}
		nextID := lastUpdateID + 1
		if data.firstUpdateID > nextID {
			return errOrderbookSnapshotOutdated
		}
		if data.update.UpdateID < nextID {
			continue // skip updates that are behind the current orderbook
		}
		if err := applyOrderbookUpdate(g, data.update); err != nil {
			return err
		}
	}
	return nil
}

// applyOrderbookUpdate applies an orderbook update to the orderbook
func applyOrderbookUpdate(g *Exchange, update *orderbook.Update) error {
	if update.Asset != asset.Spot {
		return g.Websocket.Orderbook.Update(update)
	}

	for i := range standardMarginAssetTypes {
		if enabled, _ := g.IsPairEnabled(update.Pair, standardMarginAssetTypes[i]); !enabled {
			continue
		}
		update.Asset = standardMarginAssetTypes[i]
		if err := g.Websocket.Orderbook.Update(update); err != nil {
			return err
		}
	}

	return nil
}

var spotOrderbookUpdateKey = channelKey{&subscription.Subscription{Channel: subscription.OrderbookChannel}}

var _ subscription.MatchableKey = channelKey{}

type channelKey struct {
	*subscription.Subscription
}

func (k channelKey) Match(eachKey subscription.MatchableKey) bool {
	return k.Subscription.Channel == eachKey.GetSubscription().Channel
}

func (k channelKey) GetSubscription() *subscription.Subscription {
	return k.Subscription
}
