package points

import (
	"context"
	"fmt"
	"math"
	"math/big"
	"strings"
	"time"

	lru "github.com/hashicorp/golang-lru/v2"
	client "github.com/influxdata/influxdb1-client/v2"
	"github.com/ipfs/go-cid"
	"go.opencensus.io/stats"
	"golang.org/x/xerrors"

	"github.com/filecoin-project/go-address"

	"github.com/filecoin-project/lotus/api"
	"github.com/filecoin-project/lotus/build"
	"github.com/filecoin-project/lotus/chain/actors/adt"
	"github.com/filecoin-project/lotus/chain/actors/builtin"
	"github.com/filecoin-project/lotus/chain/actors/builtin/power"
	"github.com/filecoin-project/lotus/chain/actors/builtin/reward"
	"github.com/filecoin-project/lotus/chain/store"
	"github.com/filecoin-project/lotus/chain/types"
	"github.com/filecoin-project/lotus/tools/stats/influx"
	"github.com/filecoin-project/lotus/tools/stats/metrics"
)

type LotusApi interface {
	WalletBalance(context.Context, address.Address) (types.BigInt, error)
	StateMinerPower(context.Context, address.Address, types.TipSetKey) (*api.MinerPower, error)
	StateGetActor(ctx context.Context, actor address.Address, tsk types.TipSetKey) (*types.Actor, error)
	ChainGetParentMessages(ctx context.Context, blockCid cid.Cid) ([]api.Message, error)
	ChainGetParentReceipts(ctx context.Context, blockCid cid.Cid) ([]*types.MessageReceipt, error)
	ChainGetBlockMessages(ctx context.Context, blockCid cid.Cid) (*api.BlockMessages, error)
}

type ChainPointCollector struct {
	ctx              context.Context
	api              LotusApi
	store            adt.Store
	actorDigestCache *lru.TwoQueueCache[address.Address, string]
}

func NewChainPointCollector(ctx context.Context, store adt.Store, api LotusApi) (*ChainPointCollector, error) {
	actorDigestCache, err := lru.New2Q[address.Address, string](2 << 15)
	if err != nil {
		return nil, err
	}

	collector := &ChainPointCollector{
		ctx:              ctx,
		store:            store,
		actorDigestCache: actorDigestCache,
		api:              api,
	}

	return collector, nil
}

func (c *ChainPointCollector) actorDigest(ctx context.Context, addr address.Address, tipset *types.TipSet) (string, error) {
	if code, ok := c.actorDigestCache.Get(addr); ok {
		return code, nil
	}

	actor, err := c.api.StateGetActor(ctx, addr, tipset.Key())
	if err != nil {
		return "", err
	}

	digest := builtin.ActorNameByCode(actor.Code)

	c.actorDigestCache.Add(addr, digest)

	return digest, nil
}

func (c *ChainPointCollector) Collect(ctx context.Context, tipset *types.TipSet) (client.BatchPoints, error) {
	start := time.Now()
	done := metrics.Timer(ctx, metrics.TipsetCollectionDuration)
	defer func() {
		log.Infow("record tipset", "elapsed", time.Now().Sub(start).Seconds())
		done()
	}()

	pl := influx.NewPointList()
	height := tipset.Height()

	log.Debugw("collecting tipset points", "height", tipset.Height())
	stats.Record(ctx, metrics.TipsetCollectionHeight.M(int64(height)))

	if err := c.collectBlockheaderPoints(ctx, pl, tipset); err != nil {
		log.Errorw("failed to record tipset", "height", height, "error", err, "tipset", tipset.Key())
	}

	if err := c.collectMessagePoints(ctx, pl, tipset); err != nil {
		log.Errorw("failed to record messages", "height", height, "error", err, "tipset", tipset.Key())
	}

	if err := c.collectStaterootPoints(ctx, pl, tipset); err != nil {
		log.Errorw("failed to record state", "height", height, "error", err, "tipset", tipset.Key())
	}

	tsTimestamp := time.Unix(int64(tipset.MinTimestamp()), int64(0))

	nb, err := influx.NewBatch()
	if err != nil {
		return nil, err
	}

	for _, pt := range pl.Points() {
		pt.SetTime(tsTimestamp)
		nb.AddPoint(influx.NewPointFrom(pt))
	}

	log.Infow("collected tipset points", "count", len(nb.Points()), "height", tipset.Height())

	stats.Record(ctx, metrics.TipsetCollectionPoints.M(int64(len(nb.Points()))))

	return nb, nil
}

