package keeper

import (
	"sort"

	sdk "github.com/cosmos/cosmos-sdk/types"
	sdkerrors "github.com/cosmos/cosmos-sdk/types/errors"

	"github.com/kava-labs/kava/x/hard/types"
)

// LiqData holds liquidation-related data
type LiqData struct {
	price            sdk.Dec
	ltv              sdk.Dec
	conversionFactor sdk.Int
}

// AttemptKeeperLiquidation enables a keeper to liquidate an individual borrower's position
func (k Keeper) AttemptKeeperLiquidation(ctx sdk.Context, keeper sdk.AccAddress, borrower sdk.AccAddress) error {
	deposit, found := k.GetDeposit(ctx, borrower)
	if !found {
		return types.ErrDepositNotFound
	}

	borrow, found := k.GetBorrow(ctx, borrower)
	if !found {
		return types.ErrBorrowNotFound
	}

	// Call incentive hooks
	k.BeforeDepositModified(ctx, deposit)
	k.BeforeBorrowModified(ctx, borrow)

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

	deposit, found = k.GetDeposit(ctx, borrower)
	if !found {
		return types.ErrDepositNotFound
	}

	borrow, found = k.GetBorrow(ctx, borrower)
	if !found {
		return types.ErrBorrowNotFound
	}

	isWithinRange, err := k.IsWithinValidLtvRange(ctx, deposit, borrow)
	if err != nil {
		return err
	}
	if isWithinRange {
		return sdkerrors.Wrapf(types.ErrBorrowNotLiquidatable, "position is within valid LTV range")
	}

	// Sending coins to auction module with keeper address getting % of the profits
	borrowDenoms := getDenoms(borrow.Amount)
	depositDenoms := getDenoms(deposit.Amount)
	err = k.SeizeDeposits(ctx, keeper, deposit, borrow, depositDenoms, borrowDenoms)
	if err != nil {
		return err
	}

	deposit.Amount = sdk.NewCoins()
	k.DeleteDeposit(ctx, deposit)
	k.AfterDepositModified(ctx, deposit)

	borrow.Amount = sdk.NewCoins()
	k.DeleteBorrow(ctx, borrow)
	k.AfterBorrowModified(ctx, borrow)
	return nil
}

// SeizeDeposits seizes a list of deposits and sends them to auction
func (k Keeper) SeizeDeposits(ctx sdk.Context, keeper sdk.AccAddress, deposit types.Deposit,
	borrow types.Borrow, dDenoms, bDenoms []string) error {
	liqMap, err := k.LoadLiquidationData(ctx, deposit, borrow)
	if err != nil {
		return err
	}

	// Seize % of every deposit and send to the keeper
	keeperRewardCoins := sdk.Coins{}
	for _, depCoin := range deposit.Amount {
		mm, _ := k.GetMoneyMarket(ctx, depCoin.Denom)
		keeperReward := mm.KeeperRewardPercentage.MulInt(depCoin.Amount).TruncateInt()
		if keeperReward.GT(sdk.ZeroInt()) {
			// Send keeper their reward
			keeperCoin := sdk.NewCoin(depCoin.Denom, keeperReward)
			keeperRewardCoins = append(keeperRewardCoins, keeperCoin)
		}
	}
	if !keeperRewardCoins.Empty() {
		k.DecrementSuppliedCoins(ctx, keeperRewardCoins)
		err := k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, keeper, keeperRewardCoins)
		if err != nil {
			return err
		}
	}

	// All deposit amounts not given to keeper as rewards are eligible to be auctioned off
	aucDeposits := deposit.Amount.Sub(keeperRewardCoins)

	// Build valuation map to hold deposit coin USD valuations
	depositCoinValues := types.NewValuationMap()
	for _, deposit := range aucDeposits {
		dData := liqMap[deposit.Denom]
		dCoinUsdValue := sdk.NewDecFromInt(deposit.Amount).Quo(sdk.NewDecFromInt(dData.conversionFactor)).Mul(dData.price)
		depositCoinValues.Increment(deposit.Denom, dCoinUsdValue)
	}

	// Build valuation map to hold borrow coin USD valuations
	borrowCoinValues := types.NewValuationMap()
	for _, bCoin := range borrow.Amount {
		bData := liqMap[bCoin.Denom]
		bCoinUsdValue := sdk.NewDecFromInt(bCoin.Amount).Quo(sdk.NewDecFromInt(bData.conversionFactor)).Mul(bData.price)
		borrowCoinValues.Increment(bCoin.Denom, bCoinUsdValue)
	}

	// Loan-to-Value ratio after sending keeper their reward
	depositUsdValue := depositCoinValues.Sum()
	if depositUsdValue.IsZero() {
		// Deposit value can be zero if params.KeeperRewardPercent is 1.0, or all deposit asset prices are zero.
		// In this case the full deposit will be sent to the keeper and no auctions started.
		return nil
	}
	ltv := borrowCoinValues.Sum().Quo(depositUsdValue)

	liquidatedCoins, err := k.StartAuctions(ctx, deposit.Depositor, borrow.Amount, aucDeposits, depositCoinValues, borrowCoinValues, ltv, liqMap)
	// If some coins were liquidated and sent to auction prior to error, still need to emit liquidation event
	if !liquidatedCoins.Empty() {
		ctx.EventManager().EmitEvent(
			sdk.NewEvent(
				types.EventTypeHardLiquidation,
				sdk.NewAttribute(types.AttributeKeyLiquidatedOwner, deposit.Depositor.String()),
				sdk.NewAttribute(types.AttributeKeyLiquidatedCoins, liquidatedCoins.String()),
				sdk.NewAttribute(types.AttributeKeyKeeper, keeper.String()),
				sdk.NewAttribute(types.AttributeKeyKeeperRewardCoins, keeperRewardCoins.String()),
			),
		)
	}
	// Returns nil if there's no error
	return err
}

