/**
 * Charges Matcher Provider
 *
 * Provides database-integrated charge matching functionality using the Injector pattern.
 * Integrates with existing modules: charges, transactions, and documents.
 */

import { subYears } from 'date-fns';
import { CONTEXT, Inject, Injectable, Scope } from 'graphql-modules';
import { dateToTimelessDateString } from '../../../shared/helpers/index.js';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { mergeChargesExecutor } from '../../charges/helpers/merge-charges.helper.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import {
  isAccountingDocument,
  isInvoice,
  isReceipt,
} from '../../documents/helpers/common.helper.js';
import { DocumentsProvider } from '../../documents/providers/documents.provider.js';
import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js';
import { validateChargeIsUnmatched } from '../helpers/charge-validator.helper.js';
import {
  ChargeType,
  type AutoMatchChargesResult,
  type ChargeMatchesResult,
  type ChargeWithData,
  type DocumentCharge,
  type TransactionCharge,
} from '../types.js';
import { determineMergeDirection, processChargeForAutoMatch } from './auto-match.provider.js';
import { aggregateDocuments } from './document-aggregator.js';
import { findMatches, type MatchResult } from './single-match.provider.js';
import { aggregateTransactions } from './transaction-aggregator.js';

/**
 * Charges Matcher Provider
 *
 * Provides high-level charge matching operations with database integration.
 * Uses the Injector pattern to access existing providers from other modules.
 */
@Injectable({
  scope: Scope.Operation,
})
export class ChargesMatcherProvider {
  constructor(
    private adminContextProvider: AdminContextProvider,
    private chargesProvider: ChargesProvider,
    private transactionsProvider: TransactionsProvider,
    private documentsProvider: DocumentsProvider,
    @Inject(CONTEXT) private context: GraphQLModules.ModuleContext,
  ) {}

