package keeper

import (
	"errors"

	errorsmod "cosmossdk.io/errors"
	sdkmath "cosmossdk.io/math"
	sdk "github.com/cosmos/cosmos-sdk/types"
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"
	"github.com/kava-labs/kava/x/hard/types"
)

// Borrow funds
func (k Keeper) Borrow(ctx sdk.Context, borrower sdk.AccAddress, coins sdk.Coins) error {
	// Set any new denoms' global borrow index to 1.0
	for _, coin := range coins {
		_, foundInterestFactor := k.GetBorrowInterestFactor(ctx, coin.Denom)
		if !foundInterestFactor {
			_, foundMm := k.GetMoneyMarket(ctx, coin.Denom)
			if foundMm {
				k.SetBorrowInterestFactor(ctx, coin.Denom, sdk.OneDec())
			}
		}
	}

	// Call incentive hooks
	existingDeposit, hasExistingDeposit := k.GetDeposit(ctx, borrower)
	if hasExistingDeposit {
		k.BeforeDepositModified(ctx, existingDeposit)
	}
	existingBorrow, hasExistingBorrow := k.GetBorrow(ctx, borrower)
	if hasExistingBorrow {
		k.BeforeBorrowModified(ctx, existingBorrow)
	}

	k.SyncSupplyInterest(ctx, borrower)
	k.SyncBorrowInterest(ctx, borrower)

	// Validate borrow amount within user and protocol limits
	err := k.ValidateBorrow(ctx, borrower, coins)
	if err != nil {
		return err
	}

	// Sends coins from Hard module account to user
	err = k.bankKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, borrower, coins)
	if err != nil {
		if errors.Is(err, sdkerrors.ErrInsufficientFunds) {
			macc := k.accountKeeper.GetModuleAccount(ctx, types.ModuleAccountName)
			modAccCoins := k.bankKeeper.GetAllBalances(ctx, macc.GetAddress())
			for _, coin := range coins {
				_, isNegative := modAccCoins.SafeSub(coin)
				if isNegative {
					return errorsmod.Wrapf(types.ErrBorrowExceedsAvailableBalance,
						"the requested borrow amount of %s exceeds the total amount of %s%s available to borrow",
						coin, modAccCoins.AmountOf(coin.Denom), coin.Denom,
					)
				}
			}
		}
		return err
	}

	interestFactors := types.BorrowInterestFactors{}
	currBorrow, foundBorrow := k.GetBorrow(ctx, borrower)
	if foundBorrow {
		interestFactors = currBorrow.Index
	}
	for _, coin := range coins {
		interestFactorValue, foundValue := k.GetBorrowInterestFactor(ctx, coin.Denom)
		if foundValue {
			interestFactors = interestFactors.SetInterestFactor(coin.Denom, interestFactorValue)
		}
	}

	// Calculate new borrow amount
	var amount sdk.Coins
	if foundBorrow {
		amount = currBorrow.Amount.Add(coins...)
	} else {
		amount = coins
	}

	// Construct the user's new/updated borrow with amount and interest factors
	borrow := types.NewBorrow(borrower, amount, interestFactors)
	if borrow.Amount.Empty() {
		k.DeleteBorrow(ctx, borrow)
	} else {
		k.SetBorrow(ctx, borrow)
	}

	// Update total borrowed amount by newly borrowed coins. Don't add user's pending interest as
	// it has already been included in the total borrowed coins by the BeginBlocker.
	k.IncrementBorrowedCoins(ctx, coins)

	if !hasExistingBorrow {
		k.AfterBorrowCreated(ctx, borrow)
	} else {
		k.AfterBorrowModified(ctx, borrow)
	}

	ctx.EventManager().EmitEvent(
		sdk.NewEvent(
			types.EventTypeHardBorrow,
			sdk.NewAttribute(types.AttributeKeyBorrower, borrower.String()),
			sdk.NewAttribute(types.AttributeKeyBorrowCoins, coins.String()),
		),
	)

	return nil
}

