import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import Decimal from 'decimal.js';

import { EChargeCalculationBasis, EChargeFrequencies, ELoanTypes } from '../enums/loan-types.enum';
import { createFilteredObject } from '../helpers/common.helper';
import { ELoanChargeType } from '../enums/loan-charge-type.enum';
import {
  ILoanStatementTransaction,
  ILoanStatementTransactionDoc,
  ILoanStatementTransactionView,
  ILoanStatementTransactionWithId,
  LOAN_STATEMENT_TRANSACTION_FIELDS,
  LoanStatementTransactionModel,
} from '../models/LoanStatementTransaction.model';
import { ILoanChargeView, LoanCharge } from '../models/LoanCharges.model';
import { LoanProduct } from '../models/LoanProducts.model';
import { BorrowerModel } from '../models/Borrower.model';
import { ILoanPaymentWithId, LoanPaymentModel } from '../models/LoanPayment.model';
import { defaultDateFormat } from '../helpers/date.helper';
import { TermLoanCalculatedModel } from '../models/TermLoanCalculated.model';
import { TermLoanModel } from '../models/TermLoan.model';
import {
  ELedgerReportType,
  getBorrowerWithPeriods,
  getPeriods,
  getStatementBorrowersTransactions,
} from '../db/loan-statement.db';
import { BBCDateModel } from '../models/BBCDate.model';

import { EFinancialIndex, FinancialIndexesService } from './financial-indexes.service';
import { ITransactionsFilter, LoanTransactionsService } from './loan-transactions.service';
import { LoanStatementStatusService, SelectablePeriod } from './loan-statement-status.service';
import { LoanChargesService } from './loan-charges.service';
import { LoanPaymentsService } from './loan-payments.service';
import { LoanStatementBalanceService } from './loan-statement-balance.service';
import { TermLoanService } from './term-loan.service';
import { UploadsService } from './uploads.service';
import { LoanStatementEffectsService } from './loan-statement-effects.service';

dayjs.extend(utc);
dayjs.extend(timezone);

export interface IStatementPeriod {
  start: dayjs.Dayjs;
  end: dayjs.Dayjs;
}

export class LoanStatementService {

  constructor(
    private readonly financialIndexesService: FinancialIndexesService,
    private readonly loanChargesService: LoanChargesService,
    private readonly loanPaymentsService: LoanPaymentsService,
    private readonly loanStatementBalanceService: LoanStatementBalanceService,
    private readonly loanStatementStatusService: LoanStatementStatusService,
    private readonly loanTransactionsService: LoanTransactionsService,
    private readonly termLoanService: TermLoanService,
    private readonly uploadsService: UploadsService,
    private readonly getLoanStatementEffectsService: () => LoanStatementEffectsService,
  ) {
  }

  private getGroupedData(statements: { date: Date, amount: number, chargeType: ELoanChargeType, memo: string }[]) {
    const groups = {
      [ELoanChargeType.INTEREST_FEE]: 0,
      [ELoanChargeType.ADMIN_FEE]: 0,
      [ELoanChargeType.UNUSED_LINE_FEE]: 0,
      [ELoanChargeType.WIRE_FEE]: 0,
      [ELoanChargeType.ANNUAL_LINE_FEE]: 0,
    };
    const otherStatements = [];
    let totalAmount = 0;
    const groupedData = statements.reduce((acc, record) => {
      totalAmount = new Decimal(totalAmount).add(record.amount).toNumber();
      if (record.chargeType === ELoanChargeType.OTHER || record.chargeType === ELoanChargeType.RECOVERABLE) {
        otherStatements.push(record);
        return acc;
      }
      return {
        ...acc,
        [record.chargeType]: new Decimal(acc[record.chargeType]).add(record.amount).toNumber(),
      };
    }, groups);
    return { groupedData, otherStatements };
  }

  async getTransactionById(transactionId: string) {
    return LoanStatementTransactionModel.findById(transactionId).lean();
  }

  async getFirstStatementTransaction(productId: string) {
    const loanCharges = await this.loanChargesService.getLoanChargeForProduct(productId);
    const chargesIds = loanCharges.map((charge) => charge._id);
    const firstStatementTransactions = await LoanStatementTransactionModel.aggregate<ILoanStatementTransactionDoc>([
      {
        $match: {
          'chargeId': { '$in': chargesIds },
        },
      }, {
        $sort: {
          'date': 1,
          'createdAt': 1,
          'order': 1,
        },
      }, {
        $limit: 1,
      },
    ]);

    if (!firstStatementTransactions.length) {
      return null;
    }
    return firstStatementTransactions[0];
  }

  async getBorrowerWithPeriods(borrowerId: string, period: {
    selectedPeriod: string,
    selectedStart: Date,
    selectedEnd: Date
  }, splitPeriods = false) {
    return getBorrowerWithPeriods(borrowerId, period, splitPeriods);
  }

  async getPeriods(selectedPeriod: string, productId: string): Promise<IStatementPeriod[]> {
    return getPeriods(selectedPeriod, productId);
  }

  getDays(period: {
    start: dayjs.Dayjs,
    end: dayjs.Dayjs
  }) {
    const days: string[] = [];
    for (let day = dayjs(period.start); day.isBefore(period.end); day = day.add(1, 'day')) {
      days.push(day.format('YYYY-MM-DD'));
    }
    return days;
  }

