// Copyright 2024 The Erigon Authors
// This file is part of Erigon.
//
// Erigon is free software: you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Erigon is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with Erigon. If not, see <http://www.gnu.org/licenses/>.

package requests

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"math/big"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/valyala/fastjson"

	ethereum "github.com/erigontech/erigon"
	"github.com/erigontech/erigon/common"
	"github.com/erigontech/erigon/common/hexutil"
	"github.com/erigontech/erigon/common/log/v3"
	"github.com/erigontech/erigon/execution/types"
	"github.com/erigontech/erigon/p2p"
	"github.com/erigontech/erigon/rpc"
	"github.com/erigontech/erigon/rpc/ethapi"
)

type callResult struct {
	Target      string
	Took        time.Duration
	RequestID   int
	Method      string
	RequestBody string
	Response    []byte
	Result      *fastjson.Value
	Err         error
}

type CommonResponse struct {
	Version   string    `json:"jsonrpc"`
	RequestId int       `json:"id"`
	Error     *EthError `json:"error"`
}

type EthError struct {
	Code    int    `json:"code"`
	Message string `json:"message"`
}

func (e EthError) Error() string {
	return fmt.Sprintf("%d: %s", e.Code, e.Message)
}

type RequestGenerator interface {
	PingErigonRpc() PingResult
	GetBalance(address common.Address, blockRef rpc.BlockReference) (*big.Int, error)
	AdminNodeInfo() (p2p.NodeInfo, error)
	GetBlockByNumber(ctx context.Context, blockNum rpc.BlockNumber, withTxs bool) (*Block, error)
	GetTransactionByHash(hash common.Hash) (*ethapi.RPCTransaction, error)
	GetTransactionReceipt(ctx context.Context, hash common.Hash) (*types.Receipt, error)
	TraceTransaction(hash common.Hash) ([]TransactionTrace, error)
	GetTransactionCount(address common.Address, blockRef rpc.BlockReference) (*big.Int, error)
	BlockNumber() (uint64, error)
	SendTransaction(signedTx types.Transaction) (common.Hash, error)
	SendRawTransactionSync(signedTx types.Transaction, timeoutMs *uint64) (*types.Receipt, error)
	FilterLogs(ctx context.Context, query ethereum.FilterQuery) ([]types.Log, error)
	SubscribeFilterLogs(ctx context.Context, query ethereum.FilterQuery, ch chan<- types.Log) (ethereum.Subscription, error)
	Subscribe(ctx context.Context, method SubMethod, subChan any, args ...any) (ethereum.Subscription, error)
	TxpoolContent() (int, int, int, error)
	Call(args ethapi.CallArgs, blockRef rpc.BlockReference, overrides *ethapi.StateOverrides) ([]byte, error)
	TraceCall(blockRef rpc.BlockReference, args ethapi.CallArgs, traceOpts ...TraceOpt) (*TraceCallResult, error)
	DebugAccountAt(blockHash common.Hash, txIndex uint64, account common.Address) (*AccountResult, error)
	GetCode(address common.Address, blockRef rpc.BlockReference) (hexutil.Bytes, error)
	EstimateGas(args ethereum.CallMsg, blockNum BlockNumber) (uint64, error)
	GasPrice() (*big.Int, error)
	GetBlockReceipts(ctx context.Context, blockRef rpc.BlockNumberOrHash) (types.Receipts, error)
	GetRootHash(ctx context.Context, startBlock uint64, endBlock uint64) (common.Hash, error)
}

type requestGenerator struct {
	sync.Mutex
	reqID              int
	client             *http.Client
	subscriptionClient *rpc.Client
	requestClient      *rpc.Client
	logger             log.Logger
	target             string
}

type (
	// RPCMethod is the type for rpc methods used
	RPCMethod string
	// SubMethod is the type for sub methods used in subscriptions
	SubMethod string
)

