import { RuleEngineService } from './rule-engine.service';
import type { BookingEvaluationInput } from './rule-engine.service';
import type { Rate } from './entities/rate.entity';

describe('RuleEngineService — requested service without a configured surcharge rate', () => {
  const hazardRate: Rate = {
    id: 'rate-hazard',
    rateType: 'HAZARD_SURCHARGE',
    trigger: 'HAZARDOUS',
    rateValue: 50,
    rateUnit: 'PER_CONTAINER',
    currency: 'USD',
    status: 'LIVE',
    containerTypeId: null,
    cargoTypeId: null,
  } as Rate;

  let ratesRepo: { findLiveRates: jest.Mock };
  let service: RuleEngineService;

  beforeEach(() => {
    ratesRepo = { findLiveRates: jest.fn().mockResolvedValue([]) };
    service = new RuleEngineService(
      { findById: jest.fn().mockResolvedValue(null) } as never, // cargoTypes
      { findById: jest.fn().mockResolvedValue(null) } as never, // serviceTypes
      { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never, // weightLimits
      { findAllActive: jest.fn().mockResolvedValue([]) } as never, // priorityConfigs
      ratesRepo as never,
      { findById: jest.fn().mockResolvedValue(null) } as never, // shippingLines
      {} as never, // dataSource (unused by evaluate)
    );
  });

  const input = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
    serviceTypeId: 'svc-1',
    paymentCurrency: 'USD',
    tradeDirection: 'IMPORT',
    isHazardous: false,
    totalWagons: 1,
    containers: [],
    ...overrides,
  });

  it('hard-blocks a hazardous booking when no HAZARDOUS surcharge rate is LIVE', async () => {
    const result = await service.evaluate(input({ isHazardous: true }));
    expect(result.hardBlocked).toHaveLength(1);
    expect(result.hardBlocked[0]).toContain('hazardous');
  });

  it('passes a hazardous booking when a HAZARDOUS surcharge rate is LIVE', async () => {
    ratesRepo.findLiveRates.mockResolvedValue([hazardRate]);
    const result = await service.evaluate(input({ isHazardous: true }));
    expect(result.hardBlocked).toHaveLength(0);
  });

  it('does not block a non-hazardous booking when no surcharge rates exist', async () => {
    const result = await service.evaluate(input({}));
    expect(result.hardBlocked).toHaveLength(0);
  });

  it('hard-blocks on per-container opt-in counts even without the booking-level flag', async () => {
    const result = await service.evaluate(
      input({
        containers: [
          {
            containerTypeId: 'ct-20',
            quantity: 2,
            vgmPerUnitTons: 10,
            totalVgmTons: 20,
            reeferQuantity: 1,
          },
        ],
      }),
    );
    expect(result.hardBlocked).toHaveLength(1);
    expect(result.hardBlocked[0]).toContain('reefer');
  });
});

describe('RuleEngineService — overweight surcharge by trade direction', () => {
  const baseImportRate: Rate = {
    id: 'rate-import-20',
    rateType: 'CONTAINER_IMPORT',
    trigger: 'ALWAYS',
    rateValue: 1000,
    rateUnit: 'PER_CONTAINER',
    currency: 'USD',
    status: 'LIVE',
    containerTypeId: 'ct-20',
    cargoTypeId: null,
    originYardId: 'yard-dj',
    destinationYardId: 'yard-adama',
  } as Rate;

  const configuredOverweight: Rate = {
    id: 'rate-ow',
    rateType: 'OVERWEIGHT_PER_TON',
    trigger: 'OVERWEIGHT',
    rateValue: 10,
    rateUnit: 'PER_TON',
    currency: 'USD',
    status: 'LIVE',
    containerTypeId: null,
    cargoTypeId: null,
  } as Rate;

  let service: RuleEngineService;

  beforeEach(() => {
    service = new RuleEngineService(
      { findById: jest.fn().mockResolvedValue(null) } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {
        findActiveByContainerTypeId: jest
          .fn()
          .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
      } as never,
      { findAllActive: jest.fn().mockResolvedValue([]) } as never,
      {
        findLiveRates: jest.fn().mockResolvedValue([baseImportRate, configuredOverweight]),
      } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {} as never,
    );
  });

  // One 20ft at 25 t against a 20 t limit → 5 t excess.
  const overweightInput = (tradeDirection: string): BookingEvaluationInput => ({
    serviceTypeId: 'svc-1',
    paymentCurrency: 'USD',
    tradeDirection,
    isHazardous: false,
    totalWagons: 1,
    originYardId: 'yard-dj',
    destinationYardId: 'yard-adama',
    containers: [
      { containerTypeId: 'ct-20', quantity: 1, vgmPerUnitTons: 25, totalVgmTons: 25 },
    ],
  });

  it('IMPORT derives the per-ton price from base freight ÷ (2 × limit), not the configured rate', async () => {
    const result = await service.evaluate(overweightInput('IMPORT'));
    const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
    expect(ow).toHaveLength(1);
    // 1000 / (2 × 20) = 25 USD/t on 5 excess tons.
    expect(ow[0].unitPriceUsd).toBe(25);
    expect(ow[0].calculatedAmount).toBe(125);
    expect(ow[0].triggerValue).toBe(5);
    expect(ow[0].rateId).toBe(baseImportRate.id);
  });

  it('EXPORT keeps billing the configured OVERWEIGHT rate', async () => {
    const result = await service.evaluate(overweightInput('EXPORT'));
    const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
    expect(ow).toHaveLength(1);
    expect(ow[0].rateId).toBe(configuredOverweight.id);
    // 5 excess tons × the configured 10 USD/t.
    expect(ow[0].calculatedAmount).toBe(50);
    expect(ow[0].unitPriceUsd).toBeUndefined();
  });

  it('IMPORT without a route-matching base rate bills no overweight (base freight blocks anyway)', async () => {
    const result = await service.evaluate({
      ...overweightInput('IMPORT'),
      destinationYardId: 'yard-elsewhere',
    });
    const ow = result.appliedModifiers.filter((m) => m.surchargeCode === 'OVERWEIGHT_PER_TON');
    expect(ow).toHaveLength(0);
  });
});

