package gateway

import (
	"context"
	"errors"
	"math/big"
	"strings"
	"sync"
	"time"

	"github.com/ethereum/go-ethereum"
	"github.com/ethereum/go-ethereum/accounts/abi"
	"github.com/ethereum/go-ethereum/common"
	"github.com/ethereum/go-ethereum/core/types"
	"github.com/ethereum/go-ethereum/ethclient"
	"github.com/shopspring/decimal"
	"go.uber.org/zap"

	"github.com/tech/sendico/chain/gateway/internal/keymanager"
	"github.com/tech/sendico/chain/gateway/storage/model"
	"github.com/tech/sendico/pkg/merrors"
	"github.com/tech/sendico/pkg/mlogger"
)

// TransferExecutor handles on-chain submission of transfers.
type TransferExecutor interface {
	SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error)
	AwaitConfirmation(ctx context.Context, network Network, txHash string) (*types.Receipt, error)
}

// NewOnChainExecutor constructs a TransferExecutor that talks to an EVM-compatible chain.
func NewOnChainExecutor(logger mlogger.Logger, keyManager keymanager.Manager) TransferExecutor {
	return &onChainExecutor{
		logger:     logger.Named("executor"),
		keyManager: keyManager,
		clients:    map[string]*ethclient.Client{},
	}
}

type onChainExecutor struct {
	logger     mlogger.Logger
	keyManager keymanager.Manager

	mu      sync.Mutex
	clients map[string]*ethclient.Client
}

