import { GraphQLError } from 'graphql';
import { type Injector } from 'graphql-modules';
import { Repeater } from 'graphql-yoga';
import {
  ChargeSortByField,
  type Resolvers,
  type ResolversTypes,
} from '../../../__generated__/types.js';
import { EMPTY_UUID } from '../../../shared/constants.js';
import type { Currency } from '../../../shared/enums.js';
import { formatFinancialAmount } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { ScopeProvider } from '../../auth/providers/scope.provider.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { accountant_statusArray } from '../../charges/types.js';
import { FinancialEntitiesProvider } from '../../financial-entities/providers/financial-entities.provider.js';
import { IGetFinancialEntitiesByIdsResult } from '../../financial-entities/types.js';
import {
  ledgerGenerationByCharge,
  ledgerUnbalancedBusinessesByCharge,
} from '../helpers/ledger-by-charge-type.helper.js';
import { isChargeLocked } from '../helpers/ledger-lock.js';
import {
  convertLedgerRecordToInput,
  convertLedgerRecordToProto,
  ledgerRecordsGenerationFullMatchComparison,
  ledgerRecordsGenerationPartialMatchComparison,
} from '../helpers/ledgrer-storage.helper.js';
import { getLedgerBalanceInfo, updateLedgerBalanceByEntry } from '../helpers/utils.helper.js';
import { LedgerProvider } from '../providers/ledger.provider.js';
import type {
  IGetLedgerRecordsByChargesIdsResult,
  IInsertLedgerRecordsParams,
  LedgerModule,
} from '../types.js';
import { commonChargeLedgerResolver } from './common.resolver.js';

// Resolve a ledger record's local currency from its OWNING business, so
// multi-business reads format each record in the right currency. Falls back to
// the request's primary business when the per-business preference is missing.
async function recordLocalCurrency(
  injector: Injector,
  ownerId: string | null | undefined,
): Promise<Currency> {
  if (ownerId) {
    const currency = await injector
      .get(ScopeProvider)
      .getBusinessPreference(ownerId, 'defaultLocalCurrency');
    if (currency) {
      return currency;
    }
  }
  const { defaultLocalCurrency } = await injector
    .get(AdminContextProvider)
    .getVerifiedAdminContext();
  return defaultLocalCurrency;
}

