import _ from 'lodash';
import mongoose from 'mongoose';
import dayjs from 'dayjs';
import Decimal from 'decimal.js';

import { IEmail } from '../interfaces/email.interface';
import { calculateTimeZone, isProductActive } from '../helpers/date.helper';
import { createFilteredObject } from '../helpers/common.helper';
import { IPaginatorOptions } from '../interfaces/collaterals.interface';
import { BBCDateModel } from '../models/BBCDate.model';
import { ELoanTypes } from '../enums/loan-types.enum';
import {
  ELoanTransactionTypes,
  ILoanTransaction,
  ILoanTransactionDoc, ILoanTransactionLean,
  ILoanTransactionUpload,
  ILoanTransactionView,
  ILoanTransactionViewWithBank,
  ILoanTransactionWithId,
  LOAN_TRANSACTION_FIELDS,
  LoanTransaction,
} from '../models/LoanTransaction.model';
import { ITEMS_PAGINATION } from '../db/collaterals.db';
import {
  PostponedTransaction,
} from '../models/PostponedTransactions.model';
import { LoanProduct } from '../models/LoanProducts.model';
import { ILoanProductDoc } from '../models/LoanProducts.model';
import { BorrowerModel } from '../models/Borrower.model';
import { BorrowerCompliance, IComplianceBorrowerDoc } from '../models/BorrowerCompliance.model';
import {
  ILoanStatementTransaction,
  LOAN_STATEMENT_TRANSACTION_FIELDS,
  LoanStatementTransactionModel,
} from '../models/LoanStatementTransaction.model';
import { ILoanPaymentWithId, LoanPaymentModel } from '../models/LoanPayment.model';
import { fieldsToUnset, getPostponedTransactions } from '../db/loan-products.db';
import { getLastTransactionForDate } from '../db/loan-transactions.db';

import { IUploadOption, UploadsService } from './uploads.service';
import { ILoanTransactionFileDoc, LoanTransactionFileModel } from '../models/LoanTransactionFile.model';
import { AttachedFilesService } from './attached-files.service';
import { BanksService } from './banks.service';
import { BorrowersDB } from './borrowers.db';
import { CalendarService } from './calendar.service';
import { LoanChargesService } from './loan-charges.service';
import { LoanPaymentsService } from './loan-payments.service';
import { LoanStatementService } from './loan-statement.service';
import { LoanStatementStatusService } from './loan-statement-status.service';
import { NodemailerService } from './nodemailer.service';
import { TermLoanService } from './term-loan.service';
import { LockService } from './lock.service';
import { PostponedTransactionsService } from './postponed-transactions.service';
import { MainQueueService } from './main-queue.service';
import { EventMap } from '../microservice/event-map';
import { QueueMap } from '../microservice/queue-map';

interface ILoanTransactionsServiceConfig {
  portfolioEmail: string;
}

export interface ITransactionsFilter {
  periodStart: Date;
  periodEnd: Date;
  borrowerId: string;
  productId: string;
  amount?: number;
  reference?: string;
}

export interface ITransactionsMultiProductsFilter {
  periodStart: Date;
  periodEnd: Date;
  productIds: string[];
  amount?: number;
  reference?: string;
}

export class LoanTransactionsService {

  private readonly config: ILoanTransactionsServiceConfig;

  constructor(
    config: ILoanTransactionsServiceConfig,
    private readonly attachedFilesService: AttachedFilesService,
    private readonly banksService: BanksService,
    private readonly borrowersDB: BorrowersDB,
    private readonly calendarService: CalendarService,
    private readonly loanChargesService: LoanChargesService,
    private readonly getLoanPaymentsService: () => LoanPaymentsService,
    private readonly getLoanStatementService: () => LoanStatementService,
    private readonly loanStatementStatusService: LoanStatementStatusService,
    private readonly getNodemailerService: () => NodemailerService,
    private readonly lockService: LockService,
    private readonly termLoanService: TermLoanService,
    private readonly uploadsService: UploadsService,
    private readonly postponedTransactionsService: PostponedTransactionsService,
    private readonly mainQueueService: MainQueueService,
  ) {
    this.config = config;
  }

  async getTransactionsById(transactionId: string) {
    return LoanTransaction.findById(transactionId).lean();
  }

  private async getLoanTransactionsInternal<T extends boolean>(
    productIds: mongoose.Types.ObjectId[],
    transactionsFilter: {
      periodStart: Date;
      periodEnd: Date;
      amount?: number;
      reference?: string;
    },
    paginatorOptions: IPaginatorOptions,
    withBanks: T,
    isReverse = false,
    isMinified = false,
  ) {
    const optionalFilters = [];

    if (transactionsFilter.amount) {
      optionalFilters.push({ amount: transactionsFilter.amount });
    }

    if (transactionsFilter.reference) {
      optionalFilters.push({
        reference: {
          $regex: transactionsFilter.reference,
          $options: 'i',
        },
      });
    }

    const pipeline: mongoose.PipelineStage[] = [
      {
        $match: {
          date: {
            $gte: transactionsFilter.periodStart,
            $lte: transactionsFilter.periodEnd,
          },
          productId: { $in: productIds },
          ...Object.assign({}, ...optionalFilters),
        },
      },
      {
        $sort: {
          date: isReverse ? -1 : 1,
          order: isReverse ? -1 : 1,
          createdAt: isReverse ? -1 : 1,
        },
      },
      ...ITEMS_PAGINATION(paginatorOptions),
    ];

    if (withBanks) {
      pipeline.push(
        {
          $lookup: {
            from: 'banks',
            localField: 'bankId',
            foreignField: '_id',
            as: 'bank',
          },
        },
        {
          $unwind: {
            path: '$bank',
            preserveNullAndEmptyArrays: true,
          },
        },
      );
    }

    pipeline.push(
      {
        $unset: fieldsToUnset,
      },
    );

    if (isMinified) {
      pipeline.push({
        $project: {
          _id: 1,
          date: 1,
          amount: 1,
          reference: 1,
          balance: 1,
          transactionType: 1,
        },
      });
    }

    return LoanTransaction.aggregate<
      T extends true ? ILoanTransactionViewWithBank : ILoanTransactionView
    >(pipeline);
  }