// StartAuctions attempts to start auctions for seized assets
func (k Keeper) StartAuctions(ctx sdk.Context, borrower sdk.AccAddress, borrows, deposits sdk.Coins,
	depositCoinValues, borrowCoinValues types.ValuationMap, ltv sdk.Dec, liqMap map[string]LiqData) (sdk.Coins, error) {
	// Sort keys to ensure deterministic behavior
	bKeys := borrowCoinValues.GetSortedKeys()
	dKeys := depositCoinValues.GetSortedKeys()

	// Set up auction constants
	returnAddrs := []sdk.AccAddress{borrower}
	weights := []sdk.Int{sdk.NewInt(100)}
	debt := sdk.NewCoin("debt", sdk.ZeroInt())

	macc := k.supplyKeeper.GetModuleAccount(ctx, types.ModuleAccountName)
	maccCoins := macc.SpendableCoins(ctx.BlockTime())

	var liquidatedCoins sdk.Coins
	for _, bKey := range bKeys {
		bValue := borrowCoinValues.Get(bKey)
		maxLotSize := bValue.Quo(ltv)

		for _, dKey := range dKeys {
			dValue := depositCoinValues.Get(dKey)
			if maxLotSize.Equal(sdk.ZeroDec()) {
				break // exit out of the loop if we have cleared the full amount
			}

			if dValue.GTE(maxLotSize) { // We can start an auction for the whole borrow amount]
				bid := sdk.NewCoin(bKey, borrows.AmountOf(bKey))

				lotSize := maxLotSize.MulInt(liqMap[dKey].conversionFactor).Quo(liqMap[dKey].price)
				if lotSize.TruncateInt().Equal(sdk.ZeroInt()) {
					continue
				}
				lot := sdk.NewCoin(dKey, lotSize.TruncateInt())

				insufficientLotFunds := false
				if lot.Amount.GT(maccCoins.AmountOf(dKey)) {
					insufficientLotFunds = true
					lot = sdk.NewCoin(lot.Denom, maccCoins.AmountOf(dKey))
				}

				// Sanity check that we can deliver coins to the liquidator account
				if deposits.AmountOf(dKey).LT(lot.Amount) {
					return liquidatedCoins, types.ErrInsufficientCoins
				}

				// Start auction: bid = full borrow amount, lot = maxLotSize
				_, err := k.auctionKeeper.StartCollateralAuction(ctx, types.ModuleAccountName, lot, bid, returnAddrs, weights, debt)
				if err != nil {
					return liquidatedCoins, err
				}
				// Decrement supplied coins and decrement borrowed coins optimistically
				err = k.DecrementSuppliedCoins(ctx, sdk.Coins{lot})
				if err != nil {
					return liquidatedCoins, err
				}
				err = k.DecrementBorrowedCoins(ctx, sdk.Coins{bid})
				if err != nil {
					return liquidatedCoins, err
				}

				// Add lot to liquidated coins
				liquidatedCoins = liquidatedCoins.Add(lot)

				// Update USD valuation maps
				borrowCoinValues.SetZero(bKey)
				depositCoinValues.Decrement(dKey, maxLotSize)
				// Update deposits, borrows
				borrows = borrows.Sub(sdk.NewCoins(bid))
				if insufficientLotFunds {
					deposits = deposits.Sub(sdk.NewCoins(sdk.NewCoin(dKey, deposits.AmountOf(dKey))))
				} else {
					deposits = deposits.Sub(sdk.NewCoins(lot))
				}
				// Update max lot size
				maxLotSize = sdk.ZeroDec()
			} else { // We can only start an auction for the partial borrow amount
				maxBid := dValue.Mul(ltv)
				bidSize := maxBid.MulInt(liqMap[bKey].conversionFactor).Quo(liqMap[bKey].price)
				bid := sdk.NewCoin(bKey, bidSize.TruncateInt())
				lot := sdk.NewCoin(dKey, deposits.AmountOf(dKey))

				if bid.Amount.Equal(sdk.ZeroInt()) || lot.Amount.Equal(sdk.ZeroInt()) {
					continue
				}

				insufficientLotFunds := false
				if lot.Amount.GT(maccCoins.AmountOf(dKey)) {
					insufficientLotFunds = true
					lot = sdk.NewCoin(lot.Denom, maccCoins.AmountOf(dKey))
				}

				// Sanity check that we can deliver coins to the liquidator account
				if deposits.AmountOf(dKey).LT(lot.Amount) {
					return liquidatedCoins, types.ErrInsufficientCoins
				}

				// Start auction: bid = maxBid, lot = whole deposit amount
				_, err := k.auctionKeeper.StartCollateralAuction(ctx, types.ModuleAccountName, lot, bid, returnAddrs, weights, debt)
				if err != nil {
					return liquidatedCoins, err
				}
				// Decrement supplied coins and decrement borrowed coins optimistically
				err = k.DecrementSuppliedCoins(ctx, sdk.Coins{lot})
				if err != nil {
					return liquidatedCoins, err
				}
				err = k.DecrementBorrowedCoins(ctx, sdk.Coins{bid})
				if err != nil {
					return liquidatedCoins, err
				}

				// Add lot to liquidated coins
				liquidatedCoins = liquidatedCoins.Add(lot)

				// Update variables to account for partial auction
				borrowCoinValues.Decrement(bKey, maxBid)
				depositCoinValues.SetZero(dKey)

				borrows = borrows.Sub(sdk.NewCoins(bid))
				if insufficientLotFunds {
					deposits = deposits.Sub(sdk.NewCoins(sdk.NewCoin(dKey, deposits.AmountOf(dKey))))
				} else {
					deposits = deposits.Sub(sdk.NewCoins(lot))
				}

				// Update max lot size
				maxLotSize = borrowCoinValues.Get(bKey).Quo(ltv)
			}
		}
	}

	// Send any remaining deposit back to the original borrower
	for _, dKey := range dKeys {
		remaining := deposits.AmountOf(dKey)
		if remaining.GT(sdk.ZeroInt()) {
			returnCoin := sdk.NewCoins(sdk.NewCoin(dKey, remaining))
			err := k.supplyKeeper.SendCoinsFromModuleToAccount(ctx, types.ModuleAccountName, borrower, returnCoin)
			if err != nil {
				return liquidatedCoins, err
			}
		}
	}

	return liquidatedCoins, nil
}