func (c *ChainPointCollector) collectBlockheaderPoints(ctx context.Context, pl *influx.PointList, tipset *types.TipSet) error {
	start := time.Now()
	done := metrics.Timer(ctx, metrics.TipsetCollectionBlockHeaderDuration)
	defer func() {
		log.Infow("collect blockheader points", "elapsed", time.Now().Sub(start).Seconds())
		done()
	}()

	cids := []string{}
	for _, cid := range tipset.Cids() {
		cids = append(cids, cid.String())
	}

	p := influx.NewPoint("chain.height", int64(tipset.Height()))
	p.AddTag("tipset", strings.Join(cids, " "))
	pl.AddPoint(p)

	p = influx.NewPoint("chain.block_count", len(cids))
	pl.AddPoint(p)

	tsTime := time.Unix(int64(tipset.MinTimestamp()), int64(0))
	p = influx.NewPoint("chain.blocktime", tsTime.Unix())
	pl.AddPoint(p)

	totalGasLimit := int64(0)
	totalUniqGasLimit := int64(0)
	seen := make(map[cid.Cid]struct{})
	for _, blockheader := range tipset.Blocks() {
		bs, err := blockheader.Serialize()
		if err != nil {
			return err
		}
		p := influx.NewPoint("chain.election", blockheader.ElectionProof.WinCount)
		p.AddTag("miner", blockheader.Miner.String())
		pl.AddPoint(p)

		p = influx.NewPoint("chain.blockheader_size", len(bs))
		pl.AddPoint(p)

		msgs, err := c.api.ChainGetBlockMessages(ctx, blockheader.Cid())
		if err != nil {
			return xerrors.Errorf("ChainGetBlockMessages failed: %w", msgs)
		}
		for _, m := range msgs.BlsMessages {
			c := m.Cid()
			totalGasLimit += m.GasLimit
			if _, ok := seen[c]; !ok {
				totalUniqGasLimit += m.GasLimit
				seen[c] = struct{}{}
			}
		}
		for _, m := range msgs.SecpkMessages {
			c := m.Cid()
			totalGasLimit += m.Message.GasLimit
			if _, ok := seen[c]; !ok {
				totalUniqGasLimit += m.Message.GasLimit
				seen[c] = struct{}{}
			}
		}
	}
	p = influx.NewPoint("chain.gas_limit_total", totalGasLimit)
	pl.AddPoint(p)
	p = influx.NewPoint("chain.gas_limit_uniq_total", totalUniqGasLimit)
	pl.AddPoint(p)

	{
		baseFeeIn := tipset.Blocks()[0].ParentBaseFee
		newBaseFee := store.ComputeNextBaseFee(baseFeeIn, totalUniqGasLimit, len(tipset.Blocks()), tipset.Height())

		baseFeeRat := new(big.Rat).SetFrac(newBaseFee.Int, new(big.Int).SetUint64(build.FilecoinPrecision))
		baseFeeFloat, _ := baseFeeRat.Float64()
		p = influx.NewPoint("chain.basefee", baseFeeFloat)
		pl.AddPoint(p)

		baseFeeChange := new(big.Rat).SetFrac(newBaseFee.Int, baseFeeIn.Int)
		baseFeeChangeF, _ := baseFeeChange.Float64()
		p = influx.NewPoint("chain.basefee_change_log", math.Log(baseFeeChangeF)/math.Log(1.125))
		pl.AddPoint(p)
	}
	{
		blks := int64(len(cids))
		p = influx.NewPoint("chain.gas_fill_ratio", float64(totalGasLimit)/float64(blks*build.BlockGasTarget))
		pl.AddPoint(p)
		p = influx.NewPoint("chain.gas_capacity_ratio", float64(totalUniqGasLimit)/float64(blks*build.BlockGasTarget))
		pl.AddPoint(p)
		p = influx.NewPoint("chain.gas_waste_ratio", float64(totalGasLimit-totalUniqGasLimit)/float64(blks*build.BlockGasTarget))
		pl.AddPoint(p)
	}

	return nil
}

