import { GraphQLError, GraphQLResolveInfo } from 'graphql';
import type { Injector } from 'graphql-modules';
import type {
  AddBusinessTripTravelAndSubsistenceExpenseInput,
  BusinessTripExpenseCategories,
} from '../../../__generated__/types.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import type { AdminContext } from '../../admin-context/types.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { isSupplementalFeeTransaction } from '../../ledger/helpers/fee-transactions.js';
import { LedgerError } from '../../ledger/helpers/utils.helper.js';
import { generateLedgerRecordsForBusinessTrip } from '../../ledger/resolvers/ledger-generation/business-trip-ledger-generation.resolver.js';
import type { IGetExpensesByChargeIdsResult } from '../../misc-expenses/types.js';
import { ChargeTagsProvider } from '../../tags/providers/charge-tags.provider.js';
import type { IGetTransactionsByChargeIdsResult } from '../../transactions/types.js';
import { BusinessTripEmployeePaymentsProvider } from '../providers/business-trips-employee-payments.provider.js';
import { BusinessTripAccommodationsExpensesProvider } from '../providers/business-trips-expenses-accommodations.provider.js';
import { BusinessTripCarRentalExpensesProvider } from '../providers/business-trips-expenses-car-rental.provider.js';
import { BusinessTripFlightsExpensesProvider } from '../providers/business-trips-expenses-flights.provider.js';
import { BusinessTripOtherExpensesProvider } from '../providers/business-trips-expenses-other.provider.js';
import { BusinessTripExpensesTransactionsMatchProvider } from '../providers/business-trips-expenses-transactions-match.provider.js';
import { BusinessTripTravelAndSubsistenceExpensesProvider } from '../providers/business-trips-expenses-travel-and-subsistence.provider.js';
import { BusinessTripExpensesProvider } from '../providers/business-trips-expenses.provider.js';
import { BusinessTripsProvider } from '../providers/business-trips.provider.js';
import type {
  business_trip_transaction_type,
  IGetBusinessTripsExpenseMatchesByTransactionIdsResult,
  IUpdateBusinessTripEmployeePaymentParams,
  IUpdateBusinessTripExpenseParams,
} from '../types.js';

function validateTransactionAgainstBusinessTripsExpenses(
  transaction: IGetTransactionsByChargeIdsResult,
  transactionMatchingExpenses: IGetBusinessTripsExpenseMatchesByTransactionIdsResult[],
  miscExpenses: IGetExpensesByChargeIdsResult[],
  financialAccounts: AdminContext['financialAccounts'],
): boolean {
  if (!transactionMatchingExpenses?.length && !miscExpenses?.length) {
    throw new LedgerError(
      `Transaction reference "${transaction.source_reference}" is not part of a business trip`,
    );
  }

  const totalAmount = transactionMatchingExpenses.reduce(
    (acc, expense) => acc + Number(expense.amount),
    0,
  );

  const direction = isSupplementalFeeTransaction(transaction, financialAccounts) ? -1 : 1;

  const miscExpensesAmount = miscExpenses.reduce(
    (acc, expense) => Number(expense.amount) * direction + acc,
    0,
  );

  if (Math.abs(Number(transaction.amount) - miscExpensesAmount - totalAmount) > 0.005) {
    throw new LedgerError(
      `Transaction reference "${transaction.source_reference}" amount does not match the business trip expenses total amount`,
    );
  }

  return true;
}

export const validateTransactionAgainstBusinessTrips = async (
  injector: Injector,
  transaction: IGetTransactionsByChargeIdsResult,
): Promise<boolean> => {
  const transactionMatchingExpenses = await injector
    .get(BusinessTripExpensesTransactionsMatchProvider)
    .getBusinessTripsExpenseMatchesByTransactionIdLoader.load(transaction.id);

  const { financialAccounts } = await injector.get(AdminContextProvider).getVerifiedAdminContext();

  return validateTransactionAgainstBusinessTripsExpenses(
    transaction,
    transactionMatchingExpenses,
    [],
    financialAccounts,
  );
};