  async calculateBorrowerStatement(borrowerId: string, selectedPeriod: SelectablePeriod) {
    const products = await this.loanChargesService.getLoanProducts(borrowerId);
    for (const product of products) {
      const periods = await this.getPeriods(selectedPeriod, product._id.toString());
      if (selectedPeriod === 'ENTIRE_LOAN') {
        const charges = await this.loanChargesService.getLoanChargeForProduct(product._id.toString());
        await this.cleanProductAllStatements(charges.map((charge) => charge._id.toString()));
      }
      await this.calculateProductStatement(product._id.toString(), periods);
    }
  }

  async recalculateAllProducts() {
    const borrowers = await BorrowerModel.find({ active: true });
    for (const borrower of borrowers) {
      const products = await this.loanChargesService.getLoanProducts(borrower._id.toString());
      await Promise.all(products.map(async (product) => {
        await this.loanTransactionsService.recalculateProduct(product._id.toString());
      }));
    }
  }

  async calculateAllBorrowers() {
    const statementDate = this.loanStatementStatusService.getStatementDateFromStringPeriod('CURRENT_MONTH');
    const date = this.loanStatementStatusService.getDateFromStatementDate(statementDate);
    const periods = this.loanStatementStatusService.getPeriodsFromStatementDate(statementDate);
    const borrowers = await BorrowerModel.find({ active: true });
    for (const borrower of borrowers) {
      const products = await this.loanChargesService.getLoanProducts(borrower._id.toString());
      await Promise.all(products.map(async (product) => {
        const validationError = await this.loanStatementStatusService.validateDate(product._id.toString(), date);
        if (!validationError) {
          try {
            console.log('calculating borrower/product', borrower.name, product.code);
            await this.calculateProductStatement(product._id.toString(), periods);
          } catch (e) {
            console.error(e);
          }
        }
      }));
    }
  }

  async calculateProductStatement(productId: string, periods: IStatementPeriod[]) {
    await Promise.all(periods.map(async (period) => {
      const days = this.getDays(period);
      for (const day of days) {
        await this.calculateStatement(productId, period, dayjs.utc(day).toDate());
      }
      const statementDate = this.loanStatementStatusService.getStatementDateFromPeriod(period);
      await this.loanStatementStatusService.updateStatementStatus(productId, statementDate, true);
    }));
    const startDate = periods[0].start.startOf('day').toDate();
    await this.loanStatementBalanceService.calculateProductBalance(productId, startDate);
  }

  async useFloatingBalance(productId: string) {
    const product = await this.loanChargesService.getLoanProductById(productId);
    return product.type === ELoanTypes.REVOLVER;
  }