  async getLoanTransactions<T extends boolean>(
    transactionsFilter: ITransactionsFilter,
    paginatorOptions: IPaginatorOptions,
    withBanks: T,
    isReverse = false,
    isMinified = false,
  ) {
    const borrowerProducts = await this.loanChargesService.getLoanProducts(
      transactionsFilter.borrowerId,
    );

    const productIds = borrowerProducts
      .filter((product) =>
        transactionsFilter.productId
          ? product._id.toString() === transactionsFilter.productId
          : true,
      )
      .map((product) => product._id);

    return this.getLoanTransactionsInternal(
      productIds,
      transactionsFilter,
      paginatorOptions,
      withBanks,
      isReverse,
      isMinified,
    );
  }

  async getLoanTransactionsForProducts<T extends boolean>(
    transactionsFilter: ITransactionsMultiProductsFilter,
    paginatorOptions: IPaginatorOptions,
    withBanks: T,
    isReverse = false,
    isMinified = false,
  ) {
    return this.getLoanTransactionsInternal(
      transactionsFilter.productIds.map(
        (id) => new mongoose.Types.ObjectId(id),
      ),
      transactionsFilter,
      paginatorOptions,
      withBanks,
      isReverse,
      isMinified,
    );
  }

  async getLoanTransactionsForFilter(periodStart: Date, periodEnd: Date, productId: string) {
    return LoanTransaction.aggregate<ILoanTransactionView>([
      {
        $match: {
          $and: [
            { 'date': { $gte: periodStart } },
            { 'date': { $lte: periodEnd } },
            { 'productId': new mongoose.Types.ObjectId(productId) },
          ],
        },
      }, {
        $sort: {
          'date': -1,
          'order': -1,
          'createdAt': -1,
        },
      }, {
        $unset: fieldsToUnset,
      },
    ]);
  }

  async getLoanTransactionsForProduct(productId: string) {
    return LoanTransaction.aggregate<ILoanTransactionView>([
      {
        $match: {
          $and: [
            { 'productId': new mongoose.Types.ObjectId(productId) },
          ],
        },
      }, {
        $sort: {
          'date': -1,
          'order': -1,
          'createdAt': -1,
        },
      }, {
        $unset: fieldsToUnset,
      },
    ]);
  }

  async getLoanTransactionFiles() {
    const transactionFiles = await LoanTransactionFileModel.aggregate<ILoanTransactionFileDoc>([
      {
        $sort: {
          'effectiveDate': -1,
          'createdAt': 1,
        },
      }, {
        $unset: [...fieldsToUnset.filter((field) => field !== 'createdAt')],
      },
    ]);
    return await Promise.all(transactionFiles.map(async (file) => {
      const effectiveDate = dayjs(file.effectiveDate).format('YYYY-MM-DD');
      const attachedFiles = await this.attachedFilesService.getAttachedFilesByEffectiveDate(effectiveDate);
      return { ...file, attachedFileIds: attachedFiles.map((attachedFile) => attachedFile._id.toString()) };
    }));
  }

  async getDisbursementTransactions(productId: string, date: Date) {
    const startOfDay = dayjs.utc(date).startOf('day').toDate();
    const endOfDay = dayjs.utc(date).endOf('day').toDate();
    return LoanTransaction.countDocuments(
      {
        date: { $gte: startOfDay, $lte: endOfDay },
        productId: new mongoose.Types.ObjectId(productId),
        transactionType: ELoanTransactionTypes.DISBURSEMENT,
        bankId: { $ne: null },
      },
    );
  }

  async getTotalLoanTransactions(transactionsFilter: ITransactionsFilter) {
    const borrowerProducts = await this.loanChargesService.getLoanProducts(transactionsFilter.borrowerId);
    const productIds = borrowerProducts
      .filter((product) => transactionsFilter.productId ? product._id.toString() === transactionsFilter.productId : true)
      .map((product) => product._id);
    return LoanTransaction.countDocuments(
      {
        $and: [
          { 'date': { $gte: transactionsFilter.periodStart } },
          { 'date': { $lte: transactionsFilter.periodEnd } },
          { 'productId': { $in: productIds } },
        ],
      },
    );
  }

