package native

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

	"github.com/nspcc-dev/neo-go/pkg/core/dao"
	"github.com/nspcc-dev/neo-go/pkg/core/interop"
	"github.com/nspcc-dev/neo-go/pkg/core/interop/contract"
	"github.com/nspcc-dev/neo-go/pkg/core/interop/runtime"
	"github.com/nspcc-dev/neo-go/pkg/core/native/nativenames"
	"github.com/nspcc-dev/neo-go/pkg/core/native/nativeprices"
	"github.com/nspcc-dev/neo-go/pkg/core/native/noderoles"
	"github.com/nspcc-dev/neo-go/pkg/core/state"
	"github.com/nspcc-dev/neo-go/pkg/core/storage"
	"github.com/nspcc-dev/neo-go/pkg/core/transaction"
	"github.com/nspcc-dev/neo-go/pkg/crypto/hash"
	"github.com/nspcc-dev/neo-go/pkg/crypto/keys"
	"github.com/nspcc-dev/neo-go/pkg/smartcontract"
	"github.com/nspcc-dev/neo-go/pkg/smartcontract/callflag"
	"github.com/nspcc-dev/neo-go/pkg/smartcontract/manifest"
	"github.com/nspcc-dev/neo-go/pkg/util"
	"github.com/nspcc-dev/neo-go/pkg/vm/stackitem"
)

// Notary represents Notary native contract.
type Notary struct {
	interop.ContractMD
	GAS   *GAS
	NEO   *NEO
	Desig *Designate
}

type NotaryCache struct {
	maxNotValidBeforeDelta uint32
	notaryServiceFeePerKey int64
}

// NotaryService is a Notary module interface.
type NotaryService interface {
	UpdateNotaryNodes(pubs keys.PublicKeys)
}

const (
	notaryContractID = -10
	// prefixDeposit is a prefix for storing Notary deposits.
	prefixDeposit                 = 1
	defaultDepositDeltaTill       = 5760
	defaultMaxNotValidBeforeDelta = 140         // 20 rounds for 7 validators, a little more than half an hour
	defaultNotaryServiceFeePerKey = 1000_0000   // 0.1 GAS
	maxNotaryServiceFeePerKey     = 1_0000_0000 // 1 GAS
)

var (
	maxNotValidBeforeDeltaKey = []byte{10}
	notaryServiceFeeKey       = []byte{5}
)

var (
	_ interop.Contract        = (*Notary)(nil)
	_ dao.NativeContractCache = (*NotaryCache)(nil)
)

// Copy implements NativeContractCache interface.
func (c *NotaryCache) Copy() dao.NativeContractCache {
	cp := &NotaryCache{}
	copyNotaryCache(c, cp)
	return cp
}

func copyNotaryCache(src, dst *NotaryCache) {
	*dst = *src
}

