import {
  addAssets,
  Assets,
  fromHex,
  toHex,
  TxBuilder,
  UTxO,
} from '@lucid-evolution/lucid';
import {
  RobDatum,
  RobOrderType,
  parseRobDatumOrThrow,
  serialiseRobDatum,
  serialiseRobRedeemer,
} from './types-new';
import { calculateFeeFromRatio } from '../../utils/indigo-helpers';
import {
  BigIntOrd,
  fromDecimal,
  sum,
  zeroNegatives,
} from '../../utils/bigint-utils';
import {
  readonlyArray as RA,
  array as A,
  function as F,
  option as O,
  ord as Ord,
} from 'fp-ts';
import { SystemParams } from '../../types/system-params';
import { match, P } from 'ts-pattern';
import { getInlineDatumOrThrow } from '../../utils/lucid-utils';
import {
  adaAssetClass,
  AssetClass,
  assetClassValueOf,
  isSameAssetClass,
  lovelacesAmt,
  mkAssetsOf,
} from '@3rd-eye-labs/cardano-offchain-common';
import {
  Rational,
  rationalFloor,
  rationalFromInt,
  rationalMul,
  rationalToFloat,
} from '../../types/rational';
import { insertSorted, shuffle } from '../../utils/array-utils';
import { iassetValueOfCollateral } from '../cdp/helpers';
import { OracleIdx } from '../price-oracle/types-new';
import Decimal from 'decimal.js';

export const MIN_ROB_COLLATERAL_AMT = 3_000_000n;

/**
 * The maximum of redemptions for a Tx that is doing only sell order redemptions.
 * Based on the benchmarks.
 */
export const MAX_SELL_ROB_REDEMPTIONS_COUNT = 18;
/**
 * The maximum of redemptions for a Tx that is doing only buy order redemptions.
 * Based on the benchmarks.
 */
export const MAX_BUY_ROB_REDEMPTIONS_COUNT = 20;

/**
 * Helper for ROB redemptions. A redeemer is selling iAssets against
 * a buy order, i.e. buying a collateral asset.
 * The return is the amount of collateral asset he buys.
 */
export function calculatePurchaseAmtWhenRobBuyOrder(
  sellIassetAmt: bigint,
  redemptionReimbursementRatio: Rational,
  price: Rational,
): bigint {
  const reimbursementIAsset = calculateFeeFromRatio(
    redemptionReimbursementRatio,
    sellIassetAmt,
  );

  const collateralForRedemption = rationalFloor(
    rationalMul(rationalFromInt(sellIassetAmt - reimbursementIAsset), price),
  );

  return collateralForRedemption;
}

/**
 * A redeemer is purchasing given collateral amount against buy order.
 * Calculate what is the amount of iAsset the user sells/spends.
 */
export function calculateSpendAmtWhenRobBuyOrder(
  purchaseCollateralAmt: bigint,
  redemptionReimbursementRatio: Rational,
  price: Rational,
): bigint {
  const priceDecimal = Decimal(price.numerator).div(price.denominator);
  const reimbRatio = Decimal(redemptionReimbursementRatio.numerator).div(
    redemptionReimbursementRatio.denominator,
  );

  // TODO: make corrections here in case we can be ceiling this number instead.
  // We want to be flooring here so that we don't go over the collateral to spend.
  // Flooring multiple times is necessary here otherwise we could go over the collateral available.
  // iassets = floor(collateral / price) / floor(1 - reimbursement fee)
  return fromDecimal(
    Decimal(purchaseCollateralAmt)
      .div(priceDecimal)
      .floor()
      .div(Decimal(1).minus(reimbRatio))
      .floor(),
  );
}

/**
 * Helper for ROB redemptions. A redeemer is selling collateral against
 * a sell order, i.e. buying iassets.
 * The return is the amount of iassets he buys.
 */
export function calculatePurchaseAmtWhenRobSellOrder(
  sellCollateralAmt: bigint,
  redemptionReimbursementRatio: Rational,
  price: Rational,
): bigint {
  const reimbursementCollateral = calculateFeeFromRatio(
    redemptionReimbursementRatio,
    sellCollateralAmt,
  );

  const redeemedIAssetAmt = iassetValueOfCollateral(
    sellCollateralAmt - reimbursementCollateral,
    price,
  );

  return redeemedIAssetAmt;
}

