package oracle

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

	"github.com/ethereum-optimism/optimism/go/gas-oracle/bindings"
	"github.com/ethereum-optimism/optimism/go/gas-oracle/gasprices"

	"github.com/ethereum/go-ethereum/accounts/abi/bind"
	"github.com/ethereum/go-ethereum/crypto"
	"github.com/ethereum/go-ethereum/ethclient"
	"github.com/ethereum/go-ethereum/log"
)

var (
	// errInvalidSigningKey represents the error when the signing key used
	// is not the Owner of the contract and therefore cannot update the gasprice
	errInvalidSigningKey = errors.New("invalid signing key")
	// errNoChainID represents the error when the chain id is not provided
	// and it cannot be remotely fetched
	errNoChainID = errors.New("no chain id provided")
	// errNoPrivateKey represents the error when the private key is not provided to
	// the application
	errNoPrivateKey = errors.New("no private key provided")
	// errWrongChainID represents the error when the configured chain id is not
	// correct
	errWrongChainID = errors.New("wrong chain id provided")
	// errNoBaseFee represents the error when the base fee is not found on the
	// block. This means that the block being queried is pre eip1559
	errNoBaseFee = errors.New("base fee not found on block")
)

// GasPriceOracle manages a hot key that can update the L2 Gas Price
type GasPriceOracle struct {
	l1ChainID       *big.Int
	l2ChainID       *big.Int
	ctx             context.Context
	stop            chan struct{}
	contract        *bindings.GasPriceOracle
	l2Backend       DeployContractBackend
	l1Backend       bind.ContractTransactor
	gasPriceUpdater *gasprices.GasPriceUpdater
	config          *Config
}

// Start runs the GasPriceOracle
func (g *GasPriceOracle) Start() error {
	if g.config.l1ChainID == nil {
		return fmt.Errorf("layer-one: %w", errNoChainID)
	}
	if g.config.l2ChainID == nil {
		return fmt.Errorf("layer-two: %w", errNoChainID)
	}
	if g.config.privateKey == nil {
		return errNoPrivateKey
	}

	address := crypto.PubkeyToAddress(g.config.privateKey.PublicKey)
	log.Info("Starting Gas Price Oracle", "l1-chain-id", g.l1ChainID,
		"l2-chain-id", g.l2ChainID, "address", address.Hex())

	price, err := g.contract.GasPrice(&bind.CallOpts{
		Context: context.Background(),
	})
	if err != nil {
		return err
	}
	gasPriceGauge.Update(int64(price.Uint64()))

	if g.config.enableL1BaseFee {
		go g.BaseFeeLoop()
	}
	if g.config.enableL2GasPrice {
		go g.Loop()
	}

	return nil
}

func (g *GasPriceOracle) Stop() {
	close(g.stop)
}

func (g *GasPriceOracle) Wait() {
	<-g.stop
}

// ensure makes sure that the configured private key is the owner
// of the `OVM_GasPriceOracle`. If it is not the owner, then it will
// not be able to make updates to the L2 gas price.
func (g *GasPriceOracle) ensure() error {
	owner, err := g.contract.Owner(&bind.CallOpts{
		Context: g.ctx,
	})
	if err != nil {
		return err
	}
	address := crypto.PubkeyToAddress(g.config.privateKey.PublicKey)
	if address != owner {
		log.Error("Signing key does not match contract owner", "signer", address.Hex(), "owner", owner.Hex())
		return errInvalidSigningKey
	}
	return nil
}

// Loop is the main logic of the gas-oracle
func (g *GasPriceOracle) Loop() {
	timer := time.NewTicker(time.Duration(g.config.epochLengthSeconds) * time.Second)
	defer timer.Stop()
	for {
		select {
		case <-timer.C:
			log.Trace("polling", "time", time.Now())
			if err := g.Update(); err != nil {
				log.Error("cannot update gas price", "message", err)
			}

		case <-g.ctx.Done():
			g.Stop()
		}
	}
}

func (g *GasPriceOracle) BaseFeeLoop() {
	timer := time.NewTicker(15 * time.Second)
	defer timer.Stop()

	updateBaseFee, err := wrapUpdateBaseFee(g.l1Backend, g.l2Backend, g.config)
	if err != nil {
		panic(err)
	}

	for {
		select {
		case <-timer.C:
			if err := updateBaseFee(); err != nil {
				log.Error("cannot update l1 base fee", "messgae", err)
			}

		case <-g.ctx.Done():
			g.Stop()
		}
	}
}

// Update will update the gas price
func (g *GasPriceOracle) Update() error {
	l2GasPrice, err := g.contract.GasPrice(&bind.CallOpts{
		Context: g.ctx,
	})
	if err != nil {
		return fmt.Errorf("cannot get gas price: %w", err)
	}

	if err := g.gasPriceUpdater.UpdateGasPrice(); err != nil {
		return fmt.Errorf("cannot update gas price: %w", err)
	}

	newGasPrice, err := g.contract.GasPrice(&bind.CallOpts{
		Context: g.ctx,
	})
	if err != nil {
		return fmt.Errorf("cannot get gas price: %w", err)
	}

	local := g.gasPriceUpdater.GetGasPrice()
	log.Info("Update", "original", l2GasPrice, "current", newGasPrice, "local", local)
	return nil
}