// newNotary returns Notary native contract.
func newNotary() *Notary {
	n := &Notary{ContractMD: *interop.NewContractMD(nativenames.Notary, notaryContractID)}
	defer n.UpdateHash()

	desc := newDescriptor("onNEP17Payment", smartcontract.VoidType,
		manifest.NewParameter("from", smartcontract.Hash160Type),
		manifest.NewParameter("amount", smartcontract.IntegerType),
		manifest.NewParameter("data", smartcontract.AnyType))
	md := newMethodAndPrice(n.onPayment, 1<<15, callflag.States)
	n.AddMethod(md, desc)

	desc = newDescriptor("lockDepositUntil", smartcontract.BoolType,
		manifest.NewParameter("address", smartcontract.Hash160Type),
		manifest.NewParameter("till", smartcontract.IntegerType))
	md = newMethodAndPrice(n.lockDepositUntil, 1<<15, callflag.States)
	n.AddMethod(md, desc)

	desc = newDescriptor("withdraw", smartcontract.BoolType,
		manifest.NewParameter("from", smartcontract.Hash160Type),
		manifest.NewParameter("to", smartcontract.Hash160Type))
	md = newMethodAndPrice(n.withdraw, 1<<15, callflag.All)
	n.AddMethod(md, desc)

	desc = newDescriptor("balanceOf", smartcontract.IntegerType,
		manifest.NewParameter("addr", smartcontract.Hash160Type))
	md = newMethodAndPrice(n.balanceOf, 1<<15, callflag.ReadStates)
	n.AddMethod(md, desc)

	desc = newDescriptor("expirationOf", smartcontract.IntegerType,
		manifest.NewParameter("addr", smartcontract.Hash160Type))
	md = newMethodAndPrice(n.expirationOf, 1<<15, callflag.ReadStates)
	n.AddMethod(md, desc)

	desc = newDescriptor("verify", smartcontract.BoolType,
		manifest.NewParameter("signature", smartcontract.SignatureType))
	md = newMethodAndPrice(n.verify, nativeprices.NotaryVerificationPrice, callflag.ReadStates)
	n.AddMethod(md, desc)

	desc = newDescriptor("getMaxNotValidBeforeDelta", smartcontract.IntegerType)
	md = newMethodAndPrice(n.getMaxNotValidBeforeDelta, 1<<15, callflag.ReadStates)
	n.AddMethod(md, desc)

	desc = newDescriptor("setMaxNotValidBeforeDelta", smartcontract.VoidType,
		manifest.NewParameter("value", smartcontract.IntegerType))
	md = newMethodAndPrice(n.setMaxNotValidBeforeDelta, 1<<15, callflag.States)
	n.AddMethod(md, desc)

	desc = newDescriptor("getNotaryServiceFeePerKey", smartcontract.IntegerType)
	md = newMethodAndPrice(n.getNotaryServiceFeePerKey, 1<<15, callflag.ReadStates)
	n.AddMethod(md, desc)

	desc = newDescriptor("setNotaryServiceFeePerKey", smartcontract.VoidType,
		manifest.NewParameter("value", smartcontract.IntegerType))
	md = newMethodAndPrice(n.setNotaryServiceFeePerKey, 1<<15, callflag.States)
	n.AddMethod(md, desc)

	return n
}

// Metadata implements the Contract interface.
func (n *Notary) Metadata() *interop.ContractMD {
	return &n.ContractMD
}

// Initialize initializes Notary native contract and implements the Contract interface.
func (n *Notary) Initialize(ic *interop.Context) error {
	setIntWithKey(n.ID, ic.DAO, maxNotValidBeforeDeltaKey, defaultMaxNotValidBeforeDelta)
	setIntWithKey(n.ID, ic.DAO, notaryServiceFeeKey, defaultNotaryServiceFeePerKey)

	cache := &NotaryCache{
		maxNotValidBeforeDelta: defaultMaxNotValidBeforeDelta,
		notaryServiceFeePerKey: defaultNotaryServiceFeePerKey,
	}
	ic.DAO.SetCache(n.ID, cache)
	return nil
}

func (n *Notary) InitializeCache(d *dao.Simple) error {
	cache := &NotaryCache{
		maxNotValidBeforeDelta: uint32(getIntWithKey(n.ID, d, maxNotValidBeforeDeltaKey)),
		notaryServiceFeePerKey: getIntWithKey(n.ID, d, notaryServiceFeeKey),
	}

	d.SetCache(n.ID, cache)
	return nil
}

// OnPersist implements the Contract interface.
func (n *Notary) OnPersist(ic *interop.Context) error {
	var (
		nFees    int64
		notaries keys.PublicKeys
		err      error
	)
	for _, tx := range ic.Block.Transactions {
		if tx.HasAttribute(transaction.NotaryAssistedT) {
			if notaries == nil {
				notaries, err = n.GetNotaryNodes(ic.DAO)
				if err != nil {
					return fmt.Errorf("failed to get notary nodes: %w", err)
				}
			}
			nKeys := tx.GetAttributes(transaction.NotaryAssistedT)[0].Value.(*transaction.NotaryAssisted).NKeys
			nFees += int64(nKeys) + 1
			if tx.Sender() == n.Hash {
				payer := tx.Signers[1]
				balance := n.GetDepositFor(ic.DAO, payer.Account)
				balance.Amount.Sub(balance.Amount, big.NewInt(tx.SystemFee+tx.NetworkFee))
				if balance.Amount.Sign() == 0 {
					n.removeDepositFor(ic.DAO, payer.Account)
				} else {
					err := n.putDepositFor(ic.DAO, balance, payer.Account)
					if err != nil {
						return fmt.Errorf("failed to update deposit for %s: %w", payer.Account.StringBE(), err)
					}
				}
			}
		}
	}
	if nFees == 0 {
		return nil
	}
	feePerKey := n.GetNotaryServiceFeePerKey(ic.DAO)
	singleReward := calculateNotaryReward(nFees, feePerKey, len(notaries))
	for _, notary := range notaries {
		n.GAS.mint(ic, notary.GetScriptHash(), singleReward, false)
	}
	return nil
}