  async calculateStatement(productId: string, period: IStatementPeriod, statementDate?: Date) {
    if (!statementDate) {
      statementDate = dayjs.utc().startOf('day').toDate();
    }

    const primeRate = await this.financialIndexesService.getFinancialIndexValue(EFinancialIndex.PRIME_RATE, statementDate);
    if (!primeRate) {
      return;
    }
    await this.cleanProductStatement(productId, statementDate);

    const balanceDate = dayjs.utc(statementDate).add(1, 'day').subtract(1, 'second').toDate();
    const balance = await this.loanChargesService.getLoanProductBalance(productId, balanceDate);

    const loanStatementEffectsService = this.getLoanStatementEffectsService();
    await loanStatementEffectsService.saveMonthData(productId, statementDate);

    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    const product = await this.loanChargesService.getLoanProductById(productId);

    // If product is already deactivated for this statement date (inclusive) — skip everything early
    if (product.deactivationDate && dayjs(statementDate).isSameOrAfter(dayjs(product.deactivationDate))) {
      return;
    }

    const daysPerYear = 360;
    const dayInMonth = dayjs(period.end).diff(period.start, 'days') + 1;
    let orderIndex = 0;
    for (const charge of charges) {
      if (charge.applyFrom.getTime() > statementDate.getTime()) {
        continue;
      }
      const statementDateFormat = dayjs(statementDate).format('YYYY-MM-DD');
      let calculateFee = false;
      switch (charge.frequency) {
        case EChargeFrequencies.DISBURSEMENT: {
          calculateFee = true;
          break;
        }
        case EChargeFrequencies.DAILY: {
          calculateFee = true;
          break;
        }
        case EChargeFrequencies.WEEKLY: {
          if (dayjs(charge.applyFrom).day() === dayjs(statementDate).day()) {
            calculateFee = true;
          }
          break;
        }
        case EChargeFrequencies.MONTHLY: {
          const lastDayOfMonthFormat = dayjs(statementDate).endOf('month').format('YYYY-MM-DD');
          if (lastDayOfMonthFormat === statementDateFormat) {
            calculateFee = true;
          }
          break;
        }
        case EChargeFrequencies.ANNUAL: {
          const chargeDate = dayjs(charge.applyFrom).year(statementDate.getFullYear()).format('YYYY-MM-DD');
          if (chargeDate === statementDateFormat) {
            calculateFee = true;
          }
          break;
        }
        case EChargeFrequencies.ONE_TIME: {
          const chargeDate = dayjs(charge.applyFrom).format('YYYY-MM-DD');
          if (chargeDate === statementDateFormat) {
            calculateFee = true;
          }
          break;
        }
      }

      const useFloating = await this.useFloatingBalance(productId);

      const getBalance = () => {
        return Math.max(0, useFloating ? balance.floatedBalance : balance.balance);
      };

      if (calculateFee) {
        let fee = 0;
        let calculatedFee = 0;
        let percentFee = charge.percent;
        if (charge.chargeType === ELoanChargeType.INTEREST_FEE) {
          percentFee = new Decimal(primeRate).add(percentFee).toDP(4).toNumber();
          const minPercent = product.minPercent ?? 0;
          const maxPercent = (product.maxPercent ?? 0) === 0 ? 1 : product.maxPercent;
          percentFee = Math.min(Math.max(percentFee, minPercent), maxPercent);
        }
        const dailyPercent = new Decimal(percentFee).div(daysPerYear).toNumber();
        switch (charge.calculationBasis) {
          case EChargeCalculationBasis.DAILY_BALANCE:
            if (dayjs(statementDate).isAfter(dayjs(charge.applyFrom)) || dayjs(statementDate).isSame(dayjs(charge.applyFrom))) {
              fee = new Decimal(getBalance()).mul(dailyPercent).toDP(2).toNumber();
            }
            break;
          case EChargeCalculationBasis.AVERAGE_MONTHLY_BALANCE:
            if (dayjs(statementDate).isAfter(dayjs(charge.applyFrom)) || dayjs(statementDate).isSame(dayjs(charge.applyFrom))) {
              fee = new Decimal(getBalance()).mul(percentFee).div(dayInMonth).toDP(2).toNumber();
            }
            if (!percentFee && charge.minimumAmount) {
              fee = new Decimal(charge.minimumAmount).div(dayInMonth).toDP(2).toNumber();
            }
            break;
          case EChargeCalculationBasis.UNUSED_AMOUNT:
            if (dayjs(statementDate).isAfter(dayjs(charge.applyFrom)) || dayjs(statementDate).isSame(dayjs(charge.applyFrom))) {
              calculatedFee = new Decimal(product.commitment).sub(getBalance()).mul(charge.percent).div(daysPerYear).toDP(2).toNumber();
              fee = Math.max(calculatedFee, 0);
            }
            break;
          case EChargeCalculationBasis.COMMITMENT:
            fee = new Decimal(product.commitment).mul(charge.percent).toDP(2).toNumber();
            break;
          case EChargeCalculationBasis.DISBURSEMENT_AMOUNT:
            if (dayjs(statementDate).isAfter(dayjs(charge.applyFrom)) || dayjs(statementDate).isSame(dayjs(charge.applyFrom))) {
              fee = await this.calculateDisbursementFee(product._id.toString(), charge, statementDate);
            }
            break;
          case EChargeCalculationBasis.FIXED:
            if (dayjs(statementDate).isAfter(dayjs(charge.applyFrom)) || dayjs(statementDate).isSame(dayjs(charge.applyFrom))) {
              calculatedFee = new Decimal(getBalance()).mul(dailyPercent).toDP(2).toNumber();
              fee = Math.max(calculatedFee, charge.minimumAmount);
            }
            break;
        }

        const checkMinAmount = (fee: number) => {
          if (charge.minimumAmount > 0) {
            if (charge.chargeType === ELoanChargeType.WIRE_FEE) {
              return fee ? Math.max(charge.minimumAmount, fee) : 0;
            }
            if (charge.frequency === EChargeFrequencies.DAILY && charge.calculationBasis === EChargeCalculationBasis.AVERAGE_MONTHLY_BALANCE) {
              return fee;
            }
            return fee ? Math.max(charge.minimumAmount, fee) : charge.minimumAmount;
          }
          return fee;
        };

        fee = checkMinAmount(fee);

        if (fee !== null && fee > 0) {
          if (dayjs(statementDate).isSameOrAfter(dayjs(charge.applyFrom))) {
            const newStatement: ILoanStatementTransaction = {
              order: orderIndex,
              date: statementDate,
              chargeId: charge._id,
              amount: fee,
              amountPaid: 0,
              isSystem: true,
              memo: 'System Generated',
              balance: 0,
            };
            const newStatementDoc = await LoanStatementTransactionModel.create(newStatement);
            await newStatementDoc.save();
            orderIndex = orderIndex + 1;
          }
        }
      }
    }

    await this.reorderStatementForDate(productId, statementDate);
  }

  async reorderStatementForDate(productId: string, date: Date) {
    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    const lastTransactions = await LoanStatementTransactionModel.aggregate<ILoanStatementTransactionDoc>([
      {
        $match: {
          'chargeId': { $in: charges.map((charge) => charge._id) },
          'date': { $eq: date },
          'isSystem': false,
        },
      }, {
        $sort: {
          'order': 1,
        },
      },
    ]);
    if (lastTransactions.length > 0) {
      let lastOrder = await this.getLastTransactionOrder(lastTransactions[0]);
      for (const transaction of lastTransactions) {
        lastOrder = lastOrder + 1;
        await LoanStatementTransactionModel.findByIdAndUpdate(transaction._id, { order: lastOrder });
      }
    }
  }