func (c *ChainPointCollector) collectStaterootPoints(ctx context.Context, pl *influx.PointList, tipset *types.TipSet) error {
	start := time.Now()
	done := metrics.Timer(ctx, metrics.TipsetCollectionStaterootDuration)
	defer func() {
		log.Infow("collect stateroot points", "elapsed", time.Now().Sub(start).Seconds())
		done()
	}()

	attoFil := types.NewInt(build.FilecoinPrecision).Int

	netBal, err := c.api.WalletBalance(ctx, reward.Address)
	if err != nil {
		return err
	}

	netBalFil := new(big.Rat).SetFrac(netBal.Int, attoFil)
	netBalFilFloat, _ := netBalFil.Float64()
	p := influx.NewPoint("network.balance", netBalFilFloat)
	pl.AddPoint(p)

	totalPower, err := c.api.StateMinerPower(ctx, address.Address{}, tipset.Key())
	if err != nil {
		return err
	}

	// We divide the power into gibibytes because 2^63 bytes is 8 exbibytes which is smaller than the Filecoin Mainnet.
	// Dividing by a gibibyte gives us more room to work with. This will allow the dashboard to report network and miner
	// sizes up to 8192 yobibytes.
	gibi := types.NewInt(1024 * 1024 * 1024)
	p = influx.NewPoint("chain.power", types.BigDiv(totalPower.TotalPower.QualityAdjPower, gibi).Int64())
	pl.AddPoint(p)

	powerActor, err := c.api.StateGetActor(ctx, power.Address, tipset.Key())
	if err != nil {
		return err
	}

	powerActorState, err := power.Load(c.store, powerActor)
	if err != nil {
		return err
	}

	return powerActorState.ForEachClaim(func(addr address.Address, claim power.Claim) error {
		// BigCmp returns 0 if values are equal
		if types.BigCmp(claim.QualityAdjPower, types.NewInt(0)) == 0 {
			return nil
		}

		p = influx.NewPoint("chain.miner_power", types.BigDiv(claim.QualityAdjPower, gibi).Int64())
		p.AddTag("miner", addr.String())
		pl.AddPoint(p)

		return nil
	})
}

type msgTag struct {
	actor    string
	method   uint64
	exitcode uint8
}

func (c *ChainPointCollector) collectMessagePoints(ctx context.Context, pl *influx.PointList, tipset *types.TipSet) error {
	start := time.Now()
	done := metrics.Timer(ctx, metrics.TipsetCollectionMessageDuration)
	defer func() {
		log.Infow("collect message points", "elapsed", time.Now().Sub(start).Seconds())
		done()
	}()

	cids := tipset.Cids()
	if len(cids) == 0 {
		return fmt.Errorf("no cids in tipset")
	}

	msgs, err := c.api.ChainGetParentMessages(ctx, cids[0])
	if err != nil {
		return err
	}

	recp, err := c.api.ChainGetParentReceipts(ctx, cids[0])
	if err != nil {
		return err
	}

	msgn := make(map[msgTag][]cid.Cid)

	totalGasUsed := int64(0)
	for _, r := range recp {
		totalGasUsed += r.GasUsed
	}
	p := influx.NewPoint("chain.gas_used_total", totalGasUsed)
	pl.AddPoint(p)

	for i, msg := range msgs {
		digest, err := c.actorDigest(ctx, msg.Message.To, tipset)
		if err != nil {
			continue
		}

		// FIXME: use float so this doesn't overflow
		// FIXME: this doesn't work as time points get overridden
		p := influx.NewPoint("chain.message_gaspremium", msg.Message.GasPremium.Int64())
		pl.AddPoint(p)
		p = influx.NewPoint("chain.message_gasfeecap", msg.Message.GasFeeCap.Int64())
		pl.AddPoint(p)

		bs, err := msg.Message.Serialize()
		if err != nil {
			return err
		}

		p = influx.NewPoint("chain.message_size", len(bs))
		pl.AddPoint(p)

		tag := msgTag{
			actor:    digest,
			method:   uint64(msg.Message.Method),
			exitcode: uint8(recp[i].ExitCode),
		}

		found := false
		for _, c := range msgn[tag] {
			if c.Equals(msg.Cid) {
				found = true
				break
			}
		}
		if !found {
			msgn[tag] = append(msgn[tag], msg.Cid)
		}
	}

	for t, m := range msgn {
		p := influx.NewPoint("chain.message_count", len(m))
		p.AddTag("actor", t.actor)
		p.AddTag("method", fmt.Sprintf("%d", t.method))
		p.AddTag("exitcode", fmt.Sprintf("%d", t.exitcode))
		pl.AddPoint(p)

	}

	return nil
}