// PostPersist implements the Contract interface.
func (n *Notary) PostPersist(ic *interop.Context) error {
	return nil
}

// onPayment records the deposited amount as belonging to "from" address with a lock
// till the specified chain's height.
func (n *Notary) onPayment(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	if h := ic.VM.GetCallingScriptHash(); h != n.GAS.Hash {
		panic(fmt.Errorf("only GAS can be accepted for deposit, got %s", h.StringBE()))
	}
	from := toUint160(args[0])
	to := from
	amount := toBigInt(args[1])
	data, ok := args[2].(*stackitem.Array)
	if !ok || len(data.Value().([]stackitem.Item)) != 2 {
		panic(errors.New("`data` parameter should be an array of 2 elements"))
	}
	additionalParams := data.Value().([]stackitem.Item)
	if !additionalParams[0].Equals(stackitem.Null{}) {
		to = toUint160(additionalParams[0])
	}

	allowedChangeTill := ic.Tx.Sender() == to
	currentHeight := ic.BlockHeight()
	deposit := n.GetDepositFor(ic.DAO, to)
	till := toUint32(additionalParams[1])
	if till < currentHeight {
		panic(fmt.Errorf("`till` shouldn't be less then the chain's height %d", currentHeight))
	}
	if deposit != nil && till < deposit.Till {
		panic(fmt.Errorf("`till` shouldn't be less then the previous value %d", deposit.Till))
	}
	feePerKey := n.GetNotaryServiceFeePerKey(ic.DAO)
	if deposit == nil {
		if amount.Cmp(big.NewInt(2*feePerKey)) < 0 {
			panic(fmt.Errorf("first deposit can not be less then %d, got %d", 2*feePerKey, amount.Int64()))
		}
		deposit = &state.Deposit{
			Amount: new(big.Int),
		}
		if !allowedChangeTill {
			till = currentHeight + defaultDepositDeltaTill
		}
	} else if !allowedChangeTill { // only deposit's owner is allowed to set or update `till`
		till = deposit.Till
	}
	deposit.Amount.Add(deposit.Amount, amount)
	deposit.Till = till

	if err := n.putDepositFor(ic.DAO, deposit, to); err != nil {
		panic(fmt.Errorf("failed to put deposit for %s into the storage: %w", from.StringBE(), err))
	}
	return stackitem.Null{}
}

// lockDepositUntil updates the chain's height until which the deposit is locked.
func (n *Notary) lockDepositUntil(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	addr := toUint160(args[0])
	ok, err := runtime.CheckHashedWitness(ic, addr)
	if err != nil {
		panic(fmt.Errorf("failed to check witness for %s: %w", addr.StringBE(), err))
	}
	if !ok {
		return stackitem.NewBool(false)
	}
	till := toUint32(args[1])
	if till < ic.BlockHeight() {
		return stackitem.NewBool(false)
	}
	deposit := n.GetDepositFor(ic.DAO, addr)
	if deposit == nil {
		return stackitem.NewBool(false)
	}
	if till < deposit.Till {
		return stackitem.NewBool(false)
	}
	deposit.Till = till
	err = n.putDepositFor(ic.DAO, deposit, addr)
	if err != nil {
		panic(fmt.Errorf("failed to put deposit for %s into the storage: %w", addr.StringBE(), err))
	}
	return stackitem.NewBool(true)
}