  async getBalancesDaily(productId: string, period: { start: Date, end: Date }) {
    const days = this.getDays({ start: dayjs(period.start).utcOffset(0), end: dayjs(period.end).utcOffset(0) });
    return Promise.all(days.map(async (day) => {
      const balance = await this.loanChargesService.getLoanProductBalance(productId, dayjs.utc(day).toDate());
      return { day, ...balance };
    }));
  }

  private async calculateDisbursementFee(productId: string, charge: ILoanChargeView, day: Date): Promise<number> {
    const totalDoc = await this.loanTransactionsService.getDisbursementTransactions(productId, day);
    if (totalDoc === 0) {
      return 0;
    }
    return new Decimal(totalDoc).mul(charge.minimumAmount).toDP(2).toNumber();
  }

  async cleanProductStatement(productId: string, statementDate: Date) {
    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    await LoanStatementTransactionModel.deleteMany({
      chargeId: { $in: charges.map((charge) => charge._id) },
      date: statementDate,
      isSystem: true,
    });
  }

  async cleanProductAllStatements(chargesId: string[]) {
    await LoanStatementTransactionModel.deleteMany({ chargeId: { $in: chargesId }, isSystem: true });
  }

  async getProductStatementPdfData(productId: string, start: string, end: string) {
    const product = await LoanProduct.findById(productId).lean();
    const borrower = await BorrowerModel.findById(product.borrowerId).lean();
    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    const periodStart = dayjs(start);
    const periodEnd = dayjs(end);

    const statements = await LoanStatementTransactionModel.aggregate<{
      date: Date,
      amount: number,
      chargeType: ELoanChargeType,
      memo: string
    }>([
      {
        $match: {
          $and: [
            { 'chargeId': { $in: charges.map((charge) => charge._id) } },
            { 'date': { $gte: periodStart.toDate() } },
            { 'date': { $lte: periodEnd.toDate() } },
          ],
        },
      }, {
        $sort: {
          'date': 1,
          'order': 1,
        },
      }, {
        $lookup: {
          from: 'loan_charges',
          localField: 'chargeId',
          foreignField: '_id',
          as: 'charge',
        },
      }, {
        $unwind: {
          'path': '$charge',
        },
      }, {
        $addFields: {
          'chargeType': '$charge.chargeType',
          'chargeName': '$charge.name',
        },
      }, {
        $unset: ['_id', 'productId', 'chargeId', 'charge', 'createdAt', 'updatedAt', '__v'],
      },
    ]);

    const totalAmount = 0;
    const { groupedData, otherStatements } = this.getGroupedData(statements);

    const filter: ITransactionsFilter = {
      borrowerId: borrower._id.toString(),
      productId: product._id.toString(),
      periodEnd: periodEnd.toDate(),
      periodStart: periodStart.toDate(),
    };

    const transactions = await this.loanTransactionsService.getLoanTransactions(filter, null, true);

    return {
      groupedData,
      otherStatements,
      totalAmount,
      transactions,
    };
  }

  async getProductShortStatementRepresentation(productId: string, start: string, end: string) {
    const product = await LoanProduct.findById(productId).lean();
    const borrower = await BorrowerModel.findById(product.borrowerId).lean();
    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    const periodStart = dayjs(start);
    const periodEnd = dayjs(end);
    const statements = await LoanStatementTransactionModel.aggregate<{
      date: Date,
      amount: number,
      chargeType: ELoanChargeType,
      memo: string
    }>([
      {
        $match: {
          $and: [
            { 'chargeId': { $in: charges.map((charge) => charge._id) } },
            { 'date': { $gte: periodStart.toDate() } },
            { 'date': { $lte: periodEnd.toDate() } },
          ],
        },
      }, {
        $sort: {
          'date': 1,
          'order': 1,
        },
      }, {
        $lookup: {
          from: 'loan_charges',
          localField: 'chargeId',
          foreignField: '_id',
          as: 'charge',
        },
      }, {
        $unwind: {
          'path': '$charge',
        },
      }, {
        $addFields: {
          'chargeType': '$charge.chargeType',
        },
      }, {
        $unset: ['_id', 'productId', 'chargeId', 'charge', 'createdAt', 'updatedAt', '__v'],
      },
    ]);

    const totalAmount = 0;
    const { groupedData, otherStatements } = this.getGroupedData(statements);

    const chargeMap = {
      [ELoanChargeType.INTEREST_FEE]: 'INTEREST',
      [ELoanChargeType.ADMIN_FEE]: 'ADMIN FEE',
      [ELoanChargeType.UNUSED_LINE_FEE]: 'UNUSED',
      [ELoanChargeType.WIRE_FEE]: 'WIRE FEES',
      [ELoanChargeType.ANNUAL_LINE_FEE]: 'ANNUAL LINE',
      [ELoanChargeType.OTHER]: 'OTHER',
      [ELoanChargeType.RECOVERABLE]: 'RECOVERABLE',
    };

    const preparedStatement = Object.entries(groupedData).map(([chargeType, amount]) => {
      return [chargeMap[chargeType], null, amount];
    });

    otherStatements.forEach((record) => {
      preparedStatement.push([chargeMap[record.chargeType], record.memo, record.amount]);
    });

    const preparedStatementHeader = [
      [`STATEMENT: ${borrower.name}`],
      ['PERIOD FROM', periodStart.format('MM/DD/YYYY'), 'PERIOD TO', dayjs(end.slice(0, 10)).format('MM/DD/YYYY')],
      [null],
      ['THIS PERIOD STATEMENT', null, 'AMOUNT'],
    ];

    const preparedStatementTotal = [
      [null],
      [null, 'TOTAL', totalAmount],
      [null],
      [null],
    ];

    const preparedTransactionsHeader = [
      [`LOAN TRANSACTIONS: ${product.name}`],
      ['Date', 'Type', 'Amount', 'Reference', 'Bank', 'Balance'],
    ];

    const filter: ITransactionsFilter = {
      borrowerId: borrower._id.toString(),
      productId: product._id.toString(),
      periodEnd: periodEnd.toDate(),
      periodStart: periodStart.toDate(),
    };

    const transactions = await this.loanTransactionsService.getLoanTransactions(filter, null, true);
    const preparedTransactions = transactions.map((transaction) => {
      return [
        dayjs(transaction.date).format('MM/DD/YYYY'),
        transaction.transactionType,
        transaction.amount,
        transaction.reference,
        transaction.bank?.name ?? '',
        transaction.balance,
      ];
    });

    return [
      ...preparedStatementHeader,
      ...preparedStatement,
      ...preparedStatementTotal,
      ...preparedTransactionsHeader,
      ...preparedTransactions,
    ];
  }

