import { Test, type TestingModule } from '@nestjs/testing';
import {
  BadRequestException,
  ForbiddenException,
  NotFoundException,
} from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ClsService } from 'nestjs-cls';
import { ServicePaymentCollectionService } from './service-payment-collection.service';
import { ServicePaymentCollectionRepository } from './repositories/service-payment-collection.repository';
import { ServicePaymentCollectionConfirmationRepository } from './repositories/service-payment-collection-confirmation.repository';
import { CollectionAnalyticsRepository } from './repositories/collection-analytics.repository';
import { BookingEntity } from '../../entities/booking.entity';
import { QueryEntity } from '../../entities/query.entity';
import { BookingTransportItemEntity } from '../../entities/booking-transport-item.entity';
import { BookingActivityItemEntity } from '../../entities/booking-activity-item.entity';
import { BookingSpecialItemEntity } from '../../entities/booking-special-item.entity';
import { PaymentSettlementEntity } from '../../entities/payment-settlement.entity';
import { SettlementPaymentEntity } from '../../entities/settlement-payment.entity';
import { PaymentSettlementService } from '../payment-settlement/payment-settlement.service';
import { AuditService } from '../audit/audit.service';
import { PermissionCheckService } from '../../common/permissions/permission-check.service';
import { AccessLevel, ModuleName } from '../../entities/role-permission.entity';

/**
 * ServicePaymentCollectionService — unit tests.
 *
 * Covers the state-transition surface + the two behaviours that most
 * often break in ledger integrations:
 *   1. Amend flow voids the prior confirmation BEFORE writing the new
 *      settlement_payment (order-of-operations guard).
 *   2. Perm gate escalation — editing a COLLECTED row + amending a
 *      confirmed row both require :DELETE, not :EDIT.
 *
 * Every test asserts via mocks; no DB. Follows the same
 * Test.createTestingModule + jest.Mock pattern as
 * payment-settlement.service.spec.ts. Tenant scoping is trusted to
 * TenantAwareRepository (already covered elsewhere) — no cross-tenant
 * flip test here since the repos are mocked.
 */