  /**
   * Find potential matches for an unmatched charge
   *
   * @param chargeId - ID of the unmatched charge to find matches for
   * @returns Top 5 matches ordered by confidence score
   * @throws Error if charge not found
   * @throws Error if charge is already matched
   * @throws Error if charge data is invalid
   */
  async findMatchesForCharge(chargeId: string): Promise<ChargeMatchesResult> {
    const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext();

    // Step 1: Load source charge data
    const sourceCharge = await this.chargesProvider.getChargeByIdLoader.load(chargeId);
    if (!sourceCharge || sourceCharge instanceof Error) {
      throw new Error(`Source charge not found: ${chargeId}`);
    }

    // Step 2: Load transactions and documents for source charge
    const sourceTransactions =
      await this.transactionsProvider.transactionsByChargeIDLoader.load(chargeId);
    const sourceDocuments = (
      await this.documentsProvider.getDocumentsByChargeIdLoader.load(chargeId)
    ).filter(doc => isAccountingDocument(doc.type));

    // Step 3: Validate source charge is unmatched
    const sourceChargeWithData = {
      ...sourceCharge,
      transactions: sourceTransactions,
      documents: sourceDocuments,
    };
    validateChargeIsUnmatched(sourceChargeWithData);

    // Step 4: Determine reference date and date window from source charge
    let referenceDate: Date;
    const hasTransactions = sourceTransactions && sourceTransactions.length > 0;
    if (hasTransactions) {
      // Use earliest transaction event_date
      const aggregated = aggregateTransactions(sourceTransactions);
      referenceDate = aggregated.date;
    } else {
      // Use latest document date
      const aggregated = aggregateDocuments(sourceDocuments, ownerId);
      referenceDate = aggregated.date;
    }

    // Step 5: Load candidate charges from database
    // Use 12-month window centered on reference date
    const windowStart = new Date(referenceDate);
    windowStart.setMonth(windowStart.getMonth() - 12);
    const windowEnd = new Date(referenceDate);
    windowEnd.setMonth(windowEnd.getMonth() + 12);

    const candidateCharges = await this.chargesProvider.getChargesByFilters({
      ownerIds: [ownerId],
      fromAnyDate: dateToTimelessDateString(windowStart),
      toAnyDate: dateToTimelessDateString(windowEnd),
    });

    // Step 6: Load transactions and documents for all candidate charges
    const candidateChargesWithData: Array<TransactionCharge | DocumentCharge> = [];

    await Promise.all(
      candidateCharges.map(async candidate => {
        // Skip the source charge itself
        if (candidate.id === chargeId) {
          return;
        }

        const candidateTransactionsPromise =
          this.transactionsProvider.transactionsByChargeIDLoader.load(candidate.id);
        const candidateDocumentsPromise = this.documentsProvider.getDocumentsByChargeIdLoader
          .load(candidate.id)
          .then(docs => docs.filter(doc => isAccountingDocument(doc.type)));
        const [candidateTransactions, candidateDocuments] = await Promise.all([
          candidateTransactionsPromise,
          candidateDocumentsPromise,
        ]);

        const candidateInvoiceDocuments = candidateDocuments.filter(doc => isInvoice(doc.type));
        const candidateReceiptDocuments = candidateDocuments.filter(doc => isReceipt(doc.type));

        const hasTxs = candidateTransactions && candidateTransactions.length > 0;
        const hasDocs = candidateDocuments && candidateDocuments.length > 0;
        const hasInvoiceDocs = candidateInvoiceDocuments && candidateInvoiceDocuments.length > 0;
        const hasReceiptDocs = candidateReceiptDocuments && candidateReceiptDocuments.length > 0;

        // Only include unmatched charges (not both types)
        if (hasTxs && !hasReceiptDocs) {
          candidateChargesWithData.push({
            chargeId: candidate.id,
            transactions: candidateTransactions,
          });
        }
        if (hasDocs && !hasTxs) {
          if (hasReceiptDocs) {
            candidateChargesWithData.push({
              chargeId: candidate.id,
              documents: candidateReceiptDocuments,
            });
          } else if (hasInvoiceDocs) {
            candidateChargesWithData.push({
              chargeId: candidate.id,
              documents: candidateInvoiceDocuments,
            });
          } else {
            candidateChargesWithData.push({
              chargeId: candidate.id,
              documents: candidateDocuments,
            });
          }
        }
        // Skip matched charges (have both) and empty charges (have neither)
      }),
    );

    // Step 7: Build source charge object for findMatches
    let sourceChargeData: TransactionCharge | DocumentCharge;
    if (hasTransactions) {
      sourceChargeData = {
        chargeId,
        transactions: sourceTransactions,
      };
    } else {
      sourceChargeData = {
        chargeId,
        documents: sourceDocuments,
      };
    }

    // Step 8: Call core findMatches function
    const matches: MatchResult[] = await findMatches(
      sourceChargeData,
      candidateChargesWithData,
      ownerId,
      this.context.injector,
      {
        maxMatches: 5,
        dateWindowMonths: 12,
      },
    );

    // Step 9: Format and return result
    return {
      matches: matches.map(match => ({
        chargeId: match.chargeId,
        confidenceScore: match.confidenceScore,
      })),
    };
  }