  async createStatementTransaction(transaction: Partial<ILoanStatementTransactionWithId>) {
    const pureTransactions = createFilteredObject<ILoanStatementTransactionView>(transaction, LOAN_STATEMENT_TRANSACTION_FIELDS);
    const lastOrder = await this.getLastTransactionOrder(transaction);
    await this.saveLoanTransaction({ ...pureTransactions, isSystem: false, amountPaid: 0, order: lastOrder + 1 });
  }

  async updateStatementTransaction(transaction: Partial<ILoanStatementTransactionWithId>) {
    const foundTransaction = await LoanStatementTransactionModel.findById(transaction._id).lean();
    if (!foundTransaction) {
      return;
    }
    const pureTransactions = foundTransaction.isSystem
      ? createFilteredObject<ILoanStatementTransactionDoc>({
        ...foundTransaction,
        memo: transaction.memo,
      }, LOAN_STATEMENT_TRANSACTION_FIELDS)
      : createFilteredObject<ILoanStatementTransactionDoc>(transaction, LOAN_STATEMENT_TRANSACTION_FIELDS);
    await this.saveLoanTransaction(pureTransactions as Partial<ILoanStatementTransactionWithId>);
  }

  async saveLoanTransaction(transaction: Partial<ILoanStatementTransactionWithId>) {
    if (!transaction._id) {
      delete transaction._id;
      const updatedStatementTransaction = new LoanStatementTransactionModel(transaction);
      await updatedStatementTransaction.save();
    } else {
      await LoanStatementTransactionModel.findByIdAndUpdate(transaction._id, transaction, { new: true });
    }
    const charge = await LoanCharge.findById(transaction.chargeId);
    await this.loanStatementBalanceService.calculateProductBalance(charge.productId.toString(), new Date(transaction.date));
    const statementDate = this.loanStatementStatusService.getStatementDateForDate(new Date(transaction.date));
    await this.loanStatementStatusService.updateStatementStatus(charge.productId.toString(), statementDate, false);
  }

  async deleteStatementTransaction(transactionId: string) {
    const foundTransaction = await LoanStatementTransactionModel.findById(transactionId).lean();
    if (!foundTransaction) {
      return;
    }
    const charge = await LoanCharge.findById(foundTransaction.chargeId);
    await LoanStatementTransactionModel.findByIdAndDelete(transactionId);
    await this.loanStatementBalanceService.calculateProductBalance(charge.productId.toString(), new Date(foundTransaction.date));
    const statementDate = this.loanStatementStatusService.getStatementDateForDate(new Date(foundTransaction.date));
    await this.loanStatementStatusService.updateStatementStatus(charge.productId.toString(), statementDate, false);
  }

  async addBalancesToStatements(transactionDocs: ILoanStatementTransactionDoc[]) {
    return await Promise.all(transactionDocs.map(async (item) => {
      const charge = await LoanCharge.findById(item.chargeId).lean();
      const balanceDate = dayjs.utc(item.date).add(1, 'day').subtract(1, 'second').toDate();
      const balances = await this.loanChargesService.getLoanProductBalance(charge.productId.toString(), balanceDate);
      return { ...item, floatedBalance: balances.floatedBalance };
    }));
  }

  async getLastTransactionOrder(transaction: Partial<ILoanStatementTransactionDoc>, onlySystem = false) {
    const charge = await LoanCharge.findById(transaction.chargeId);
    if (!charge) {
      return null;
    }
    const charges = await this.loanChargesService.getLoanChargeForProduct(charge.productId.toString());
    const getOnlySystem = () => onlySystem ? { isSystem: true } : {};
    const lastTransactions = await LoanStatementTransactionModel.aggregate<ILoanStatementTransactionDoc>([
      {
        $match: {
          'chargeId': { $in: charges.map((charge) => charge._id) },
          '_id': { $ne: transaction._id },
          'date': { $eq: new Date(transaction.date) },
          ...getOnlySystem(),
        },
      }, {
        $sort: {
          'date': -1,
          'order': -1,
        },
      }, {
        $limit: 1,
      },
    ]);
    if (!lastTransactions || lastTransactions.length === 0) {
      return 0;
    }
    return lastTransactions[0].order;
  }

