package discovery

import (
	"errors"
	"sync"
	"time"

	"github.com/btcsuite/btcd/btcec/v2"
	"github.com/lightninglabs/neutrino/cache"
	"github.com/lightninglabs/neutrino/cache/lru"
	"github.com/lightningnetwork/lnd/channeldb"
	"github.com/lightningnetwork/lnd/lnwire"
)

const (
	// maxBannedPeers limits the maximum number of banned pubkeys that
	// we'll store.
	// TODO(eugene): tune.
	maxBannedPeers = 10_000

	// banThreshold is the point at which non-channel peers will be banned.
	// TODO(eugene): tune.
	banThreshold = 100

	// banTime is the amount of time that the non-channel peer will be
	// banned for. Channel announcements from channel peers will be dropped
	// if it's not one of our channels.
	// TODO(eugene): tune.
	banTime = time.Hour * 48

	// resetDelta is the time after a peer's last ban update that we'll
	// reset its ban score.
	// TODO(eugene): tune.
	resetDelta = time.Hour * 48

	// purgeInterval is how often we'll remove entries from the
	// peerBanIndex and allow peers to be un-banned. This interval is also
	// used to reset ban scores of peers that aren't banned.
	purgeInterval = time.Minute * 10
)

var ErrPeerBanned = errors.New("peer has bypassed ban threshold - banning")

// ClosedChannelTracker handles closed channels being gossiped to us.
type ClosedChannelTracker interface {
	// GraphCloser is used to mark channels as closed and to check whether
	// certain channels are closed.
	GraphCloser

	// IsChannelPeer checks whether we have a channel with a peer.
	IsChannelPeer(*btcec.PublicKey) (bool, error)
}

// GraphCloser handles tracking closed channels by their scid.
type GraphCloser interface {
	// PutClosedScid marks a channel as closed so that we won't validate
	// channel announcements for it again.
	PutClosedScid(lnwire.ShortChannelID) error

	// IsClosedScid checks if a short channel id is closed.
	IsClosedScid(lnwire.ShortChannelID) (bool, error)
}

// NodeInfoInquirier handles queries relating to specific nodes and channels
// they may have with us.
type NodeInfoInquirer interface {
	// FetchOpenChannels returns the set of channels that we have with the
	// peer identified by the passed-in public key.
	FetchOpenChannels(*btcec.PublicKey) ([]*channeldb.OpenChannel, error)
}

// ScidCloserMan helps the gossiper handle closed channels that are in the
// ChannelGraph.
type ScidCloserMan struct {
	graph     GraphCloser
	channelDB NodeInfoInquirer
}

// NewScidCloserMan creates a new ScidCloserMan.
func NewScidCloserMan(graph GraphCloser,
	channelDB NodeInfoInquirer) *ScidCloserMan {

	return &ScidCloserMan{
		graph:     graph,
		channelDB: channelDB,
	}
}

// PutClosedScid marks scid as closed so the gossiper can ignore this channel
// in the future.
func (s *ScidCloserMan) PutClosedScid(scid lnwire.ShortChannelID) error {
	return s.graph.PutClosedScid(scid)
}

// IsClosedScid checks whether scid is closed so that the gossiper can ignore
// it.
func (s *ScidCloserMan) IsClosedScid(scid lnwire.ShortChannelID) (bool,
	error) {

	return s.graph.IsClosedScid(scid)
}

// IsChannelPeer checks whether we have a channel with the peer.
func (s *ScidCloserMan) IsChannelPeer(peerKey *btcec.PublicKey) (bool, error) {
	chans, err := s.channelDB.FetchOpenChannels(peerKey)
	if err != nil {
		return false, err
	}

	return len(chans) > 0, nil
}

// A compile-time constraint to ensure ScidCloserMan implements
// ClosedChannelTracker.
var _ ClosedChannelTracker = (*ScidCloserMan)(nil)

// cachedBanInfo is used to track a peer's ban score and if it is banned.
type cachedBanInfo struct {
	score      uint64
	lastUpdate time.Time
}