// ValidateBorrow validates a borrow request against borrower and protocol requirements
func (k Keeper) ValidateBorrow(ctx sdk.Context, borrower sdk.AccAddress, amount sdk.Coins) error {
	if amount.IsZero() {
		return types.ErrBorrowEmptyCoins
	}

	// The reserve coins aren't available for users to borrow
	macc := k.accountKeeper.GetModuleAccount(ctx, types.ModuleName)
	hardMaccCoins := FilterCoinsByDenoms(k.bankKeeper.GetAllBalances(ctx, macc.GetAddress()), amount)
	reserveCoins, foundReserveCoins := k.GetTotalReserves(ctx)
	if !foundReserveCoins {
		reserveCoins = sdk.NewCoins()
	} else {
		reserveCoins = FilterCoinsByDenoms(reserveCoins, amount)
	}

	fundsAvailableToBorrow, isNegative := hardMaccCoins.SafeSub(reserveCoins...)
	if isNegative {
		return errorsmod.Wrapf(types.ErrReservesExceedCash, "reserves %s > cash %s", reserveCoins, hardMaccCoins)
	}
	if amount.IsAnyGT(fundsAvailableToBorrow) {
		return errorsmod.Wrapf(types.ErrExceedsProtocolBorrowableBalance, "requested borrow %s > available to borrow %s", amount, fundsAvailableToBorrow)
	}

	// Get the proposed borrow USD value
	proprosedBorrowUSDValue := sdk.ZeroDec()
	for _, coin := range amount {
		moneyMarket, found := k.GetMoneyMarket(ctx, coin.Denom)
		if !found {
			return errorsmod.Wrapf(types.ErrMarketNotFound, "no money market found for denom %s", coin.Denom)
		}

		// Calculate this coin's USD value and add it borrow's total USD value
		assetPriceInfo, err := k.pricefeedKeeper.GetCurrentPrice(ctx, moneyMarket.SpotMarketID)
		if err != nil {
			return errorsmod.Wrapf(types.ErrPriceNotFound, "no price found for market %s", moneyMarket.SpotMarketID)
		}
		coinUSDValue := sdk.NewDecFromInt(coin.Amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor)).Mul(assetPriceInfo.Price)

		// Validate the requested borrow value for the asset against the money market's global borrow limit
		if moneyMarket.BorrowLimit.HasMaxLimit {
			var assetTotalBorrowedAmount sdkmath.Int
			totalBorrowedCoins, found := k.GetBorrowedCoins(ctx)
			if !found {
				assetTotalBorrowedAmount = sdk.ZeroInt()
			} else {
				assetTotalBorrowedAmount = totalBorrowedCoins.AmountOf(coin.Denom)
			}
			newProposedAssetTotalBorrowedAmount := sdk.NewDecFromInt(assetTotalBorrowedAmount.Add(coin.Amount))
			if newProposedAssetTotalBorrowedAmount.GT(moneyMarket.BorrowLimit.MaximumLimit) {
				return errorsmod.Wrapf(types.ErrGreaterThanAssetBorrowLimit,
					"proposed borrow would result in %s borrowed, but the maximum global asset borrow limit is %s",
					newProposedAssetTotalBorrowedAmount, moneyMarket.BorrowLimit.MaximumLimit)
			}
		}
		proprosedBorrowUSDValue = proprosedBorrowUSDValue.Add(coinUSDValue)
	}

	// Get the total borrowable USD amount at user's existing deposits
	deposit, found := k.GetDeposit(ctx, borrower)
	if !found {
		return errorsmod.Wrapf(types.ErrDepositsNotFound, "no deposits found for %s", borrower)
	}
	totalBorrowableAmount := sdk.ZeroDec()
	for _, coin := range deposit.Amount {
		moneyMarket, found := k.GetMoneyMarket(ctx, coin.Denom)
		if !found {
			return errorsmod.Wrapf(types.ErrMarketNotFound, "no money market found for denom %s", coin.Denom)
		}

		// Calculate the borrowable amount and add it to the user's total borrowable amount
		assetPriceInfo, err := k.pricefeedKeeper.GetCurrentPrice(ctx, moneyMarket.SpotMarketID)
		if err != nil {
			return errorsmod.Wrapf(types.ErrPriceNotFound, "no price found for market %s", moneyMarket.SpotMarketID)
		}
		depositUSDValue := sdk.NewDecFromInt(coin.Amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor)).Mul(assetPriceInfo.Price)
		borrowableAmountForDeposit := depositUSDValue.Mul(moneyMarket.BorrowLimit.LoanToValue)
		totalBorrowableAmount = totalBorrowableAmount.Add(borrowableAmountForDeposit)
	}

	// Get the total USD value of user's existing borrows
	existingBorrowUSDValue := sdk.ZeroDec()
	existingBorrow, found := k.GetBorrow(ctx, borrower)
	if found {
		for _, coin := range existingBorrow.Amount {
			moneyMarket, found := k.GetMoneyMarket(ctx, coin.Denom)
			if !found {
				return errorsmod.Wrapf(types.ErrMarketNotFound, "no money market found for denom %s", coin.Denom)
			}

			// Calculate this borrow coin's USD value and add it to the total previous borrowed USD value
			assetPriceInfo, err := k.pricefeedKeeper.GetCurrentPrice(ctx, moneyMarket.SpotMarketID)
			if err != nil {
				return errorsmod.Wrapf(types.ErrPriceNotFound, "no price found for market %s", moneyMarket.SpotMarketID)
			}
			coinUSDValue := sdk.NewDecFromInt(coin.Amount).Quo(sdk.NewDecFromInt(moneyMarket.ConversionFactor)).Mul(assetPriceInfo.Price)
			existingBorrowUSDValue = existingBorrowUSDValue.Add(coinUSDValue)
		}
	}

	// Borrow's updated total USD value must be greater than the minimum global USD borrow limit
	totalBorrowUSDValue := proprosedBorrowUSDValue.Add(existingBorrowUSDValue)
	if totalBorrowUSDValue.LT(k.GetMinimumBorrowUSDValue(ctx)) {
		return errorsmod.Wrapf(types.ErrBelowMinimumBorrowValue, "the proposed borrow's USD value $%s is below the minimum borrow limit $%s", totalBorrowUSDValue, k.GetMinimumBorrowUSDValue(ctx))
	}

	// Validate that the proposed borrow's USD value is within user's borrowable limit
	if proprosedBorrowUSDValue.GT(totalBorrowableAmount.Sub(existingBorrowUSDValue)) {
		return errorsmod.Wrapf(types.ErrInsufficientLoanToValue, "requested borrow %s exceeds the allowable amount as determined by the collateralization ratio", amount)
	}
	return nil
}