  /**
   * Auto-match all unmatched charges
   *
   * Automatically merges charges that have a single high-confidence match (≥0.95).
   * Skips charges with multiple high-confidence matches (ambiguous).
   * Processes all unmatched charges and returns a summary of actions taken.
   *
   * @returns Summary of matches made, skipped charges, and errors
   */
  async autoMatchCharges(): Promise<AutoMatchChargesResult> {
    const { ownerId } = await this.adminContextProvider.getVerifiedAdminContext();

    // Step 1: Load all charges for this user
    const prevYear = dateToTimelessDateString(subYears(new Date(), 1));
    const allCharges = await this.chargesProvider.getChargesByFilters({
      ownerIds: [ownerId],
      fromAnyDate: prevYear,
    });

    // Step 2: Load transactions and documents for all charges
    const chargesWithData: ChargeWithData[] = [];
    const mergedChargeIds = new Set<string>(); // Track merged charges to exclude from processing

    await Promise.all(
      allCharges.map(async charge => {
        const transactionsPromise = this.transactionsProvider.transactionsByChargeIDLoader.load(
          charge.id,
        );
        const documentsPromise = this.documentsProvider.getDocumentsByChargeIdLoader
          .load(charge.id)
          .then(docs => docs.filter(doc => isAccountingDocument(doc.type)));

        const [transactions, documents] = await Promise.all([
          transactionsPromise,
          documentsPromise,
        ]);

        chargesWithData.push({
          chargeId: charge.id,
          ownerId: charge.owner_id ?? ownerId,
          type: ChargeType.TRANSACTION_ONLY, // Will be determined by processChargeForAutoMatch
          description: charge.user_description ?? undefined,
          transactions,
          documents,
        });
      }),
    );

    // Step 3: Filter to get only unmatched charges
    const unmatchedCharges = chargesWithData.filter(charge => {
      const hasTx = charge.transactions && charge.transactions.length > 0;
      const hasAccountingDocs =
        charge.documents &&
        charge.documents.filter(doc => isAccountingDocument(doc.type)).length > 0;
      const hasReceipts = charge.documents?.some(doc => isReceipt(doc.type));
      return (hasTx && !hasReceipts) || (!hasTx && hasAccountingDocs);
    });

    // Step 4: Process each unmatched charge
    const result: AutoMatchChargesResult = {
      totalMatches: 0,
      mergedCharges: [],
      skippedCharges: [],
      errors: [],
    };

    for (const sourceCharge of unmatchedCharges) {
      // Skip if this charge was already merged in this run
      if (mergedChargeIds.has(sourceCharge.chargeId)) {
        continue;
      }

      try {
        // Get candidates (exclude already merged charges)
        const candidates = chargesWithData.filter(
          c => c.chargeId !== sourceCharge.chargeId && !mergedChargeIds.has(c.chargeId),
        );

        // Process this charge for auto-match
        const processResult = await processChargeForAutoMatch(
          sourceCharge,
          candidates,
          ownerId,
          this.context.injector,
        );

        if (processResult.status === 'matched' && processResult.match) {
          // Found a single high-confidence match - execute merge
          const matchedChargeId = processResult.match.chargeId;
          const matchedCharge = chargesWithData.find(c => c.chargeId === matchedChargeId);

          if (!matchedCharge) {
            result.errors.push(
              `Matched charge ${matchedChargeId} not found in charge pool for ${sourceCharge.chargeId}`,
            );
            continue;
          }

          // Determine merge direction
          const [sourceToMerge, targetToKeep] = determineMergeDirection(
            sourceCharge,
            matchedCharge,
          );

          try {
            // Execute merge via existing merge functionality
            await mergeChargesExecutor(
              [sourceToMerge.chargeId],
              targetToKeep.chargeId,
              this.context.injector,
            );

            // Track successful merge
            result.totalMatches++;
            result.mergedCharges.push({
              chargeId: targetToKeep.chargeId,
              confidenceScore: processResult.match.confidenceScore,
            });

            // Mark both charges as processed (merged away charge and kept charge)
            mergedChargeIds.add(sourceToMerge.chargeId);
            mergedChargeIds.add(targetToKeep.chargeId); // Don't process the kept charge again
          } catch (mergeError) {
            result.errors.push(
              `Failed to merge ${sourceToMerge.chargeId} into ${targetToKeep.chargeId}: ${
                mergeError instanceof Error ? mergeError.message : String(mergeError)
              }`,
            );
          }
        } else if (processResult.status === 'skipped') {
          // Multiple high-confidence matches - ambiguous
          result.skippedCharges.push(sourceCharge.chargeId);
        }
        // status === 'no-match': do nothing, silently skip
      } catch (error) {
        // Capture error but continue processing other charges
        result.errors.push(
          `Error processing charge ${sourceCharge.chargeId}: ${
            error instanceof Error ? error.message : String(error)
          }`,
        );
      }
    }

    return result;
  }
}