var Methods = struct {
	// ETHGetTransactionCount represents the eth_getTransactionCount method
	ETHGetTransactionCount RPCMethod
	// ETHGetBalance represents the eth_getBalance method
	ETHGetBalance RPCMethod
	// ETHSendRawTransaction represents the eth_sendRawTransaction method
	ETHSendRawTransaction RPCMethod
	// ETHSendRawTransactionSync represents the eth_sendRawTransactionSync method
	ETHSendRawTransactionSync RPCMethod
	// ETHGetBlockByNumber represents the eth_getBlockByNumber method
	ETHGetBlockByNumber RPCMethod
	// ETHGetBlock represents the eth_getBlock method
	ETHGetBlock RPCMethod
	// ETHGetLogs represents the eth_getLogs method
	ETHGetLogs RPCMethod
	// ETHBlockNumber represents the eth_blockNumber method
	ETHBlockNumber RPCMethod
	// AdminNodeInfo represents the admin_nodeInfo method
	AdminNodeInfo RPCMethod
	// TxpoolContent represents the txpool_content method
	TxpoolContent RPCMethod
	// OTSGetBlockDetails represents the ots_getBlockDetails method
	OTSGetBlockDetails RPCMethod
	// ETHNewHeads represents the eth_newHeads sub method
	ETHNewHeads              SubMethod
	ETHLogs                  SubMethod
	TraceCall                RPCMethod
	TraceTransaction         RPCMethod
	DebugAccountAt           RPCMethod
	ETHGetCode               RPCMethod
	ETHEstimateGas           RPCMethod
	ETHGasPrice              RPCMethod
	ETHGetTransactionByHash  RPCMethod
	ETHGetTransactionReceipt RPCMethod
	ETHGetBlockReceipts      RPCMethod
	BorGetRootHash           RPCMethod
	ETHCall                  RPCMethod
}{
	ETHGetTransactionCount:    "eth_getTransactionCount",
	ETHGetBalance:             "eth_getBalance",
	ETHSendRawTransaction:     "eth_sendRawTransaction",
	ETHSendRawTransactionSync: "eth_sendRawTransactionSync",
	ETHGetBlockByNumber:       "eth_getBlockByNumber",
	ETHGetBlock:               "eth_getBlock",
	ETHGetLogs:                "eth_getLogs",
	ETHBlockNumber:            "eth_blockNumber",
	AdminNodeInfo:             "admin_nodeInfo",
	TxpoolContent:             "txpool_content",
	OTSGetBlockDetails:        "ots_getBlockDetails",
	ETHNewHeads:               "eth_newHeads",
	ETHLogs:                   "eth_logs",
	TraceCall:                 "trace_call",
	TraceTransaction:          "trace_transaction",
	DebugAccountAt:            "debug_accountAt",
	ETHGetCode:                "eth_getCode",
	ETHEstimateGas:            "eth_estimateGas",
	ETHGasPrice:               "eth_gasPrice",
	ETHGetTransactionByHash:   "eth_getTransactionByHash",
	ETHGetTransactionReceipt:  "eth_getTransactionReceipt",
	ETHGetBlockReceipts:       "eth_getBlockReceipts",
	BorGetRootHash:            "bor_getRootHash",
	ETHCall:                   "eth_call",
}

func (req *requestGenerator) rpcCallJSON(method RPCMethod, body string, response any) callResult {
	ctx := context.Background()
	req.reqID++
	start := time.Now()
	targetUrl := "http://" + req.target

	err := retryConnects(ctx, func(ctx context.Context) error {
		return post(ctx, req.client, targetUrl, string(method), body, response, req.logger)
	})

	return callResult{
		RequestBody: body,
		Target:      targetUrl,
		Took:        time.Since(start),
		RequestID:   req.reqID,
		Method:      string(method),
		Err:         err,
	}
}

func (req *requestGenerator) rpcCall(ctx context.Context, result any, method RPCMethod, args ...any) error {
	client, err := req.rpcClient(ctx)
	if err != nil {
		return err
	}

	return retryConnects(ctx, func(ctx context.Context) error {
		return client.CallContext(ctx, result, string(method), args...)
	})
}

func (req *requestGenerator) rpcCallOnce(ctx context.Context, result any, method RPCMethod, args ...any) error {
	client, err := req.rpcClient(ctx)
	if err != nil {
		return err
	}

	return client.CallContext(ctx, result, string(method), args...)
}

const requestTimeout = time.Second * 20
const connectionTimeout = time.Millisecond * 500

func isConnectionError(err error) bool {
	var opErr *net.OpError
	switch {
	case errors.As(err, &opErr):
		return opErr.Op == "dial"

	case errors.Is(err, context.DeadlineExceeded):
		return true
	}

	return false
}

func retryConnects(ctx context.Context, op func(context.Context) error) error {
	ctx, cancel := context.WithTimeout(ctx, requestTimeout)
	defer cancel()
	return retry(ctx, op, isConnectionError, time.Second*1, nil)
}

func retry(ctx context.Context, op func(context.Context) error, isRecoverableError func(error) bool, delay time.Duration, lastErr error) error {
	opctx, cancel := context.WithTimeout(ctx, connectionTimeout)
	defer cancel()

	err := op(opctx)

	if err == nil {
		return nil
	}

	if !isRecoverableError(err) {
		return err
	}

	if errors.Is(err, context.DeadlineExceeded) {
		if lastErr != nil {
			return lastErr
		}

		err = nil
	}

	delayTimer := time.NewTimer(delay)
	select {
	case <-delayTimer.C:
		return retry(ctx, op, isRecoverableError, delay, err)
	case <-ctx.Done():
		if errors.Is(ctx.Err(), context.DeadlineExceeded) {
			return err
		}
		return ctx.Err()
	}
}