// Size returns the "size" of an entry.
func (c *cachedBanInfo) Size() (uint64, error) {
	return 1, nil
}

// isBanned returns true if the ban score is greater than the ban threshold.
func (c *cachedBanInfo) isBanned() bool {
	return c.score >= banThreshold
}

// banman is responsible for banning peers that are misbehaving. The banman is
// in-memory and will be reset upon restart of LND. If a node's pubkey is in
// the peerBanIndex, it has a ban score. Ban scores start at 1 and are
// incremented by 1 for each instance of misbehavior. It uses an LRU cache to
// cut down on memory usage in case there are many banned peers and to protect
// against DoS.
type banman struct {
	// peerBanIndex tracks our peers' ban scores and if they are banned and
	// for how long. The ban score is incremented when our peer gives us
	// gossip messages that are invalid.
	peerBanIndex *lru.Cache[[33]byte, *cachedBanInfo]

	wg   sync.WaitGroup
	quit chan struct{}
}

// newBanman creates a new banman with the default maxBannedPeers.
func newBanman() *banman {
	return &banman{
		peerBanIndex: lru.NewCache[[33]byte, *cachedBanInfo](
			maxBannedPeers,
		),
		quit: make(chan struct{}),
	}
}

// start kicks off the banman by calling purgeExpiredBans.
func (b *banman) start() {
	b.wg.Add(1)
	go b.purgeExpiredBans()
}

// stop halts the banman.
func (b *banman) stop() {
	close(b.quit)
	b.wg.Wait()
}

// purgeOldEntries removes ban entries if their ban has expired.
func (b *banman) purgeExpiredBans() {
	defer b.wg.Done()

	purgeTicker := time.NewTicker(purgeInterval)
	defer purgeTicker.Stop()

	for {
		select {
		case <-purgeTicker.C:
			b.purgeBanEntries()

		case <-b.quit:
			return
		}
	}
}

// purgeBanEntries does two things:
// - removes peers from our ban list whose ban timer is up
// - removes peers whose ban scores have expired.
func (b *banman) purgeBanEntries() {
	keysToRemove := make([][33]byte, 0)

	sweepEntries := func(pubkey [33]byte, banInfo *cachedBanInfo) bool {
		if banInfo.isBanned() {
			// If the peer is banned, check if the ban timer has
			// expired.
			if banInfo.lastUpdate.Add(banTime).Before(time.Now()) {
				keysToRemove = append(keysToRemove, pubkey)
			}

			return true
		}

		if banInfo.lastUpdate.Add(resetDelta).Before(time.Now()) {
			// Remove non-banned peers whose ban scores have
			// expired.
			keysToRemove = append(keysToRemove, pubkey)
		}

		return true
	}

	b.peerBanIndex.Range(sweepEntries)

	for _, key := range keysToRemove {
		b.peerBanIndex.Delete(key)
	}
}

// isBanned checks whether the peer identified by the pubkey is banned.
func (b *banman) isBanned(pubkey [33]byte) bool {
	banInfo, err := b.peerBanIndex.Get(pubkey)
	switch {
	case errors.Is(err, cache.ErrElementNotFound):
		return false

	default:
		return banInfo.isBanned()
	}
}

// incrementBanScore increments a peer's ban score.
func (b *banman) incrementBanScore(pubkey [33]byte) {
	banInfo, err := b.peerBanIndex.Get(pubkey)
	switch {
	case errors.Is(err, cache.ErrElementNotFound):
		cachedInfo := &cachedBanInfo{
			score:      1,
			lastUpdate: time.Now(),
		}
		_, _ = b.peerBanIndex.Put(pubkey, cachedInfo)
	default:
		cachedInfo := &cachedBanInfo{
			score:      banInfo.score + 1,
			lastUpdate: time.Now(),
		}

		_, _ = b.peerBanIndex.Put(pubkey, cachedInfo)
	}
}