export const getTransactionMatchedAmount = async (
  injector: Injector,
  transaction: IGetTransactionsByChargeIdsResult,
): Promise<{ isFullyMatched: boolean; amount: number; errors?: string[] }> => {
  const transactionMatchingExpensesPromise = injector
    .get(BusinessTripExpensesTransactionsMatchProvider)
    .getBusinessTripsExpenseMatchesByTransactionIdLoader.load(transaction.id);

  const [transactionMatchingExpenses] = await Promise.all([transactionMatchingExpensesPromise]);

  const expensesSum = transactionMatchingExpenses.reduce(
    (acc, expense) => (expense.amount ? acc + Number(expense.amount) : acc),
    0,
  );

  const { financialAccounts } = await injector.get(AdminContextProvider).getVerifiedAdminContext();

  try {
    validateTransactionAgainstBusinessTripsExpenses(
      transaction,
      transactionMatchingExpenses,
      [],
      financialAccounts,
    );
  } catch (e) {
    const errors = [];
    if (e instanceof LedgerError) {
      errors.push(e.message);
    }
    return {
      isFullyMatched: false,
      amount: expensesSum,
      errors,
    };
  }

  const isFullyMatched = Math.abs(Number(transaction.amount) - expensesSum) < 0.005;

  return {
    isFullyMatched,
    amount: Number(transaction.amount),
  };
};

export async function coreExpenseUpdate(
  injector: Injector,
  fields: IUpdateBusinessTripExpenseParams &
    IUpdateBusinessTripEmployeePaymentParams & { id: string },
  categoryToValidate?: business_trip_transaction_type,
) {
  const { id, businessTripId, date, valueDate, amount, currency, employeeBusinessId, chargeId } =
    fields;
  const currentExpense = await injector
    .get(BusinessTripExpensesProvider)
    .getBusinessTripsExpensesByIdLoader.load(id);

  if (!currentExpense) {
    throw new GraphQLError(`Business trip expense with id ${id} not found`);
  }
  if (categoryToValidate && currentExpense.category !== categoryToValidate) {
    throw new GraphQLError(
      `Business trip expense with id ${id} is not a ${categoryToValidate} expense`,
    );
  }

  const updateCoreExpensePromise = async () => {
    return businessTripId
      ? injector.get(BusinessTripExpensesProvider).updateBusinessTripExpense({
          businessTripExpenseId: id,
          businessTripId,
          ...(currentExpense.payed_by_employee
            ? {
                date,
                valueDate,
                currency,
                employeeBusinessId,
              }
            : {}),
        })
      : Promise.resolve();
  };

  const updateEmployeePaymentPromise = async () => {
    const hasCommonFieldsToUpdate =
      date || valueDate || amount || currency || employeeBusinessId || chargeId;
    return hasCommonFieldsToUpdate
      ? injector.get(BusinessTripEmployeePaymentsProvider).updateBusinessTripEmployeePayment({
          businessTripExpenseId: id,
          chargeId,
          date,
          valueDate,
          currency,
          employeeBusinessId,
        })
      : Promise.resolve();
  };

  const [updatedTripExpense] = await Promise.all([
    updateCoreExpensePromise(),
    updateEmployeePaymentPromise(),
  ]);

  return updatedTripExpense;
}

export async function generateChargeForEmployeePayment(
  injector: Injector,
  businessTripId: string,
  description?: string,
) {
  const {
    ownerId,
    businessTrips: { businessTripTaxCategoryId },
  } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
  if (!businessTripTaxCategoryId) {
    throw new GraphQLError('Business trip tax category not set');
  }
  try {
    const { id: chargeId } = await injector.get(ChargesProvider).generateCharge({
      ownerId,
      taxCategoryId: businessTripTaxCategoryId,
      userDescription: description || 'Employee payment charge',
      type: 'BUSINESS_TRIP',
    });

    if (!chargeId) {
      throw new GraphQLError('Failed to generate initial charge');
    }

    await injector.get(BusinessTripsProvider).updateChargeBusinessTrip(chargeId, businessTripId);

    return chargeId;
  } catch (e) {
    console.error('Failed to generate charge for employee payment', e);
    throw new GraphQLError('Failed to generate charge for employee payment');
  }
}