  async getPreviousTransaction(transaction: ILoanStatementTransactionDoc) {
    const charge = await LoanCharge.findById(transaction.chargeId);
    if (!charge) {
      return null;
    }
    const charges = await this.loanChargesService.getLoanChargeForProduct(charge.productId.toString());

    const lastTransactions = await LoanStatementTransactionModel.aggregate<ILoanStatementTransactionDoc>([
      {
        $match: {
          'chargeId': { $in: charges.map((charge) => charge._id) },
          '_id': { $ne: transaction._id },
          $or: [
            {
              $and: [
                { 'date': { $eq: transaction.date } },
                { 'order': { $lt: transaction.order } },
              ],
            },
            {
              'date': { $lt: transaction.date },
            },
          ],
        },
      }, {
        $sort: {
          'date': -1,
          'order': -1,
        },
      }, {
        $limit: 1,
      },
    ]);
    if (!lastTransactions || lastTransactions.length === 0) {
      return null;
    }
    return lastTransactions[0];
  }

  async getTransactionsFromDate(productId: string, startDate: Date) {
    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    return LoanStatementTransactionModel.aggregate<ILoanStatementTransactionDoc>([
      {
        $match: {
          'chargeId': { $in: charges.map((charge) => charge._id) },
          'date': { $gte: startDate },
        },
      }, {
        $sort: {
          'date': 1,
          'order': 1,
        },
      },
    ]);
  }

  async getTransactionsForPeriod(productId: string, period: { start: Date, end: Date }) {
    const charges = await this.loanChargesService.getLoanChargeForProduct(productId);
    return LoanStatementTransactionModel.aggregate<ILoanStatementTransactionDoc>([
      {
        $match: {
          $and: [
            { 'chargeId': { $in: charges.map((charge) => charge._id) } },
            { 'date': { $gte: period.start } },
            { 'date': { $lt: period.end } },
          ],
        },
      }, {
        $sort: {
          'date': 1,
          'order': 1,
        },
      },
    ]);
  }

  async recalculateAllBalances(borrowerIds: string[], userId: string): Promise<void> {
    for (const borrowerId of borrowerIds) {
      const allProducts = await LoanProduct.find({ borrowerId }).lean();
      for (const product of allProducts) {

        const firstTransaction = await this.loanTransactionsService.getFirstTransaction(product._id.toString());
        if (firstTransaction) {
          try {
            await this.loanTransactionsService.updateLoanTransaction(firstTransaction);
          } catch (e) {
            console.error(e);
          }
        }

        const firstStatementTransaction = await this.getFirstStatementTransaction(product._id.toString());
        if (firstStatementTransaction) {
          try {
            await this.updateStatementTransaction(firstStatementTransaction as Partial<ILoanStatementTransactionWithId>);
          } catch (e) {
            console.error(e);
          }
        }

        const allPayments = await LoanPaymentModel.find({ productId: product._id.toString() }).lean();
        for (const payment of allPayments) {
          await this.loanPaymentsService.updateLoanPayment(payment as unknown as Partial<ILoanPaymentWithId>, userId);
        }

      }
    }
  }

  async recalculateAllStatements(selectedPeriod: SelectablePeriod, borrowerIds: string[]): Promise<void> {
    for (const borrowerId of borrowerIds) {
      try {
        await this.calculateBorrowerStatement(borrowerId, selectedPeriod);
      } catch (e) {
        console.error('recalculateAllStatements - ERROR, borrowerId', borrowerId, e);
      }
    }
  }

  async recalculateAllTermLoans(borrowerIds: string[]): Promise<void> {
    for (const borrowerId of borrowerIds) {
      try {
        const products = await this.loanChargesService.getLoanProducts(borrowerId);
        const termProducts = products.filter((product) => product.type === ELoanTypes.TERM);
        await Promise.all(termProducts.map(async (termProduct) => {
          await this.termLoanService.calculateTermLoan(termProduct._id.toString(), true);
        }));
      } catch (e) {
        console.error('recalculateAllTermLoans - ERROR, borrowerId', borrowerId, e);
      }
    }
  }

  async getStatementBorrowersTransactions(start: string, end: string, selectedPeriod: string, selectedBorrowerIds: string[], ledgerType: ELedgerReportType) {
    return getStatementBorrowersTransactions(start, end, selectedPeriod, selectedBorrowerIds, ledgerType);
  }

