import { Injector } from 'graphql-modules';
import type { FinancialAmount } from '../../../__generated__/types.js';
import { EMPTY_UUID } from '../../../shared/constants.js';
import { Currency } from '../../../shared/enums.js';
import {
  formatCurrency,
  formatFinancialAmount,
  getCurrencySymbol,
} from '../../../shared/helpers/index.js';
import type {
  LedgerBalanceInfoType,
  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 type { IGetChargesByIdsResult } from '../../charges/types.js';
import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js';
import { TaxCategoriesProvider } from '../../financial-entities/providers/tax-categories.provider.js';
import type { IGetFinancialEntitiesByIdsResult } from '../../financial-entities/types.js';
import type { IGetTransactionsByChargeIdsResult } from '../../transactions/types.js';
import type { IGetLedgerRecordsByChargesIdsResult } from '../types.js';

export class LedgerError extends Error {
  constructor(message: string) {
    super(message);
  }
}

export function isTransactionsOppositeSign([first, second]: IGetTransactionsByChargeIdsResult[]) {
  if (!first || !second) {
    throw new LedgerError('Transactions are missing');
  }
  const firstAmount = Number(first.amount);
  const secondAmount = Number(second.amount);
  if (Number.isNaN(firstAmount) || Number.isNaN(secondAmount)) {
    throw new LedgerError('Transaction amount is not a number');
  }
  return Number(first.amount) > 0 !== Number(second.amount) > 0;
}

export function validateTransactionBasicVariables(transaction: IGetTransactionsByChargeIdsResult) {
  const currency = formatCurrency(transaction.currency);
  const debitDate = transaction.debit_date_override ?? transaction.debit_date;
  if (!debitDate) {
    throw new LedgerError(
      `Transaction reference "${transaction.source_reference}" is missing debit date for currency ${currency}`,
    );
  }
  const valueDate = transaction.debit_timestamp ?? debitDate;

  if (!transaction.business_id) {
    throw new LedgerError(
      `Transaction reference "${transaction.source_reference}" is missing business_id`,
    );
  }

  const transactionBusinessId = transaction.business_id;

  return {
    currency,
    valueDate,
    transactionBusinessId,
  };
}

type WithRequired<T, K extends keyof T> = T & { [P in K]-?: NonNullable<T[P]> };
export type ValidateTransaction = Omit<
  WithRequired<IGetTransactionsByChargeIdsResult, 'debit_date' | 'business_id' | 'debit_timestamp'>,
  'currency'
> & { currency: Currency };

type ValidationOptions = {
  skipBusinessId?: boolean;
};

export function validateTransactionRequiredVariables(
  transaction: IGetTransactionsByChargeIdsResult,
  validationOptions: ValidationOptions = {},
): ValidateTransaction {
  if (!transaction.debit_date && !transaction.debit_date_override) {
    throw new LedgerError(
      `Transaction reference "${transaction.source_reference}" is missing debit date for currency ${transaction.currency}`,
    );
  }

  if (!transaction.business_id && !validationOptions.skipBusinessId) {
    throw new LedgerError(
      `Transaction reference "${transaction.source_reference}" is missing business_id`,
    );
  }

  const debit_timestamp =
    transaction.debit_date_override ?? transaction.debit_timestamp ?? transaction.debit_date;

  return {
    ...transaction,
    debit_timestamp,
    currency: formatCurrency(transaction.currency),
  } as ValidateTransaction;
}

export function generatePartialLedgerEntry(
  transaction: ValidateTransaction,
  ownerId: string,
  exchangeRate?: number,
): Omit<StrictLedgerProto, 'creditAccountID1' | 'debitAccountID1'> {
  // set amounts
  let amount = Number(transaction.amount);
  let foreignAmount: number | undefined = undefined;
  if (exchangeRate) {
    foreignAmount = amount;
    // calculate amounts in ILS
    amount = exchangeRate * amount;
  }
  const absAmount = Math.abs(amount);
  const absForeignAmount = foreignAmount ? Math.abs(foreignAmount) : undefined;

  const isCreditorCounterparty = amount > 0;
  return {
    id: transaction.id,
    invoiceDate: transaction.event_date,
    valueDate: transaction.debit_timestamp,
    currency: transaction.currency,
    creditAmount1: absForeignAmount,
    localCurrencyCreditAmount1: absAmount,
    debitAmount1: absForeignAmount,
    localCurrencyDebitAmount1: absAmount,
    description: transaction.source_description ?? undefined,
    reference: transaction.origin_key,
    isCreditorCounterparty,
    ownerId,
    currencyRate: transaction.currency_rate ? Number(transaction.currency_rate) : undefined,
    chargeId: transaction.charge_id,
  };
}

function entrySingleAccountBalancer(
  ledgerBalance: Map<
    string,
    {
      amount: number;
      entityId: string;
      foreignAmounts?: Partial<Record<Currency, { local: number; foreign: number }>>;
    }
  >,
  entry: LedgerProto,
  isCredit: boolean,
  ledgerEntityNumber: 1 | 2,
  defaultLocalCurrency: Currency,
) {
  const entityId = isCredit
    ? ledgerEntityNumber === 1
      ? entry.creditAccountID1
      : entry.creditAccountID2
    : ledgerEntityNumber === 1
      ? entry.debitAccountID1
      : entry.debitAccountID2;

  if (!entityId) {
    return;
  }

  const current = ledgerBalance.get(entityId);

  const factor = isCredit ? 1 : -1;
  const currency = entry.currency;
  const amount =
    ((isCredit
      ? ledgerEntityNumber === 1
        ? entry.creditAmount1
        : entry.creditAmount2
      : ledgerEntityNumber === 1
        ? entry.debitAmount1
        : entry.debitAmount2) ?? 0) * factor;
  const localAmount =
    ((isCredit
      ? ledgerEntityNumber === 1
        ? entry.localCurrencyCreditAmount1
        : entry.localCurrencyCreditAmount2
      : ledgerEntityNumber === 1
        ? entry.localCurrencyDebitAmount1
        : entry.localCurrencyDebitAmount2) ?? 0) * factor;
  if (current) {
    current.amount += localAmount;
    current.foreignAmounts ||= {};
    if (amount && currency !== defaultLocalCurrency) {
      current.foreignAmounts[currency] = {
        foreign: (current.foreignAmounts[currency]?.foreign ?? 0) + amount,
        local: (current.foreignAmounts[currency]?.local ?? 0) + localAmount,
      };
    }
  } else {
    ledgerBalance.set(entityId, {
      amount: localAmount,
      entityId,
      foreignAmounts:
        amount && currency !== defaultLocalCurrency
          ? {
              [currency]: {
                foreign: amount,
                local: localAmount,
              },
            }
          : {},
    });
  }
}

export function updateLedgerBalanceByEntry(
  entry: LedgerProto,
  ledgerBalance: Map<
    string,
    {
      amount: number;
      entityId: string;
      foreignAmounts?: Partial<Record<Currency, { local: number; foreign: number }>>;
    }
  >,
  defaultLocalCurrency: Currency,
): void {
  entrySingleAccountBalancer(ledgerBalance, entry, true, 1, defaultLocalCurrency);
  entrySingleAccountBalancer(ledgerBalance, entry, true, 2, defaultLocalCurrency);
  entrySingleAccountBalancer(ledgerBalance, entry, false, 1, defaultLocalCurrency);
  entrySingleAccountBalancer(ledgerBalance, entry, false, 2, defaultLocalCurrency);

  return;
}

export async function getLedgerBalanceInfo(
  injector: Injector,
  ledgerBalance: Map<string, { amount: number; entityId: string }>,
  errors: Set<string> = new Set(),
  allowedUnbalancedBusinesses: Set<string> = new Set(),
  financialEntities?: Array<IGetFinancialEntitiesByIdsResult>,
): Promise<
  LedgerBalanceInfoType & {
    financialEntities: Array<IGetFinancialEntitiesByIdsResult>;
  }
> {
  const { defaultLocalCurrency } = await injector
    .get(AdminContextProvider)
    .getVerifiedAdminContext();
  let ledgerBalanceSum = 0;
  let isBalanced = true;
  const unbalancedEntities: Array<{ entityId: string; balance: FinancialAmount }> = [];

  if (!financialEntities) {
    const financialEntityIDs = new Set<string>(
      Array.from(ledgerBalance.values()).map(v => v.entityId),
    );
    financialEntities = (await injector
      .get(FinancialEntitiesProvider)
      .getFinancialEntityByIdLoader.loadMany(Array.from(financialEntityIDs))
      .then(res =>
        res.filter(fe => !!fe && 'id' in fe),
      )) as Array<IGetFinancialEntitiesByIdsResult>;
  }

  for (const { amount, entityId } of ledgerBalance.values()) {
    if (Math.abs(amount) < 0.005) {
      continue;
    }
    const isBusiness =
      financialEntities?.some(
        financialEntity => financialEntity.id === entityId && financialEntity.type === 'business',
      ) ?? true;

    if (isBusiness && !allowedUnbalancedBusinesses.has(entityId)) {
      let businessIdentification = `ID="${entityId}"`;
      if (financialEntities) {
        const businessName = financialEntities.find(fe => fe.id === entityId)?.name;
        if (businessName) {
          businessIdentification = `"${businessName}"`;
        }
      }
      errors.add(
        `Business ${businessIdentification} is unbalanced (By ${amount.toFixed(2)}${getCurrencySymbol(defaultLocalCurrency)})`,
      );
      isBalanced = false;
    }
    unbalancedEntities.push({
      entityId,
      balance: formatFinancialAmount(amount, defaultLocalCurrency),
    });
    ledgerBalanceSum += amount;
  }
  if (Math.abs(ledgerBalanceSum) >= 0.005) {
    errors.add(`Total ledger balance is ${ledgerBalanceSum}`);
    isBalanced = false;
  }

  return {
    isBalanced,
    unbalancedEntities,
    balanceSum: ledgerBalanceSum,
    financialEntities,
  };
}

export function ledgerProtoToRecordsConverter(
  records: LedgerProto[],
): IGetLedgerRecordsByChargesIdsResult[] {
  return records.map(record => {
    const adjustedRecord: IGetLedgerRecordsByChargesIdsResult = {
      charge_id: record.chargeId,
      created_at: new Date(),
      credit_entity1: record.creditAccountID1 ?? null,
      credit_entity2: record.creditAccountID2 ?? null,
      credit_foreign_amount1: record.creditAmount1?.toString() ?? null,
      credit_foreign_amount2: record.creditAmount2?.toString() ?? null,
      credit_local_amount1: record.localCurrencyCreditAmount1?.toString(),
      credit_local_amount2: record.localCurrencyCreditAmount2?.toString() ?? null,
      currency: record.currency,
      debit_entity1: record.debitAccountID1 ?? null,
      debit_entity2: record.debitAccountID2 ?? null,
      debit_foreign_amount1: record.debitAmount1?.toString() ?? null,
      debit_foreign_amount2: record.debitAmount2?.toString() ?? null,
      debit_local_amount1: record.localCurrencyDebitAmount1?.toString(),
      debit_local_amount2: record.localCurrencyDebitAmount2?.toString() ?? null,
      description: record.description ?? null,
      id: EMPTY_UUID,
      invoice_date: record.invoiceDate,
      owner_id: record.ownerId ?? null,
      reference1: record.reference ?? null,
      updated_at: new Date(),
      value_date: record.valueDate,
      locked: false,
    };
    return adjustedRecord;
  });
}

export async function getFinancialAccountTaxCategoryId(
  injector: Injector,
  transaction: IGetTransactionsByChargeIdsResult,
): Promise<string> {
  const taxCategory = await injector
    .get(TaxCategoriesProvider)
    .taxCategoryByFinancialAccountIdsAndCurrenciesLoader.load({
      financialAccountId: transaction.account_id,
      currency: transaction.currency as Currency,
    });

  if (!taxCategory) {
    throw new LedgerError(
      `Account id "${transaction.account_id}", with currency ${transaction.currency} is missing tax category`,
    );
  }

  return taxCategory.id;
}

export async function multipleForeignCurrenciesBalanceEntries(
  injector: Injector,
  defaultLocalCurrency: Currency,
  documentEntries: LedgerProto[],
  transactionEntries: LedgerProto[],
  charge: IGetChargesByIdsResult,
  foreignAmounts: Partial<Record<Currency, { local: number; foreign: number }>>,
  balanceAgainstLocal?: boolean,
): Promise<LedgerProto[]> {
  const useDocuments = !charge.documents_optional_flag;
  if (!transactionEntries.length) {
    throw new LedgerError(`Failed to locate transaction entries for charge "${charge.id}"`);
  }
  if (!documentEntries.length && useDocuments) {
    throw new LedgerError(`Failed to locate document entries for charge "${charge.id}"`);
  }

  const ledgerEntries: LedgerProto[] = [];

  const { mainBusinessId } = await getChargeBusinesses(charge.id, injector);
  if (mainBusinessId && Object.keys(foreignAmounts).length > 0) {
    const transactionEntry = transactionEntries.reduce((prev, curr) => {
      if (!prev) {
        return curr;
      }
      return prev.valueDate.getTime() > curr.valueDate.getTime() ? prev : curr;
    });

    const invoiceDate = new Date(
      Math.min(
        ...(useDocuments
          ? documentEntries.map(entry => entry.invoiceDate.getTime())
          : transactionEntries.map(entry => entry.valueDate.getTime())),
      ),
    );

    // get the main foreign currency + diff in local currency
    let mainForeignCurrency: { amount: number; currency: Currency } | undefined = undefined;
    let localDiff = 0;

    if (Object.keys(foreignAmounts).length === 1 && balanceAgainstLocal) {
      const [currency, { local, foreign }] = Object.entries(foreignAmounts)[0];
      if (foreign === 0) {
        return [];
      }

      const isCreditorCounterparty = foreign < 0;
      const ledgerEntry: LedgerProto = {
        id: transactionEntry.id + `|${currency}-balance`, // NOTE: this field is dummy
        creditAccountID1: mainBusinessId,
        debitAccountID1: mainBusinessId,
        localCurrencyCreditAmount1: Math.abs(local),
        localCurrencyDebitAmount1: Math.abs(local),
        ...(isCreditorCounterparty
          ? {
              creditAmount1: Math.abs(foreign),
            }
          : {
              debitAmount1: Math.abs(foreign),
            }),
        description: 'Foreign currency balance',
        isCreditorCounterparty,
        invoiceDate,
        valueDate: transactionEntry.valueDate,
        currency: currency as Currency,
        ownerId: transactionEntry.ownerId,
        chargeId: charge.id,
      };
      return [ledgerEntry];
    }

    for (const [currency, { local }] of Object.entries(foreignAmounts)) {
      if (mainForeignCurrency) {
        if (mainForeignCurrency.amount < local) {
          mainForeignCurrency = { amount: local, currency: currency as Currency };
        }
      } else {
        mainForeignCurrency = { amount: local, currency: currency as Currency };
      }
      localDiff += local;
    }

    if (balanceAgainstLocal && !foreignAmounts[defaultLocalCurrency]) {
      foreignAmounts[defaultLocalCurrency] = {
        local: -localDiff,
        foreign: 0,
      };
    }

    for (const [currency, { local, foreign }] of Object.entries(foreignAmounts)) {
      let localToUse = local;

      if (!balanceAgainstLocal) {
        if (Math.abs(foreign) < 0.005) {
          continue;
        }

        if (mainForeignCurrency?.currency === currency) {
          localToUse -= localDiff;
        }
      }

      const isLocalCurrencyAndShouldBeBalanced =
        balanceAgainstLocal && mainForeignCurrency && defaultLocalCurrency === currency;
      const isCreditorCounterparty = isLocalCurrencyAndShouldBeBalanced
        ? mainForeignCurrency!.amount > 0
        : foreign < 0;
      const ledgerEntry: LedgerProto = {
        id: transactionEntry.id + `|${currency}-balance`, // NOTE: this field is dummy
        ...(isCreditorCounterparty
          ? {
              creditAccountID1: mainBusinessId,
            }
          : {
              debitAccountID1: mainBusinessId,
            }),
        localCurrencyCreditAmount1: Math.abs(localToUse),
        localCurrencyDebitAmount1: Math.abs(localToUse),
        ...(balanceAgainstLocal && !foreign
          ? {}
          : {
              creditAmount1: Math.abs(foreign),
              debitAmount1: Math.abs(foreign),
            }),
        description: 'Foreign currency balance',
        isCreditorCounterparty,
        invoiceDate,
        valueDate: transactionEntry.valueDate,
        currency: currency as Currency,
        ownerId: transactionEntry.ownerId,
        chargeId: charge.id,
      };
      ledgerEntries.push(ledgerEntry);
    }
  }

  return ledgerEntries;
}