// withdraw sends all deposited GAS for "from" address to "to" address.
func (n *Notary) withdraw(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	from := toUint160(args[0])
	ok, err := runtime.CheckHashedWitness(ic, from)
	if err != nil {
		panic(fmt.Errorf("failed to check witness for %s: %w", from.StringBE(), err))
	}
	if !ok {
		return stackitem.NewBool(false)
	}
	to := from
	if !args[1].Equals(stackitem.Null{}) {
		to = toUint160(args[1])
	}
	deposit := n.GetDepositFor(ic.DAO, from)
	if deposit == nil {
		return stackitem.NewBool(false)
	}
	if ic.BlockHeight() < deposit.Till {
		return stackitem.NewBool(false)
	}
	cs, err := ic.GetContract(n.GAS.Hash)
	if err != nil {
		panic(fmt.Errorf("failed to get GAS contract state: %w", err))
	}
	transferArgs := []stackitem.Item{stackitem.NewByteArray(n.Hash.BytesBE()), stackitem.NewByteArray(to.BytesBE()), stackitem.NewBigInteger(deposit.Amount), stackitem.Null{}}
	err = contract.CallFromNative(ic, n.Hash, cs, "transfer", transferArgs, true)
	if err != nil {
		panic(fmt.Errorf("failed to transfer GAS from Notary account: %w", err))
	}
	if !ic.VM.Estack().Pop().Bool() {
		panic("failed to transfer GAS from Notary account: `transfer` returned false")
	}
	n.removeDepositFor(ic.DAO, from)
	return stackitem.NewBool(true)
}

// balanceOf returns the deposited GAS amount for the specified address.
func (n *Notary) balanceOf(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	acc := toUint160(args[0])
	return stackitem.NewBigInteger(n.BalanceOf(ic.DAO, acc))
}

// BalanceOf is an internal representation of `balanceOf` Notary method.
func (n *Notary) BalanceOf(dao *dao.Simple, acc util.Uint160) *big.Int {
	deposit := n.GetDepositFor(dao, acc)
	if deposit == nil {
		return big.NewInt(0)
	}
	return deposit.Amount
}

// expirationOf returns the deposit lock height for the specified address.
func (n *Notary) expirationOf(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	acc := toUint160(args[0])
	return stackitem.Make(n.ExpirationOf(ic.DAO, acc))
}

// ExpirationOf is an internal representation of `expirationOf` Notary method.
func (n *Notary) ExpirationOf(dao *dao.Simple, acc util.Uint160) uint32 {
	deposit := n.GetDepositFor(dao, acc)
	if deposit == nil {
		return 0
	}
	return deposit.Till
}

// verify checks whether the transaction was signed by one of the notaries.
func (n *Notary) verify(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	sig, err := args[0].TryBytes()
	if err != nil {
		panic(fmt.Errorf("failed to get signature bytes: %w", err))
	}
	tx := ic.Tx
	if len(tx.GetAttributes(transaction.NotaryAssistedT)) == 0 {
		return stackitem.NewBool(false)
	}
	for _, signer := range tx.Signers {
		if signer.Account == n.Hash {
			if signer.Scopes != transaction.None {
				return stackitem.NewBool(false)
			}
			break
		}
	}
	if tx.Sender() == n.Hash {
		if len(tx.Signers) != 2 {
			return stackitem.NewBool(false)
		}
		payer := tx.Signers[1].Account
		balance := n.GetDepositFor(ic.DAO, payer)
		if balance == nil || balance.Amount.Cmp(big.NewInt(tx.NetworkFee+tx.SystemFee)) < 0 {
			return stackitem.NewBool(false)
		}
	}
	notaries, err := n.GetNotaryNodes(ic.DAO)
	if err != nil {
		panic(fmt.Errorf("failed to get notary nodes: %w", err))
	}
	shash := hash.NetSha256(uint32(ic.Network), tx)
	var verified bool
	for _, n := range notaries {
		if n.Verify(sig, shash[:]) {
			verified = true
			break
		}
	}
	return stackitem.NewBool(verified)
}

// GetNotaryNodes returns public keys of notary nodes.
func (n *Notary) GetNotaryNodes(d *dao.Simple) (keys.PublicKeys, error) {
	nodes, _, err := n.Desig.GetDesignatedByRole(d, noderoles.P2PNotary, math.MaxUint32)
	return nodes, err
}

// getMaxNotValidBeforeDelta is a Notary contract method and returns the maximum NotValidBefore delta.
func (n *Notary) getMaxNotValidBeforeDelta(ic *interop.Context, _ []stackitem.Item) stackitem.Item {
	return stackitem.NewBigInteger(big.NewInt(int64(n.GetMaxNotValidBeforeDelta(ic.DAO))))
}