func (o *onChainExecutor) SubmitTransfer(ctx context.Context, transfer *model.Transfer, source *model.ManagedWallet, destinationAddress string, network Network) (string, error) {
	if o.keyManager == nil {
		o.logger.Error("key manager not configured")
		return "", executorInternal("key manager is not configured", nil)
	}
	rpcURL := strings.TrimSpace(network.RPCURL)
	if rpcURL == "" {
		o.logger.Error("network rpc url missing", zap.String("network", network.Name))
		return "", executorInvalid("network rpc url is not configured")
	}
	if source == nil || transfer == nil {
		o.logger.Error("transfer context missing")
		return "", executorInvalid("transfer context missing")
	}
	if strings.TrimSpace(source.KeyReference) == "" {
		o.logger.Error("source wallet missing key reference", zap.String("wallet_ref", source.WalletRef))
		return "", executorInvalid("source wallet missing key reference")
	}
	if strings.TrimSpace(source.DepositAddress) == "" {
		o.logger.Error("source wallet missing deposit address", zap.String("wallet_ref", source.WalletRef))
		return "", executorInvalid("source wallet missing deposit address")
	}
	if !common.IsHexAddress(destinationAddress) {
		o.logger.Error("invalid destination address", zap.String("transfer_ref", transfer.TransferRef), zap.String("address", destinationAddress))
		return "", executorInvalid("invalid destination address " + destinationAddress)
	}

	o.logger.Info("submitting transfer",
		zap.String("transfer_ref", transfer.TransferRef),
		zap.String("source_wallet_ref", source.WalletRef),
		zap.String("network", network.Name),
		zap.String("destination", strings.ToLower(destinationAddress)),
	)

	client, err := o.getClient(ctx, rpcURL)
	if err != nil {
		o.logger.Warn("failed to initialise rpc client",
			zap.String("network", network.Name),
			zap.String("rpc_url", rpcURL),
			zap.Error(err),
		)
		return "", err
	}

	sourceAddress := common.HexToAddress(source.DepositAddress)
	destination := common.HexToAddress(destinationAddress)

	ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
	defer cancel()

	nonce, err := client.PendingNonceAt(ctx, sourceAddress)
	if err != nil {
		o.logger.Warn("failed to fetch nonce",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.String("wallet_ref", source.WalletRef),
			zap.Error(err),
		)
		return "", executorInternal("failed to fetch nonce", err)
	}

	gasPrice, err := client.SuggestGasPrice(ctx)
	if err != nil {
		o.logger.Warn("failed to suggest gas price",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.String("network", network.Name),
			zap.Error(err),
		)
		return "", executorInternal("failed to suggest gas price", err)
	}

	var tx *types.Transaction
	var txHash string

	chainID := new(big.Int).SetUint64(network.ChainID)

	if strings.TrimSpace(transfer.ContractAddress) == "" {
		o.logger.Warn("native token transfer requested but not supported", zap.String("transfer_ref", transfer.TransferRef))
		return "", merrors.NotImplemented("executor: native token transfers not yet supported")
	}

	if !common.IsHexAddress(transfer.ContractAddress) {
		o.logger.Warn("invalid token contract address",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.String("contract", transfer.ContractAddress),
		)
		return "", executorInvalid("invalid token contract address " + transfer.ContractAddress)
	}
	tokenAddress := common.HexToAddress(transfer.ContractAddress)

	decimals, err := erc20Decimals(ctx, client, tokenAddress)
	if err != nil {
		o.logger.Warn("failed to read token decimals",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.String("contract", transfer.ContractAddress),
			zap.Error(err),
		)
		return "", err
	}

	amount := transfer.NetAmount
	if amount == nil || strings.TrimSpace(amount.Amount) == "" {
		o.logger.Warn("transfer missing net amount", zap.String("transfer_ref", transfer.TransferRef))
		return "", executorInvalid("transfer missing net amount")
	}
	amountInt, err := toBaseUnits(amount.Amount, decimals)
	if err != nil {
		o.logger.Warn("failed to convert amount to base units",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.String("amount", amount.Amount),
			zap.Error(err),
		)
		return "", err
	}

	input, err := erc20ABI.Pack("transfer", destination, amountInt)
	if err != nil {
		o.logger.Warn("failed to encode transfer call",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.Error(err),
		)
		return "", executorInternal("failed to encode transfer call", err)
	}

	callMsg := ethereum.CallMsg{
		From:     sourceAddress,
		To:       &tokenAddress,
		GasPrice: gasPrice,
		Data:     input,
	}
	gasLimit, err := client.EstimateGas(ctx, callMsg)
	if err != nil {
		o.logger.Warn("failed to estimate gas",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.Error(err),
		)
		return "", executorInternal("failed to estimate gas", err)
	}

	tx = types.NewTransaction(nonce, tokenAddress, big.NewInt(0), gasLimit, gasPrice, input)

	signedTx, err := o.keyManager.SignTransaction(ctx, source.KeyReference, tx, chainID)
	if err != nil {
		o.logger.Warn("failed to sign transaction",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.String("wallet_ref", source.WalletRef),
			zap.Error(err),
		)
		return "", err
	}

	if err := client.SendTransaction(ctx, signedTx); err != nil {
		o.logger.Warn("failed to send transaction",
			zap.String("transfer_ref", transfer.TransferRef),
			zap.Error(err),
		)
		return "", executorInternal("failed to send transaction", err)
	}

	txHash = signedTx.Hash().Hex()
	o.logger.Info("transaction submitted",
		zap.String("transfer_ref", transfer.TransferRef),
		zap.String("tx_hash", txHash),
		zap.String("network", network.Name),
	)

	return txHash, nil
}

func (o *onChainExecutor) getClient(ctx context.Context, rpcURL string) (*ethclient.Client, error) {
	o.mu.Lock()
	client, ok := o.clients[rpcURL]
	o.mu.Unlock()
	if ok {
		return client, nil
	}

	c, err := ethclient.DialContext(ctx, rpcURL)
	if err != nil {
		return nil, executorInternal("failed to connect to rpc "+rpcURL, err)
	}

	o.mu.Lock()
	defer o.mu.Unlock()
	if existing, ok := o.clients[rpcURL]; ok {
		// Another routine initialised it in the meantime; prefer the existing client and close the new one.
		c.Close()
		return existing, nil
	}
	o.clients[rpcURL] = c
	return c, nil
}

