// SPDX-License-Identifier: GPL-3.0-only
pragma solidity >0.7.0;
pragma experimental ABIEncoderV2;

import "../FreeCollateralExternal.sol";
import "../SettleAssetsExternal.sol";
import "../../internal/markets/Market.sol";
import "../../internal/markets/CashGroup.sol";
import "../../internal/markets/AssetRate.sol";
import "../../internal/balances/BalanceHandler.sol";
import "../../internal/portfolio/PortfolioHandler.sol";
import "../../internal/portfolio/TransferAssets.sol";
import "../../math/SafeInt256.sol";
import "@openzeppelin/contracts/math/SafeMath.sol";

library TradingAction {
    using PortfolioHandler for PortfolioState;
    using AccountContextHandler for AccountContext;
    using Market for MarketParameters;
    using CashGroup for CashGroupParameters;
    using AssetRate for AssetRateParameters;
    using SafeInt256 for int256;
    using SafeMath for uint256;

    event LendBorrowTrade(
        address account,
        uint16 currencyId,
        uint40 maturity,
        int256 netAssetCash,
        int256 netfCash,
        int256 netFee
    );
    event AddRemoveLiquidity(
        address account,
        uint16 currencyId,
        uint40 maturity,
        int256 netAssetCash,
        int256 netfCash,
        int256 netLiquidityTokens
    );

    event SettledCashDebt(
        address settledAccount,
        uint16 currencyId,
        int256 amountToSettleAsset,
        int256 fCashAmount
    );

    event nTokenResidualPurchase(
        uint16 currencyId,
        uint40 maturity,
        int256 fCashAmountToPurchase,
        int256 netAssetCashNToken
    );

    /// @dev Used internally to manage stack issues
    struct TradeContext {
        int256 cash;
        int256 fCashAmount;
        int256 fee;
        int256 netCash;
        int256 totalFee;
        uint256 blockTime;
    }

    /// @dev Executes trades for a bitmapped portfolio
    function executeTradesBitmapBatch(
        address account,
        AccountContext calldata accountContext,
        bytes32[] calldata trades
    ) external returns (int256, bool) {
        CashGroupParameters memory cashGroup =
            CashGroup.buildCashGroupStateful(accountContext.bitmapCurrencyId);
        MarketParameters memory market;
        bytes32 ifCashBitmap =
            BitmapAssetsHandler.getAssetsBitmap(account, accountContext.bitmapCurrencyId);
        bool didIncurDebt;
        TradeContext memory c;
        c.blockTime = block.timestamp;

        for (uint256 i; i < trades.length; i++) {
            uint256 maturity;
            (maturity, c.cash, c.fCashAmount, c.fee) = _executeTrade(
                account,
                cashGroup,
                market,
                trades[i],
                c.blockTime
            );

            (ifCashBitmap, c.fCashAmount) = BitmapAssetsHandler.addifCashAsset(
                account,
                accountContext.bitmapCurrencyId,
                maturity,
                accountContext.nextSettleTime,
                c.fCashAmount,
                ifCashBitmap
            );

            if (c.fCashAmount < 0) didIncurDebt = true;
            c.netCash = c.netCash.add(c.cash);
            c.totalFee = c.totalFee.add(c.fee);
        }

        BitmapAssetsHandler.setAssetsBitmap(account, accountContext.bitmapCurrencyId, ifCashBitmap);
        BalanceHandler.incrementFeeToReserve(accountContext.bitmapCurrencyId, c.totalFee);

        return (c.netCash, didIncurDebt);
    }

    /// @dev Executes trades for an array portfolio
    function executeTradesArrayBatch(
        address account,
        uint256 currencyId,
        PortfolioState memory portfolioState,
        bytes32[] calldata trades
    ) external returns (PortfolioState memory, int256) {
        CashGroupParameters memory cashGroup = CashGroup.buildCashGroupStateful(currencyId);
        MarketParameters memory market;
        TradeContext memory c;
        c.blockTime = block.timestamp;

        for (uint256 i; i < trades.length; i++) {
            TradeActionType tradeType = TradeActionType(uint256(uint8(bytes1(trades[i]))));

            if (
                tradeType == TradeActionType.AddLiquidity ||
                tradeType == TradeActionType.RemoveLiquidity
            ) {
                // Liquidity tokens can only be added by array portfolio
                c.cash = _executeLiquidityTrade(
                    account,
                    cashGroup,
                    market,
                    tradeType,
                    trades[i],
                    portfolioState,
                    c.netCash
                );
            } else {
                uint256 maturity;
                (maturity, c.cash, c.fCashAmount, c.fee) = _executeTrade(
                    account,
                    cashGroup,
                    market,
                    trades[i],
                    c.blockTime
                );
                // Stack issues here :(
                _addfCashAsset(portfolioState, currencyId, maturity, c.fCashAmount);
                c.totalFee = c.totalFee.add(c.fee);
            }

            c.netCash = c.netCash.add(c.cash);
        }

        BalanceHandler.incrementFeeToReserve(currencyId, c.totalFee);

        return (portfolioState, c.netCash);
    }

    /// @dev used to clear the stack
    function _addfCashAsset(
        PortfolioState memory portfolioState,
        uint256 currencyId,
        uint256 maturity,
        int256 notional
    ) private pure {
        portfolioState.addAsset(currencyId, maturity, Constants.FCASH_ASSET_TYPE, notional, false);
    }

    function _executeTrade(
        address account,
        CashGroupParameters memory cashGroup,
        MarketParameters memory market,
        bytes32 trade,
        uint256 blockTime
    )
        private
        returns (
            uint256 maturity,
            int256 cashAmount,
            int256 fCashAmount,
            int256 fee
        )
    {
        TradeActionType tradeType = TradeActionType(uint256(uint8(bytes1(trade))));
        if (tradeType == TradeActionType.PurchaseNTokenResidual) {
            (maturity, cashAmount, fCashAmount) = _purchaseNTokenResidual(
                cashGroup,
                blockTime,
                trade
            );
        } else if (tradeType == TradeActionType.SettleCashDebt) {
            (maturity, cashAmount, fCashAmount) = _settleCashDebt(cashGroup, blockTime, trade);
        } else if (tradeType == TradeActionType.Lend || tradeType == TradeActionType.Borrow) {
            (cashAmount, fCashAmount, fee) = _executeLendBorrowTrade(
                cashGroup,
                market,
                tradeType,
                blockTime,
                trade
            );

            // This is a little ugly but required to deal with stack issues. We know the market is loaded with the proper
            // maturity inside _executeLendBorrowTrade
            maturity = market.maturity;
            emit LendBorrowTrade(
                account,
                uint16(cashGroup.currencyId),
                uint40(maturity),
                cashAmount,
                fCashAmount,
                fee
            );
        } else {
            revert("Invalid trade type");
        }
    }

    function _executeLiquidityTrade(
        address account,
        CashGroupParameters memory cashGroup,
        MarketParameters memory market,
        TradeActionType tradeType,
        bytes32 trade,
        PortfolioState memory portfolioState,
        int256 netCash
    ) private returns (int256) {
        uint256 marketIndex = uint256(uint8(bytes1(trade << 8)));
        cashGroup.loadMarket(market, marketIndex, true, block.timestamp);

        int256 cashAmount;
        int256 fCashAmount;
        int256 tokens;
        if (tradeType == TradeActionType.AddLiquidity) {
            cashAmount = int256(uint88(bytes11(trade << 16)));
            // Setting cash amount to zero will deposit all net cash accumulated in this trade into
            // liquidity. This feature allows accounts to borrow in one maturity to provide liquidity
            // in another in a single transaction without dust. It also allows liquidity providers to
            // sell off the net cash residuals and use the cash amount in the new market without dust
            if (cashAmount == 0) {
                cashAmount = netCash;
                require(cashAmount > 0, "Invalid cash roll");
            }

            (tokens, fCashAmount) = market.addLiquidity(cashAmount);
            cashAmount = cashAmount.neg(); // Net cash is negative
        } else {
            tokens = int256(uint88(bytes11(trade << 16)));
            (cashAmount, fCashAmount) = market.removeLiquidity(tokens);
            tokens = tokens.neg();
        }

        {
            uint256 minImpliedRate = uint256(uint32(bytes4(trade << 104)));
            uint256 maxImpliedRate = uint256(uint32(bytes4(trade << 136)));
            require(market.lastImpliedRate >= minImpliedRate, "Trade failed, slippage");
            if (maxImpliedRate != 0)
                require(market.lastImpliedRate <= maxImpliedRate, "Trade failed, slippage");
            market.setMarketStorage();
        }

        // Add the assets in this order so they are sorted
        portfolioState.addAsset(
            cashGroup.currencyId,
            market.maturity,
            Constants.FCASH_ASSET_TYPE,
            fCashAmount,
            false
        );
        portfolioState.addAsset(
            cashGroup.currencyId,
            market.maturity,
            marketIndex + 1,
            tokens,
            false
        );

        emit AddRemoveLiquidity(
            account,
            uint16(cashGroup.currencyId),
            uint40(market.maturity),
            cashAmount,
            fCashAmount,
            tokens
        );

        return (cashAmount);
    }

    function _executeLendBorrowTrade(
        CashGroupParameters memory cashGroup,
        MarketParameters memory market,
        TradeActionType tradeType,
        uint256 blockTime,
        bytes32 trade
    )
        private
        returns (
            int256,
            int256,
            int256
        )
    {
        uint256 marketIndex = uint256(uint8(bytes1(trade << 8)));
        cashGroup.loadMarket(market, marketIndex, false, blockTime);

        int256 fCashAmount = int256(uint88(bytes11(trade << 16)));
        if (tradeType == TradeActionType.Borrow) fCashAmount = fCashAmount.neg();

        (int256 cashAmount, int256 fee) =
            market.calculateTrade(
                cashGroup,
                fCashAmount,
                market.maturity.sub(blockTime),
                marketIndex
            );
        require(cashAmount != 0, "Trade failed, liquidity");

        uint256 rateLimit = uint256(uint32(bytes4(trade << 104)));
        if (rateLimit != 0) {
            if (tradeType == TradeActionType.Borrow) {
                require(market.lastImpliedRate <= rateLimit, "Trade failed, slippage");
            } else {
                require(market.lastImpliedRate >= rateLimit, "Trade failed, slippage");
            }
        }
        market.setMarketStorage();

        return (cashAmount, fCashAmount, fee);
    }

    /// @notice If an account has a negative cash balance we allow anyone to lend to to that account at a penalty
    /// rate to the 3 month market.
    function _settleCashDebt(
        CashGroupParameters memory cashGroup,
        uint256 blockTime,
        bytes32 trade
    )
        internal
        returns (
            uint256,
            int256,
            int256
        )
    {
        address counterparty = address(bytes20(trade << 8));
        int256 amountToSettleAsset = int256(int88(bytes11(trade << 168)));

        AccountContext memory counterpartyContext =
            AccountContextHandler.getAccountContext(counterparty);

        if (counterpartyContext.mustSettleAssets()) {
            counterpartyContext = SettleAssetsExternal.settleAssetsAndFinalize(counterparty);
        }

        // This will check if the amountToSettleAsset is valid and revert if it is not. Amount to settle is a positive
        // number denominated in asset terms. If amountToSettleAsset is set equal to zero on the input, will return the
        // max amount to settle.
        amountToSettleAsset = BalanceHandler.setBalanceStorageForSettleCashDebt(
            counterparty,
            cashGroup,
            amountToSettleAsset,
            counterpartyContext
        );

        // Settled account must borrow from the 3 month market at a penalty rate. Even if the market is
        // not initialized we can still settle cash debts because we reference the previous 3 month market's oracle
        // rate which is where the new 3 month market's oracle rate will be initialized to.
        uint256 threeMonthMaturity = DateTime.getReferenceTime(blockTime) + Constants.QUARTER;
        int256 fCashAmount =
            _getfCashSettleAmount(cashGroup, threeMonthMaturity, blockTime, amountToSettleAsset);

        // It's possible that this action will put an account into negative free collateral. In this case they
        // will immediately become eligible for liquidation and the account settling the debt can also liquidate
        // them in the same transaction. Do not run a free collateral check here to allow this to happen.
        {
            PortfolioAsset[] memory assets = new PortfolioAsset[](1);
            assets[0].currencyId = cashGroup.currencyId;
            assets[0].maturity = threeMonthMaturity;
            assets[0].notional = fCashAmount.neg(); // This is the debt the settled account will incur
            assets[0].assetType = Constants.FCASH_ASSET_TYPE;
            counterpartyContext = TransferAssets.placeAssetsInAccount(
                counterparty,
                counterpartyContext,
                assets
            );
        }
        counterpartyContext.setAccountContext(counterparty);

        emit SettledCashDebt(
            counterparty,
            uint16(cashGroup.currencyId),
            amountToSettleAsset,
            fCashAmount.neg()
        );

        return (threeMonthMaturity, amountToSettleAsset.neg(), fCashAmount);
    }

    /// @dev Helper method to calculate the fCashAmount from the penalty settlement rate
    function _getfCashSettleAmount(
        CashGroupParameters memory cashGroup,
        uint256 threeMonthMaturity,
        uint256 blockTime,
        int256 amountToSettleAsset
    ) private view returns (int256) {
        uint256 oracleRate = cashGroup.calculateOracleRate(threeMonthMaturity, blockTime);

        int256 exchangeRate =
            Market.getExchangeRateFromImpliedRate(
                oracleRate.add(cashGroup.getSettlementPenalty()),
                threeMonthMaturity.sub(blockTime)
            );

        // Amount to settle is positive, this returns the fCashAmount that the settler will
        // receive as a positive number
        return
            cashGroup.assetRate.convertToUnderlying(amountToSettleAsset).mul(exchangeRate).div(
                Constants.RATE_PRECISION
            );
    }

    /// @dev Enables purchasing of NToken residuals
    function _purchaseNTokenResidual(
        CashGroupParameters memory cashGroup,
        uint256 blockTime,
        bytes32 trade
    )
        internal
        returns (
            uint256,
            int256,
            int256
        )
    {
        uint256 maturity = uint256(uint32(bytes4(trade << 8)));
        int256 fCashAmountToPurchase = int256(int88(bytes11(trade << 40)));
        require(maturity > blockTime, "Invalid maturity");
        // Require that the residual to purchase does not fall on an existing maturity (i.e.
        // it is an idiosyncratic maturity)
        require(
            !DateTime.isValidMarketMaturity(cashGroup.maxMarketIndex, maturity, blockTime),
            "Invalid maturity"
        );

        address nTokenAddress = nTokenHandler.nTokenAddress(cashGroup.currencyId);
        // prettier-ignore
        (
            /* currencyId */,
            /* totalSupply */,
            /* incentiveRate */,
            uint256 lastInitializedTime,
            bytes6 parameters
        ) = nTokenHandler.getNTokenContext(nTokenAddress);

        // Restrict purchasing until some amount of time after the last initialized time to ensure that arbitrage
        // opportunities are not available (by generating residuals and then immediately purchasing them at a discount)
        require(
            blockTime >
                lastInitializedTime.add(
                    uint256(uint8(parameters[Constants.RESIDUAL_PURCHASE_TIME_BUFFER])) * 3600
                ),
            "Insufficient block time"
        );

        int256 notional =
            BitmapAssetsHandler.getifCashNotional(nTokenAddress, cashGroup.currencyId, maturity);
        // Check if amounts are valid and set them to the max available if necessary
        if (notional < 0 && fCashAmountToPurchase < 0) {
            if (fCashAmountToPurchase < notional) fCashAmountToPurchase = notional;
        } else if (notional > 0 && fCashAmountToPurchase > 0) {
            if (fCashAmountToPurchase > notional) fCashAmountToPurchase = notional;
        } else {
            revert("Invalid amount");
        }

        int256 netAssetCashNToken =
            _getResidualPriceAssetCash(
                cashGroup,
                maturity,
                blockTime,
                fCashAmountToPurchase,
                parameters
            );

        _updateNTokenPortfolio(
            nTokenAddress,
            cashGroup.currencyId,
            maturity,
            lastInitializedTime,
            fCashAmountToPurchase,
            netAssetCashNToken
        );

        emit nTokenResidualPurchase(
            uint16(cashGroup.currencyId),
            uint40(maturity),
            fCashAmountToPurchase,
            netAssetCashNToken
        );

        return (maturity, netAssetCashNToken.neg(), fCashAmountToPurchase);
    }

    function _getResidualPriceAssetCash(
        CashGroupParameters memory cashGroup,
        uint256 maturity,
        uint256 blockTime,
        int256 fCashAmount,
        bytes6 parameters
    ) internal view returns (int256) {
        uint256 oracleRate = cashGroup.calculateOracleRate(maturity, blockTime);
        uint256 purchaseIncentive =
            uint256(uint8(parameters[Constants.RESIDUAL_PURCHASE_INCENTIVE])) *
                10 *
                Constants.BASIS_POINT;

        if (fCashAmount > 0) {
            oracleRate = oracleRate.add(purchaseIncentive);
        } else if (oracleRate > purchaseIncentive) {
            oracleRate = oracleRate.sub(purchaseIncentive);
        } else {
            // If the oracle rate is less than the purchase incentive floor the interest rate at zero
            oracleRate = 0;
        }

        int256 exchangeRate =
            Market.getExchangeRateFromImpliedRate(oracleRate, maturity.sub(blockTime));

        // Returns the net asset cash from the nToken perspective, which is the same sign as the fCash amount
        return
            cashGroup.assetRate.convertFromUnderlying(
                fCashAmount.mul(Constants.RATE_PRECISION).div(exchangeRate)
            );
    }

    function _updateNTokenPortfolio(
        address nTokenAddress,
        uint256 currencyId,
        uint256 maturity,
        uint256 lastInitializedTime,
        int256 fCashAmountToPurchase,
        int256 netAssetCashNToken
    ) private {
        bytes32 ifCashBitmap = BitmapAssetsHandler.getAssetsBitmap(nTokenAddress, currencyId);
        // prettier-ignore
        (
            ifCashBitmap,
            /* notional */
        ) = BitmapAssetsHandler.addifCashAsset(
            nTokenAddress,
            currencyId,
            maturity,
            lastInitializedTime,
            fCashAmountToPurchase.neg(),
            ifCashBitmap
        );
        BitmapAssetsHandler.setAssetsBitmap(nTokenAddress, currencyId, ifCashBitmap);

        // prettier-ignore
        (
            int256 nTokenCashBalance,
            /* storedNTokenBalance */,
            /* lastClaimTime */,
            /* lastClaimSupply */
        ) = BalanceHandler.getBalanceStorage(nTokenAddress, currencyId);
        nTokenCashBalance = nTokenCashBalance.add(netAssetCashNToken);

        // This will ensure that the cash balance is not negative
        BalanceHandler.setBalanceStorageForNToken(nTokenAddress, currencyId, nTokenCashBalance);
    }
}