  async getBorrowerComplianceData(borrowerId: string, date: Date) {
    const end = dayjs.utc(date).subtract(1, 'month').endOf('month');
    const start = end.startOf('month');
    const products = await this.loanChargesService.getLoanProducts(borrowerId);
    const data = {
      productTotals: products.reduce((acc, product) => ({ ...acc, [product._id.toString()]: 0 }), <{
        [productId: string]: number
      }>{}),
      total: 0,
      totalPaid: 0,
      dueDate: null,
      revolverBalance: 0,
      termBalance: 0,
    };
    await Promise.all(products.map(async (product) => {
      const lastTransaction = await this.loanTransactionsService.getLastTransactionForDate(product._id.toString(), end.toDate());
      if (lastTransaction) {
        switch (product.type) {
          case ELoanTypes.REVOLVER: {
            data.revolverBalance = lastTransaction.balance ?? 0;
            break;
          }
          case ELoanTypes.TERM: {
            data.termBalance = lastTransaction.balance ?? 0;
            break;
          }
        }
      }
      const lastStatementTransaction = await this.loanStatementBalanceService.getLastStatementTransactionForDate(product._id.toString(), end.toDate());
      const transactions = await this.getTransactionsForPeriod(product._id.toString(), {
        start: start.toDate(),
        end: end.toDate(),
      });
      data.totalPaid = transactions.reduce((acc, t) => new Decimal(acc).add(t.amountPaid).toNumber(), 0);
      if (lastStatementTransaction) {
        data.productTotals[product._id.toString()] = lastStatementTransaction.balance ?? 0;
      }
      if (product.type === ELoanTypes.TERM) {
        const termLoan = await this.termLoanService.getTermLoan(product._id.toString(), true);
        if (termLoan) {
          const lastDate = termLoan.calculated.slice().reverse().find((c) => dayjs(c.relevantStatement).isBefore(date));
          if (lastDate) {
            const calculatedResultsForDate = termLoan.calculated.filter((c) => c.relevantStatement === lastDate.relevantStatement);
            const totalPrincipal = calculatedResultsForDate.reduce((acc, c) => new Decimal(acc).add(c.monthlyPrincipal).toNumber(), 0);
            const totalPrincipalPaid = calculatedResultsForDate.reduce((totalAcc, c) => {
              const totalMonthPaid = c.payments
                ? c.payments.reduce((acc, p) => new Decimal(acc).add(p.amount).toNumber(), 0)
                : 0;
              return new Decimal(totalAcc).add(totalMonthPaid).toNumber();
            }, 0);
            data.productTotals[product._id.toString()] = new Decimal(data.productTotals[product._id.toString()]).add(totalPrincipal).sub(totalPrincipalPaid).toNumber();
          }
        }
      }
    }));
    data.total = Object.values(data.productTotals).reduce((acc, productDue) => new Decimal(acc).add(productDue).toNumber(), 0);
    return data;
  }

  private async getOutstandingStatements(borrowerIds: string[], selectedDate: Date, fullMonth: boolean) {
    const results = await Promise.all(borrowerIds.map(async (borrowerId) => {
      const borrower = await BorrowerModel.findById(borrowerId).lean();
      const products = await this.loanChargesService.getLoanProducts(borrowerId);
      return await Promise.all(products
        .filter(product => !dayjs(product.deactivationDate).isBefore(dayjs(selectedDate)))
        .map(async (product) => {
          if (dayjs(product.deactivationDate).isBefore(dayjs(selectedDate))) {
            return null;
          }
          const relevantStatementEndMonth = fullMonth
            ? dayjs(selectedDate).utcOffset(0).startOf('month').subtract(1, 'seconds')
            : dayjs(selectedDate).utcOffset(0).endOf('month');
          const relevantStatementEnd = fullMonth
            ? dayjs(selectedDate).utcOffset(0).startOf('month').subtract(1, 'seconds')
            : dayjs(selectedDate).utcOffset(0).endOf('day');
          const relevantStatementStart = dayjs(selectedDate).utcOffset(0).startOf('month').subtract(1, 'seconds').startOf('month');

          const paymentFilterEnd = dayjs(selectedDate).utcOffset(0).endOf('day');
          const paymentFilterStart = relevantStatementStart;

          const lastTransaction = await this.loanTransactionsService.getLastTransactionForDate(product._id.toString(), dayjs(selectedDate).endOf('day').toDate());
          let actualBalance = 0;
          if (lastTransaction) {
            actualBalance = new Decimal(lastTransaction.balance).toDP(2).toNumber();
          }

          const statementFilter = {
            productIds: [product._id.toString()],
            start: relevantStatementStart.toDate(),
            end: relevantStatementEnd.toDate(),
          };
          const paymentFilter = {
            productIds: [product._id.toString()],
            start: paymentFilterStart.toDate(),
            end: paymentFilterEnd.toDate(),
          };
          const statementTransactions = await this.loanChargesService.getLoanStatementsForForProductWithCharges(statementFilter);
          const statementPayments = await this.loanPaymentsService.getLoanPayments(paymentFilter, null);
          const paidStatementIds = statementPayments.reduce((acc, s) => [...acc, ...s.paid.map((p) => p.statementId.toString())], []);
          const fees = {
            [ELoanChargeType.INTEREST_FEE]: 0,
            [ELoanChargeType.ADMIN_FEE]: 0,
            [ELoanChargeType.UNUSED_LINE_FEE]: 0,
            [ELoanChargeType.WIRE_FEE]: 0,
            [ELoanChargeType.OTHER]: 0,
            [ELoanChargeType.RECOVERABLE]: 0,
            [ELoanChargeType.ANNUAL_LINE_FEE]: 0,
          };
          const sumResults = statementTransactions.reduce((acc, t) => {
            const amountPaid = paidStatementIds.includes(t._id.toString()) ? t.amountPaid : 0;
            return {
              ...acc,
              [t.charge.chargeType]: new Decimal(acc[t.charge.chargeType] ?? 0).add(t.amount ?? 0).sub(amountPaid ?? 0).toNumber(),
            };
          }, fees);

          let principal = 0;
          let principalExpectedDate = null;
          if (product.type === ELoanTypes.TERM) {
            const termLoan = await TermLoanModel.findOne({ productId: product._id.toString(), actual: true });
            if (termLoan) {
              const calculatedTermLoan = await TermLoanCalculatedModel.findOne({
                termLoanId: termLoan._id.toString(),
                relevantStatement: relevantStatementEndMonth.format('YYYY-MM-DD'),
              });
              if (calculatedTermLoan) {
                if (calculatedTermLoan.payments && calculatedTermLoan.payments.length > 0) {
                  const totalPayment = calculatedTermLoan.payments.reduce((acc, p) => new Decimal(acc ?? 0).add(p.amount ?? 0).toNumber(), 0);
                  principal = new Decimal(calculatedTermLoan.monthlyPrincipal).sub(totalPayment).toDP(2).toNumber();
                } else {
                  principal = calculatedTermLoan.monthlyPrincipal;
                  principalExpectedDate = calculatedTermLoan.paymentDueDate;
                }
              }
            }
          }

          return {
            borrowerCode: borrower.code,
            ...product,
            actualBalance,
            ...sumResults,
            principal,
            principalExpectedDate,
          };
        }));
    }));
    return results
      .flat()
      .sort((a, b) => a.code.toUpperCase().localeCompare(b.code.toUpperCase()));
  }