describe('RuleEngineService — empty-container return per route + container type', () => {
  const returnRate20: Rate = {
    id: 'rate-return-20',
    rateType: 'RETURN_SURCHARGE',
    trigger: 'WITH_RETURN',
    rateValue: 20,
    rateUnit: 'PER_CONTAINER',
    currency: 'USD',
    status: 'LIVE',
    containerTypeId: 'ct-20',
    cargoTypeId: null,
    tradeDirection: 'IMPORT',
    originYardId: 'yard-dj',
    destinationYardId: 'yard-adama',
  } as Rate;

  let service: RuleEngineService;

  beforeEach(() => {
    service = new RuleEngineService(
      { findById: jest.fn().mockResolvedValue(null) } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {
        findActiveByContainerTypeId: jest
          .fn()
          .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
      } as never,
      { findAllActive: jest.fn().mockResolvedValue([]) } as never,
      { findLiveRates: jest.fn().mockResolvedValue([returnRate20]) } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {} as never,
    );
  });

  const returnInput = (overrides: Partial<BookingEvaluationInput>): BookingEvaluationInput => ({
    serviceTypeId: 'svc-1',
    paymentCurrency: 'USD',
    tradeDirection: 'IMPORT',
    isHazardous: false,
    totalWagons: 1,
    originYardId: 'yard-dj',
    destinationYardId: 'yard-adama',
    containers: [
      {
        containerTypeId: 'ct-20',
        quantity: 4,
        vgmPerUnitTons: 10,
        totalVgmTons: 40,
        returnQuantity: 2,
      },
    ],
    ...overrides,
  });

  it('bills the route + type matched rate on the opted-in count', async () => {
    const result = await service.evaluate(returnInput({}));
    const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
    expect(result.hardBlocked).toHaveLength(0);
    expect(ret).toHaveLength(1);
    expect(ret[0].rateId).toBe(returnRate20.id);
    expect(ret[0].triggerValue).toBe(2);
    expect(ret[0].calculatedAmount).toBe(40);
    expect(ret[0].billingUnit).toBe('PER_CONTAINER');
  });

  it('hard-blocks when the booking route has no matching return rate', async () => {
    const result = await service.evaluate(
      returnInput({ destinationYardId: 'yard-elsewhere' }),
    );
    expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
    expect(
      result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE'),
    ).toHaveLength(0);
  });

  it('hard-blocks an EXPORT booking asking for return (rates are import-only)', async () => {
    const result = await service.evaluate(returnInput({ tradeDirection: 'EXPORT' }));
    expect(result.hardBlocked.some((m) => m.includes('return'))).toBe(true);
  });

  it('PER_WAGON bills the wagons the empties ride back on, not the boxes', async () => {
    // Same service, but the return rate is sold per wagon: 4× 20ft return =
    // 2 wagons (two 20ft share a wagon) × 20 USD, not 4 × 20.
    service = new RuleEngineService(
      { findById: jest.fn().mockResolvedValue(null) } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {
        findActiveByContainerTypeId: jest
          .fn()
          .mockResolvedValue([{ id: 'wlr-20', maxVgmTons: 20, maxCapacityTons: null }]),
      } as never,
      { findAllActive: jest.fn().mockResolvedValue([]) } as never,
      {
        findLiveRates: jest
          .fn()
          .mockResolvedValue([{ ...returnRate20, rateUnit: 'PER_WAGON' } as Rate]),
      } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {} as never,
    );

    const result = await service.evaluate(
      returnInput({
        containers: [
          {
            containerTypeId: 'ct-20',
            quantity: 4,
            vgmPerUnitTons: 10,
            totalVgmTons: 40,
            returnQuantity: 4,
            wagonsPerUnit: 0.5,
          },
        ],
      }),
    );

    const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
    expect(ret).toHaveLength(1);
    expect(ret[0].triggerValue).toBe(2);
    expect(ret[0].calculatedAmount).toBe(40);
    expect(ret[0].billingUnit).toBe('PER_WAGON');
  });

  it('legacy booking-level flag bills every container at its type rate', async () => {
    const result = await service.evaluate(
      returnInput({
        withReturn: true,
        containers: [
          { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
        ],
      }),
    );
    const ret = result.appliedModifiers.filter((m) => m.surchargeCode === 'RETURN_SURCHARGE');
    expect(ret).toHaveLength(1);
    expect(ret[0].triggerValue).toBe(4);
    expect(ret[0].calculatedAmount).toBe(80);
  });
});

describe('RuleEngineService — lashing (bulk-only, per direction + commodity)', () => {
  const lashingBulkImport: Rate = {
    id: 'rate-lash-bulk',
    rateType: 'LASHING',
    trigger: 'LASHING',
    rateValue: 2,
    rateUnit: 'PER_TON',
    currency: 'USD',
    status: 'LIVE',
    containerTypeId: null,
    cargoTypeId: null,
    tradeDirection: 'IMPORT',
    originYardId: null,
    destinationYardId: null,
  } as Rate;

  const buildService = (rates: Rate[]): RuleEngineService =>
    new RuleEngineService(
      {
        findById: jest
          .fn()
          .mockResolvedValue({ hasLashing: true, requiresDirectorApproval: false }),
      } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
      { findAllActive: jest.fn().mockResolvedValue([]) } as never,
      { findLiveRates: jest.fn().mockResolvedValue(rates) } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {} as never,
    );

  const bulkInput = (overrides: Partial<BookingEvaluationInput> = {}): BookingEvaluationInput => ({
    serviceTypeId: 'svc-1',
    paymentCurrency: 'USD',
    tradeDirection: 'IMPORT',
    isHazardous: false,
    cargoTypeId: 'cargo-sugar',
    totalWagons: 0,
    bulkTons: 100,
    bulkWagons: 3,
    containers: [],
    ...overrides,
  });

  const lashingMods = (result: Awaited<ReturnType<RuleEngineService['evaluate']>>) =>
    result.appliedModifiers.filter((m) => m.surchargeCode === 'LASHING');

  it('bulk lashing bills per ton on the direction-matched rate', async () => {
    const result = await buildService([lashingBulkImport]).evaluate(bulkInput());
    const mods = lashingMods(result);
    expect(mods).toHaveLength(1);
    expect(mods[0].triggerValue).toBe(100);
    expect(mods[0].calculatedAmount).toBe(200);
    expect(mods[0].billingUnit).toBe('PER_TON');
  });

  it('a rate for the other direction never bills', async () => {
    const result = await buildService([
      { ...lashingBulkImport, tradeDirection: 'EXPORT' } as Rate,
    ]).evaluate(bulkInput());
    expect(lashingMods(result)).toHaveLength(0);
  });

  it('PER_WAGON bulk lashing bills the wagons the bulk occupies', async () => {
    const result = await buildService([
      { ...lashingBulkImport, rateUnit: 'PER_WAGON', rateValue: 25 } as Rate,
    ]).evaluate(bulkInput());
    const mods = lashingMods(result);
    expect(mods[0].triggerValue).toBe(3);
    expect(mods[0].calculatedAmount).toBe(75);
  });

  it('the commodity-scoped rate wins over the commodity-wide catch-all', async () => {
    const result = await buildService([
      lashingBulkImport,
      { ...lashingBulkImport, id: 'rate-lash-sugar', rateValue: 7, cargoTypeId: 'cargo-sugar' } as Rate,
    ]).evaluate(bulkInput());
    const mods = lashingMods(result);
    expect(mods).toHaveLength(1);
    expect(mods[0].unitPriceUsd).toBe(7);
    expect(mods[0].calculatedAmount).toBe(700);
  });

  it('container bookings never incur lashing (bulk-only service)', async () => {
    const result = await buildService([lashingBulkImport]).evaluate(
      bulkInput({
        cargoTypeId: null,
        hasLashing: true,
        containers: [
          { containerTypeId: 'ct-20', quantity: 4, vgmPerUnitTons: 10, totalVgmTons: 40 },
        ],
      }),
    );
    expect(lashingMods(result)).toHaveLength(0);
  });

  it('no lashing charge when the cargo does not need lashing', async () => {
    const service = new RuleEngineService(
      {
        findById: jest
          .fn()
          .mockResolvedValue({ hasLashing: false, requiresDirectorApproval: false }),
      } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      { findActiveByContainerTypeId: jest.fn().mockResolvedValue([]) } as never,
      { findAllActive: jest.fn().mockResolvedValue([]) } as never,
      { findLiveRates: jest.fn().mockResolvedValue([lashingBulkImport]) } as never,
      { findById: jest.fn().mockResolvedValue(null) } as never,
      {} as never,
    );
    const result = await service.evaluate(bulkInput());
    expect(lashingMods(result)).toHaveLength(0);
  });
});