export const updateExistingTripExpense = async (
  injector: Injector,
  businessTripExpenseId: string,
  transactionId: string,
  category?: BusinessTripExpenseCategories,
  amount?: number | null,
) => {
  const updateTransactionMatchPromise = injector
    .get(BusinessTripExpensesTransactionsMatchProvider)
    .insertBusinessTripExpenseMatch({
      businessTripExpenseId,
      transactionId,
      amount,
    });
  const insertToCategoryPromise = async () => {
    switch (category) {
      case 'FLIGHT':
        return injector.get(BusinessTripFlightsExpensesProvider).insertBusinessTripFlightsExpense({
          id: businessTripExpenseId,
        });
      case 'ACCOMMODATION':
        return injector
          .get(BusinessTripAccommodationsExpensesProvider)
          .insertBusinessTripAccommodationsExpense({
            id: businessTripExpenseId,
          });
      case 'TRAVEL_AND_SUBSISTENCE':
        return injector
          .get(BusinessTripTravelAndSubsistenceExpensesProvider)
          .insertBusinessTripTravelAndSubsistenceExpense({ id: businessTripExpenseId });
      case 'OTHER':
        return injector.get(BusinessTripOtherExpensesProvider).insertBusinessTripOtherExpense({
          id: businessTripExpenseId,
        });
      case 'CAR_RENTAL':
        return injector
          .get(BusinessTripCarRentalExpensesProvider)
          .insertBusinessTripCarRentalExpense({
            id: businessTripExpenseId,
            days: 0,
            isFuelExpense: false,
          });
      default:
        throw new GraphQLError(`Invalid category ${category}`);
    }
  };
  await Promise.all([updateTransactionMatchPromise, insertToCategoryPromise()]);
};

export async function createTravelAndSubsistenceExpense(
  context: GraphQLModules.Context,
  fields: AddBusinessTripTravelAndSubsistenceExpenseInput,
): Promise<string> {
  const { injector } = context;
  const adminContext = await injector.get(AdminContextProvider).getVerifiedAdminContext();
  const { businessTripTagId } = adminContext.businessTrips;
  try {
    const coreExpensePromise = injector
      .get(BusinessTripExpensesProvider)
      .insertBusinessTripExpense({
        businessTripId: fields.businessTripId,
        category: 'TRAVEL_AND_SUBSISTENCE',
      })
      .then(res => res[0]);

    const chargeGenerationPromise = generateChargeForEmployeePayment(
      injector,
      fields.businessTripId,
      fields.expenseType ?? undefined,
    );

    const [coreExpense, chargeId] = await Promise.all([
      coreExpensePromise,
      chargeGenerationPromise,
    ]);

    const [charge] = await Promise.all([
      injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId),
      injector
        .get(BusinessTripTravelAndSubsistenceExpensesProvider)
        .insertBusinessTripTravelAndSubsistenceExpense({
          id: coreExpense.id,
          expenseType: fields.expenseType,
        }),
      injector.get(BusinessTripEmployeePaymentsProvider).insertBusinessTripEmployeePayment({
        businessTripExpenseId: coreExpense.id,
        chargeId,
        date: fields.date,
        valueDate: fields.valueDate,
        amount: fields.amount,
        currency: fields.currency,
        employeeBusinessId: fields.employeeBusinessId,
      }),
      businessTripTagId
        ? injector.get(ChargeTagsProvider).insertChargeTag({ chargeId, tagId: businessTripTagId })
        : Promise.resolve(),
    ]);

    if (!charge) {
      throw new GraphQLError('Failed to generate charge for employee payment');
    }

    // generate ledger records
    await generateLedgerRecordsForBusinessTrip(
      charge,
      { insertLedgerRecordsIfNotExists: true },
      context,
      {} as GraphQLResolveInfo,
    );

    return coreExpense.id;
  } catch (e) {
    console.error(`Error adding new business trip travel & subsistence expense`, e);
    throw new GraphQLError('Error adding new business trip travel & subsistence expense');
  }
}