  async uploadLoanTransactions(uploadedItems: ILoanTransactionUpload[], uploadOption: IUploadOption, userId: string, shift = 0) {
    const orderedItems = uploadedItems.map((item, index) => ({
      ...item,
      order: index + shift,
    }));
    const transactionsToRecalculate: { productId: string, date: Date, order: number, transactionId: string }[] = [];
    const statementToSave: string[] = [];
    await Promise.all(orderedItems.map(async (item) => {
      const foundBank = await this.banksService.findBankByNumber(item.bankAccountNumber);

      const productId = await this.loanChargesService.getLoanProductIdByCode(item.ledgerAccountCode);
      if (productId) {
        const itemToSave: Partial<ILoanTransaction> = {
          ...(_.pick(item, LOAN_TRANSACTION_FIELDS)),
          date: calculateTimeZone(item.date),
          bankId: new mongoose.Types.ObjectId(String(foundBank._id)),
          productId: new mongoose.Types.ObjectId(productId),
          balance: 0,
          floatedBalance: 0,
          effectiveDate: await this.calculateEffectiveDate(item),
          userId,
        };
        const newLoanTransaction = new LoanTransaction({
          order: item.order,
          ...itemToSave,
        });
        await newLoanTransaction.save();
        transactionsToRecalculate.push({
          productId: newLoanTransaction.productId.toString(),
          date: newLoanTransaction.date,
          transactionId: newLoanTransaction._id.toString(),
          order: newLoanTransaction.order,
        });
      }

      const chargeId = await this.loanChargesService.getLoanChargeIdByCode(item.ledgerAccountCode);
      if (chargeId) {
        const itemToSave: Partial<ILoanStatementTransaction> = {
          ...(_.pick(item, LOAN_STATEMENT_TRANSACTION_FIELDS)),
          date: calculateTimeZone(item.date),
          chargeId: new mongoose.Types.ObjectId(chargeId),
          memo: item.reference,
          isSystem: false,
          amountPaid: 0,
        };
        const loanStatementService = this.getLoanStatementService();
        const lastOrder = await loanStatementService.getLastTransactionOrder(itemToSave);
        itemToSave.order = lastOrder + 1;
        const newLoanStatement = new LoanStatementTransactionModel(itemToSave);
        await newLoanStatement.save();
        statementToSave.push(newLoanStatement._id.toString());
      }
    }));

    const newLoanTransactionFile = new LoanTransactionFileModel({
      userUploaded: '',
      transactionsId: transactionsToRecalculate.map((transaction) => new mongoose.Types.ObjectId(transaction.transactionId)),
      statementIds: statementToSave.map((statement) => new mongoose.Types.ObjectId(statement)),
      fileName: uploadOption.fileName,
      sheetName: uploadOption.sheetName,
      effectiveDate: uploadOption.effectiveDate,
    });
    await newLoanTransactionFile.save();

    const earliestTransactionIds = new Map<string, { transactionId: string, date: Date }>();

    transactionsToRecalculate.forEach((transaction) => {
      const { productId, transactionId, date } = transaction;
      if (earliestTransactionIds.has(productId)) {
        if (date < earliestTransactionIds.get(productId).date) {
          earliestTransactionIds.set(productId, { transactionId, date });
        }
      } else {
        earliestTransactionIds.set(productId, { transactionId, date });
      }
    });

    for (const [productId, values] of earliestTransactionIds) {
      await this.recalculateBalanceQueued(productId, values.transactionId);
    }
  }

  async normalizeLoanTransaction(transaction: ILoanTransactionDoc, userId: string) {
    transaction.effectiveDate = await this.calculateEffectiveDate(transaction);
    transaction.date = new Date(transaction.date);
    switch (ELoanTransactionTypes[transaction.transactionType]) {
      case ELoanTransactionTypes.DISBURSEMENT:
        transaction.amount = new Decimal(transaction.amount).abs().toNumber();
        break;
      case ELoanTransactionTypes.COLLECTION:
        transaction.amount = new Decimal(transaction.amount).abs().neg().toNumber();
        break;
    }
    transaction.userId = userId;
    return transaction;
  }

  async createLoanTransaction(transaction: Partial<ILoanTransactionWithId>) {
    const pureTransactions = createFilteredObject<ILoanTransactionView>(transaction, LOAN_TRANSACTION_FIELDS);
    const product = await this.loanChargesService.getLoanProductById(pureTransactions.productId.toString());
    if (product) {
      const bank = await this.banksService.findBankByAccountCode(product.code);
      if (bank) {
        pureTransactions.bankId = bank._id;
      }
    }
    pureTransactions.customerId = '';
    return await this.saveLoanTransaction(pureTransactions);
  }

  async updateLoanTransaction(transaction: ILoanTransactionDoc) {
    const pureTransactions = createFilteredObject<ILoanTransactionView>(transaction, LOAN_TRANSACTION_FIELDS);
    await this.saveLoanTransaction(pureTransactions);
  }