func (o *onChainExecutor) AwaitConfirmation(ctx context.Context, network Network, txHash string) (*types.Receipt, error) {
	if strings.TrimSpace(txHash) == "" {
		o.logger.Warn("missing transaction hash for confirmation", zap.String("network", network.Name))
		return nil, executorInvalid("tx hash is required")
	}
	rpcURL := strings.TrimSpace(network.RPCURL)
	if rpcURL == "" {
		o.logger.Warn("network rpc url missing while awaiting confirmation", zap.String("tx_hash", txHash))
		return nil, executorInvalid("network rpc url is not configured")
	}

	client, err := o.getClient(ctx, rpcURL)
	if err != nil {
		return nil, err
	}

	hash := common.HexToHash(txHash)
	ticker := time.NewTicker(3 * time.Second)
	defer ticker.Stop()

	for {
		receipt, err := client.TransactionReceipt(ctx, hash)
		if err != nil {
			if errors.Is(err, ethereum.NotFound) {
				select {
				case <-ticker.C:
					o.logger.Debug("transaction not yet mined",
						zap.String("tx_hash", txHash),
						zap.String("network", network.Name),
					)
					continue
				case <-ctx.Done():
					o.logger.Warn("context cancelled while awaiting confirmation",
						zap.String("tx_hash", txHash),
						zap.String("network", network.Name),
					)
					return nil, ctx.Err()
				}
			}
			o.logger.Warn("failed to fetch transaction receipt",
				zap.String("tx_hash", txHash),
				zap.String("network", network.Name),
				zap.Error(err),
			)
			return nil, executorInternal("failed to fetch transaction receipt", err)
		}
		o.logger.Info("transaction confirmed",
			zap.String("tx_hash", txHash),
			zap.String("network", network.Name),
			zap.Uint64("block_number", receipt.BlockNumber.Uint64()),
			zap.Uint64("status", receipt.Status),
		)
		return receipt, nil
	}
}

var (
	erc20ABI abi.ABI
)

func init() {
	var err error
	erc20ABI, err = abi.JSON(strings.NewReader(erc20ABIJSON))
	if err != nil {
		panic("executor: failed to parse erc20 abi: " + err.Error())
	}
}

const erc20ABIJSON = `
[
	{
		"constant": false,
		"inputs": [
			{ "name": "_to", "type": "address" },
			{ "name": "_value", "type": "uint256" }
		],
		"name": "transfer",
		"outputs": [{ "name": "", "type": "bool" }],
		"payable": false,
		"stateMutability": "nonpayable",
		"type": "function"
	},
	{
		"constant": true,
		"inputs": [],
		"name": "decimals",
		"outputs": [{ "name": "", "type": "uint8" }],
		"payable": false,
		"stateMutability": "view",
        "type": "function"
	}
]`

func erc20Decimals(ctx context.Context, client *ethclient.Client, token common.Address) (uint8, error) {
	callData, err := erc20ABI.Pack("decimals")
	if err != nil {
		return 0, executorInternal("failed to encode decimals call", err)
	}
	msg := ethereum.CallMsg{
		To:   &token,
		Data: callData,
	}
	output, err := client.CallContract(ctx, msg, nil)
	if err != nil {
		return 0, executorInternal("decimals call failed", err)
	}
	values, err := erc20ABI.Unpack("decimals", output)
	if err != nil {
		return 0, executorInternal("failed to unpack decimals", err)
	}
	if len(values) == 0 {
		return 0, executorInternal("decimals call returned no data", nil)
	}
	decimals, ok := values[0].(uint8)
	if !ok {
		return 0, executorInternal("decimals call returned unexpected type", nil)
	}
	return decimals, nil
}

func toBaseUnits(amount string, decimals uint8) (*big.Int, error) {
	value, err := decimal.NewFromString(strings.TrimSpace(amount))
	if err != nil {
		return nil, executorInvalid("invalid amount " + amount + ": " + err.Error())
	}
	if value.IsNegative() {
		return nil, executorInvalid("amount must be positive")
	}
	multiplier := decimal.NewFromInt(1).Shift(int32(decimals))
	scaled := value.Mul(multiplier)
	if !scaled.Equal(scaled.Truncate(0)) {
		return nil, executorInvalid("amount " + amount + " exceeds token precision")
	}
	return scaled.BigInt(), nil
}

func executorInvalid(msg string) error {
	return merrors.InvalidArgument("executor: " + msg)
}

func executorInternal(msg string, err error) error {
	if err != nil {
		msg = msg + ": " + err.Error()
	}
	return merrors.Internal("executor: " + msg)
}