  async getOutstandingStatementsExcel(borrowerIds: string[], selectedDate: Date, fullMonth: boolean) {
    const results = await this.getOutstandingStatements(borrowerIds, selectedDate, fullMonth);
    const header = [
      ['Date', dayjs(selectedDate).format(defaultDateFormat)],
      ['', '', 'Outstanding statement'],
      ['client ID', 'product ID', 'product name', 'balance', 'interest', 'admin', 'unused', 'wire & other', 'recoverable expenses', 'principal'],
    ];
    const onlyData = results.map((res) => {
      return [
        res.borrowerCode,
        res.code,
        res.name,
        res.actualBalance,
        res[ELoanChargeType.INTEREST_FEE],
        res[ELoanChargeType.ADMIN_FEE],
        res[ELoanChargeType.UNUSED_LINE_FEE],
        new Decimal(res[ELoanChargeType.WIRE_FEE]).add(res[ELoanChargeType.OTHER]).add(res[ELoanChargeType.ANNUAL_LINE_FEE]).toNumber(),
        res[ELoanChargeType.RECOVERABLE],
        res.principal,
      ];
    });
    return await this.uploadsService.convertDataToFile([{ data: [...header, ...onlyData] }]);
  }

  async getBorrowerProductTotals(borrowerId: string) {
    return await this.getBorrowerComplianceData(borrowerId, new Date());
  }

  async getProductsTotals(productIds: string[]) {
    const totals: { [productId: string]: number } = {};
    await Promise.all(productIds.map(async (productId) => {
      const product = await LoanProduct.findById(productId);
      if (product) {
        const borrowerData = await this.getBorrowerComplianceData(product.borrowerId.toString(), new Date());
        Object.entries(borrowerData.productTotals).forEach(([id, value]) => {
          if (productIds.includes(id)) {
            totals[id] = value;
          }
        });
      }
    }));
    return totals;
  }

  private getAccruedStatement(lastStatements: { amountPaid: number, amount: number }[]) {
    const totals = lastStatements.reduce((acc, s) => {
      return {
        amountPaid: new Decimal(acc.amountPaid).add(s.amountPaid).toNumber(),
        amount: new Decimal(acc.amount).add(s.amount).toNumber(),
      };
    }, { amountPaid: 0, amount: 0 });
    return new Decimal(totals.amount).sub(totals.amountPaid).toNumber();
  }

  async calculateAccruedStatementForBBC(bbcDateId: string) {
    const bbcDate = await BBCDateModel.findById(bbcDateId);
    if (!bbcDate) {
      return 0;
    }
    const charges = await this.loanChargesService.getLoanCharges(bbcDate.borrowerId.toString());
    const chargesId = charges.map((c) => c._id);

    const lastStatements = await LoanStatementTransactionModel.aggregate<{ amountPaid: number, amount: number }>([
      {
        $match: {
          'date': { $lt: bbcDate.bbcDate },
          'chargeId': { $in: chargesId },
          $expr: { $gt: ['$amount', '$amountPaid'] },
        },
      }, {
        $project: {
          _id: 0,
          amount: 1,
          amountPaid: 1,
        },
      },
    ]);
    return this.getAccruedStatement(lastStatements);
  }

  async calculateAccruedStatementForBorrower(borrowerId: string) {
    const charges = await this.loanChargesService.getLoanCharges(borrowerId);
    const chargesId = charges.map((c) => c._id);

    const lastStatements = await LoanStatementTransactionModel.aggregate<{ amountPaid: number, amount: number }>([
      {
        $match: {
          'date': { $lt: new Date() },
          'chargeId': { $in: chargesId },
          $expr: { $gt: ['$amount', '$amountPaid'] },
        },
      }, {
        $project: {
          _id: 0,
          amount: 1,
          amountPaid: 1,
        },
      },
    ]);
    return this.getAccruedStatement(lastStatements);
  }
}