  async saveLoanTransaction(transaction: Partial<ILoanTransactionLean>): Promise<ILoanTransactionLean> {
    let startId: string;
    let updatedTransaction: ILoanTransactionLean;
    if (!transaction._id) {
      delete transaction._id;
      const newTransaction = new LoanTransaction(transaction);
      startId = newTransaction._id.toString();
      const saved = await newTransaction.save();
      updatedTransaction = saved.toObject() as unknown as ILoanTransactionLean;
    } else {
      startId = transaction._id.toString();
      const updated = await LoanTransaction.findByIdAndUpdate(transaction._id, transaction, { new: true }).lean();
      if (!updated) {
        throw new Error(`LoanTransaction not found for update: ${transaction._id.toString()}`);
      }
      updatedTransaction = updated as unknown as ILoanTransactionLean;
    }
    if (startId) {
      this.recalculateBalanceQueued(transaction.productId.toString(), startId).catch(console.error);
    }
    const statementDate = this.loanStatementStatusService.getStatementDateForDate(new Date(transaction.date));
    this.loanStatementStatusService.updateStatementStatus(transaction.productId.toString(), statementDate, false).catch(console.error);
    this.termLoanService.setTermLoanExpected(transaction.productId.toString()).catch(console.error);
    return updatedTransaction;
  }