// FilterCoinsByDenoms filters the given coins by retaining only those whose denoms
// are present in the filterByCoins list.
//
// Parameters:
// - coins: The list of coins to be filtered.
// - filterByCoins: The list of coins whose denoms will be used as a filter.
//
// Returns:
// - A new list of coins that includes only those coins whose denom is in the filterByCoins list.
func FilterCoinsByDenoms(coins, filterByCoins sdk.Coins) sdk.Coins {
	// Create a map to store the denoms that we want to filter by.
	denoms := make(map[string]struct{})

	// Populate the map with denoms from filterByCoins.
	for _, denom := range filterByCoins.Denoms() {
		denoms[denom] = struct{}{}
	}

	// Prepare a slice to hold the filtered coins.
	filteredCoins := make(sdk.Coins, 0, len(coins))

	// Iterate through the list of coins and add those that have a denom in the denoms map.
	for _, coin := range coins {
		if _, exists := denoms[coin.Denom]; exists {
			filteredCoins = append(filteredCoins, coin)
		}
	}

	return filteredCoins
}

// IncrementBorrowedCoins increments the total amount of borrowed coins by the newCoins parameter
func (k Keeper) IncrementBorrowedCoins(ctx sdk.Context, newCoins sdk.Coins) {
	borrowedCoins, found := k.GetBorrowedCoins(ctx)
	if !found {
		if !newCoins.Empty() {
			k.SetBorrowedCoins(ctx, newCoins)
		}
	} else {
		k.SetBorrowedCoins(ctx, borrowedCoins.Add(newCoins...))
	}
}

