import { lastDayOfMonth } from 'date-fns';
import type {
  Maybe,
  ResolverFn,
  ResolversParentTypes,
  ResolversTypes,
} from '../../../../__generated__/types.js';
import type { LedgerProto, StrictLedgerProto } from '../../../../shared/types/index.js';
import { AdminContextProvider } from '../../../admin-context/providers/admin-context.provider.js';
import { getChargeBusinesses } from '../../../charges/helpers/common.helper.js';
import { ExchangeProvider } from '../../../exchange-rates/providers/exchange.provider.js';
import { TaxCategoriesProvider } from '../../../financial-entities/providers/tax-categories.provider.js';
import { EmployeesProvider } from '../../../salaries/providers/employees.provider.js';
import { FundsProvider } from '../../../salaries/providers/funds.provider.js';
import { SalariesProvider } from '../../../salaries/providers/salaries.provider.js';
import { TransactionsProvider } from '../../../transactions/providers/transactions.provider.js';
import { validateExchangeRate } from '../../helpers/exchange-ledger.helper.js';
import {
  getEntriesFromFeeTransaction,
  splitFeeTransactions,
} from '../../helpers/fee-transactions.js';
import { storeInitialGeneratedRecords } from '../../helpers/ledgrer-storage.helper.js';
import { generateMiscExpensesLedger } from '../../helpers/misc-expenses-ledger.helper.js';
import { generateEntriesFromSalaryRecords } from '../../helpers/salary-charge-ledger.helper.js';
import {
  generatePartialLedgerEntry,
  getFinancialAccountTaxCategoryId,
  getLedgerBalanceInfo,
  LedgerError,
  ledgerProtoToRecordsConverter,
  updateLedgerBalanceByEntry,
  ValidateTransaction,
  validateTransactionRequiredVariables,
} from '../../helpers/utils.helper.js';
import { BalanceCancellationProvider } from '../../providers/balance-cancellation.provider.js';
import { UnbalancedBusinessesProvider } from '../../providers/unbalanced-businesses.provider.js';
import { generateLedgerRecordsForCommonCharge } from './common-ledger-generation.resolver.js';

export const generateLedgerRecordsForSalary: ResolverFn<
  Maybe<ResolversTypes['GeneratedLedgerRecords']>,
  ResolversParentTypes['Charge'],
  GraphQLModules.Context,
  { insertLedgerRecordsIfNotExists: boolean }