  async recalculateProduct(productId: string) {
    const firstTransactions = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        '$match': {
          'productId': new mongoose.Types.ObjectId(productId),
        },
      }, {
        '$sort': {
          'date': 1,
        },
      }, {
        '$limit': 1,
      },
    ]);

    if (firstTransactions.length > 0) {
      await this.recalculateBalanceQueued(firstTransactions[0].productId.toString(), firstTransactions[0]._id.toString());
    }
  }

  async recalculateBalanceLegacy(transactionId: string) {
    const changedTransaction = await LoanTransaction.findById<ILoanTransactionDoc & {
      createdAt: Date
    }>(transactionId);
    if (!changedTransaction) {
      console.error(`no transactions with id ${transactionId}`);
      return;
    }

    const recalcBalance = async () => {
      await LoanProduct.findByIdAndUpdate(changedTransaction.productId, { isBalanceActual: false });
      const previousTransaction = await LoanTransaction.aggregate<ILoanTransactionDoc>([
        {
          $match: {
            'productId': new mongoose.Types.ObjectId(changedTransaction.productId.toString()),
            'date': { $lt: new Date(changedTransaction.date) },
          },
        }, {
          $sort: {
            'date': -1,
            'order': -1,
            'createdAt': -1,
          },
        }, {
          $limit: 1,
        },
      ]);
      let currentBalance = previousTransaction.length
        ? previousTransaction[0].balance ?? 0
        : 0;
      const recalculatedTransactions = await LoanTransaction.aggregate<ILoanTransactionDoc>([
        {
          $match: {
            'productId': new mongoose.Types.ObjectId(changedTransaction.productId.toString()),
            'date': { $gte: new Date(changedTransaction.date) },
          },
        }, {
          $sort: {
            'date': 1,
            'order': 1,
            'createdAt': 1,
          },
        },
      ]);
      for (const transaction of recalculatedTransactions) {
        currentBalance = new Decimal(currentBalance).add(transaction.amount).toNumber();
        await LoanTransaction.findByIdAndUpdate(transaction._id, { balance: currentBalance });
      }
      await LoanProduct.findByIdAndUpdate(changedTransaction.productId, { isBalanceActual: true });
    };

    const recalcFloatedBalance = async () => {
      await LoanProduct.findByIdAndUpdate(changedTransaction.productId, { isFloatedBalanceActual: false });
      const previousEqualBalancesTransaction = await LoanTransaction.aggregate<ILoanTransactionDoc>([
        {
          $match: {
            'productId': new mongoose.Types.ObjectId(changedTransaction.productId.toString()),
            'date': { $lt: new Date(changedTransaction.date) },
            $expr: { $eq: ['$balance', '$floatedBalance'] },
          },
        }, {
          $sort: {
            'date': -1,
            'order': -1,
            'createdAt': -1,
          },
        }, {
          $limit: 1,
        },
      ]);
      let currentFloatedBalance = 0;
      if (previousEqualBalancesTransaction.length) {
        const previousTransaction = await LoanTransaction.aggregate<ILoanTransactionDoc>([
          {
            $match: {
              'productId': new mongoose.Types.ObjectId(previousEqualBalancesTransaction[0].productId.toString()),
              'date': { $lt: new Date(previousEqualBalancesTransaction[0].date) },
            },
          }, {
            $sort: {
              'date': -1,
              'order': -1,
              'createdAt': -1,
            },
          }, {
            $limit: 1,
          },
        ]);
        if (previousTransaction.length) {
          currentFloatedBalance = previousTransaction[0].floatedBalance;
        }
      }
      const initTransaction = previousEqualBalancesTransaction.length ? previousEqualBalancesTransaction[0] : changedTransaction;
      const recalculatedTransactions = await LoanTransaction.aggregate<ILoanTransactionDoc>([
        {
          $match: {
            'productId': new mongoose.Types.ObjectId(initTransaction.productId.toString()),
            'date': { $gte: new Date(initTransaction.date) },
          },
        }, {
          $sort: {
            'date': 1,
            'order': 1,
            'createdAt': 1,
          },
        },
      ]);
      for (const transaction of recalculatedTransactions) {
        const totalPostponed = await getPostponedTransactions(transaction.date, transaction.productId.toString(), true);
        switch (transaction.transactionType) {
          case ELoanTransactionTypes.COLLECTION: {
            await this.postponedTransactionsService.cleanRecalculated(transaction._id.toString());
            await this.postponedTransactionsService.createPostponedTransaction(transaction);
            currentFloatedBalance = new Decimal(currentFloatedBalance).add(totalPostponed).toNumber();
            break;
          }
          default: {
            currentFloatedBalance = new Decimal(currentFloatedBalance).add(transaction.amount).add(totalPostponed).toNumber();
          }
        }
        await LoanTransaction.findByIdAndUpdate(transaction._id, { floatedBalance: currentFloatedBalance });
      }
      await LoanProduct.findByIdAndUpdate(changedTransaction.productId, { isFloatedBalanceActual: true });
    };

    await recalcBalance();
    await recalcFloatedBalance();
  }

  async recalculateBalanceQueued(productId: string, transactionId: string) {
    await this.mainQueueService.sendTaskToQueue({
      data: { productId, transactionId },
      pattern: EventMap.recalc.PRODUCT_BALANCE,
    }, QueueMap.main);
  }

  async recalculateBalance(transactionId: string) {
    const startTransaction = await LoanTransaction.findById(transactionId).lean();
    if (!startTransaction) {
      console.error(`Transaction not found: ${transactionId}`);
      return;
    }

    const earliestTransaction = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          productId: startTransaction.productId,
          date: { $lte: new Date(startTransaction.date) },
        },
      },
      { $sort: { date: 1, order: 1, createdAt: 1 } },
      { $limit: 1 },
    ]);

    const fromDate = earliestTransaction.length
      ? earliestTransaction[0].date
      : startTransaction.date;

    const allTransactions = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          productId: startTransaction.productId,
          date: { $gte: fromDate },
        },
      },
      { $sort: { date: 1, order: 1, createdAt: 1 } },
    ]);

    let balance = 0;
    let floatedBalance = 0;

    const prevBeforeStart = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          productId: startTransaction.productId,
          date: { $lt: fromDate },
        },
      },
      { $sort: { date: -1, order: -1, createdAt: -1 } },
      { $limit: 1 },
    ]);

    if (prevBeforeStart.length) {
      balance = prevBeforeStart[0].balance || 0;
      floatedBalance = prevBeforeStart[0].floatedBalance || 0;
    }

    for (const tr of allTransactions) {
      // regular balance recalculation
      balance = new Decimal(balance).add(tr.amount).toNumber();

      // floatedBalance recalculation
      const totalPostponed = await getPostponedTransactions(tr.date, tr.productId.toString(), true);
      switch (tr.transactionType) {
        case ELoanTransactionTypes.COLLECTION: {
          await this.postponedTransactionsService.cleanRecalculated(tr._id.toString());
          await this.postponedTransactionsService.createPostponedTransaction(tr);
          floatedBalance = new Decimal(floatedBalance).add(totalPostponed).toNumber();
          break;
        }
        default: {
          floatedBalance = new Decimal(floatedBalance).add(tr.amount).add(totalPostponed).toNumber();
        }
      }

      await LoanTransaction.updateOne(
        { _id: tr._id },
        { balance, floatedBalance },
      );
    }

    await LoanProduct.updateOne(
      { _id: startTransaction.productId },
      { isBalanceActual: true, isFloatedBalanceActual: true },
    );
  }

  async deleteLoanTransaction(transactionId: string, updateLoanPayment: boolean, userId = null) {
    const currentTransaction = await LoanTransaction.findById(transactionId).lean();
    if (!currentTransaction) {
      console.error(`no transactions with id ${transactionId}`);
      return;
    }
    const nextTransaction = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          'productId': new mongoose.Types.ObjectId(currentTransaction.productId.toString()),
          'date': { $gte: new Date(currentTransaction.date) },
          '_id': { $ne: new mongoose.Types.ObjectId(transactionId) },
        },
      }, {
        $sort: {
          'date': 1,
          'order': 1,
          'createdAt': 1,
        },
      }, {
        $limit: 1,
      },
    ]);
    await LoanTransaction.findByIdAndDeleteWithUserId(transactionId, userId);
    await PostponedTransaction.findOneAndDelete({ transactionId });
    if (currentTransaction.loanPaymentId && updateLoanPayment) {
      const payment = await LoanPaymentModel.findById(currentTransaction.loanPaymentId).lean();
      if (payment) {
        const loanPaymentsService = this.getLoanPaymentsService();
        await loanPaymentsService.updateLoanPayment(payment as unknown as Partial<ILoanPaymentWithId>, userId);
      }
    }
    if (nextTransaction.length) {
      this.recalculateBalanceQueued(nextTransaction[0].productId.toString(), nextTransaction[0]._id.toString()).catch(console.error);
    }
    const statementDate = this.loanStatementStatusService.getStatementDateForDate(new Date(currentTransaction.date));
    this.loanStatementStatusService.updateStatementStatus(currentTransaction.productId.toString(), statementDate, false).catch(console.error);
    this.termLoanService.setTermLoanExpected(currentTransaction.productId.toString()).catch(console.error);
  }

  async deleteLoanTransactionFile(transactionFileId: string, userId: string) {
    const foundFile = await LoanTransactionFileModel.findById(transactionFileId).lean();
    if (!foundFile) {
      return null;
    }
    await Promise.all(foundFile.transactionsId.map(async (transactionId) => {
      await this.deleteLoanTransaction(transactionId.toString(), true, userId);
    }));
    await Promise.all(foundFile.statementIds.map(async (statementId) => {
      await LoanStatementTransactionModel.findByIdAndDelete(statementId);
    }));
    await LoanTransactionFileModel.findByIdAndDelete(transactionFileId);
  }

  async getFirstTransaction(productId: string, excludeAdjustments = false) {
    const excludeFilter = excludeAdjustments
      ? {
        'transactionType': {
          $nin: ['ADJUSTMENT'],
        },
      }
      : {};
    const lastTransaction = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          'productId': new mongoose.Types.ObjectId(productId),
          ...excludeFilter,
        },
      }, {
        $sort: {
          'date': 1,
          'order': 1,
          'createdAt': 1,
        },
      }, {
        $limit: 1,
      },
    ]);
    if (!lastTransaction.length) {
      return null;
    }
    return lastTransaction[0];
  }

  async getLastTransactionForDate(productId: string, date: Date = new Date(), excludeAdjustments = false) {
    return getLastTransactionForDate(productId, date, excludeAdjustments);
  }

  async getLastDisbursementTransactionForDate(productId: string, startDate: Date, endDate: Date) {
    return LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          $and: [
            { 'date': { $gte: startDate } },
            { 'date': { $lte: endDate } },
            { 'productId': new mongoose.Types.ObjectId(productId) },
            { 'transactionType': { $ne: ELoanTransactionTypes.COLLECTION } },
          ],
        },
      }, {
        $sort: {
          'date': -1,
          'order': -1,
          'createdAt': -1,
        },
      },
    ]);
  }

  async getPaymentsForPeriod(productId: string, startDate: Date, endDate: Date) {
    return LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          $and: [
            { 'date': { $gte: startDate } },
            { 'date': { $lte: endDate } },
            { 'productId': new mongoose.Types.ObjectId(productId) },
            { 'transactionType': ELoanTransactionTypes.COLLECTION },
          ],
        },
      }, {
        $sort: {
          'date': -1,
          'order': -1,
          'createdAt': -1,
        },
      },
    ]);
  }

  async getLoanBalanceForBBCDateId(BBCDateId: string) {
    const bbcDate = await BBCDateModel.findById(BBCDateId);
    const loanProducts = await LoanProduct.aggregate<ILoanProductDoc>([
      {
        '$match': {
          'borrowerId': new mongoose.Types.ObjectId(bbcDate.borrowerId),
        },
      },
    ]);
    const balances = {
      [ELoanTypes.TERM]: 0,
      [ELoanTypes.REVOLVER]: 0,
    };
    await Promise.all(loanProducts
      .filter((product) => isProductActive(product.startDate, product.deactivationDate, bbcDate.bbcDate))
      .map(async (product) => {
        const lastTransaction = await this.getLastTransactionForDate(product._id.toString(), dayjs(bbcDate.bbcDate).utcOffset(0).endOf('day').toDate());
        if (lastTransaction) {
          balances[ELoanTypes[product.type]] = new Decimal(balances[ELoanTypes[product.type]]).add(lastTransaction.balance).toNumber();
        }
      }));
    return balances;
  }

  async getLoanBalanceForBBCDateIdPerProduct(BBCDateId: string, productType?: ELoanTypes): Promise<{
    [productId: string]: { balance: number, product: ILoanProductDoc }
  }> {
    const bbcDate = await BBCDateModel.findById(BBCDateId);
    const productFilter = productType ? { 'type': productType } : {};
    const loanProducts = await LoanProduct.aggregate<ILoanProductDoc>([
      {
        '$match': {
          'borrowerId': new mongoose.Types.ObjectId(bbcDate.borrowerId),
          ...productFilter,
        },
      },
    ]);
    const balances = {};
    await Promise.all(loanProducts.map(async (product) => {
      const lastTransaction = await this.getLastTransactionForDate(product._id.toString(), dayjs(bbcDate.bbcDate).utcOffset(0).endOf('day').toDate());
      balances[product._id.toString()] = { balance: lastTransaction.balance, product };
    }, {}));
    return balances;
  }

  async getLoanBalanceForBorrowerId(borrowerId: string) {
    const products = await this.loanChargesService.getLoanProducts(borrowerId);
    const extraInfo = {
      [ELoanTypes.REVOLVER]: {
        balance: 0,
        lastActivityDate: null,
      },
      [ELoanTypes.TERM]: {
        balance: 0,
        lastActivityDate: null,
      },
      accruedStatement: {
        balance: 0,
        lastActivityDate: null,
      },
      effectiveLoanBalance: {
        balance: 0,
        lastActivityDate: null,
      },
    };
    if (products.length === 0) {
      return extraInfo;
    }
    for (const product of products) {
      try {
        const lastTransaction = await this.getLastTransactionForDate(product._id.toString());
        if (lastTransaction) {
          extraInfo[product.type].balance = new Decimal(extraInfo[product.type].balance).add(lastTransaction.balance).toNumber();
        }
        const latRegularTransaction = await this.getLastTransactionForDate(product._id.toString(), new Date(), true);
        if (latRegularTransaction) {
          if (!extraInfo[product.type].lastActivityDate || latRegularTransaction.date > extraInfo[product.type].lastActivityDate) {
            extraInfo[product.type].lastActivityDate = latRegularTransaction.date;
          }
        }
      } catch (error) {
        console.error('Error:', error);
      }
    }
    const loanStatementService = this.getLoanStatementService();
    extraInfo.accruedStatement.balance = await loanStatementService.calculateAccruedStatementForBorrower(borrowerId);
    extraInfo.effectiveLoanBalance.balance = new Decimal(extraInfo[ELoanTypes.REVOLVER].balance).add(extraInfo.accruedStatement.balance).toNumber();
    return extraInfo;
  }

  async getUnsentTransactionForDate(borrowerId: string, date: Date) {
    const products = await this.loanChargesService.getBorrowersLoanProducts(borrowerId);
    return LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match:
          {
            'productId': { $in: products.map((product) => product._id) },
            'isSent': { $in: [false, null] },
            'date': { $gte: date },
          },
      }, {
        $lookup: {
          from: 'loan_products',
          localField: 'productId',
          foreignField: '_id',
          as: 'product',
        },
      }, {
        $unwind: {
          path: '$product',
        },
      },
    ]);
  }

  async getTransactionReport(transactionIds: string[], borrowerId: string, effectiveDate: Date) {
    const transactionIdsObjects = transactionIds.map((t) => new mongoose.Types.ObjectId(t));
    const uploadedTransactions = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match: {
          '_id': {
            $in: transactionIdsObjects,
          },
        },
      }, {
        $lookup: {
          from: 'loan_products',
          localField: 'productId',
          foreignField: '_id',
          as: 'product',
        },
      }, {
        $unwind: {
          path: '$product',
        },
      }, {
        $match: {
          'product.borrowerId': new mongoose.Types.ObjectId(borrowerId),
        },
      },
    ]);

    const products = await this.loanChargesService.getBorrowersLoanProducts(borrowerId);

    const backDatedTransactions = await LoanTransaction.aggregate<ILoanTransactionDoc>([
      {
        $match:
          {
            '_id': { $nin: transactionIdsObjects },
            'productId': { $in: products.map((product) => product._id) },
            'isSent': { $in: [false, null] },
            'date': { $lte: effectiveDate },
          },
      }, {
        $lookup: {
          from: 'loan_products',
          localField: 'productId',
          foreignField: '_id',
          as: 'product',
        },
      }, {
        $unwind: {
          path: '$product',
        },
      },
    ]);

    const productGroups = products
      .reduce((acc, product) => {
        acc[product._id.toString()] = [];
        return acc;
      }, {});

    const headerRow = [
      'date',
      'amount',
      'reference',
      'transaction type',
    ];

    const backDatedTransactionsTitle = [[''], ['Backdated transactions']];

    const transactionIdsToMark = [...uploadedTransactions, ...backDatedTransactions].map((tr) => tr._id.toString());

    const mappedTransactions: { [productId: string]: (string | number | Date)[] } = uploadedTransactions
      .filter((t) => !!t)
      .reduce((acc, t) => {
        const reportRow = [
          t.date,
          t.amount,
          t.reference,
          t.transactionType,
        ];
        acc[t.productId.toString()] = [...acc[t.productId.toString()], reportRow];
        return acc;
      }, _.cloneDeep(productGroups));

    const mappedBackDatedTransactions: { [productId: string]: (string | number | Date)[] } = backDatedTransactions
      .filter((t) => !!t)
      .reduce((acc, t) => {
        const reportRow = [
          t.date,
          t.amount,
          t.reference,
          t.transactionType,
        ];
        acc[t.productId.toString()] = [...acc[t.productId.toString()], reportRow];
        return acc;
      }, _.cloneDeep(productGroups));

    const transactions = products
      .filter((product) => {
        const uploaded = mappedTransactions[product._id.toString()];
        const backdated = mappedBackDatedTransactions[product._id.toString()];
        return uploaded.length > 0 || backdated.length > 0;
      })
      .map((product) => {
        const uploaded = mappedTransactions[product._id.toString()];
        const backdated = mappedBackDatedTransactions[product._id.toString()];
        return {
          [product.name]: [
            headerRow,
            ...uploaded,
            ...backDatedTransactionsTitle,
            headerRow,
            ...backdated,
          ],
        };
      });
    return { transactionIdsToMark, transactions };
  }

  async getBorrowerIdsForFile(transactionFileId: string) {
    const transactionFile = await LoanTransactionFileModel.findById(transactionFileId).lean();
    if (!transactionFile) {
      console.log('unknown file id', transactionFileId);
      return;
    }

    const productIdsSet = new Set<string>();
    await Promise.all(transactionFile.transactionsId.map(async (transactionId) => {
      const transaction = await LoanTransaction.findById(transactionId);
      if (transaction) {
        productIdsSet.add(transaction.productId.toString());
      }
    }));
    const productIds = Array.from(productIdsSet.values());

    const borrowerIdsSet = new Map<string, boolean>();
    await Promise.all(productIds.map(async (productId) => {
      const product = await LoanProduct.findById(productId);
      if (product) {
        const foundBorrower: IComplianceBorrowerDoc = await BorrowerCompliance.findOne({ borrower: product.borrowerId.toString() });
        const isActive = foundBorrower.isEmailingActive && foundBorrower.isDailyTransactionsEmailingActive;
        borrowerIdsSet.set(product.borrowerId.toString(), isActive);
      }
    }));
    return Object.fromEntries(borrowerIdsSet);
  }

  async getEmailBorrowers(transactionFileId: string) {
    return await this.getBorrowerIdsForFile(transactionFileId);
  }

  async sendReport(transactionFileId: string, selectedBorrowerIds: string[]) {
    const transactionFile = await LoanTransactionFileModel.findById(transactionFileId).lean();
    if (!transactionFile) {
      console.log('unknown file id', transactionFileId);
      return;
    }
    const borrowerIds = await this.getBorrowerIdsForFile(transactionFileId);
    const transactionIds = transactionFile.transactionsId.map((id) => id.toString());

    await Promise.all(Object.entries(borrowerIds).map(async ([borrowerId, active]) => {
      if (!selectedBorrowerIds.includes(borrowerId)) {
        return;
      }
      if (!active) {
        return;
      }
      const borrower = await BorrowerModel.findById(borrowerId).lean();
      if (!borrower) {
        console.log('unknown borrower', borrowerId);
        return;
      }
      const foundBorrower: IComplianceBorrowerDoc = await BorrowerCompliance.findOne({ borrower: borrowerId });
      if (!foundBorrower) {
        return;
      }
      const addresses = [...foundBorrower.mainEmails, ...foundBorrower.financialEmails].reduce((acc, email) => email.isActive ? [...acc, email.email] : acc, <string[]>[]);
      await this.sendBorrowerReport(
        borrowerId,
        borrower.code,
        addresses,
        transactionIds,
        new Date(transactionFile.effectiveDate),
      );
    }));
  }

  async sendAllReport(effectiveDate: Date) {
    const borrowers = await this.borrowersDB.getActiveBorrowers();
    await Promise.all(borrowers.map(async (borrower) => {
      if (!borrower.active) {
        return;
      }
      const foundBorrower: IComplianceBorrowerDoc = await BorrowerCompliance.findOne({ borrower: borrower._id.toString() });
      if (!foundBorrower) {
        return;
      }
      const addresses = [...foundBorrower.mainEmails, ...foundBorrower.financialEmails].reduce((acc, email) => email.isActive ? [...acc, email.email] : acc, <string[]>[]);
      const dailyTransactions = await this.getUnsentTransactionForDate(borrower._id.toString(), effectiveDate);
      const transactionIds = dailyTransactions.map((tr) => tr._id.toString());
      await this.sendBorrowerReport(
        borrower._id.toString(),
        borrower.code,
        addresses,
        transactionIds,
        effectiveDate,
      );
    }));
  }

  private async sendBorrowerReport(
    borrowerId: string,
    borrowerCode: string,
    addresses: string[],
    transactionIds: string[],
    effectiveDate: Date,
  ) {
    const title = `${borrowerCode} ACTIVITY REPORT: ${dayjs(effectiveDate).format('MM-DD-YYYY')}`;
    const fileName = title;
    const subject = title;

    const {
      transactions,
      transactionIdsToMark,
    } = await this.getTransactionReport(transactionIds, borrowerId, effectiveDate);

    if (transactionIdsToMark.length === 0) {
      return;
    }

    const attachedFile = await this.uploadsService.convertDataToFile(transactions);
    const attachment = { filename: `${fileName}.xlsx`, content: attachedFile };

    const attachedPDFs = await this.attachedFilesService.getAttachedRealFilesByTransactionFileId(
      borrowerId,
      dayjs.utc(effectiveDate).format('YYYY-MM-DD'),
    );

    let text = 'Attached is your daily activity report. Please log into the portal to view your account balance.';

    if (attachedPDFs.length > 0) {
      text += ' Uploaded PDF files are available for downloading.';
    }

    const email: IEmail = {
      addresses: [...addresses, this.config.portfolioEmail],
      subject,
      text,
    };

    const nodemailerService = this.getNodemailerService();

    await nodemailerService.sendLoanUploadReport(email, [attachment], borrowerId);

    await Promise.all(transactionIdsToMark.map(async (id) => {
      await this.switchTransactionSentStatus(id, true);
    }));
  }

  async calculateEffectiveDate(transaction: Partial<ILoanTransactionDoc>): Promise<Date> {
    if (transaction.transactionType !== ELoanTransactionTypes.COLLECTION) {
      return new Date(transaction.date);
    }
    const nextDate = await this.calendarService.getNextWorkingDay(dayjs(transaction.date).format('YYYY-MM-DD'));
    return new Date(nextDate);
  }

  async switchTransactionSentStatus(transactionId: string, isSent?: boolean) {
    if (isSent) {
      await LoanTransaction.findByIdAndUpdate(transactionId, { isSent });
    } else {
      const transaction = await LoanTransaction.findById(transactionId).lean();
      if (transaction) {
        await LoanTransaction.findByIdAndUpdate(transactionId, { isSent: !(transaction.isSent ?? true) });
      }
    }
  }

  async checkLockStatus(transaction: ILoanTransactionLean) {
    const product = await this.loanChargesService.getLoanProductById(transaction.productId.toString());
    if (product) {
      return await this.lockService.isDateLocked(new Date(transaction.date), product.borrowerId.toString());
    }
    return false;
  };
}
