import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Injector } from 'graphql-modules';
import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js';
import { ChargesProvider } from '../../charges/providers/charges.provider.js';
import { BY_SCORE_EVALUATION_CAP } from '../helpers/awaiting-match-queue.helper.js';
import { QueueMatchEvaluatorProvider } from '../providers/queue-match-evaluator.provider.js';
import { chargesAwaitingMatchQueueResolver } from '../resolvers/charges-awaiting-match-queue.resolver.js';
import type { ChargeMatchProto } from '../types.js';

const OWNER_ID = 'owner-1';

/**
 * Minimal enriched charge row, as returned by ChargesProvider.getChargesByFilters.
 * Count aggregates are int8 columns, surfaced as strings.
 */
function makeCharge(
  id: string,
  {
    transactions = 0,
    invoices = 0,
    receipts = 0,
    type = null,
  }: {
    transactions?: number;
    invoices?: number;
    receipts?: number;
    type?: string | null;
  } = {},
) {
  return {
    id,
    owner_id: OWNER_ID,
    type,
    transactions_count: transactions ? String(transactions) : null,
    invoices_count: invoices ? String(invoices) : null,
    receipts_count: receipts ? String(receipts) : null,
    documents_count: invoices + receipts ? String(invoices + receipts) : null,
  };
}

const txCharge = (id: string) => makeCharge(id, { transactions: 1 });
const docCharge = (id: string) => makeCharge(id, { invoices: 1 });

type ResolverFn = (
  parent: unknown,
  args: Record<string, unknown>,
  context: { injector: Injector },
  info: unknown,
) => Promise<{ baseCharges: { baseCharge: { id: string }; suggestions: ChargeMatchProto[] }[]; totalCount: number }>;

const resolve = chargesAwaitingMatchQueueResolver.Query!
  .chargesAwaitingMatchQueue as unknown as ResolverFn;

function createTestContext({
  charges,
  suggestionsById = {},
}: {
  charges: ReturnType<typeof makeCharge>[];
  suggestionsById?: Record<string, ChargeMatchProto[]>;
}) {
  const getChargesByFilters = vi.fn(async () => charges);
  const evaluateMatchesForCharges = vi.fn(async (baseCharges: { id: string }[]) =>
    baseCharges.map(baseCharge => ({
      baseCharge,
      suggestions: suggestionsById[baseCharge.id] ?? [],
    })),
  );

  const injector = {
    get: vi.fn((token: unknown) => {
      if (token === AdminContextProvider) {
        return { getVerifiedAdminContext: async () => ({ ownerId: OWNER_ID }) };
      }
      if (token === ChargesProvider) {
        return { getChargesByFilters };
      }
      if (token === QueueMatchEvaluatorProvider) {
        return { evaluateMatchesForCharges };
      }
      throw new Error(`Unexpected token requested from injector`);
    }),
  } as unknown as Injector;

  return { injector, getChargesByFilters, evaluateMatchesForCharges };
}

const baseArgs = {
  limit: 20,
  offset: 0,
  businessId: null,
  fromDate: null,
  toDate: null,
  mode: null,
  sortBy: 'BY_DATE',
};