/**
 * A redeemer is purchasing given iasset amount against sell order.
 * Calculate what is the amount of collateral the user sells/spends.
 */
export function calculateSpendAmtWhenRobSellOrder(
  purchaseIassetAmt: bigint,
  redemptionReimbursementRatio: Rational,
  price: Rational,
): bigint {
  const priceDecimal = Decimal(price.numerator).div(price.denominator);
  const reimbRatio = Decimal(redemptionReimbursementRatio.numerator).div(
    redemptionReimbursementRatio.denominator,
  );

  // collateral = floor(iasset * price) / floor(1 - reimbursement fee)
  return fromDecimal(
    Decimal(purchaseIassetAmt)
      .mul(priceDecimal)
      .floor()
      .div(Decimal(1).minus(reimbRatio))
      .floor(),
  );
}

/**
 * The amount of collateral asset available in the ROB when buy order. In case of ADA, take
 * into account the min UTXO collateral.
 */
export function robCollateralAmtToSpend(
  robAssets: Assets,
  robOrderType: RobOrderType,
): bigint {
  return match(robOrderType)
    .returnType<bigint>()
    .with({ BuyIAssetOrder: P.select() }, (content) => {
      if (isSameAssetClass(adaAssetClass, content.collateralAsset)) {
        return zeroNegatives(lovelacesAmt(robAssets) - MIN_ROB_COLLATERAL_AMT);
      } else {
        return assetClassValueOf(robAssets, content.collateralAsset);
      }
    })
    .otherwise(() => {
      throw new Error('Collateral to spend is relevant only for Buy orders.');
    });
}

/**
 * The amount if iassets available in ROB when sell order.
 */
export function robIAssetAmtToSpend(
  robAssets: Assets,
  robOrderType: RobOrderType,
  /**
   * This has to be assetclass having policyID of Indigo iAssets,
   * and the asset name being the ROB datum iasset.
   */
  robIasset: AssetClass,
): bigint {
  return match(robOrderType)
    .returnType<bigint>()
    .with({ SellIAssetOrder: P.any }, (_) => {
      return assetClassValueOf(robAssets, robIasset);
    })
    .otherwise(() => {
      throw new Error('IAssets to spend is relevant only for Sell orders.');
    });
}

/**
 * Amount to spend from the ROB universal for Buy and sell orders.
 */
export function robAmtToSpend(
  robAssets: Assets,
  robOrderType: RobOrderType,
  /**
   * This has to be assetclass having policyID of Indigo iAssets,
   * and the asset name being the ROB datum iasset.
   */
  robIasset: AssetClass,
): bigint {
  return match(robOrderType)
    .with({ BuyIAssetOrder: P.any }, () =>
      robCollateralAmtToSpend(robAssets, robOrderType),
    )
    .with({ SellIAssetOrder: P.any }, () =>
      robIAssetAmtToSpend(robAssets, robOrderType, robIasset),
    )
    .exhaustive();
}

type FilledResult = { asset: AssetClass; filledAmt: bigint };

/**
 * The assets that have filled the order and are able to be claimed.
 */
export function robBuyOrderFilledAssets(
  robAssets: Assets,
  robOrderType: RobOrderType,
  /**
   * This has to be assetclass having policyID of Indigo iAssets,
   * and the asset name being the ROB datum iasset.
   */
  robIasset: AssetClass,
): FilledResult {
  return match(robOrderType)
    .returnType<FilledResult>()
    .with({ BuyIAssetOrder: P.any }, () => {
      return {
        asset: robIasset,
        filledAmt: assetClassValueOf(robAssets, robIasset),
      };
    })
    .otherwise(() => {
      throw new Error('Expected only buy order.');
    });
}

/**
 * The assets that have filled the order and are able to be claimed.
 */
export function robSellOrderFilledAssets(
  robAssets: Assets,
  robOrderType: RobOrderType,
): FilledResult[] {
  return match(robOrderType)
    .returnType<FilledResult[]>()
    .with({ SellIAssetOrder: P.select() }, (content) => {
      return content.allowedCollateralAssets.map(([asset, _]) => {
        return {
          asset: asset,
          filledAmt:
            assetClassValueOf(robAssets, asset) -
            (isSameAssetClass(asset, adaAssetClass)
              ? MIN_ROB_COLLATERAL_AMT
              : 0n),
        } satisfies FilledResult;
      });
    })
    .otherwise(() => {
      throw new Error('Expected only sell order.');
    });
}

