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

import {
  ELoanTransactionTypes,
  ILoanTransaction,
  ILoanTransactionDoc, ILoanTransactionLean,
  ILoanTransactionWithId,
  LoanTransaction,
} from '../models/LoanTransaction.model';
import { Bank } from '../models/Banks.model';

import {
  ISplitTransaction,
  IUploadedBankTransaction,
  IUploadedBankTransactionWithInfo,
  UploadedBankTransaction,
} from '../models/UploadedBankTransaction.model';
import { getUUID } from '../helpers/main.helper';
import { CashAllocationReference } from '../models/CashAllocationReference.model';
import { CashAllocationProduct, ICashAllocationProduct } from '../models/CashAllocationProduct.model';
import {
  AllocatedBankTransaction,
  IAllocatedBankTransaction,
  PreSplitUploadedBankTransactionWithInfo,
} from '../models/AllocatedBankTransaction.model';
import { AllocatedData } from '../models/AllocatedData.model';
import { MAIN_DATE_FORMAT } from '../constants/date-formats.constants';
import { LoanTransactionsService } from './loan-transactions.service';
import { CashAllocationService } from './cash-allocation.service';
import { LoanStatementStatusService } from './loan-statement-status.service';
import { convertBankReferenceToTransactionReference } from '../helpers/bank-reference.helper';

const useExternalUploader = false; // TODO keep only external upload

const createCashAllocationReferencesOnUpload = false;

export class BankUploadedTransactionsService {

  constructor(
    private readonly cashAllocationService: CashAllocationService,
    private readonly loanStatementStatusService: LoanStatementStatusService,
    private readonly getLoanTransactionsService: () => LoanTransactionsService,
  ) {
  }

  async uploadTransactions(transactions: IUploadedBankTransaction[]) {
    if (useExternalUploader) {
      const taskId = getUUID();
      // await this.queueService.sendTaskToQueue({
      //   data: { transactions, taskId },
      //   pattern: 'bank_uploaded_transactions#CREATE',
      // }, 'goat_upload');
      return taskId;
    } else {
      const mappedTransactions = transactions.reduce((acc, transaction) => {
        const normalizedTransaction: IUploadedBankTransaction = {
          ...transaction,
          reference: transaction.reference.trim(),
          isConverted: false,
        };
        return [...acc, normalizedTransaction];
      }, <IUploadedBankTransaction[]>[]);
      await Promise.all(mappedTransactions.map(async (transaction) => {
        const newTransactions = new UploadedBankTransaction(transaction);
        await newTransactions.save();
      }));
      if (createCashAllocationReferencesOnUpload) {
        await this.cashAllocationService.createCashAllocation(mappedTransactions);
      }
      return null;
    }
  }

  async getAllocatedBankTransactions(startDate: Date, endDate: Date) {
    return AllocatedBankTransaction.aggregate<IAllocatedBankTransaction>([
      {
        $match: {
          $and: [
            { 'date': { $gte: startDate } },
            { 'date': { $lte: endDate } },
          ],
        },
      },
      {
        $sort: { order: 1, date: 1 },
      },
    ]);
  }

  async deleteAllocatedBankTransaction(transactionIds: string[]): Promise<void> {
    await AllocatedBankTransaction.deleteMany({ uploadedBankTransactionId: { $in: transactionIds.map((id) => new mongoose.Types.ObjectId(id)) } });
  }