describe('chargesAwaitingMatchQueue resolver', () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  describe('shared filtering', () => {
    it('should scope the DB fetch to the verified admin owner and pass filters through', async () => {
      const { injector, getChargesByFilters } = createTestContext({ charges: [] });

      await resolve(
        null,
        {
          ...baseArgs,
          businessId: 'business-7',
          fromDate: '2024-01-01',
          toDate: '2024-12-31',
        },
        { injector },
        null,
      );

      expect(getChargesByFilters).toHaveBeenCalledWith(
        expect.objectContaining({
          ownerIds: [OWNER_ID],
          businessIds: ['business-7'],
          fromDate: '2024-01-01',
          toDate: '2024-12-31',
        }),
      );
    });

    it('should include only unmatched charges (tx-only or doc-only) in the queue', async () => {
      const { injector } = createTestContext({
        charges: [
          txCharge('tx-1'),
          makeCharge('matched-1', { transactions: 1, receipts: 1 }),
          docCharge('doc-1'),
          makeCharge('empty-1'),
        ],
      });

      const result = await resolve(null, baseArgs, { injector }, null);

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-1', 'doc-1']);
      expect(result.totalCount).toBe(2);
    });

    it('should exclude base-charges of types that never require a document match', async () => {
      const nonMatchableTypes = [
        'BANK_DEPOSIT',
        'CREDITCARD_BANK',
        'DIVIDEND',
        'FOREIGN_SECURITIES',
        'INTERNAL',
        'VAT',
        'PAYROLL',
        'CONVERSION',
        'FINANCIAL',
      ];
      const { injector } = createTestContext({
        charges: [
          ...nonMatchableTypes.map((type, i) => makeCharge(`skip-${i}`, { transactions: 1, type })),
          txCharge('tx-1'),
          docCharge('doc-1'),
        ],
      });

      const result = await resolve(null, baseArgs, { injector }, null);

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-1', 'doc-1']);
      expect(result.totalCount).toBe(2);
    });

    it('should include COMMON, BUSINESS_TRIP and untyped base-charges', async () => {
      const { injector } = createTestContext({
        charges: [
          makeCharge('common-1', { transactions: 1, type: 'COMMON' }),
          makeCharge('trip-1', { invoices: 1, type: 'BUSINESS_TRIP' }),
          txCharge('untyped-1'),
        ],
      });

      const result = await resolve(null, baseArgs, { injector }, null);

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([
        'common-1',
        'trip-1',
        'untyped-1',
      ]);
      expect(result.totalCount).toBe(3);
    });

    it('should treat a transaction charge with invoice-only documents as unmatched', async () => {
      const { injector } = createTestContext({
        charges: [makeCharge('tx-with-invoice', { transactions: 1, invoices: 1 })],
      });

      const result = await resolve(null, baseArgs, { injector }, null);

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-with-invoice']);
    });

    it('should filter by DOC_BASE mode', async () => {
      const { injector } = createTestContext({
        charges: [txCharge('tx-1'), docCharge('doc-1'), docCharge('doc-2')],
      });

      const result = await resolve(null, { ...baseArgs, mode: 'DOC_BASE' }, { injector }, null);

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['doc-1', 'doc-2']);
      expect(result.totalCount).toBe(2);
    });

    it('should filter by TRANSACTION_BASE mode', async () => {
      const { injector } = createTestContext({
        charges: [txCharge('tx-1'), docCharge('doc-1'), txCharge('tx-2')],
      });

      const result = await resolve(
        null,
        { ...baseArgs, mode: 'TRANSACTION_BASE' },
        { injector },
        null,
      );

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['tx-1', 'tx-2']);
    });
  });

  describe('BY_DATE path', () => {
    it('should evaluate scores only for the requested page (limit + offset applied before scoring)', async () => {
      const charges = Array.from({ length: 10 }, (_, i) => txCharge(`charge-${i}`));
      const { injector, evaluateMatchesForCharges } = createTestContext({ charges });

      const result = await resolve(null, { ...baseArgs, limit: 3, offset: 4 }, { injector }, null);

      expect(evaluateMatchesForCharges).toHaveBeenCalledTimes(1);
      expect(
        (evaluateMatchesForCharges.mock.calls[0][0] as { id: string }[]).map(c => c.id),
      ).toEqual(['charge-4', 'charge-5', 'charge-6']);
      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([
        'charge-4',
        'charge-5',
        'charge-6',
      ]);
      expect(result.totalCount).toBe(10);
    });

    it('should return different rows for subsequent pages', async () => {
      const charges = Array.from({ length: 6 }, (_, i) => txCharge(`charge-${i}`));
      const { injector } = createTestContext({ charges });

      const page1 = await resolve(null, { ...baseArgs, limit: 2, offset: 0 }, { injector }, null);
      const page2 = await resolve(null, { ...baseArgs, limit: 2, offset: 2 }, { injector }, null);

      expect(page1.baseCharges.map(c => c.baseCharge.id)).toEqual(['charge-0', 'charge-1']);
      expect(page2.baseCharges.map(c => c.baseCharge.id)).toEqual(['charge-2', 'charge-3']);
    });

    it('should return an empty page when offset exceeds the queue length', async () => {
      const { injector, evaluateMatchesForCharges } = createTestContext({
        charges: [txCharge('charge-0')],
      });

      const result = await resolve(null, { ...baseArgs, limit: 5, offset: 10 }, { injector }, null);

      expect(result.baseCharges).toEqual([]);
      expect(result.totalCount).toBe(1);
      expect(evaluateMatchesForCharges).toHaveBeenCalledWith([]);
    });
  });

  describe('BY_SCORE path', () => {
    it('should cap the evaluated window, sort by top suggestion score desc, then slice the page', async () => {
      const charges = Array.from({ length: BY_SCORE_EVALUATION_CAP + 50 }, (_, i) =>
        txCharge(`charge-${i}`),
      );
      const suggestionsById: Record<string, ChargeMatchProto[]> = {
        'charge-0': [{ chargeId: 'm-0', confidenceScore: 0.5 }],
        'charge-1': [{ chargeId: 'm-1', confidenceScore: 0.99 }],
        'charge-2': [{ chargeId: 'm-2', confidenceScore: 0.75 }],
      };
      const { injector, evaluateMatchesForCharges } = createTestContext({
        charges,
        suggestionsById,
      });

      const result = await resolve(
        null,
        { ...baseArgs, sortBy: 'BY_SCORE', limit: 3, offset: 0 },
        { injector },
        null,
      );

      // Evaluated exactly the capped window, in one call
      expect(evaluateMatchesForCharges).toHaveBeenCalledTimes(1);
      expect(evaluateMatchesForCharges.mock.calls[0][0]).toHaveLength(BY_SCORE_EVALUATION_CAP);
      // Highest scoring first
      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([
        'charge-1',
        'charge-2',
        'charge-0',
      ]);
      // Pagination happens over the derived (capped) list
      expect(result.totalCount).toBe(BY_SCORE_EVALUATION_CAP);
    });

    it('should apply limit/offset AFTER sorting the whole evaluated window', async () => {
      const charges = Array.from({ length: 5 }, (_, i) => txCharge(`charge-${i}`));
      const suggestionsById: Record<string, ChargeMatchProto[]> = {
        'charge-0': [{ chargeId: 'm', confidenceScore: 0.1 }],
        'charge-1': [{ chargeId: 'm', confidenceScore: 0.9 }],
        'charge-2': [{ chargeId: 'm', confidenceScore: 0.5 }],
        'charge-3': [{ chargeId: 'm', confidenceScore: 0.7 }],
        'charge-4': [{ chargeId: 'm', confidenceScore: 0.3 }],
      };
      const { injector } = createTestContext({ charges, suggestionsById });

      const result = await resolve(
        null,
        { ...baseArgs, sortBy: 'BY_SCORE', limit: 2, offset: 2 },
        { injector },
        null,
      );

      // Sorted order: charge-1 (0.9), charge-3 (0.7), charge-2 (0.5), charge-4 (0.3), charge-0 (0.1)
      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual(['charge-2', 'charge-4']);
      expect(result.totalCount).toBe(5);
    });

    it('should rank charges without suggestions last, preserving recency order between them', async () => {
      const charges = [txCharge('charge-0'), txCharge('charge-1'), txCharge('charge-2')];
      const suggestionsById: Record<string, ChargeMatchProto[]> = {
        'charge-1': [{ chargeId: 'm', confidenceScore: 0.4 }],
      };
      const { injector } = createTestContext({ charges, suggestionsById });

      const result = await resolve(null, { ...baseArgs, sortBy: 'BY_SCORE' }, { injector }, null);

      expect(result.baseCharges.map(c => c.baseCharge.id)).toEqual([
        'charge-1',
        'charge-0',
        'charge-2',
      ]);
    });
  });
});