describe('ServicePaymentCollectionService', () => {
  let service: ServicePaymentCollectionService;
  let collections: {
    findById: jest.Mock;
    findByIdWithDetail: jest.Mock;
    findAllForBooking: jest.Mock;
    findLiveByItemSupplier: jest.Mock;
    find: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
    softDelete: jest.Mock;
  };
  let confirmations: {
    findLatestLive: jest.Mock;
    findAllForCollection: jest.Mock;
    save: jest.Mock;
    update: jest.Mock;
    find: jest.Mock;
  };
  let bookingRepo: { findOne: jest.Mock };
  let queryRepo: { findOne: jest.Mock };
  let transportRepo: { findOne: jest.Mock; manager: { query: jest.Mock } };
  let activityRepo: { findOne: jest.Mock };
  let specialRepo: { findOne: jest.Mock };
  let settlementRepo: { find: jest.Mock };
  let settlementPaymentRepo: { findOne: jest.Mock };
  let settlementsService: {
    collectPayment: jest.Mock;
    voidPayment: jest.Mock;
  };
  let audit: { log: jest.Mock; diffAndLog: jest.Mock };
  let perms: { hasAtLeast: jest.Mock };
  let cls: { get: jest.Mock };

  const USER = 'user-1';
  const BOOKING = 'booking-1';
  const CURRENCY = 'currency-1';
  const TRANSPORT_ITEM = 'transport-1';
  const JUNCTION = 'junction-1';
  const COLLECTION = 'collection-1';

  beforeEach(async () => {
    collections = {
      findById: jest.fn(),
      findByIdWithDetail: jest.fn(),
      findAllForBooking: jest.fn(),
      findLiveByItemSupplier: jest.fn().mockResolvedValue(null),
      find: jest.fn().mockResolvedValue([]),
      save: jest.fn().mockImplementation(async (row) => ({ id: COLLECTION, ...row })),
      update: jest.fn().mockResolvedValue(undefined),
      softDelete: jest.fn(),
    };
    confirmations = {
      findLatestLive: jest.fn().mockResolvedValue(null),
      findAllForCollection: jest.fn().mockResolvedValue([]),
      save: jest.fn().mockImplementation(async (row) => ({ id: 'conf-1', ...row })),
      update: jest.fn().mockResolvedValue(undefined),
      find: jest.fn().mockResolvedValue([]),
    };
    bookingRepo = {
      findOne: jest.fn().mockResolvedValue({ id: BOOKING, query_id: 'query-1' }),
    };
    queryRepo = {
      findOne: jest
        .fn()
        .mockResolvedValue({ id: 'query-1', guest_mobile: '+971555000001', guest_name: 'Guest' }),
    };
    transportRepo = {
      findOne: jest.fn().mockResolvedValue({ id: TRANSPORT_ITEM, booking_id: BOOKING }),
      manager: {
        query: jest.fn().mockResolvedValue([{ id: JUNCTION }]),
      },
    };
    activityRepo = { findOne: jest.fn() };
    specialRepo = { findOne: jest.fn() };
    settlementRepo = { find: jest.fn().mockResolvedValue([]) };
    settlementPaymentRepo = { findOne: jest.fn() };
    settlementsService = {
      collectPayment: jest.fn().mockResolvedValue({
        collection: { id: 'pc-1' },
        payments: [{ id: 'sp-1' }],
      }),
      voidPayment: jest.fn().mockResolvedValue(undefined),
    };
    audit = { log: jest.fn().mockResolvedValue(null), diffAndLog: jest.fn().mockResolvedValue(null) };
    perms = { hasAtLeast: jest.fn().mockResolvedValue(true) };
    cls = { get: jest.fn().mockReturnValue(USER) };

    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ServicePaymentCollectionService,
        { provide: ServicePaymentCollectionRepository, useValue: collections },
        {
          provide: ServicePaymentCollectionConfirmationRepository,
          useValue: confirmations,
        },
        { provide: getRepositoryToken(BookingEntity), useValue: bookingRepo },
        { provide: getRepositoryToken(QueryEntity), useValue: queryRepo },
        {
          provide: getRepositoryToken(BookingTransportItemEntity),
          useValue: transportRepo,
        },
        {
          provide: getRepositoryToken(BookingActivityItemEntity),
          useValue: activityRepo,
        },
        {
          provide: getRepositoryToken(BookingSpecialItemEntity),
          useValue: specialRepo,
        },
        {
          provide: getRepositoryToken(PaymentSettlementEntity),
          useValue: settlementRepo,
        },
        {
          provide: getRepositoryToken(SettlementPaymentEntity),
          useValue: settlementPaymentRepo,
        },
        {
          provide: CollectionAnalyticsRepository,
          useValue: { bySupplier: jest.fn(), supplierCollectionDrivers: jest.fn() },
        },
        { provide: PaymentSettlementService, useValue: settlementsService },
        { provide: AuditService, useValue: audit },
        { provide: PermissionCheckService, useValue: perms },
        { provide: ClsService, useValue: cls },
      ],
    }).compile();

    service = module.get(ServicePaymentCollectionService);
  });

  afterEach(() => jest.clearAllMocks());

  // ── setRequired ─────────────────────────────────────────────────

  describe('setRequired', () => {
    it('creates a PENDING requirement for a transport item', async () => {
      const dto = {
        service_type: 'TRANSPORT' as const,
        transport_item_id: TRANSPORT_ITEM,
        expected_amount: 200,
        currency_id: CURRENCY,
      };
      const result = await service.setRequired(dto);
      expect(perms.hasAtLeast).toHaveBeenCalledWith(
        ModuleName.PAYMENT_COLLECTION,
        AccessLevel.EDIT,
      );
      expect(collections.save).toHaveBeenCalledWith(
        expect.objectContaining({
          booking_id: BOOKING,
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          transport_item_supplier_id: JUNCTION,
          expected_amount: 200,
          status: 'PENDING',
        }),
      );
      expect(audit.log).toHaveBeenCalledWith(
        'service_payment_collection',
        expect.any(String),
        'created',
        expect.any(Array),
      );
      expect(result.id).toBe(COLLECTION);
    });

    it('rejects when no supplier is assigned on the service item', async () => {
      transportRepo.manager.query.mockResolvedValueOnce([]);
      await expect(
        service.setRequired({
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          expected_amount: 100,
          currency_id: CURRENCY,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(collections.save).not.toHaveBeenCalled();
    });

    it('scopes a requirement to an extra-supplier junction independently of the primary', async () => {
      transportRepo.manager.query.mockResolvedValueOnce([
        { id: 'junction-extra', sort_order: 1 },
      ]);
      await service.setRequired({
        service_type: 'TRANSPORT',
        transport_item_id: TRANSPORT_ITEM,
        transport_item_supplier_id: 'junction-extra',
        expected_amount: 50,
        currency_id: CURRENCY,
      });
      expect(collections.findLiveByItemSupplier).toHaveBeenCalledWith(
        'TRANSPORT',
        'junction-extra',
      );
      expect(collections.save).toHaveBeenCalledWith(
        expect.objectContaining({
          transport_item_supplier_id: 'junction-extra',
          expected_amount: 50,
        }),
      );
    });

    it('rejects when the supplier already has a live requirement', async () => {
      collections.findLiveByItemSupplier.mockResolvedValueOnce({ id: 'existing' });
      await expect(
        service.setRequired({
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          expected_amount: 100,
          currency_id: CURRENCY,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
      expect(collections.save).not.toHaveBeenCalled();
    });

    it('rejects when the user lacks PAYMENT_COLLECTION:EDIT', async () => {
      perms.hasAtLeast.mockResolvedValue(false);
      await expect(
        service.setRequired({
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          expected_amount: 100,
          currency_id: CURRENCY,
        }),
      ).rejects.toBeInstanceOf(ForbiddenException);
    });

    it('rejects when expected_amount is zero', async () => {
      await expect(
        service.setRequired({
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          expected_amount: 0,
          currency_id: CURRENCY,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('rejects when the item type does not exist', async () => {
      transportRepo.findOne.mockResolvedValueOnce(null);
      await expect(
        service.setRequired({
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          expected_amount: 100,
          currency_id: CURRENCY,
        }),
      ).rejects.toBeInstanceOf(NotFoundException);
    });

    it('rejects when multiple item FKs are set', async () => {
      await expect(
        service.setRequired({
          service_type: 'TRANSPORT',
          transport_item_id: TRANSPORT_ITEM,
          activity_item_id: 'a-1',
          expected_amount: 100,
          currency_id: CURRENCY,
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });
  });

  // ── update ──────────────────────────────────────────────────────

  describe('update', () => {
    it('rejects updates on a CANCELLED row', async () => {
      collections.findById.mockResolvedValue({
        id: COLLECTION,
        status: 'CANCELLED',
        expected_amount: 100,
        currency_id: CURRENCY,
        notes: null,
      });
      await expect(
        service.update(COLLECTION, { expected_amount: 200 }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('requires :DELETE when editing a COLLECTED row', async () => {
      collections.findById.mockResolvedValue({
        id: COLLECTION,
        status: 'COLLECTED',
        expected_amount: 100,
        currency_id: CURRENCY,
        notes: null,
      });
      perms.hasAtLeast.mockResolvedValue(false);
      await expect(
        service.update(COLLECTION, { expected_amount: 150 }),
      ).rejects.toBeInstanceOf(ForbiddenException);
      expect(perms.hasAtLeast).toHaveBeenCalledWith(
        ModuleName.PAYMENT_COLLECTION,
        AccessLevel.DELETE,
      );
    });

    it('applies a diff-audit entry on real changes', async () => {
      collections.findById
        .mockResolvedValueOnce({
          id: COLLECTION,
          status: 'PENDING',
          expected_amount: 100,
          currency_id: CURRENCY,
          notes: null,
        })
        .mockResolvedValueOnce({
          id: COLLECTION,
          status: 'PENDING',
          expected_amount: 200,
          currency_id: CURRENCY,
          notes: null,
        });
      await service.update(COLLECTION, { expected_amount: 200 });
      expect(collections.update).toHaveBeenCalledWith(
        COLLECTION,
        expect.objectContaining({ expected_amount: 200 }),
      );
      expect(audit.diffAndLog).toHaveBeenCalled();
    });
  });

  // ── cancel ──────────────────────────────────────────────────────

  describe('cancel', () => {
    it('flips a PENDING requirement to CANCELLED', async () => {
      collections.findById
        .mockResolvedValueOnce({
          id: COLLECTION,
          status: 'PENDING',
          expected_amount: 100,
        })
        .mockResolvedValueOnce({
          id: COLLECTION,
          status: 'CANCELLED',
        });
      await service.cancel(COLLECTION);
      expect(collections.update).toHaveBeenCalledWith(
        COLLECTION,
        expect.objectContaining({ status: 'CANCELLED' }),
      );
      expect(audit.log).toHaveBeenCalledWith(
        'service_payment_collection',
        COLLECTION,
        'cancelled',
        null,
      );
    });

    it('rejects cancel when a live confirmation exists', async () => {
      collections.findById.mockResolvedValue({
        id: COLLECTION,
        status: 'PENDING',
      });
      confirmations.findLatestLive.mockResolvedValueOnce({ id: 'conf-live' });
      await expect(service.cancel(COLLECTION)).rejects.toBeInstanceOf(
        BadRequestException,
      );
    });
  });

  // ── confirm ─────────────────────────────────────────────────────

  describe('confirm', () => {
    const baseCollection = {
      id: COLLECTION,
      booking_id: BOOKING,
      service_type: 'TRANSPORT' as const,
      status: 'PENDING',
      expected_amount: 200,
      currency_id: CURRENCY,
      notes: null,
    };

    it('confirms a matching amount and links the settlement_payment', async () => {
      collections.findById.mockResolvedValue(baseCollection);
      const result = await service.confirm(COLLECTION, {
        actual_amount: 200,
        collected_at: '2026-07-13T10:00:00Z',
      });
      expect(settlementsService.collectPayment).toHaveBeenCalledWith(
        expect.objectContaining({
          customer_mobile: '+971555000001',
          received_amount: 200,
          currency_id: CURRENCY,
        }),
      );
      expect(confirmations.save).toHaveBeenCalledWith(
        expect.objectContaining({
          collection_id: COLLECTION,
          actual_amount: 200,
          collected_by: USER,
          settlement_payment_id: 'sp-1',
        }),
      );
      expect(collections.update).toHaveBeenCalledWith(
        COLLECTION,
        expect.objectContaining({ status: 'COLLECTED' }),
      );
      expect(result).toBeTruthy();
    });

    it('requires difference_reason when actual ≠ expected', async () => {
      collections.findById.mockResolvedValue(baseCollection);
      await expect(
        service.confirm(COLLECTION, {
          actual_amount: 150,
          collected_at: '2026-07-13T10:00:00Z',
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('accepts a delta when difference_reason is supplied', async () => {
      collections.findById.mockResolvedValue(baseCollection);
      await service.confirm(COLLECTION, {
        actual_amount: 150,
        collected_at: '2026-07-13T10:00:00Z',
        difference_reason: 'Guest tipped short — dispute',
      });
      expect(confirmations.save).toHaveBeenCalledWith(
        expect.objectContaining({
          difference_reason: 'Guest tipped short — dispute',
        }),
      );
    });

    it('voids prior confirmation BEFORE writing the new ledger row (amend flow)', async () => {
      collections.findById.mockResolvedValue(baseCollection);
      confirmations.findLatestLive.mockResolvedValue({
        id: 'conf-old',
        settlement_payment_id: 'sp-old',
      });
      // Track call order between the void and the new ledger write.
      const callOrder: string[] = [];
      settlementsService.voidPayment.mockImplementation(async () => {
        callOrder.push('void');
      });
      settlementsService.collectPayment.mockImplementation(async () => {
        callOrder.push('collect');
        return { collection: { id: 'pc-new' }, payments: [{ id: 'sp-new' }] };
      });
      await service.confirm(COLLECTION, {
        actual_amount: 200,
        collected_at: '2026-07-13T10:00:00Z',
      });
      expect(callOrder).toEqual(['void', 'collect']);
      // The prior confirmation must be voided in our table too.
      expect(confirmations.update).toHaveBeenCalledWith(
        'conf-old',
        expect.objectContaining({
          voided_at: expect.any(Date),
          voided_by: USER,
        }),
      );
    });

    it('escalates to :DELETE when amending a confirmed row', async () => {
      collections.findById.mockResolvedValue(baseCollection);
      confirmations.findLatestLive.mockResolvedValue({
        id: 'conf-old',
        settlement_payment_id: null,
      });
      // First hasAtLeast fires with EDIT during a regular flow; the
      // amend path should call with DELETE. Fail the DELETE check to
      // prove the escalation.
      perms.hasAtLeast.mockImplementation(async (_module, level) => {
        return level !== AccessLevel.DELETE;
      });
      await expect(
        service.confirm(COLLECTION, {
          actual_amount: 200,
          collected_at: '2026-07-13T10:00:00Z',
        }),
      ).rejects.toBeInstanceOf(ForbiddenException);
    });

    it('records the confirmation even when the ledger write fails (audits the miss)', async () => {
      collections.findById.mockResolvedValue(baseCollection);
      settlementsService.collectPayment.mockRejectedValueOnce(
        new Error('receipt allocator busy'),
      );
      const result = await service.confirm(COLLECTION, {
        actual_amount: 200,
        collected_at: '2026-07-13T10:00:00Z',
      });
      // Confirmation still lands; ledger link is null.
      expect(confirmations.save).toHaveBeenCalledWith(
        expect.objectContaining({ settlement_payment_id: null }),
      );
      expect(audit.log).toHaveBeenCalledWith(
        'service_payment_collection',
        COLLECTION,
        'ledger_write_failed',
        expect.any(Array),
      );
      expect(result).toBeTruthy();
    });

    it('rejects confirm on a CANCELLED collection', async () => {
      collections.findById.mockResolvedValue({
        ...baseCollection,
        status: 'CANCELLED',
      });
      await expect(
        service.confirm(COLLECTION, {
          actual_amount: 200,
          collected_at: '2026-07-13T10:00:00Z',
        }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });
  });

  // ── voidLatestConfirmation ──────────────────────────────────────

  describe('voidLatestConfirmation', () => {
    it('requires :DELETE', async () => {
      perms.hasAtLeast.mockResolvedValue(false);
      await expect(
        service.voidLatestConfirmation(COLLECTION, { reason: 'x' }),
      ).rejects.toBeInstanceOf(ForbiddenException);
      expect(perms.hasAtLeast).toHaveBeenCalledWith(
        ModuleName.PAYMENT_COLLECTION,
        AccessLevel.DELETE,
      );
    });

    it('rejects when there is no live confirmation to void', async () => {
      collections.findById.mockResolvedValue({ id: COLLECTION, status: 'PENDING' });
      confirmations.findLatestLive.mockResolvedValue(null);
      await expect(
        service.voidLatestConfirmation(COLLECTION, { reason: 'x' }),
      ).rejects.toBeInstanceOf(BadRequestException);
    });

    it('marks the confirmation voided, refunds the ledger, and flips to PENDING', async () => {
      collections.findById.mockResolvedValue({
        id: COLLECTION,
        status: 'COLLECTED',
      });
      confirmations.findLatestLive.mockResolvedValue({
        id: 'conf-live',
        settlement_payment_id: 'sp-1',
        notes: null,
      });
      await service.voidLatestConfirmation(COLLECTION, { reason: 'Recorded twice' });
      expect(confirmations.update).toHaveBeenCalledWith(
        'conf-live',
        expect.objectContaining({ voided_at: expect.any(Date), voided_by: USER }),
      );
      expect(settlementsService.voidPayment).toHaveBeenCalledWith(
        'sp-1',
        'Recorded twice',
      );
      expect(collections.update).toHaveBeenCalledWith(
        COLLECTION,
        expect.objectContaining({ status: 'PENDING' }),
      );
    });
  });
});