// IsWithinValidLtvRange compares a borrow and deposit to see if it's within a valid LTV range at current prices
func (k Keeper) IsWithinValidLtvRange(ctx sdk.Context, deposit types.Deposit, borrow types.Borrow) (bool, error) {
	liqMap, err := k.LoadLiquidationData(ctx, deposit, borrow)
	if err != nil {
		return false, err
	}

	totalBorrowableUSDAmount := sdk.ZeroDec()
	for _, depCoin := range deposit.Amount {
		lData := liqMap[depCoin.Denom]
		usdValue := sdk.NewDecFromInt(depCoin.Amount).Quo(sdk.NewDecFromInt(lData.conversionFactor)).Mul(lData.price)
		borrowableUSDAmountForDeposit := usdValue.Mul(lData.ltv)
		totalBorrowableUSDAmount = totalBorrowableUSDAmount.Add(borrowableUSDAmountForDeposit)
	}

	totalBorrowedUSDAmount := sdk.ZeroDec()
	for _, coin := range borrow.Amount {
		lData := liqMap[coin.Denom]
		usdValue := sdk.NewDecFromInt(coin.Amount).Quo(sdk.NewDecFromInt(lData.conversionFactor)).Mul(lData.price)
		totalBorrowedUSDAmount = totalBorrowedUSDAmount.Add(usdValue)
	}

	// Check if the user's has borrowed more than they're allowed to
	if totalBorrowedUSDAmount.GT(totalBorrowableUSDAmount) {
		return false, nil
	}

	return true, nil
}

