package keeper_test

// This file is a test driver for model-based tests generated from the TLA+ model of token transfer
// Written by Andrey Kuprianov within the scope of IBC Audit performed by Informal Systems.
// In case of any questions please don't hesitate to contact andrey@informal.systems.

import (
	"encoding/json"
	"errors"
	"fmt"
	"os"
	"strconv"
	"strings"

	errorsmod "cosmossdk.io/errors"
	sdkmath "cosmossdk.io/math"

	sdk "git.cw.tr/mukan-network/mukan-sdk/types"

	"git.cw.tr/mukan-network/mukan-consensus/crypto"

	"git.cw.tr/mukan-network/mukan-ibc/modules/apps/transfer/types"
	clienttypes "git.cw.tr/mukan-network/mukan-ibc/modules/core/02-client/types"
	channeltypes "git.cw.tr/mukan-network/mukan-ibc/modules/core/04-channel/types"
	ibcerrors "git.cw.tr/mukan-network/mukan-ibc/modules/core/errors"
	ibctesting "git.cw.tr/mukan-network/mukan-ibc/testing"
)

type TlaBalance struct {
	Address []string `json:"address"`
	Denom   []string `json:"denom"`
	Amount  int64    `json:"amount"`
}

type TlaFungibleTokenPacketData struct {
	Sender   string   `json:"sender"`
	Receiver string   `json:"receiver"`
	Amount   string   `json:"amount"`
	Denom    []string `json:"denom"`
}

type TlaFungibleTokenPacket struct {
	SourceChannel string                     `json:"sourceChannel"`
	SourcePort    string                     `json:"sourcePort"`
	DestChannel   string                     `json:"destChannel"`
	DestPort      string                     `json:"destPort"`
	Data          TlaFungibleTokenPacketData `json:"data"`
}

type TlaOnRecvPacketTestCase = struct {
	// The required subset of bank balances
	BankBefore []TlaBalance `json:"bankBefore"`
	// The packet to process
	Packet TlaFungibleTokenPacket `json:"packet"`
	// The handler to call
	Handler string `json:"handler"`
	// The expected changes in the bank
	BankAfter []TlaBalance `json:"bankAfter"`
	// Whether OnRecvPacket should fail or not
	Error bool `json:"error"`
}

type FungibleTokenPacket struct {
	SourceChannel string
	SourcePort    string
	DestChannel   string
	DestPort      string
	Data          types.InternalTransferRepresentation
}

type OnRecvPacketTestCase = struct {
	description string
	// The required subset of bank balances
	bankBefore []Balance
	// The packet to process
	packet FungibleTokenPacket
	// The handler to call
	handler string
	// The expected bank state after processing (wrt. bankBefore)
	bankAfter []Balance
	// Whether OnRecvPacket should pass or fail
	pass bool
}

type OwnedCoin struct {
	Address string
	Denom   string
}

type Balance struct {
	ID      string
	Address string
	Denom   string
	Amount  sdkmath.Int
}

func AddressFromString(address string) string {
	return sdk.AccAddress(crypto.AddressHash([]byte(address))).String()
}

func AddressFromTla(addr []string) string {
	if len(addr) != 3 {
		panic(errors.New("failed to convert from TLA+ address: wrong number of address components"))
	}
	s := ""
	if len(addr[0]) == 0 && len(addr[1]) == 0 { //nolint:gocritic
		// simple address: id
		s = addr[2]
	} else if len(addr[2]) == 0 {
		// escrow address: ics20-1\x00port/channel
		s = fmt.Sprintf("%s\x00%s/%s", types.V1, addr[0], addr[1])
	} else {
		panic(errors.New("failed to convert from TLA+ address: neither simple nor escrow address"))
	}
	return s
}

func DenomFromTla(denom []string) string {
	var i int
	for i = 0; i+1 < len(denom); i += 2 {
		if len(denom[i]) != 0 || len(denom[i+1]) != 0 {
			break
		}
	}
	return strings.Join(denom[i:], "/")
}

func BalanceFromTla(balance TlaBalance) Balance {
	return Balance{
		ID:      AddressFromTla(balance.Address),
		Address: AddressFromString(AddressFromTla(balance.Address)),
		Denom:   DenomFromTla(balance.Denom),
		Amount:  sdkmath.NewInt(balance.Amount),
	}
}

func BalancesFromTla(tla []TlaBalance) []Balance {
	balances := make([]Balance, 0)
	for _, b := range tla {
		balances = append(balances, BalanceFromTla(b))
	}
	return balances
}

func FungibleTokenPacketFromTla(packet TlaFungibleTokenPacket) FungibleTokenPacket {
	denom := types.ExtractDenomFromPath(DenomFromTla(packet.Data.Denom))
	return FungibleTokenPacket{
		SourceChannel: packet.SourceChannel,
		SourcePort:    packet.SourcePort,
		DestChannel:   packet.DestChannel,
		DestPort:      packet.DestPort,
		Data: types.NewInternalTransferRepresentation(
			types.Token{
				Denom:  denom,
				Amount: packet.Data.Amount,
			},
			AddressFromString(packet.Data.Sender),
			AddressFromString(packet.Data.Receiver),
			"",
		),
	}
}