  async getMappedUploadedBankTransactions(startDate: Date, endDate: Date) {
    const bankTransactions = await this.getUploadedBankTransactions(startDate, endDate);

    const cashAllocationReferences = await CashAllocationReference.find().lean();
    const cashAllocationProducts = await CashAllocationProduct.find().lean();
    const cashAllocationProductsMap = new Map<string, ICashAllocationProduct>();
    cashAllocationProducts.forEach((product) => {
      cashAllocationProductsMap.set(product._id.toString(), product);
    });

    const findMatchingCashAllocationReference = (bankTransaction: IUploadedBankTransaction, searchInDetails: boolean) => {
      return cashAllocationReferences.find((cashAllocationReference) => {
        const referenceToSearch = cashAllocationReference.reference.toLowerCase();

        const fieldsToSearch = [bankTransaction.reference];
        if (searchInDetails) {
          fieldsToSearch.push(bankTransaction.detail1, bankTransaction.detail2, bankTransaction.detail3);
        }

        return fieldsToSearch
          .filter((field): field is string => !!field)
          .some((field) => field.toLowerCase().includes(referenceToSearch));
      });
    };

    const transactionHasMatch = (bankTransaction: IUploadedBankTransaction): boolean => {
      if (bankTransaction.splitTransactions.length > 0) {
        const totalSplit = bankTransaction.splitTransactions.reduce((acc, split) => new Decimal(split.amount).add(acc).toNumber(), 0);
        if (totalSplit === bankTransaction.amount) {
          return true;
        }
      }
      const foundCashAllocationReference = findMatchingCashAllocationReference(bankTransaction, true);
      if (!foundCashAllocationReference || !foundCashAllocationReference.cashAllocationProductId) {
        return false;
      }
      return !!cashAllocationProductsMap.get(foundCashAllocationReference.cashAllocationProductId.toString());
    };

    const transactionCanSplit = (bankTransaction: IUploadedBankTransaction): boolean => {
      const foundCashAllocationReference = findMatchingCashAllocationReference(bankTransaction, true);
      if (!foundCashAllocationReference || !foundCashAllocationReference.cashAllocationProductId) {
        return false;
      }
      const foundProduct = cashAllocationProductsMap.get(foundCashAllocationReference.cashAllocationProductId.toString());
      return foundProduct.isCommonSplit;
    };

    return bankTransactions.map((bankTransaction) => {
      return <IUploadedBankTransactionWithInfo>{
        ...bankTransaction,
        hasMatch: transactionHasMatch(bankTransaction),
        canSplit: transactionCanSplit(bankTransaction),
      };
    });
  }

  async getUploadedBankTransactions(startDate: Date, endDate: Date) {
    return UploadedBankTransaction.aggregate<IUploadedBankTransaction>([
      {
        $match: {
          $and: [
            { 'date': { $gte: startDate } },
            { 'date': { $lte: endDate } },
          ],
        },
      },
      {
        $sort: { date: 1 },
      },
    ]);
  }

  async getAllocatedTransactions(date: Date) {
    const startDate = dayjs(date).utcOffset(0).startOf('day');
    const endDate = startDate.endOf('day');
    return AllocatedBankTransaction.aggregate<IAllocatedBankTransaction>([
      {
        $match: {
          $and: [
            { 'date': { $gte: startDate.toDate() } },
            { 'date': { $lte: endDate.toDate() } },
          ],
        },
      },
      {
        $sort: { date: 1, order: 1 },
      },
    ]);
  }