// GetStoreLTV calculates the user's current LTV based on their deposits/borrows in the store
// and does not include any outsanding interest.
func (k Keeper) GetStoreLTV(ctx sdk.Context, addr sdk.AccAddress) (sdk.Dec, error) {
	// Fetch deposits and parse coin denoms
	deposit, found := k.GetDeposit(ctx, addr)
	if !found {
		return sdk.ZeroDec(), nil
	}

	// Fetch borrow balances and parse coin denoms
	borrow, found := k.GetBorrow(ctx, addr)
	if !found {
		return sdk.ZeroDec(), nil
	}

	return k.CalculateLtv(ctx, deposit, borrow)
}

// CalculateLtv calculates the potential LTV given a user's deposits and borrows.
// The boolean returned indicates if the LTV should be added to the store's LTV index.
func (k Keeper) CalculateLtv(ctx sdk.Context, deposit types.Deposit, borrow types.Borrow) (sdk.Dec, error) {
	// Load required liquidation data for every deposit/borrow denom
	liqMap, err := k.LoadLiquidationData(ctx, deposit, borrow)
	if err != nil {
		return sdk.ZeroDec(), nil
	}

	// Build valuation map to hold deposit coin USD valuations
	depositCoinValues := types.NewValuationMap()
	for _, depCoin := range deposit.Amount {
		dData := liqMap[depCoin.Denom]
		dCoinUsdValue := sdk.NewDecFromInt(depCoin.Amount).Quo(sdk.NewDecFromInt(dData.conversionFactor)).Mul(dData.price)
		depositCoinValues.Increment(depCoin.Denom, dCoinUsdValue)
	}

	// Build valuation map to hold borrow coin USD valuations
	borrowCoinValues := types.NewValuationMap()
	for _, bCoin := range borrow.Amount {
		bData := liqMap[bCoin.Denom]
		bCoinUsdValue := sdk.NewDecFromInt(bCoin.Amount).Quo(sdk.NewDecFromInt(bData.conversionFactor)).Mul(bData.price)
		borrowCoinValues.Increment(bCoin.Denom, bCoinUsdValue)
	}

	// User doesn't have any deposits, catch divide by 0 error
	sumDeposits := depositCoinValues.Sum()
	if sumDeposits.Equal(sdk.ZeroDec()) {
		return sdk.ZeroDec(), nil
	}

	// Loan-to-Value ratio
	return borrowCoinValues.Sum().Quo(sumDeposits), nil
}

// LoadLiquidationData returns liquidation data, deposit, borrow
func (k Keeper) LoadLiquidationData(ctx sdk.Context, deposit types.Deposit, borrow types.Borrow) (map[string]LiqData, error) {
	liqMap := make(map[string]LiqData)

	borrowDenoms := getDenoms(borrow.Amount)
	depositDenoms := getDenoms(deposit.Amount)
	denoms := removeDuplicates(borrowDenoms, depositDenoms)

	// Load required liquidation data for every deposit/borrow denom
	for _, denom := range denoms {
		mm, found := k.GetMoneyMarket(ctx, denom)
		if !found {
			return liqMap, sdkerrors.Wrapf(types.ErrMarketNotFound, "no market found for denom %s", denom)

		}

		priceData, err := k.pricefeedKeeper.GetCurrentPrice(ctx, mm.SpotMarketID)
		if err != nil {
			return liqMap, err
		}

		liqMap[denom] = LiqData{priceData.Price, mm.BorrowLimit.LoanToValue, mm.ConversionFactor}
	}

	return liqMap, nil
}

func getDenoms(coins sdk.Coins) []string {
	denoms := []string{}
	for _, coin := range coins {
		denoms = append(denoms, coin.Denom)
	}
	return denoms
}

func removeDuplicates(one []string, two []string) []string {
	check := make(map[string]int)
	fullList := append(one, two...)

	res := []string{}
	for _, val := range fullList {
		check[val] = 1
	}

	for key := range check {
		res = append(res, key)
	}
	sort.Strings(res)
	return res
}