func OnRecvPacketTestCaseFromTla(tc TlaOnRecvPacketTestCase) OnRecvPacketTestCase {
	return OnRecvPacketTestCase{
		description: "auto-generated",
		bankBefore:  BalancesFromTla(tc.BankBefore),
		packet:      FungibleTokenPacketFromTla(tc.Packet),
		handler:     tc.Handler,
		bankAfter:   BalancesFromTla(tc.BankAfter), // TODO different semantics
		pass:        !tc.Error,
	}
}

var addressMap = make(map[string]string)

type Bank struct {
	balances map[OwnedCoin]sdkmath.Int
}

// Make an empty bank
func MakeBank() Bank {
	return Bank{balances: make(map[OwnedCoin]sdkmath.Int)}
}

// Subtract other bank from this bank
func (bank *Bank) Sub(other *Bank) Bank {
	diff := MakeBank()
	for coin, amount := range bank.balances {
		otherAmount, exists := other.balances[coin]
		if exists {
			diff.balances[coin] = amount.Sub(otherAmount)
		} else {
			diff.balances[coin] = amount
		}
	}
	for coin, amount := range other.balances {
		if _, exists := bank.balances[coin]; !exists {
			diff.balances[coin] = amount.Neg()
		}
	}
	return diff
}

// Set specific bank balance
func (bank *Bank) SetBalance(address string, denom string, amount sdkmath.Int) {
	bank.balances[OwnedCoin{address, denom}] = amount
}

// Set several balances at once
func (bank *Bank) SetBalances(balances []Balance) {
	for _, balance := range balances {
		bank.balances[OwnedCoin{balance.Address, balance.Denom}] = balance.Amount
		addressMap[balance.Address] = balance.ID
	}
}

func NullCoin() OwnedCoin {
	return OwnedCoin{
		Address: AddressFromString(""),
		Denom:   "",
	}
}

// Set several balances at once
func BankFromBalances(balances []Balance) Bank {
	bank := MakeBank()
	for _, balance := range balances {
		coin := OwnedCoin{balance.Address, balance.Denom}
		if coin != NullCoin() { // ignore null coin
			bank.balances[coin] = balance.Amount
			addressMap[balance.Address] = balance.ID
		}
	}
	return bank
}

// String representation of all bank balances
func (bank *Bank) String() string {
	str := ""
	for coin, amount := range bank.balances {
		str += coin.Address
		if addressMap[coin.Address] != "" {
			str += "(" + addressMap[coin.Address] + ")"
		}
		str += " : " + coin.Denom + " = " + amount.String() + "\n"
	}
	return str
}

// String representation of non-zero bank balances
func (bank *Bank) NonZeroString() string {
	str := ""
	for coin, amount := range bank.balances {
		if !amount.IsZero() {
			str += coin.Address + " : " + coin.Denom + " = " + amount.String() + "\n"
		}
	}
	return str
}

// Construct a bank out of the chain bank
func BankOfChain(chain *ibctesting.TestChain) Bank {
	bank := MakeBank()
	chain.GetSimApp().BankKeeper.IterateAllBalances(chain.GetContext(), func(address sdk.AccAddress, coin sdk.Coin) (stop bool) {
		token, err := chain.GetSimApp().TransferKeeper.TokenFromCoin(chain.GetContext(), coin)
		if err != nil {
			panic(fmt.Errorf("Failed to construct token from coin: %w", err))
		}
		bank.SetBalance(address.String(), token.Denom.Path(), coin.Amount)
		return false
	})
	return bank
}

// Check that the state of the bank is the bankBefore + expectedBankChange
func (*KeeperTestSuite) CheckBankBalances(chain *ibctesting.TestChain, bankBefore *Bank, expectedBankChange *Bank) error {
	bankAfter := BankOfChain(chain)
	bankChange := bankAfter.Sub(bankBefore)
	diff := bankChange.Sub(expectedBankChange)
	nonZeroString := diff.NonZeroString()
	if len(nonZeroString) != 0 {
		return errorsmod.Wrap(ibcerrors.ErrInvalidCoins, "Unexpected changes in the bank: \n"+nonZeroString)
	}
	return nil
}