export const ledgerResolvers: LedgerModule.Resolvers & Pick<Resolvers, 'GeneratedLedgerRecords'> = {
  Query: {
    chargesWithLedgerChanges: async (_, { filters, limit }, context, info) => {
      const { injector } = context;
      const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();

      // handle sort column
      let sortColumn: 'event_date' | 'event_amount' | 'abs_event_amount' = 'event_date';
      switch (filters?.sortBy?.field) {
        case ChargeSortByField.Amount:
          sortColumn = 'event_amount';
          break;
        case ChargeSortByField.AbsAmount:
          sortColumn = 'abs_event_amount';
          break;
        case ChargeSortByField.Date:
          sortColumn = 'event_date';
          break;
      }

      // get max date of filter and ledger lock
      const fromDate =
        ledgerLock || filters?.fromDate
          ? [ledgerLock, filters?.fromDate].filter(Boolean).sort().reverse()[0]
          : undefined;

      const charges = await injector
        .get(ChargesProvider)
        .getChargesByFilters({
          ownerIds: filters?.byOwners,
          fromDate,
          toDate: filters?.toDate,
          fromAnyDate: filters?.fromAnyDate,
          toAnyDate: filters?.toAnyDate,
          sortColumn,
          asc: filters?.sortBy?.asc !== false,
          chargeType: filters?.chargesType,
          businessIds: filters?.byBusinesses,
          withoutInvoice: filters?.withoutInvoice,
          withoutReceipt: filters?.withoutReceipt,
          withoutDocuments: filters?.withoutDocuments,
          withoutLedger: filters?.withoutLedger,
          tags: filters?.byTags,
          accountantStatuses: filters?.accountantStatus as accountant_statusArray | undefined,
        })
        .catch(e => {
          const message = 'Error fetching charges';
          console.error(`${message}: ${e}`);
          throw new Error(message);
        });

      const limitedCharges = limit ? charges.slice(0, limit) : charges;

      return new Repeater<ResolversTypes['ChargesWithLedgerChangesResult']>(async (push, stop) => {
        push({ progress: 20 });
        let handledCharges: number = 0;

        function calculateProgress() {
          return ((handledCharges * 100) / limitedCharges.length) * 0.8 + 20;
        }

        await Promise.all(
          limitedCharges.map(async charge => {
            if (await isChargeLocked(charge, injector, ledgerLock)) {
              handledCharges++;
              if (handledCharges % 50 === 0 || handledCharges === limitedCharges.length) {
                push({ progress: calculateProgress() });
              }
              return;
            }
            try {
              const generatedRecordsPromise = ledgerGenerationByCharge(
                charge,
                { insertLedgerRecordsIfNotExists: false },
                context,
                info,
              );
              const storageRecordsPromise = injector
                .get(LedgerProvider)
                .getLedgerRecordsByChargesIdLoader.load(charge.id);

              const [generatedRecords, storageRecords] = await Promise.all([
                generatedRecordsPromise,
                storageRecordsPromise,
              ]);

              if (!generatedRecords || 'message' in generatedRecords) {
                handledCharges++;
                push({ progress: calculateProgress(), charge });
                return;
              }

              const newRecords = generatedRecords.records as IGetLedgerRecordsByChargesIdsResult[];

              const fullMatching = ledgerRecordsGenerationFullMatchComparison(
                storageRecords,
                newRecords,
              );

              if (fullMatching.isFullyMatched) {
                handledCharges++;
                if (handledCharges % 50 === 0 || handledCharges === limitedCharges.length) {
                  push({ progress: calculateProgress() });
                }
                return;
              }

              handledCharges++;
              push({ progress: calculateProgress(), charge });
            } catch (err) {
              console.error(err);
              handledCharges++;
              push({ progress: calculateProgress(), charge });
            }
          }),
        );
        push({ progress: 100 });
        stop();
      }) as unknown as Promise<readonly ResolversTypes['ChargesWithLedgerChangesResult'][]>;
    },
    ledgerRecordsByDates: async (_, { fromDate, toDate }, { injector }) => {
      if (fromDate > toDate) {
        throw new GraphQLError('fromDate must be before or equal to toDate');
      }
      const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
      return await injector
        .get(LedgerProvider)
        .getLedgerRecordsByDates({
          fromDate,
          toDate,
          ownerId,
        })
        .then(records =>
          records.sort((a, b) => a.invoice_date.getTime() - b.invoice_date.getTime()),
        )
        .catch(error => {
          console.error('Failed to fetch ledger records:', error);
          throw new GraphQLError('Failed to fetch ledger records');
        });
    },
    ledgerRecordsByFinancialEntity: async (_, { financialEntityId }, { injector }) => {
      return await injector
        .get(LedgerProvider)
        .getLedgerRecordsByFinancialEntityIdLoader.load(financialEntityId);
    },
  },
  Mutation: {
    regenerateLedgerRecords: async (_, { chargeId }, context, info) => {
      const { injector } = context;
      const { ledgerLock, ownerId } = await injector
        .get(AdminContextProvider)
        .getVerifiedAdminContext();
      const charge = await injector.get(ChargesProvider).getChargeByIdLoader.load(chargeId);
      if (!charge) {
        throw new GraphQLError(`Charge with id ${chargeId} not found`);
      }
      if (await isChargeLocked(charge, injector, ledgerLock)) {
        return {
          __typename: 'CommonError',
          message: `Charge with id ${chargeId} is locked`,
        };
      }
      try {
        const generated = await ledgerGenerationByCharge(
          charge,
          { insertLedgerRecordsIfNotExists: true },
          context,
          info,
        );
        if (!generated || 'message' in generated) {
          const message = generated?.message ?? 'generation error';
          throw new Error(message);
        }

        const records = generated.records as IGetLedgerRecordsByChargesIdsResult[];

        const storageLedgerRecords = await injector
          .get(LedgerProvider)
          .getLedgerRecordsByChargesIdLoader.load(chargeId);

        const fullMatching = ledgerRecordsGenerationFullMatchComparison(
          storageLedgerRecords,
          records,
        );

        if (fullMatching.isFullyMatched) {
          return {
            records: storageLedgerRecords,
            charge,
            errors: generated.errors,
          };
        }

        const { toUpdate, toRemove } = ledgerRecordsGenerationPartialMatchComparison(
          fullMatching.unmatchedStorageRecords,
          fullMatching.unmatchedNewRecords,
        );

        const [newRecords, recordsToUpdate] = toUpdate.reduce(
          (acc, record) => {
            if (record.id === EMPTY_UUID) {
              acc[0].push(record);
            } else {
              acc[1].push(record);
            }
            return acc;
          },
          [[], []] as [
            IGetLedgerRecordsByChargesIdsResult[],
            IGetLedgerRecordsByChargesIdsResult[],
          ],
        );

        const updatePromise = injector
          .get(LedgerProvider)
          .deleteLedgerRecordsByIdLoader.loadMany(recordsToUpdate.map(r => r.id))
          .catch(e => {
            const message = `Failed to delete ledger records for charge ID="${chargeId}"`;
            console.error(`${message}: ${e}`);
            if (e instanceof GraphQLError) {
              throw e;
            }
            throw new Error(message);
          })
          .then(() =>
            injector.get(LedgerProvider).insertLedgerRecords({
              ledgerRecords: recordsToUpdate
                .map(record => convertLedgerRecordToInput(record, ownerId))
                .map(record => {
                  record.chargeId = chargeId;
                  return record as IInsertLedgerRecordsParams['ledgerRecords'][number];
                }),
            }),
          )
          .catch(e => {
            if (e instanceof GraphQLError) {
              throw e;
            }
            const message = `Failed to update ledger records for charge ID="${chargeId}"`;
            console.error(`${message}: ${e}`);
            throw new Error(message);
          });
        const insertPromise =
          newRecords.length > 0
            ? injector
                .get(LedgerProvider)
                .insertLedgerRecords({
                  ledgerRecords: newRecords.map(record =>
                    convertLedgerRecordToInput(record, ownerId),
                  ) as IInsertLedgerRecordsParams['ledgerRecords'],
                })
                .catch(e => {
                  const message = `Failed to insert new ledger records for charge ID="${chargeId}"`;
                  console.error(`${message}: ${e}`);
                  if (e instanceof GraphQLError) {
                    throw e;
                  }
                  throw new Error(message);
                })
            : Promise.resolve();
        const removePromises = toRemove.map(record =>
          injector
            .get(LedgerProvider)
            .deleteLedgerRecordsByIdLoader.load(record.id)
            .catch(e => {
              const message = `Failed to delete ledger records for charge ID="${chargeId}"`;
              console.error(`${message}: ${e}`);
              if (e instanceof GraphQLError) {
                throw e;
              }
              throw new Error(message);
            }),
        );
        await Promise.all([updatePromise, insertPromise, ...removePromises]);

        return {
          records: toUpdate,
          charge,
          errors: generated.errors,
        };
      } catch (e) {
        if (e instanceof GraphQLError) {
          throw e;
        }
        return {
          __typename: 'CommonError',
          message: `Failed to generate ledger records for charge ID="${chargeId}"\n${e}`,
        };
      }
    },
    lockLedgerRecords: async (_, { date }, { injector }) => {
      try {
        const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext();

        const lockByRecords = injector
          .get(LedgerProvider)
          .lockLedgerRecords(date)
          .catch(e => {
            console.error(`Error locking ledger records for ${date}: ${e}`);
            throw new Error(`Error locking ledger records for ${date}`);
          });
        const lockByDate = injector
          .get(AdminContextProvider)
          .updateAdminContext({
            ownerId,
            ledgerLock: date,
          })
          .catch(e => {
            console.error(`Error locking ledger by date ${date}: ${e}`);
            throw new Error(`Error locking ledger by date ${date}`);
          });
        await Promise.all([lockByRecords, lockByDate]);

        return true;
      } catch (error) {
        console.error(`Error locking ledger for ${date}: ${error}`);
        return false;
      }
    },
  },
  LedgerRecord: {
    id: DbLedgerRecord => DbLedgerRecord.id,
    debitAmount1: DbLedgerRecord =>
      DbLedgerRecord.debit_foreign_amount1 == null
        ? null
        : formatFinancialAmount(DbLedgerRecord.debit_foreign_amount1, DbLedgerRecord.currency),
    debitAmount2: DbLedgerRecord =>
      DbLedgerRecord.debit_foreign_amount2 == null
        ? null
        : formatFinancialAmount(DbLedgerRecord.debit_foreign_amount2, DbLedgerRecord.currency),
    creditAmount1: DbLedgerRecord =>
      DbLedgerRecord.credit_foreign_amount1 == null
        ? null
        : formatFinancialAmount(DbLedgerRecord.credit_foreign_amount1, DbLedgerRecord.currency),
    creditAmount2: DbLedgerRecord =>
      DbLedgerRecord.credit_foreign_amount2 == null
        ? null
        : formatFinancialAmount(DbLedgerRecord.credit_foreign_amount2, DbLedgerRecord.currency),
    localCurrencyDebitAmount1: async (DbLedgerRecord, _, { injector }) => {
      const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
      return formatFinancialAmount(DbLedgerRecord.debit_local_amount1, defaultLocalCurrency);
    },
    localCurrencyDebitAmount2: async (DbLedgerRecord, _, { injector }) => {
      const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
      return DbLedgerRecord.debit_local_amount2 == null
        ? null
        : formatFinancialAmount(DbLedgerRecord.debit_local_amount2, defaultLocalCurrency);
    },
    localCurrencyCreditAmount1: async (DbLedgerRecord, _, { injector }) => {
      const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
      return formatFinancialAmount(DbLedgerRecord.credit_local_amount1, defaultLocalCurrency);
    },
    localCurrencyCreditAmount2: async (DbLedgerRecord, _, { injector }) => {
      const defaultLocalCurrency = await recordLocalCurrency(injector, DbLedgerRecord.owner_id);
      return DbLedgerRecord.credit_local_amount2 == null
        ? null
        : formatFinancialAmount(DbLedgerRecord.credit_local_amount2, defaultLocalCurrency);
    },
    invoiceDate: DbLedgerRecord => DbLedgerRecord.invoice_date,
    valueDate: DbLedgerRecord => DbLedgerRecord.value_date,
    description: DbLedgerRecord => DbLedgerRecord.description ?? null,
    reference: DbLedgerRecord => DbLedgerRecord.reference1 ?? null,
  },
  Ledger: {
    records: parent => parent.records,
    balance: async (parent, _, { injector }) => {
      if (parent.balance) {
        return parent.balance;
      }
      const { ownerId, defaultLocalCurrency } = await injector
        .get(AdminContextProvider)
        .getVerifiedAdminContext();

      const financialEntitiesIds = new Set<string>();
      parent.records.map(record => {
        if (record.debit_entity1) {
          financialEntitiesIds.add(record.debit_entity1);
        }
        if (record.debit_entity2) {
          financialEntitiesIds.add(record.debit_entity2);
        }
        if (record.credit_entity1) {
          financialEntitiesIds.add(record.credit_entity1);
        }
        if (record.credit_entity2) {
          financialEntitiesIds.add(record.credit_entity2);
        }
      });

      const financialEntitiesPromise = await injector
        .get(FinancialEntitiesProvider)
        .getFinancialEntityByIdLoader.loadMany(Array.from(financialEntitiesIds))
        .then(
          res =>
            res.filter(e => !!e && !(e instanceof Error)) as IGetFinancialEntitiesByIdsResult[],
        );
      const allowedUnbalancedBusinessesPromise = ledgerUnbalancedBusinessesByCharge(
        parent.charge,
        injector,
      );

      const [financialEntities, allowedUnbalancedBusinesses] = await Promise.all([
        financialEntitiesPromise,
        allowedUnbalancedBusinessesPromise,
      ]);

      const ledgerBalance = new Map<string, { amount: number; entityId: string }>();
      const ledgerEntries = parent.records.map(record =>
        convertLedgerRecordToProto(record, ownerId),
      );

      for (const ledgerEntry of ledgerEntries) {
        updateLedgerBalanceByEntry(ledgerEntry, ledgerBalance, defaultLocalCurrency);
      }

      return getLedgerBalanceInfo(
        injector,
        ledgerBalance,
        undefined,
        allowedUnbalancedBusinesses,
        financialEntities,
      );
    },
    validate: async ({ charge, records }, _, context, info) => {
      const { injector } = context;
      const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
      if (await isChargeLocked(charge, injector, ledgerLock)) {
        return {
          isValid: true,
          differences: [],
          matches: records.map(r => r.id),
          errors: [],
        };
      }
      try {
        const generated = await ledgerGenerationByCharge(
          charge,
          { insertLedgerRecordsIfNotExists: records.length === 0 },
          context,
          info,
        );
        if (!generated || 'message' in generated) {
          return {
            isValid: false,
            differences: [],
            matches: [],
            errors: [],
          };
        }

        const newRecords = generated.records as IGetLedgerRecordsByChargesIdsResult[];

        const fullMatching = ledgerRecordsGenerationFullMatchComparison(records, newRecords);

        if (fullMatching.isFullyMatched) {
          return {
            isValid: true,
            differences: [],
            matches: Array.from(fullMatching.fullMatches.values()).filter(Boolean) as string[],
            errors: generated.errors,
          };
        }

        const { toUpdate } = ledgerRecordsGenerationPartialMatchComparison(
          fullMatching.unmatchedStorageRecords,
          fullMatching.unmatchedNewRecords,
        );

        return {
          isValid: fullMatching.isFullyMatched && generated.errors.length === 0,
          differences: toUpdate,
          matches: Array.from(fullMatching.fullMatches.values()).filter(Boolean) as string[],
          errors: generated.errors,
        };
      } catch (err) {
        console.error(err);
        return {
          isValid: false,
          differences: [],
          matches: [],
          errors: [],
        };
      }
    },
  },
  LedgerBalanceUnbalancedEntity: {
    entity: (parent, _, { injector }) =>
      injector
        .get(FinancialEntitiesProvider)
        .getFinancialEntityByIdLoader.load(parent.entityId)
        .then(res => {
          if (!res) {
            throw new GraphQLError(`Financial entity with id ${parent.entityId} not found`);
          }
          return res;
        }),
    balance: parent => parent.balance,
  },
  CommonCharge: {
    ...commonChargeLedgerResolver,
  },
  FinancialCharge: {
    ...commonChargeLedgerResolver,
  },
  ConversionCharge: {
    ...commonChargeLedgerResolver,
  },
  SalaryCharge: {
    ...commonChargeLedgerResolver,
  },
  InternalTransferCharge: {
    ...commonChargeLedgerResolver,
  },
  DividendCharge: {
    ...commonChargeLedgerResolver,
  },
  BusinessTripCharge: {
    ...commonChargeLedgerResolver,
  },
  MonthlyVatCharge: {
    ...commonChargeLedgerResolver,
  },
  BankDepositCharge: {
    ...commonChargeLedgerResolver,
  },
  ForeignSecuritiesCharge: {
    ...commonChargeLedgerResolver,
  },
  CreditcardBankCharge: {
    ...commonChargeLedgerResolver,
  },
  GeneratedLedgerRecords: {
    __resolveType: (obj, _context, _info) => {
      if ('__typename' in obj && obj.__typename === 'CommonError') return 'CommonError';
      return 'Ledger';
    },
  },
  ChargeMetadata: {
    isLedgerLocked: async (DbCharge, _, { injector }) => {
      const { ledgerLock } = await injector.get(AdminContextProvider).getVerifiedAdminContext();
      return isChargeLocked(DbCharge, injector, ledgerLock);
    },
  },
};