export function robBuyOrderSummary(
  robAssets: Assets,
  robOrderType: RobOrderType,
  oraclePrice: Rational,
): {
  /**
   * The amount that can be spent from the ROB.
   */
  redeemableCollateral: bigint;
  /**
   * The amount paid to the ROB when everything redeemed.
   */
  payoutIAsset: bigint;
} {
  const redeemable = robCollateralAmtToSpend(robAssets, robOrderType);

  // TODO: this is incorrect since it doesn't take into account the reimbursement ratio.
  const payoutAmt = iassetValueOfCollateral(redeemable, oraclePrice);

  return {
    redeemableCollateral: redeemable,
    payoutIAsset: payoutAmt,
  };
}

/**
 * In case it's applied to a sell order instead, it will throw an error.
 */
export function isBuyOrderFullyRedeemed(
  robAssets: Assets,
  robOrderType: RobOrderType,
  oraclePrice: Rational,
): boolean {
  const summary = robBuyOrderSummary(robAssets, robOrderType, oraclePrice);

  return summary.redeemableCollateral <= 0n || summary.payoutIAsset <= 0n;
}

/**
 * Use the limit prices to decide fully redeemed.
 */
export function isFullyRedeemed(
  robAssets: Assets,
  robOrderType: RobOrderType,
  /**
   * This has to be assetclass having policyID of Indigo iAssets,
   * and the asset name being the ROB datum iasset.
   */
  robIasset: AssetClass,
): boolean {
  return match(robOrderType)
    .returnType<boolean>()
    .with({ BuyIAssetOrder: P.select() }, (content) =>
      isBuyOrderFullyRedeemed(robAssets, robOrderType, content.maxPrice),
    )

    .with({ SellIAssetOrder: P.select() }, (content) => {
      const iassetToSpend = robIAssetAmtToSpend(
        robAssets,
        robOrderType,
        robIasset,
      );

      const payoutAmts = content.allowedCollateralAssets.map((c) =>
        rationalFloor(rationalMul(rationalFromInt(iassetToSpend), c[1])),
      );

      return (
        iassetToSpend <= 0n ||
        // When for every allowed collateral asset the payout would be 0
        payoutAmts.every((amt) => amt <= 0n)
      );
    })
    .exhaustive();
}

/**
 * Right now we allow multi redemptions when the collateral asset, iasset pair is the same.
 * The on-chain however should allow even other combinations.
 */