// GetMaxNotValidBeforeDelta is an internal representation of Notary getMaxNotValidBeforeDelta method.
func (n *Notary) GetMaxNotValidBeforeDelta(dao *dao.Simple) uint32 {
	cache := dao.GetROCache(n.ID).(*NotaryCache)
	return cache.maxNotValidBeforeDelta
}

// setMaxNotValidBeforeDelta is a Notary contract method and sets the maximum NotValidBefore delta.
func (n *Notary) setMaxNotValidBeforeDelta(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	value := toUint32(args[0])
	cfg := ic.Chain.GetConfig()
	maxInc := cfg.MaxValidUntilBlockIncrement
	if value > maxInc/2 || value < uint32(cfg.GetNumOfCNs(ic.BlockHeight())) {
		panic(fmt.Errorf("MaxNotValidBeforeDelta cannot be more than %d or less than %d", maxInc/2, cfg.GetNumOfCNs(ic.BlockHeight())))
	}
	if !n.NEO.checkCommittee(ic) {
		panic("invalid committee signature")
	}
	setIntWithKey(n.ID, ic.DAO, maxNotValidBeforeDeltaKey, int64(value))
	cache := ic.DAO.GetRWCache(n.ID).(*NotaryCache)
	cache.maxNotValidBeforeDelta = value
	return stackitem.Null{}
}

// getNotaryServiceFeePerKey is a Notary contract method and returns a reward per notary request key for notary nodes.
func (n *Notary) getNotaryServiceFeePerKey(ic *interop.Context, _ []stackitem.Item) stackitem.Item {
	return stackitem.NewBigInteger(big.NewInt(int64(n.GetNotaryServiceFeePerKey(ic.DAO))))
}

// GetNotaryServiceFeePerKey is an internal representation of Notary getNotaryServiceFeePerKey method.
func (n *Notary) GetNotaryServiceFeePerKey(dao *dao.Simple) int64 {
	cache := dao.GetROCache(n.ID).(*NotaryCache)
	return cache.notaryServiceFeePerKey
}

// setNotaryServiceFeePerKey is a Notary contract method and sets a reward per notary request key for notary nodes.
func (n *Notary) setNotaryServiceFeePerKey(ic *interop.Context, args []stackitem.Item) stackitem.Item {
	value := toInt64(args[0])
	if value < 0 || value > maxNotaryServiceFeePerKey {
		panic("NotaryServiceFeePerKey value is out of range")
	}
	if !n.NEO.checkCommittee(ic) {
		panic("invalid committee signature")
	}
	setIntWithKey(n.ID, ic.DAO, notaryServiceFeeKey, int64(value))
	cache := ic.DAO.GetRWCache(n.ID).(*NotaryCache)
	cache.notaryServiceFeePerKey = value
	return stackitem.Null{}
}

// GetDepositFor returns state.Deposit for the account specified. It returns nil in case
// the deposit is not found in the storage and panics in case of any other error.
func (n *Notary) GetDepositFor(dao *dao.Simple, acc util.Uint160) *state.Deposit {
	key := append([]byte{prefixDeposit}, acc.BytesBE()...)
	deposit := new(state.Deposit)
	err := getConvertibleFromDAO(n.ID, dao, key, deposit)
	if err == nil {
		return deposit
	}
	if errors.Is(err, storage.ErrKeyNotFound) {
		return nil
	}
	panic(fmt.Errorf("failed to get deposit for %s from storage: %w", acc.StringBE(), err))
}

// putDepositFor puts the deposit on the balance of the specified account in the storage.
func (n *Notary) putDepositFor(dao *dao.Simple, deposit *state.Deposit, acc util.Uint160) error {
	key := append([]byte{prefixDeposit}, acc.BytesBE()...)
	return putConvertibleToDAO(n.ID, dao, key, deposit)
}

// removeDepositFor removes the deposit from the storage.
func (n *Notary) removeDepositFor(dao *dao.Simple, acc util.Uint160) {
	key := append([]byte{prefixDeposit}, acc.BytesBE()...)
	dao.DeleteStorageItem(n.ID, key)
}

// calculateNotaryReward calculates the reward for a single notary node based on FEE's count and Notary nodes count.
func calculateNotaryReward(nFees int64, feePerKey int64, notariesCount int) *big.Int {
	return big.NewInt(nFees * feePerKey / int64(notariesCount))
}