type PingResult callResult

func (req *requestGenerator) PingErigonRpc() PingResult {
	start := time.Now()
	res := callResult{
		RequestID: req.reqID,
	}

	// return early if the http module has issue fetching the url
	resp, err := http.Get("http://" + req.target) //nolint
	if err != nil {
		res.Took = time.Since(start)
		res.Err = err
		return PingResult(res)
	}

	// close the response body after reading its content at the end of the function
	defer func(body io.ReadCloser) {
		closeErr := body.Close()
		if closeErr != nil {
			req.logger.Warn("failed to close readCloser", "err", closeErr)
		}
	}(resp.Body)

	// return a bad request if the status code is not 200
	if resp.StatusCode != 200 {
		res.Took = time.Since(start)
		res.Err = ErrBadRequest
		return PingResult(res)
	}

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		res.Took = time.Since(start)
		res.Err = err
		return PingResult(res)
	}

	res.Response = body
	res.Took = time.Since(start)
	res.Err = err
	return PingResult(res)
}

func NewRequestGenerator(target string, logger log.Logger) RequestGenerator {
	// TODO
	//rpc.DialHTTPWithClient(target, &http.Client{
	//		Timeout: time.Second * 10,
	//	}, logger)

	return &requestGenerator{
		client: &http.Client{
			Timeout: time.Second * 10,
		},
		logger: logger,
		target: target,
	}
}

func (req *requestGenerator) rpcClient(ctx context.Context) (*rpc.Client, error) {
	if req.requestClient == nil {
		var err error
		var url string
		if strings.HasPrefix(req.target, "http") {
			url = req.target
		} else {
			url = "http://" + req.target
		}
		req.requestClient, err = rpc.DialContext(ctx, url, req.logger)
		if err != nil {
			return nil, err
		}
	}

	return req.requestClient, nil
}

func post(ctx context.Context, client *http.Client, url, method, request string, response any, logger log.Logger) error {
	start := time.Now()

	req, err := http.NewRequest("POST", url, strings.NewReader(request))
	if err != nil {
		return err
	}
	req.Header.Set("Content-Type", "application/json")
	req = req.WithContext(ctx)

	r, err := client.Do(req) // nolint:bodyclose
	if err != nil {
		return fmt.Errorf("client failed to make post request: %w", err)
	}

	defer func(body io.ReadCloser) {
		closeErr := body.Close()
		if closeErr != nil {
			logger.Warn("body close", "err", closeErr)
		}
	}(r.Body)

	if r.StatusCode != 200 {
		return fmt.Errorf("status %s", r.Status)
	}

	b, err := io.ReadAll(r.Body)
	if err != nil {
		return fmt.Errorf("failed to readAll from body: %w", err)
	}

	err = json.Unmarshal(b, &response)
	if err != nil {
		return fmt.Errorf("failed to unmarshal response: %w", err)
	}

	if len(method) > 0 {
		method = "#" + method
	}

	logger.Info(fmt.Sprintf("%s%s", url, method), "time", time.Since(start).Seconds())

	return nil
}

// subscribe connects to a websocket client and returns the subscription handler and a channel buffer
func (req *requestGenerator) Subscribe(ctx context.Context, method SubMethod, subChan any, args ...any) (ethereum.Subscription, error) {
	if req.subscriptionClient == nil {
		err := retryConnects(ctx, func(ctx context.Context) error {
			var err error
			req.subscriptionClient, err = rpc.DialWebsocket(ctx, "ws://"+req.target, "", req.logger)
			return err
		})
		if err != nil {
			return nil, fmt.Errorf("failed to dial websocket: %v", err)
		}
	}

	namespace, subMethod, err := NamespaceAndSubMethodFromMethod(string(method))

	if err != nil {
		return nil, fmt.Errorf("cannot get namespace and submethod from method: %v", err)
	}

	args = append([]any{subMethod}, args...)

	return req.subscriptionClient.Subscribe(ctx, namespace, subChan, args...)
}

// UnsubscribeAll closes all the client subscriptions and empties their global subscription channel
func (req *requestGenerator) UnsubscribeAll() {
	if req.subscriptionClient == nil {
		return
	}
	subscriptionClient := req.subscriptionClient
	req.subscriptionClient = nil
	subscriptionClient.Close()
}

// NamespaceAndSubMethodFromMethod splits a parent method into namespace and the actual method
func NamespaceAndSubMethodFromMethod(method string) (string, string, error) {
	parts := strings.SplitN(method, "_", 2)
	if len(parts) != 2 {
		return "", "", errors.New("invalid string to split")
	}
	return parts[0], parts[1], nil
}