> = async (charge, { insertLedgerRecordsIfNotExists }, context, _info) => {
  const { injector } = context;
  const { allBusinessIds } = await getChargeBusinesses(charge.id, injector);
  if (allBusinessIds.length === 1) {
    // for one business, use the default ledger generation
    return generateLedgerRecordsForCommonCharge(
      charge,
      { insertLedgerRecordsIfNotExists },
      context,
      _info,
    );
  }
  const chargeId = charge.id;
  const errors: Set<string> = new Set();
  const {
    defaultLocalCurrency,
    general: {
      taxCategories: { balanceCancellationTaxCategoryId, exchangeRateTaxCategoryId },
    },
    salaries: { batchedFundsBusinessId, batchedEmployeesBusinessId, salaryBatchedBusinessIds },
  } = await injector.get(AdminContextProvider).getVerifiedAdminContext();

  try {
    // validate ledger records are balanced
    const ledgerBalance = new Map<string, { amount: number; entityId: string }>();

    let entriesPromises: Array<Promise<void>> = [];
    const accountingLedgerEntries: LedgerProto[] = [];
    const foreignCurrencySalaryBusinesses = new Set<string>();

    // Get relevant data for generation
    const salaryRecordsPromise = injector
      .get(SalariesProvider)
      .getSalaryRecordsByChargeIdLoader.load(chargeId);

    const transactionsPromise = injector
      .get(TransactionsProvider)
      .transactionsByChargeIDLoader.load(chargeId);

    const unbalancedBusinessesPromise = injector
      .get(UnbalancedBusinessesProvider)
      .getChargeUnbalancedBusinessesByChargeIds.load(chargeId);

    const chargeBallanceCancellationsPromise = injector
      .get(BalanceCancellationProvider)
      .getBalanceCancellationByChargesIdLoader.load(chargeId);

    const [salaryRecords, transactions, unbalancedBusinesses, balanceCancellations, adminContext] =
      await Promise.all([
        salaryRecordsPromise,
        transactionsPromise,
        unbalancedBusinessesPromise,
        chargeBallanceCancellationsPromise,
        injector.get(AdminContextProvider).getVerifiedAdminContext(),
      ]);

    // generate ledger from salary records
    try {
      const { entries, monthlyEntriesProto, month } = generateEntriesFromSalaryRecords(
        salaryRecords,
        charge,
        adminContext,
      );

      entries.map(ledgerEntry => {
        accountingLedgerEntries.push(ledgerEntry);
        updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
      });

      // generate monthly expenses ledger entries
      const monthlyEntriesPromises = monthlyEntriesProto
        .filter(({ amount }) => amount !== 0)
        .map(async ({ taxCategoryId, amount, isCredit }) => {
          const taxCategory = await injector
            .get(TaxCategoriesProvider)
            .taxCategoryByIdLoader.load(taxCategoryId);
          if (!taxCategory) {
            throw new LedgerError(`Tax category "${taxCategoryId}" not found`);
          }

          const ledgerEntry: LedgerProto = {
            id: taxCategoryId,
            invoiceDate: lastDayOfMonth(new Date(`${month}-01`)),
            valueDate: lastDayOfMonth(new Date(`${month}-01`)),
            currency: defaultLocalCurrency,
            ...(isCredit
              ? { creditAccountID1: taxCategoryId }
              : { debitAccountID1: taxCategoryId }),
            localCurrencyCreditAmount1: amount,
            localCurrencyDebitAmount1: amount,
            description: `${month} salary: ${taxCategory.name}`,
            isCreditorCounterparty: false,
            ownerId: charge.owner_id,
            chargeId,
          };

          accountingLedgerEntries.push(ledgerEntry);
          updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
        });
      entriesPromises.push(...monthlyEntriesPromises);
    } catch (e) {
      if (e instanceof LedgerError) {
        errors.add(e.message);
      } else {
        throw e;
      }
    }

    const { mainTransactions, feeTransactions } = splitFeeTransactions(transactions);

    // generate ledger from main transactions
    const batchedTransactionEntriesMaterials: {
      transaction: ValidateTransaction;
      partialEntry: Omit<StrictLedgerProto, 'debitAccountID1' | 'creditAccountID1'>;
      taxCategoryId: string;
    }[] = [];

    // for each common transaction, create a ledger record
    const financialAccountLedgerEntries: LedgerProto[] = [];
    const miscExpensesLedgerEntries: LedgerProto[] = [];
    const transactionEntriesPromises = mainTransactions.map(async preValidatedTransaction => {
      try {
        const transaction = validateTransactionRequiredVariables(preValidatedTransaction);

        // preparations for core ledger entries
        let exchangeRate: number | undefined = undefined;
        if (transaction.currency !== defaultLocalCurrency) {
          // get exchange rate for currency
          exchangeRate = await injector
            .get(ExchangeProvider)
            .getExchangeRates(
              transaction.currency,
              defaultLocalCurrency,
              transaction.debit_timestamp,
            );
        }

        const partialEntry = generatePartialLedgerEntry(transaction, charge.owner_id, exchangeRate);

        const financialAccountTaxCategoryId = await getFinancialAccountTaxCategoryId(
          injector,
          transaction,
        );

        if (transaction.business_id && salaryBatchedBusinessIds.includes(transaction.business_id)) {
          batchedTransactionEntriesMaterials.push({
            transaction,
            partialEntry,
            taxCategoryId: financialAccountTaxCategoryId,
          });
          return;
        }

        const ledgerEntry: StrictLedgerProto = {
          ...partialEntry,
          ...(partialEntry.isCreditorCounterparty
            ? {
                creditAccountID1: transaction.business_id,
                debitAccountID1: financialAccountTaxCategoryId,
              }
            : {
                creditAccountID1: financialAccountTaxCategoryId,
                debitAccountID1: transaction.business_id,
              }),
        };

        financialAccountLedgerEntries.push(ledgerEntry);
        updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);

        // on foreign currency salary, update matching accounting ledger entry
        if (ledgerEntry.currency !== defaultLocalCurrency) {
          const matchingEntry = accountingLedgerEntries.find(accountingEntry => {
            return (
              transaction.business_id ===
              (accountingEntry.isCreditorCounterparty
                ? accountingEntry.debitAccountID1
                : accountingEntry.creditAccountID1)
            );
          });
          if (matchingEntry && matchingEntry.currency !== ledgerEntry.currency) {
            matchingEntry.currency = ledgerEntry.currency;
            matchingEntry.currencyRate = ledgerEntry.currencyRate;
            matchingEntry.creditAmount1 = ledgerEntry.creditAmount1;
            matchingEntry.debitAmount1 = ledgerEntry.debitAmount1;
            foreignCurrencySalaryBusinesses.add(transaction.business_id);
          }
        }
      } catch (e) {
        if (e instanceof LedgerError) {
          errors.add(e.message);
        } else {
          throw e;
        }
      }
    });
    const miscExpensesEntriesPromise = generateMiscExpensesLedger(charge, injector).then(
      entries => {
        entries.map(entry => {
          entry.ownerId = charge.owner_id;
          miscExpensesLedgerEntries.push(entry);
          updateLedgerBalanceByEntry(entry, ledgerBalance, defaultLocalCurrency);
        });
      },
    );
    entriesPromises.push(...transactionEntriesPromises, miscExpensesEntriesPromise);

    await Promise.all(entriesPromises);

    const miscLedgerEntries: LedgerProto[] = [];

    // Add exchange ledger in case of foreign currency salaries
    for (const entry of accountingLedgerEntries) {
      const businessId = entry.creditAccountID1;
      const matchingTransaction = financialAccountLedgerEntries.find(
        entry =>
          businessId ===
          (entry.isCreditorCounterparty ? entry.creditAccountID1 : entry.debitAccountID1),
      );
      if (!matchingTransaction || !businessId) {
        continue;
      }

      const balance =
        (matchingTransaction?.localCurrencyCreditAmount1 ?? 0) *
          (matchingTransaction?.isCreditorCounterparty ? 1 : -1) +
        entry.localCurrencyCreditAmount1;
      if (balance === 0) {
        continue;
      }

      const datesDiff = entry.invoiceDate.getTime() !== matchingTransaction?.invoiceDate.getTime();
      if (!datesDiff) {
        continue;
      }

      const currencyDiff = entry.currency !== matchingTransaction.currency;
      if (!currencyDiff && !foreignCurrencySalaryBusinesses.has(businessId)) {
        continue;
      }

      const amount = Math.abs(balance);
      const isCreditorCounterparty = balance < 0;

      // validate exchange rate
      const validation = validateExchangeRate(
        businessId,
        [...accountingLedgerEntries, ...financialAccountLedgerEntries],
        balance,
        defaultLocalCurrency,
      );
      if (validation === true) {
        const ledgerEntry: StrictLedgerProto = {
          id: matchingTransaction.id + '|fee', // NOTE: this field is dummy
          creditAccountID1: isCreditorCounterparty ? businessId : exchangeRateTaxCategoryId,
          localCurrencyCreditAmount1: amount,
          debitAccountID1: isCreditorCounterparty ? exchangeRateTaxCategoryId : businessId,
          localCurrencyDebitAmount1: amount,
          description: 'Exchange ledger record',
          isCreditorCounterparty,
          invoiceDate: matchingTransaction.invoiceDate,
          valueDate: entry.valueDate,
          currency: defaultLocalCurrency,
          ownerId: matchingTransaction.ownerId,
          chargeId,
        };
        miscLedgerEntries.push(ledgerEntry);
        updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
      } else {
        errors.add(validation);
      }
    }

    // for each batched transaction, create a ledger record
    const batchedLedgerEntries: LedgerProto[] = [];
    entriesPromises = [];
    const batchedTransactionEntriesPromises = batchedTransactionEntriesMaterials.map(
      async ({ transaction, partialEntry, taxCategoryId }) => {
        try {
          const unbatchedBusinesses: Array<string> = [];
          switch (transaction.business_id) {
            case batchedEmployeesBusinessId: {
              const employees = await injector
                .get(EmployeesProvider)
                .getEmployeesByEmployerLoader.load(charge.owner_id);
              unbatchedBusinesses.push(...employees.map(({ business_id }) => business_id));
              break;
            }
            case batchedFundsBusinessId: {
              const funds = await injector.get(FundsProvider).getAllFunds();
              unbatchedBusinesses.push(...funds.map(({ id }) => id));
              break;
            }
            default: {
              throw new LedgerError(
                `Business ID="${transaction.business_id}" is not supported as batched ID`,
              );
            }
          }

          const batchedEntries = [...accountingLedgerEntries, ...miscExpensesLedgerEntries]
            .filter(accountingLedgerEntry =>
              accountingLedgerEntry.creditAccountID1
                ? unbatchedBusinesses.includes(accountingLedgerEntry.creditAccountID1)
                : false,
            )
            .map(accountingLedgerEntry => {
              const externalPayments = financialAccountLedgerEntries
                .filter(
                  financialAccountEntry =>
                    !!financialAccountEntry.debitAccountID1 &&
                    accountingLedgerEntry.creditAccountID1 ===
                      financialAccountEntry.debitAccountID1,
                )
                .map(entry => entry.localCurrencyCreditAmount1)
                .reduce((a, b) => a + b, 0);
              if (externalPayments === 0) {
                return accountingLedgerEntry;
              }
              if (externalPayments < accountingLedgerEntry.localCurrencyCreditAmount1) {
                return {
                  ...accountingLedgerEntry,
                  localCurrencyCreditAmount1:
                    accountingLedgerEntry.localCurrencyCreditAmount1 - externalPayments,
                  localCurrencyDebitAmount1:
                    accountingLedgerEntry.localCurrencyDebitAmount1 - externalPayments,
                };
              }
              return {
                ...accountingLedgerEntry,
                localCurrencyCreditAmount1:
                  externalPayments - accountingLedgerEntry.localCurrencyCreditAmount1,
                creditAccountID1: accountingLedgerEntry.debitAccountID1,
                localCurrencyDebitAmount1:
                  externalPayments - accountingLedgerEntry.localCurrencyDebitAmount1,
                debitAccountID1: accountingLedgerEntry.creditAccountID1,
              };
            });

          const balance = batchedEntries
            .map(entry => entry.localCurrencyCreditAmount1)
            .reduce((a, b) => a + b, 0);
          if (balance !== partialEntry.localCurrencyCreditAmount1) {
            throw new LedgerError(
              `Batched business ID="${transaction.business_id}" cannot be unbatched as it is not balanced`,
            );
          }

          for (const batchedEntry of batchedEntries) {
            const ledgerEntry: StrictLedgerProto = {
              ...partialEntry,
              ...(partialEntry.isCreditorCounterparty
                ? {
                    creditAccountID1: batchedEntry.creditAccountID1!,
                    debitAccountID1: taxCategoryId,
                  }
                : {
                    creditAccountID1: taxCategoryId,
                    debitAccountID1: batchedEntry.creditAccountID1!,
                  }),
              creditAmount1: batchedEntry.creditAmount1,
              localCurrencyCreditAmount1: batchedEntry.localCurrencyCreditAmount1,
              debitAmount1: batchedEntry.creditAmount1,
              localCurrencyDebitAmount1: batchedEntry.localCurrencyCreditAmount1,
            };

            batchedLedgerEntries.push(ledgerEntry);
            updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
          }
        } catch (e) {
          if (e instanceof LedgerError) {
            errors.add(e.message);
          } else {
            throw e;
          }
        }
      },
    );
    entriesPromises.push(...batchedTransactionEntriesPromises);

    // create a ledger record for fee transactions
    const feeFinancialAccountLedgerEntries: LedgerProto[] = [];
    const feeFinancialAccountLedgerEntriesPromises = feeTransactions.map(async transaction => {
      await getEntriesFromFeeTransaction(transaction, charge, injector).then(ledgerEntries => {
        feeFinancialAccountLedgerEntries.push(...ledgerEntries);
        ledgerEntries.map(ledgerEntry => {
          updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
        });
      });
    });
    entriesPromises.push(...feeFinancialAccountLedgerEntriesPromises);

    await Promise.all(entriesPromises);

    // generate ledger from balance cancellation
    for (const balanceCancellation of balanceCancellations) {
      try {
        const entityBalance = ledgerBalance.get(balanceCancellation.business_id);
        if (!entityBalance) {
          throw new LedgerError(
            `Balance cancellation for business ${balanceCancellation.business_id} redundant - already balanced`,
          );
        }

        const { amount, entityId } = entityBalance;

        const financialAccountEntry = financialAccountLedgerEntries.find(entry =>
          [
            entry.creditAccountID1,
            entry.creditAccountID2,
            entry.debitAccountID1,
            entry.debitAccountID2,
          ].includes(balanceCancellation.business_id),
        );
        if (!financialAccountEntry) {
          throw new LedgerError(
            `Balance cancellation for business ${balanceCancellation.business_id} failed - no financial account entry found`,
          );
        }

        let foreignAmount: number | undefined = undefined;

        if (
          financialAccountEntry.currency !== defaultLocalCurrency &&
          financialAccountEntry.currencyRate
        ) {
          foreignAmount = financialAccountEntry.currencyRate * amount;
        }

        const isCreditorCounterparty = amount > 0;

        const ledgerEntry: LedgerProto = {
          id: balanceCancellation.charge_id,
          invoiceDate: financialAccountEntry.invoiceDate,
          valueDate: financialAccountEntry.valueDate,
          currency: financialAccountEntry.currency,
          creditAccountID1: isCreditorCounterparty ? balanceCancellationTaxCategoryId : entityId,
          creditAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
          localCurrencyCreditAmount1: Math.abs(amount),
          debitAccountID1: isCreditorCounterparty ? entityId : balanceCancellationTaxCategoryId,
          debitAmount1: foreignAmount ? Math.abs(foreignAmount) : undefined,
          localCurrencyDebitAmount1: Math.abs(amount),
          description: balanceCancellation.description ?? 'Balance Cancellation',
          isCreditorCounterparty,
          ownerId: charge.owner_id,
          currencyRate: financialAccountEntry.currencyRate,
          chargeId,
        };

        miscLedgerEntries.push(ledgerEntry);
        updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
      } catch (e) {
        if (e instanceof LedgerError) {
          errors.add(e.message);
        } else {
          throw e;
        }
      }
    }

    const allowedUnbalancedBusinesses = new Set(
      unbalancedBusinesses.map(({ business_id }) => business_id),
    );
    const ledgerBalanceInfo = await getLedgerBalanceInfo(
      injector,
      ledgerBalance,
      errors,
      allowedUnbalancedBusinesses,
    );

    const records = [
      ...accountingLedgerEntries,
      ...financialAccountLedgerEntries,
      ...batchedLedgerEntries,
      ...feeFinancialAccountLedgerEntries,
      ...miscExpensesLedgerEntries,
      ...miscLedgerEntries,
    ];

    if (insertLedgerRecordsIfNotExists) {
      await storeInitialGeneratedRecords(charge.id, records, injector);
    }

    return {
      records: ledgerProtoToRecordsConverter(records),
      charge,
      balance: ledgerBalanceInfo,
      errors: Array.from(errors),
    };
  } catch (e) {
    return {
      __typename: 'CommonError',
      message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`,
    };
  }
};