export function buildRedemptionsTx(
  /**
   * The tuple represents the ROB UTXO and the amount to payout for a redemption. In case of buy order,
   * it's denominated in iAssets, in case of sell order, it's denominated in collateral asset.
   */
  redemptions: [UTxO, bigint][],
  iasset: Uint8Array<ArrayBufferLike>,
  collateralAsset: AssetClass,
  price: Rational,
  redemptionReimbursementRatio: Rational,
  sysParams: SystemParams,
  tx: TxBuilder,
  /**
   * The number of Tx outputs before these new ones.
   */
  txOutputsBeforeCount: bigint,
  collateralAssetRefInputIdx: bigint,
  iassetRefInputIdx: bigint,
  oracleIdx: OracleIdx,
): TxBuilder {
  return F.pipe(
    redemptions,
    A.reduceWithIndex<[UTxO, bigint], TxBuilder>(
      tx,
      (idx, acc, [robUtxo, payoutAmt]) => {
        const robDatum = parseRobDatumOrThrow(getInlineDatumOrThrow(robUtxo));

        if (toHex(robDatum.iasset) !== toHex(iasset)) {
          throw new Error('Only same iAsset');
        }

        const [robOutputVal, sellOrderAllowedAssetsIdx] = match(
          robDatum.orderType,
        )
          .returnType<[Assets, bigint]>()
          .with({ BuyIAssetOrder: P.select() }, (content) => {
            if (!isSameAssetClass(content.collateralAsset, collateralAsset)) {
              throw new Error('Only same collateral asset');
            }

            const payoutIAssetAmt = payoutAmt;

            const collateralForRedemption = calculatePurchaseAmtWhenRobBuyOrder(
              payoutIAssetAmt,
              redemptionReimbursementRatio,
              price,
            );

            const resultVal = addAssets(
              robUtxo.assets,
              mkAssetsOf(collateralAsset, -collateralForRedemption),
              mkAssetsOf(
                {
                  currencySymbol: fromHex(
                    sysParams.robParams.iassetPolicyId.unCurrencySymbol,
                  ),
                  tokenName: robDatum.iasset,
                },
                payoutIAssetAmt,
              ),
            );

            return [resultVal, 0n];
          })
          .with({ SellIAssetOrder: P.select() }, (content) => {
            const allowedAssetIdx = F.pipe(
              content.allowedCollateralAssets,
              RA.findIndex(([asset, _]) =>
                isSameAssetClass(asset, collateralAsset),
              ),
              O.getOrElse<number>(() => {
                throw new Error("Doesn't allow required collateral asset.");
              }),
            );

            const payoutCollateralAmt = payoutAmt;

            const redeemedIAssetAmt = calculatePurchaseAmtWhenRobSellOrder(
              payoutCollateralAmt,
              redemptionReimbursementRatio,
              price,
            );

            const resultVal = addAssets(
              robUtxo.assets,
              mkAssetsOf(collateralAsset, payoutCollateralAmt),
              mkAssetsOf(
                {
                  currencySymbol: fromHex(
                    sysParams.robParams.iassetPolicyId.unCurrencySymbol,
                  ),
                  tokenName: robDatum.iasset,
                },
                -redeemedIAssetAmt,
              ),
            );

            return [resultVal, BigInt(allowedAssetIdx)];
          })
          .exhaustive();

        if (lovelacesAmt(robOutputVal) < MIN_ROB_COLLATERAL_AMT) {
          throw new Error(
            'Redeeming more than available or selected ROB was incorrectly initialised.',
          );
        }

        return acc
          .collectFrom([robUtxo], {
            kind: 'self',
            makeRedeemer: (ownIdx) =>
              serialiseRobRedeemer({
                Redeem: {
                  ownInputIdx: ownIdx,
                  collateralAssetRefInputIdx: collateralAssetRefInputIdx,
                  iassetRefInputIdx: iassetRefInputIdx,
                  continuingOutputIdx: txOutputsBeforeCount + BigInt(idx),
                  sellOrderAllowedAssetsIdx: sellOrderAllowedAssetsIdx,
                  priceOracleIdx: oracleIdx,
                },
              }),
          })
          .pay.ToContract(
            robUtxo.address,
            {
              kind: 'inline',
              value: serialiseRobDatum({
                ...robDatum,
                robRefInput: {
                  outputIndex: BigInt(robUtxo.outputIndex),
                  txHash: fromHex(robUtxo.txHash),
                },
              }),
            },
            robOutputVal,
          );
      },
    ),
  );
}

/**
 * Given all available LRP UTXOs, calculate total available collateral that can be redeemed.
 * Taking into account incorrectly initialised LRPs (without base collateral) and max number of ROBs.
 */
export function calculateTotalCollateralForRedemption(
  iasset: Uint8Array<ArrayBufferLike>,
  collateralAsset: AssetClass,
  iassetPrice: Rational,
  allRobs: [UTxO, RobDatum][],
  /**
   * How many LRPs can be redeemed in a single Tx.
   */
  maxRobsInTx: number,
): bigint {
  return F.pipe(
    allRobs,
    A.filterMap(([utxo, datum]) => {
      const isCorrectOrder = match(datum.orderType)
        .returnType<boolean>()
        .with(
          { BuyIAssetOrder: P.select() },
          (content) =>
            isSameAssetClass(content.collateralAsset, collateralAsset) &&
            rationalToFloat(content.maxPrice) >= rationalToFloat(iassetPrice) &&
            !isBuyOrderFullyRedeemed(utxo.assets, datum.orderType, iassetPrice),
        )
        .otherwise(() => false);

      if (toHex(datum.iasset) !== toHex(iasset) || !isCorrectOrder) {
        return O.none;
      }

      // We constrained in the logic above that the ROB is a buy order and is not yet fully redeemed.
      const collateralToSpend = robCollateralAmtToSpend(
        utxo.assets,
        datum.orderType,
      );

      return O.some(collateralToSpend);
    }),
    // From largest to smallest
    A.sort(Ord.reverse(BigIntOrd)),
    // We can fit only this number of redemptions with CDP open into a single Tx.
    A.takeLeft(maxRobsInTx),
    sum,
  );
}