  async getPreAllocatedTransactions(date: Date) {
    const startDate = dayjs(date).utcOffset(0).startOf('day');
    const endDate = startDate.endOf('day');
    const pureUploadedBankTransactions = await this.getMappedUploadedBankTransactions(startDate.toDate(), endDate.toDate());

    const { references, products } = await this.cashAllocationService.getAllCashAllocations();

    const populateAllocatedBankTransaction = (allocatedBankTransaction: IAllocatedBankTransaction, cashAllocation: ICashAllocationProduct) => {
      const updatedAllocatedBankTransaction = _.cloneDeep(allocatedBankTransaction);
      if (cashAllocation) {
        const keysToUpdate = ['class', 'location', 'DRMemo', 'CRMemo'];
        keysToUpdate.forEach((key) => {
          updatedAllocatedBankTransaction[key] = cashAllocation[key];
        });
        updatedAllocatedBankTransaction.cashAllocationProductId = cashAllocation._id;
      }
      return updatedAllocatedBankTransaction;
    };

    let order = 0;

    const uploadedBankTransactions = pureUploadedBankTransactions
      .reduce((acc, tr) => {
        if (tr.splitTransactions.length > 0) {
          const restTransaction: PreSplitUploadedBankTransactionWithInfo = {
            ...tr,
            order,
            splitTransactionId: null,
            splitMemo: '',
          };
          order = order + 1;
          const splitTransactions = tr.splitTransactions.map((splitTr) => {
            const foundRef = references.find((i) => i._id.toString() === splitTr.referenceId.toString());
            const transaction: PreSplitUploadedBankTransactionWithInfo = {
              _id: tr._id,
              order,
              accountNumber: tr.accountNumber,
              amount: splitTr.amount,
              date: tr.date,
              detail1: '',
              detail2: '',
              detail3: '',
              hasMatch: true,
              canSplit: false,
              isConverted: tr.isConverted,
              reference: foundRef ? foundRef.reference : tr.reference,
              splitTransactions: [],
              transactionType: tr.transactionType,
              splitTransactionId: splitTr._id,
              splitMemo: splitTr.memo,
            };
            restTransaction.amount = new Decimal(restTransaction.amount).sub(transaction.amount).toNumber();
            return transaction;
          });
          if (restTransaction.amount !== 0) {
            return [...acc, ...splitTransactions, restTransaction];
          }
          return [...acc, ...splitTransactions];
        }
        order = order + 1;
        return [...acc, { ...tr, splitTransactionId: null, splitMemo: '', order }];
      }, <PreSplitUploadedBankTransactionWithInfo[]>[]);

    const allocatedBankTransactions = uploadedBankTransactions
      .reduce((acc, tr) => {
        const allocatedBankTransaction: IAllocatedBankTransaction = {
          order: tr.order,
          uploadedBankTransactionId: tr._id,
          accountNumber: tr.accountNumber,
          amount: tr.amount,
          date: tr.date,
          reference: tr.reference,
          cashAllocationReferenceId: null,
          cashAllocationProductId: null,
          accounts: null,
          class: '',
          location: '',
          memo: tr.splitMemo,
          splitTransactionId: tr.splitTransactionId,
        };
        const foundCashAllocationReferences = references
          .filter((reference) => tr.reference.toLowerCase().includes(reference.reference.toLowerCase()));
        if (!foundCashAllocationReferences.length) {
          return acc;
        }
        const useOnlyOneReference = true;
        const reducedFoundCashAllocationReferences = useOnlyOneReference ? foundCashAllocationReferences.slice(0, 1) : foundCashAllocationReferences;
        return [
          ...acc,
          ...reducedFoundCashAllocationReferences.map((foundCashAllocationReference) => {
            const foundCashAllocationProduct = products
              .find((product) => product._id.toString() === foundCashAllocationReference.cashAllocationProductId.toString());
            if (!foundCashAllocationProduct) {
              return null;
            }
            return foundCashAllocationProduct
              ? populateAllocatedBankTransaction({
                ...allocatedBankTransaction,
                cashAllocationReferenceId: foundCashAllocationReference._id,
                accounts: foundCashAllocationProduct.accounts,
                memo: (tr.splitMemo && tr.splitMemo.trim() !== '')
                  ? tr.splitMemo
                  : (tr.amount > 0 ? foundCashAllocationProduct.DRMemo : foundCashAllocationProduct.CRMemo),
              }, foundCashAllocationProduct)
              : allocatedBankTransaction;
          })];
      }, <IAllocatedBankTransaction[]>[])
      .filter((tr) => !!tr);
    return { allocatedBankTransactions, uploadedBankTransactions };
  }

  async resetAllocatedBankTransactions(date: Date) {
    const { allocatedBankTransactions } = await this.getPreAllocatedTransactions(date);
    const uniqueTransactionsIds = new Set(allocatedBankTransactions.map((tr) => tr.uploadedBankTransactionId.toString()));

    await this.deleteAllocatedBankTransaction(Array.from(uniqueTransactionsIds));
    await Promise.all(allocatedBankTransactions.map(async (transaction) => {
      const newTransaction = new AllocatedBankTransaction(transaction);
      await newTransaction.save();
    }));
    await this.updateLockStatus(date, false);
  }

  async createAllocatedBankTransactions(date: Date) {
    const cashAllocations = await this.getAllocatedTransactions(date);
    const allocatedData = await AllocatedData.findOne({ date: dayjs(date).format(MAIN_DATE_FORMAT) });
    if (cashAllocations.length > 0 && (allocatedData && allocatedData.isLocked)) {
      return;
    }
    await this.resetAllocatedBankTransactions(date);
  }