// DecrementBorrowedCoins decrements the total amount of borrowed coins by the coins parameter
func (k Keeper) DecrementBorrowedCoins(ctx sdk.Context, coins sdk.Coins) error {
	borrowedCoins, found := k.GetBorrowedCoins(ctx)
	if !found {
		return errorsmod.Wrapf(types.ErrBorrowedCoinsNotFound, "cannot repay coins if no coins are currently borrowed")
	}

	updatedBorrowedCoins, isNegative := borrowedCoins.SafeSub(coins...)
	if isNegative {
		coinsToSubtract := sdk.NewCoins()
		for _, coin := range coins {
			if borrowedCoins.AmountOf(coin.Denom).LT(coin.Amount) {
				if borrowedCoins.AmountOf(coin.Denom).GT(sdk.ZeroInt()) {
					coinsToSubtract = coinsToSubtract.Add(sdk.NewCoin(coin.Denom, borrowedCoins.AmountOf(coin.Denom)))
				}
			} else {
				coinsToSubtract = coinsToSubtract.Add(coin)
			}
		}
		updatedBorrowedCoins = borrowedCoins.Sub(coinsToSubtract...)
	}

	k.SetBorrowedCoins(ctx, updatedBorrowedCoins)
	return nil
}

// GetSyncedBorrow returns a borrow object containing current balances and indexes
func (k Keeper) GetSyncedBorrow(ctx sdk.Context, borrower sdk.AccAddress) (types.Borrow, bool) {
	borrow, found := k.GetBorrow(ctx, borrower)
	if !found {
		return types.Borrow{}, false
	}

	return k.loadSyncedBorrow(ctx, borrow), true
}

// loadSyncedBorrow calculates a user's synced borrow, but does not update state
func (k Keeper) loadSyncedBorrow(ctx sdk.Context, borrow types.Borrow) types.Borrow {
	totalNewInterest := sdk.Coins{}
	newBorrowIndexes := types.BorrowInterestFactors{}
	for _, coin := range borrow.Amount {
		interestFactorValue, foundInterestFactorValue := k.GetBorrowInterestFactor(ctx, coin.Denom)
		if foundInterestFactorValue {
			// Locate the interest factor by coin denom in the user's list of interest factors
			foundAtIndex := -1
			for i := range borrow.Index {
				if borrow.Index[i].Denom == coin.Denom {
					foundAtIndex = i
					break
				}
			}

			// Calculate interest owed by user for this asset
			if foundAtIndex != -1 {
				storedAmount := sdk.NewDecFromInt(borrow.Amount.AmountOf(coin.Denom))
				userLastInterestFactor := borrow.Index[foundAtIndex].Value
				coinInterest := (storedAmount.Quo(userLastInterestFactor).Mul(interestFactorValue)).Sub(storedAmount)
				totalNewInterest = totalNewInterest.Add(sdk.NewCoin(coin.Denom, coinInterest.TruncateInt()))
			}
		}

		borrowIndex := types.NewBorrowInterestFactor(coin.Denom, interestFactorValue)
		newBorrowIndexes = append(newBorrowIndexes, borrowIndex)
	}

	return types.NewBorrow(borrow.Borrower, borrow.Amount.Add(totalNewInterest...), newBorrowIndexes)
}