/**
 * Pick random subset from all the ROBs (it does the necessary filtering) satisfying the target collateral to spend.
 * It's relevant for BUY orders only.
 */
export function randomRobsSubsetSatisfyingTargetCollateral(
  iasset: Uint8Array<ArrayBufferLike>,
  collateralAsset: AssetClass,
  targetCollateralToSpend: bigint,
  iassetPrice: Rational,
  allLrps: [UTxO, RobDatum][],
  /**
   * How many LRPs can be redeemed in a single Tx.
   */
  maxLrpsInTx: number,
  randomiseFn: (arr: [UTxO, RobDatum][]) => [UTxO, RobDatum][] = shuffle,
): [UTxO, RobDatum][] {
  if (
    targetCollateralToSpend <= 0n ||
    iassetValueOfCollateral(targetCollateralToSpend, iassetPrice) <= 0n
  ) {
    throw new Error('Must redeem and payout more than 0.');
  }

  const shuffled = randomiseFn(
    F.pipe(
      allLrps,
      A.filter(
        ([utxo, datum]) =>
          toHex(datum.iasset) === toHex(iasset) &&
          match(datum.orderType)
            .with(
              { BuyIAssetOrder: P.select() },
              (content) =>
                isSameAssetClass(collateralAsset, content.collateralAsset) &&
                rationalToFloat(content.maxPrice) >=
                  rationalToFloat(iassetPrice),
            )
            // Only buy order types
            .otherwise(() => false) &&
          !isBuyOrderFullyRedeemed(utxo.assets, datum.orderType, iassetPrice),
      ),
    ),
  );

  // Sorted from highest to lowest by lovelaces to spend
  let result: [UTxO, RobDatum][] = [];
  let runningSum = 0n;

  for (let i = 0; i < shuffled.length; i++) {
    const element = shuffled[i];

    const lovelacesToSpend = robCollateralAmtToSpend(
      element[0].assets,
      element[1].orderType,
    );

    const remainingToRedeem = targetCollateralToSpend - runningSum;
    const remainingToPayout = iassetValueOfCollateral(
      remainingToRedeem,
      iassetPrice,
    );

    // When we can't add a new redemption because otherwise there would be no payout.
    // Try to replace the smallest collected with a following larger one when available.
    if (result.length > 0 && remainingToPayout <= 0n) {
      const last = result[result.length - 1];

      const lastSummary = robBuyOrderSummary(
        last[0].assets,
        last[1].orderType,
        iassetPrice,
      );

      // Pop the smallest collected when the current is larger.
      if (lastSummary.redeemableCollateral < lovelacesToSpend) {
        result.pop()!;
        runningSum -= lastSummary.redeemableCollateral;
      } else {
        continue;
      }
    }

    result = insertSorted(
      result,
      element,
      Ord.contramap<bigint, [UTxO, RobDatum]>(
        ([utxo, dat]) => robCollateralAmtToSpend(utxo.assets, dat.orderType),
        // From highest to lowest
      )(Ord.reverse(BigIntOrd)),
    );
    runningSum += lovelacesToSpend;

    // When more items than max allowed, pop the one with smallest value
    if (result.length > maxLrpsInTx) {
      const popped = result.pop()!;
      runningSum -= robCollateralAmtToSpend(
        popped[0].assets,
        popped[1].orderType,
      );
    }

    if (runningSum >= targetCollateralToSpend) {
      return result;
    }
  }

  const remainingToSpend = targetCollateralToSpend - runningSum;

  if (
    remainingToSpend > 0n &&
    iassetValueOfCollateral(remainingToSpend, iassetPrice) > 0n
  ) {
    throw new Error("Couldn't achieve target lovelaces");
  }

  return result;
}