func (suite *KeeperTestSuite) TestModelBasedRelay() {
	dirname := "model_based_tests/"
	files, err := os.ReadDir(dirname)
	if err != nil {
		panic(fmt.Errorf("Failed to read model-based test files: %w", err))
	}
	for _, fileInfo := range files {
		tlaTestCases := []TlaOnRecvPacketTestCase{}
		if !strings.HasSuffix(fileInfo.Name(), ".json") {
			continue
		}
		jsonBlob, err := os.ReadFile(dirname + fileInfo.Name())
		if err != nil {
			panic(fmt.Errorf("Failed to read JSON test fixture: %w", err))
		}
		err = json.Unmarshal(jsonBlob, &tlaTestCases)
		if err != nil {
			panic(fmt.Errorf("Failed to parse JSON test fixture: %w", err))
		}

		suite.SetupTest()
		pathAtoB := ibctesting.NewTransferPath(suite.chainA, suite.chainB).DisableUniqueChannelIDs()
		pathBtoC := ibctesting.NewTransferPath(suite.chainB, suite.chainC).DisableUniqueChannelIDs()
		pathAtoB.Setup()
		pathBtoC.Setup()

		for i, tlaTc := range tlaTestCases {
			tc := OnRecvPacketTestCaseFromTla(tlaTc)
			registerDenomFn := func() {
				if !suite.chainB.GetSimApp().TransferKeeper.HasDenom(suite.chainB.GetContext(), tc.packet.Data.Token.Denom.Hash()) {
					suite.chainB.GetSimApp().TransferKeeper.SetDenom(suite.chainB.GetContext(), tc.packet.Data.Token.Denom)
				}
			}

			description := fileInfo.Name() + " # " + strconv.Itoa(i+1)
			suite.Run(fmt.Sprintf("Case %s", description), func() {
				seq := uint64(1)
				packet := channeltypes.NewPacket([]byte("mockdata"), seq, tc.packet.SourcePort, tc.packet.SourceChannel, tc.packet.DestPort, tc.packet.DestChannel, clienttypes.NewHeight(1, 100), 0)
				bankBefore := BankFromBalances(tc.bankBefore)
				realBankBefore := BankOfChain(suite.chainB)
				// First validate the packet itself (mimics what happens when the packet is being sent and/or received)
				err := packet.ValidateBasic()
				if err != nil {
					suite.Require().False(tc.pass, err.Error())
					return
				}
				switch tc.handler {
				case "SendTransfer":
					var sender sdk.AccAddress
					sender, err = sdk.AccAddressFromBech32(tc.packet.Data.Sender)
					if err != nil {
						panic(errors.New("MBT failed to convert sender address"))
					}
					registerDenomFn()
					denom := tc.packet.Data.Token.Denom.IBCDenom()
					err = sdk.ValidateDenom(denom)
					if err == nil {
						amount, ok := sdkmath.NewIntFromString(tc.packet.Data.Token.Amount)
						if !ok {
							panic(errors.New("MBT failed to parse amount from string"))
						}

						msg := types.NewMsgTransfer(
							tc.packet.SourcePort,
							tc.packet.SourceChannel,
							sdk.NewCoin(denom, amount),
							sender.String(),
							tc.packet.Data.Receiver,
							suite.chainA.GetTimeoutHeight(), 0, // only use timeout height
							"",
						)

						_, err = suite.chainB.GetSimApp().TransferKeeper.Transfer(suite.chainB.GetContext(), msg)

					}
				case "OnRecvPacket":
					err = suite.chainB.GetSimApp().TransferKeeper.OnRecvPacket(
						suite.chainB.GetContext(),
						tc.packet.Data,
						packet.SourcePort,
						packet.SourceChannel,
						packet.DestinationPort,
						packet.DestinationChannel,
					)

				case "OnTimeoutPacket":
					registerDenomFn()
					err = suite.chainB.GetSimApp().TransferKeeper.OnTimeoutPacket(suite.chainB.GetContext(), packet.SourcePort, packet.SourceChannel, tc.packet.Data)

				case "OnRecvAcknowledgementResult":
					err = suite.chainB.GetSimApp().TransferKeeper.OnAcknowledgementPacket(
						suite.chainB.GetContext(), packet.SourcePort, packet.SourceChannel, tc.packet.Data,
						channeltypes.NewResultAcknowledgement(nil))
				case "OnRecvAcknowledgementError":
					registerDenomFn()
					err = suite.chainB.GetSimApp().TransferKeeper.OnAcknowledgementPacket(
						suite.chainB.GetContext(), packet.SourcePort, packet.SourceChannel, tc.packet.Data,
						channeltypes.NewErrorAcknowledgement(errors.New("MBT Error Acknowledgement")))
				default:
					err = fmt.Errorf("Unknown handler:  %s", tc.handler)
				}
				if err != nil {
					suite.Require().False(tc.pass, err.Error())
					return
				}
				bankAfter := BankFromBalances(tc.bankAfter)
				expectedBankChange := bankAfter.Sub(&bankBefore)
				if err := suite.CheckBankBalances(suite.chainB, &realBankBefore, &expectedBankChange); err != nil {
					suite.Require().False(tc.pass, err.Error())
					return
				}
				suite.Require().True(tc.pass)
			})
		}
	}
}