  async convertBankTransactions(startDate: Date, userId: string) {
    const allocatedTransactions = await this.getAllocatedTransactions(startDate);
    const transactionIds = allocatedTransactions.map((transaction) => transaction.uploadedBankTransactionId.toString());
    const transactions = await UploadedBankTransaction.find({ _id: { $in: transactionIds } });
    const cashAllocations = await this.cashAllocationService.getAllCashAllocations();
    const banks = await Bank.find().lean();
    const loanTransactionsMap = transactions.reduce((acc, transaction) => {
      return {
        ...acc,
        [transaction._id.toString()]: [] as mongoose.Types.ObjectId[],
      };
    }, <{ [transactionId: string]: mongoose.Types.ObjectId[] }>{});
    const productMap: Record<string, string> = {};
    const productIds = new Set<string>();
    await Promise.all(allocatedTransactions.map(async (transactionToConvert) => {
      const transaction = transactions.find((tr) => tr._id.toString() === transactionToConvert.uploadedBankTransactionId.toString());
      if (!transaction || transaction.isConverted) {
        return;
      }
      const splitTransaction = transactionToConvert.splitTransactionId
        ? transaction.splitTransactions.find((tr) => tr._id.toString() === transactionToConvert.splitTransactionId.toString())
        : null;

      const foundCashAllocationProduct = cashAllocations.products.find((c) => c._id.toString() === transactionToConvert.cashAllocationProductId.toString());
      if (!foundCashAllocationProduct || !foundCashAllocationProduct.productId) {
        return;
      }
      const foundBank = banks.find((b) => b.bankAccountNumber === transaction.accountNumber);
      const getLoanTransaction = (): ILoanTransaction => ({
        amount: transactionToConvert.amount,
        bankId: foundBank ? new mongoose.Types.ObjectId(String(foundBank._id)) : null,
        customerId: null,
        date: new Date(transaction.date),
        description: '',
        effectiveDate: null,
        productId: foundCashAllocationProduct.productId,
        reference: splitTransaction ? convertBankReferenceToTransactionReference(splitTransaction.memo) : convertBankReferenceToTransactionReference(transactionToConvert.reference),
        referenceRaw: splitTransaction ? convertBankReferenceToTransactionReference(splitTransaction.memo) : transactionToConvert.reference,
        transactionType: transaction.amount > 0 ? ELoanTransactionTypes.COLLECTION : ELoanTransactionTypes.DISBURSEMENT,
        balance: 0,
        floatedBalance: 0,
      });
      const loanTransactionsService = this.getLoanTransactionsService();
      const normalizedTransaction = await loanTransactionsService.normalizeLoanTransaction(getLoanTransaction() as ILoanTransactionDoc, userId);
      const createdLoanTransaction = await loanTransactionsService.createLoanTransaction(normalizedTransaction as Partial<ILoanTransactionWithId>);
      loanTransactionsMap[String(transaction._id)] = [...loanTransactionsMap[String(transaction._id)], new mongoose.Types.ObjectId(String(createdLoanTransaction._id))];
      productIds.add(String(foundCashAllocationProduct.productId));
      if (!productMap[createdLoanTransaction.productId.toString()]) {
        productMap[createdLoanTransaction.productId.toString()] = createdLoanTransaction.date.toISOString();
      } else {
        const foundDate = new Date(productMap[createdLoanTransaction.productId.toString()]);
        if (foundDate > createdLoanTransaction.date) {
          productMap[createdLoanTransaction.productId.toString()] = createdLoanTransaction.date.toISOString();
        }
      }
    }));

    await Promise.all(transactions.map(async (transaction) => {
      await transaction.updateOne({
        isConverted: true,
        loanTransactionIds: loanTransactionsMap[String(transaction._id)],
      });
    }));
    await this.recalculateProducts(Array.from(productIds), productMap);
    return transactions.map((transaction) => transaction._id.toString())
  }

  async recalculateProducts(productIds: string[], productMap: Record<string, string>) {
    // TODO rework in something better
    setTimeout(async () => {
      await Promise.all(productIds.map(async (productId) => {
        const loanTransactionsService = this.getLoanTransactionsService();
        await loanTransactionsService.recalculateProduct(productId);
      }));
    }, 2_000);
    await this.loanStatementStatusService.recalculateStatementQueued(productMap);
  }

  async preSplitBankTransactions(transactionId: string, splitTransaction: ISplitTransaction[]) {
    const mappedSplitTransactions: ISplitTransaction[] = splitTransaction.map((transaction) => {
      const { _id, ...transactionRest } = transaction;
      return _id.toString().includes('new_') ? transactionRest : transaction;
    });
    await UploadedBankTransaction.updateOne({ _id: new mongoose.Types.ObjectId(transactionId) }, { splitTransactions: mappedSplitTransactions });
  }

  async deleteBankTransaction(transactionIds: string[]) {
    await UploadedBankTransaction.deleteMany({
      _id: { $in: transactionIds },
      isConverted: { $ne: true },
    });
  }

  async revertAllBankTransactions(startDate: Date, endDate: Date, userId: string) {
    const transactions = await this.getUploadedBankTransactions(startDate, endDate);
    const transactionIds = transactions.map((t) => t._id.toString());
    await this.revertBankTransactions(transactionIds, userId);
  }

