package chainfee

import (
	"encoding/json"
	"fmt"
	"sort"
	"sync"
	"time"

	"github.com/btcsuite/btcd/btcutil"
	"github.com/btcsuite/btcd/rpcclient"
	"github.com/lightningnetwork/lnd/fn"
)

const (
	// fetchFilterInterval is the interval between successive fetches of
	// our peers' feefilters.
	fetchFilterInterval = time.Minute * 5

	// minNumFilters is the minimum number of feefilters we need during a
	// polling interval. If we have fewer than this, we won't consider the
	// data.
	minNumFilters = 6
)

var (
	// errNoData is an error that's returned if fetchMedianFilter is
	// called and there is no data available.
	errNoData = fmt.Errorf("no data available")
)

// filterManager is responsible for determining an acceptable minimum fee to
// use based on our peers' feefilter values.
type filterManager struct {
	// median stores the median of our outbound peer's feefilter values.
	median    fn.Option[SatPerKWeight]
	medianMtx sync.RWMutex

	fetchFunc func() ([]SatPerKWeight, error)

	wg   sync.WaitGroup
	quit chan struct{}
}

// newFilterManager constructs a filterManager with a callback that fetches the
// set of peers' feefilters.
func newFilterManager(cb func() ([]SatPerKWeight, error)) *filterManager {
	f := &filterManager{
		quit: make(chan struct{}),
	}

	f.fetchFunc = cb

	return f
}

// Start starts the filterManager.
func (f *filterManager) Start() {
	f.wg.Add(1)
	go f.fetchPeerFilters()
}

// Stop stops the filterManager.
func (f *filterManager) Stop() {
	close(f.quit)
	f.wg.Wait()
}

// fetchPeerFilters fetches our peers' feefilter values and calculates the
// median.
func (f *filterManager) fetchPeerFilters() {
	defer f.wg.Done()

	filterTicker := time.NewTicker(fetchFilterInterval)
	defer filterTicker.Stop()

	for {
		select {
		case <-filterTicker.C:
			filters, err := f.fetchFunc()
			if err != nil {
				log.Errorf("Encountered err while fetching "+
					"fee filters: %v", err)
				return
			}

			f.updateMedian(filters)

		case <-f.quit:
			return
		}
	}
}

// fetchMedianFilter fetches the median feefilter value.
func (f *filterManager) FetchMedianFilter() (SatPerKWeight, error) {
	f.medianMtx.RLock()
	defer f.medianMtx.RUnlock()

	// If there is no median, return errNoData so the caller knows to
	// ignore the output and continue.
	return f.median.UnwrapOrErr(errNoData)
}

type bitcoindPeerInfoResp struct {
	Inbound      bool    `json:"inbound"`
	MinFeeFilter float64 `json:"minfeefilter"`
}

func fetchBitcoindFilters(client *rpcclient.Client) ([]SatPerKWeight, error) {
	resp, err := client.RawRequest("getpeerinfo", nil)
	if err != nil {
		return nil, err
	}

	var peerResps []bitcoindPeerInfoResp
	err = json.Unmarshal(resp, &peerResps)
	if err != nil {
		return nil, err
	}

	// We filter for outbound peers since it is harder for an attacker to
	// be our outbound peer and therefore harder for them to manipulate us
	// into broadcasting high-fee or low-fee transactions.
	var outboundPeerFilters []SatPerKWeight
	for _, peerResp := range peerResps {
		if peerResp.Inbound {
			continue
		}

		// The value that bitcoind returns for the "minfeefilter" field
		// is in fractions of a bitcoin that represents the satoshis
		// per KvB. We need to convert this fraction to whole satoshis
		// by multiplying with COIN. Then we need to convert the
		// sats/KvB to sats/KW.
		//
		// Convert the sats/KvB from fractions of a bitcoin to whole
		// satoshis.
		filterKVByte := SatPerKVByte(
			peerResp.MinFeeFilter * btcutil.SatoshiPerBitcoin,
		)

		if !isWithinBounds(filterKVByte) {
			continue
		}

		// Convert KvB to KW and add it to outboundPeerFilters.
		outboundPeerFilters = append(
			outboundPeerFilters, filterKVByte.FeePerKWeight(),
		)
	}

	// Check that we have enough data to use. We don't return an error as
	// that would stop the querying goroutine.
	if len(outboundPeerFilters) < minNumFilters {
		return nil, nil
	}

	return outboundPeerFilters, nil
}

func fetchBtcdFilters(client *rpcclient.Client) ([]SatPerKWeight, error) {
	resp, err := client.GetPeerInfo()
	if err != nil {
		return nil, err
	}

	var outboundPeerFilters []SatPerKWeight
	for _, peerResp := range resp {
		// We filter for outbound peers since it is harder for an
		// attacker to be our outbound peer and therefore harder for
		// them to manipulate us into broadcasting high-fee or low-fee
		// transactions.
		if peerResp.Inbound {
			continue
		}

		// The feefilter is already in units of sat/KvB.
		filter := SatPerKVByte(peerResp.FeeFilter)

		if !isWithinBounds(filter) {
			continue
		}

		outboundPeerFilters = append(
			outboundPeerFilters, filter.FeePerKWeight(),
		)
	}

	// Check that we have enough data to use. We don't return an error as
	// that would stop the querying goroutine.
	if len(outboundPeerFilters) < minNumFilters {
		return nil, nil
	}

	return outboundPeerFilters, nil
}

// updateMedian takes a slice of feefilter values, computes the median, and
// updates our stored median value.
func (f *filterManager) updateMedian(feeFilters []SatPerKWeight) {
	// If there are no elements, don't update.
	numElements := len(feeFilters)
	if numElements == 0 {
		return
	}

	f.medianMtx.Lock()
	defer f.medianMtx.Unlock()

	// Log the new median.
	median := med(feeFilters)
	f.median = fn.Some(median)
	log.Debugf("filterManager updated moving median to: %v",
		median.FeePerKVByte())
}

// isWithinBounds returns false if the filter is unusable and true if it is.
func isWithinBounds(filter SatPerKVByte) bool {
	// Ignore values of 0 and MaxSatoshi. A value of 0 likely means that
	// the peer hasn't sent over a feefilter and a value of MaxSatoshi
	// means the peer is using bitcoind and is in IBD.
	switch filter {
	case 0:
		return false

	case btcutil.MaxSatoshi:
		return false
	}

	return true
}

// med calculates the median of a slice.
// NOTE: Passing in an empty slice will panic!
func med(f []SatPerKWeight) SatPerKWeight {
	// Copy the original slice so that sorting doesn't modify the original.
	fCopy := make([]SatPerKWeight, len(f))
	copy(fCopy, f)

	sort.Slice(fCopy, func(i, j int) bool {
		return fCopy[i] < fCopy[j]
	})

	var median SatPerKWeight

	numElements := len(fCopy)
	switch numElements % 2 {
	case 0:
		// There's an even number of elements, so we need to average.
		middle := numElements / 2
		upper := fCopy[middle]
		lower := fCopy[middle-1]
		median = (upper + lower) / 2

	case 1:
		median = fCopy[numElements/2]
	}

	return median
}