// NewGasPriceOracle creates a new GasPriceOracle based on a Config
func NewGasPriceOracle(cfg *Config) (*GasPriceOracle, error) {
	// Create the L2 client
	l2Client, err := ethclient.Dial(cfg.layerTwoHttpUrl)
	if err != nil {
		return nil, err
	}

	l1Client, err := ethclient.Dial(cfg.ethereumHttpUrl)
	if err != nil {
		return nil, err
	}

	// Ensure that we can actually connect to both backends
	if err := ensureConnection(l2Client); err != nil {
		log.Error("Unable to connect to layer two", "addr", cfg.layerTwoHttpUrl)
	}
	if err := ensureConnection(l1Client); err != nil {
		log.Error("Unable to connect to layer one", "addr", cfg.ethereumHttpUrl)
	}

	address := cfg.gasPriceOracleAddress
	contract, err := bindings.NewGasPriceOracle(address, l2Client)
	if err != nil {
		return nil, err
	}

	// Fetch the current gas price to use as the current price
	currentPrice, err := contract.GasPrice(&bind.CallOpts{
		Context: context.Background(),
	})
	if err != nil {
		return nil, err
	}

	// Create a gas pricer for the gas price updater
	log.Info("Creating GasPricer", "currentPrice", currentPrice,
		"floorPrice", cfg.floorPrice, "targetGasPerSecond", cfg.targetGasPerSecond,
		"maxPercentChangePerEpoch", cfg.maxPercentChangePerEpoch)

	gasPricer, err := gasprices.NewGasPricer(
		currentPrice.Uint64(),
		cfg.floorPrice,
		func() float64 {
			return float64(cfg.targetGasPerSecond)
		},
		cfg.maxPercentChangePerEpoch,
	)
	if err != nil {
		return nil, err
	}

	l2ChainID, err := l2Client.ChainID(context.Background())
	if err != nil {
		return nil, err
	}
	l1ChainID, err := l1Client.ChainID(context.Background())
	if err != nil {
		return nil, err
	}

	if cfg.l2ChainID != nil {
		if cfg.l2ChainID.Cmp(l2ChainID) != 0 {
			return nil, fmt.Errorf("%w: L2: configured with %d and got %d",
				errWrongChainID, cfg.l2ChainID, l2ChainID)
		}
	} else {
		cfg.l2ChainID = l2ChainID
	}

	if cfg.l1ChainID != nil {
		if cfg.l1ChainID.Cmp(l1ChainID) != 0 {
			return nil, fmt.Errorf("%w: L1: configured with %d and got %d",
				errWrongChainID, cfg.l1ChainID, l1ChainID)
		}
	} else {
		cfg.l1ChainID = l1ChainID
	}

	if cfg.privateKey == nil {
		return nil, errNoPrivateKey
	}

	tip, err := l2Client.HeaderByNumber(context.Background(), nil)
	if err != nil {
		return nil, err
	}

	// Start at the tip
	epochStartBlockNumber := tip.Number.Uint64()
	// getLatestBlockNumberFn is used by the GasPriceUpdater
	// to get the latest block number
	getLatestBlockNumberFn := wrapGetLatestBlockNumberFn(l2Client)
	// updateL2GasPriceFn is used by the GasPriceUpdater to
	// update the gas price
	updateL2GasPriceFn, err := wrapUpdateL2GasPriceFn(l2Client, cfg)
	if err != nil {
		return nil, err
	}
	// getGasUsedByBlockFn is used by the GasPriceUpdater
	// to fetch the amount of gas that a block has used
	getGasUsedByBlockFn := wrapGetGasUsedByBlock(l2Client)

	log.Info("Creating GasPriceUpdater", "epochStartBlockNumber", epochStartBlockNumber,
		"averageBlockGasLimitPerEpoch", cfg.averageBlockGasLimitPerEpoch,
		"epochLengthSeconds", cfg.epochLengthSeconds)

	gasPriceUpdater, err := gasprices.NewGasPriceUpdater(
		gasPricer,
		epochStartBlockNumber,
		cfg.averageBlockGasLimitPerEpoch,
		cfg.epochLengthSeconds,
		getLatestBlockNumberFn,
		getGasUsedByBlockFn,
		updateL2GasPriceFn,
	)

	if err != nil {
		return nil, err
	}

	gpo := GasPriceOracle{
		l2ChainID:       l2ChainID,
		l1ChainID:       l1ChainID,
		ctx:             context.Background(),
		stop:            make(chan struct{}),
		contract:        contract,
		gasPriceUpdater: gasPriceUpdater,
		config:          cfg,
		l2Backend:       l2Client,
		l1Backend:       l1Client,
	}

	if err := gpo.ensure(); err != nil {
		return nil, err
	}

	return &gpo, nil
}

// Ensure that we can actually connect
func ensureConnection(client *ethclient.Client) error {
	t := time.NewTicker(5 * time.Second)
	defer t.Stop()
	for ; true; <-t.C {
		_, err := client.ChainID(context.Background())
		if err == nil {
			break
		} else {
			return err
		}
	}
	return nil
}