  async revertBankTransactions(transactionIds: string[], userId: string) {
    const foundTransaction = await UploadedBankTransaction.find({ _id: { $in: transactionIds }, isConverted: true });
    const productIds = new Set<string>();
    const productMap: Record<string, string> = {};
    await Promise.all(foundTransaction.map(async (transaction) => {
      try {
        await Promise.all(transaction.loanTransactionIds.map(async (loanTransactionId) => {
          const loanTransaction = await LoanTransaction.findById(loanTransactionId).lean<ILoanTransactionLean>().exec();

          if (!productMap[loanTransaction.productId.toString()]) {
            productMap[loanTransaction.productId.toString()] = loanTransaction.date.toISOString();
          } else {
            const foundDate = new Date(productMap[loanTransaction.productId.toString()]);
            if (foundDate > loanTransaction.date) {
              productMap[loanTransaction.productId.toString()] = loanTransaction.date.toISOString();
            }
          }

          const loanTransactionsService = this.getLoanTransactionsService();
          const isLocked = await loanTransactionsService.checkLockStatus(loanTransaction);
          if (isLocked) {
            throw new Error('period is locked');
          }
          const validationError = await this.loanStatementStatusService.validateDate(loanTransaction.productId.toString(), new Date(transaction.date));
          if (validationError) {
            throw new Error('validation error');
          }
          await loanTransactionsService.deleteLoanTransaction(loanTransactionId.toString(), false, userId);
          productIds.add(loanTransaction.productId.toString());
        }));
        transaction.loanTransactionIds = [];
        transaction.isConverted = false;
        await transaction.save();
      } catch (error) {
        console.log(error);
      }
    }));
    await this.recalculateProducts(Array.from(productIds), productMap);
  }

  async getUniqueDates() {
    const uniqueDates = await UploadedBankTransaction.aggregate([
      {
        $group: {
          _id: { $dateToString: { format: '%Y-%m-%d', date: '$date' } },
        },
      },
      {
        $sort: { _id: 1 },
      },
    ]);
    return uniqueDates.map(d => d._id);
  }

  async saveAllocatedBankTransactions(date: Date, transactions: IAllocatedBankTransaction[]) {
    const startDate = dayjs(date).utcOffset(0).startOf('day');
    const endDate = startDate.endOf('day');
    const existingAllocatedTransactions = await this.getAllocatedBankTransactions(startDate.toDate(), endDate.toDate());
    const existingTransactionIds = existingAllocatedTransactions.map((t) => t._id.toString());
    const transactionIds = transactions.map((t) => t._id.toString());
    const transactionIdsToDelete = existingTransactionIds.reduce((acc, id) => {
      if (transactionIds.includes(id)) {
        return acc;
      }
      return [...acc, id];
    }, <string[]>[]);

    await Promise.all(transactions.map(async (transaction, order) => {
      const { _id, ...rest } = transaction;
      if (_id.toString().includes('new_')) {
        const newTransaction = new AllocatedBankTransaction({ ...rest, order });
        await newTransaction.save();
      } else {
        await AllocatedBankTransaction.updateOne({ _id }, { ...rest, order });
      }
    }));

    await AllocatedBankTransaction.deleteMany({ _id: { $in: transactionIdsToDelete } });
    await this.updateLockStatus(date, true);
  }

  async createAllocatedBankTransaction(transaction: IAllocatedBankTransaction) {
    const { _id, ...rest } = transaction;
    const startDate = dayjs(transaction.date).utcOffset(0).startOf('day');
    const endDate = startDate.endOf('day');
    const existingAllocatedTransactions = await this.getAllocatedBankTransactions(startDate.toDate(), endDate.toDate());
    const order = existingAllocatedTransactions.length > 0 ? existingAllocatedTransactions[existingAllocatedTransactions.length - 1].order + 1 : 0;
    const newTransaction = new AllocatedBankTransaction({ ...rest, order });
    await newTransaction.save();
    await this.updateLockStatus(transaction.date, true);
  }

  async updateLockStatus(date: Date, isLocked: boolean) {
    const normalizedDate = dayjs(date).utcOffset(0).startOf('day');
    await AllocatedData.updateOne({ date: normalizedDate.format(MAIN_DATE_FORMAT) }, { isLocked }, { upsert: true });
  }
}
