import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common';

import { RateChangeRequest } from '../entities/rate-change-request.entity';
import { Rate } from '../entities/rate.entity';
import { RateChangeRequestsService } from './rate-change-requests.service';

/**
 * The guarantee under test: editing a LIVE rate never moves the live value.
 * A rate at 100 keeps charging 100 while a change to 200 sits PENDING; only
 * approval applies it, and only then through RatesService (so every rate rule
 * is re-checked against the state at approval time).
 */
describe('RateChangeRequestsService', () => {
  const liveRate = (overrides: Partial<Rate> = {}): Rate =>
    ({
      id: 'rate-1',
      status: 'LIVE',
      rateType: 'OCEAN_FREIGHT',
      appliesTo: 'CONTAINER',
      trigger: 'ALWAYS',
      currency: 'USD',
      // Postgres numeric comes back as a string — the no-op check must cope.
      rateValue: '100.0000' as unknown as number,
      rateUnit: 'PER_CONTAINER',
      containerTypeId: null,
      cargoTypeId: null,
      tradeDirection: null,
      proposedByStaffId: 'staff-1',
      ...overrides,
    }) as unknown as Rate;

  const build = (opts: {
    rate?: Rate;
    pending?: RateChangeRequest | null;
    applyThrows?: Error;
  } = {}) => {
    const rate = opts.rate ?? liveRate();
    const saved: RateChangeRequest[] = [];

    const repo = {
      findOne: jest.fn(async ({ where }: { where: Record<string, unknown> }) => {
        if (where.status === 'PENDING' && where.rateId) return opts.pending ?? null;
        return saved.find((r) => r.id === where.id) ?? opts.pending ?? null;
      }),
      create: jest.fn((data: Partial<RateChangeRequest>) => ({ id: 'req-1', ...data })),
      save: jest.fn(async (entity: RateChangeRequest) => {
        saved.push(entity);
        return entity;
      }),
      find: jest.fn(async () => saved),
    };

    const rates = {
      findById: jest.fn(async () => rate),
      assertUpdateValid: jest.fn(async () => undefined),
      applyApprovedUpdate: jest.fn(async () => {
        if (opts.applyThrows) throw opts.applyThrows;
        return rate;
      }),
    };

    const inbox = { notify: jest.fn(async () => undefined) };

    const service = new RateChangeRequestsService(
      repo as never,
      rates as never,
      inbox as never,
    );
    // `pending` is the very object approve/reject mutate — assert on it, not a copy.
    return { service, repo, rates, inbox, pending: opts.pending };
  };

  describe('submit', () => {
    it('files a pending request instead of touching the live rate', async () => {
      const { service, rates } = build();

      const request = await service.submit({ rateId: 'rate-1', update: { rateValue: 200 } });

      expect(request.status).toBe('PENDING');
      expect(request.payload).toEqual({ rateValue: 200 });
      // The old value is snapshotted for the approver's diff...
      expect(request.previousValues).toEqual({ rateValue: '100.0000' });
      // ...and nothing wrote to the rate itself.
      expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
    });

    it('keeps only the fields that actually changed', async () => {
      const { service } = build();

      // A form posts every field back; only rateValue differs from the live rate.
      const request = await service.submit({
        rateId: 'rate-1',
        update: {
          rateValue: 200,
          currency: 'USD',
          rateUnit: 'PER_CONTAINER',
          appliesTo: 'CONTAINER',
        },
      });

      expect(request.payload).toEqual({ rateValue: 200 });
    });

    it('carries a re-routed leg — a yard-only edit is a real change', async () => {
      const { service } = build({
        rate: liveRate({ originYardId: 'yard-a', destinationYardId: 'yard-b' }),
      });

      const request = await service.submit({
        rateId: 'rate-1',
        update: {
          rateValue: 100,
          originYardId: 'yard-a',
          destinationYardId: 'yard-c',
        },
      });

      expect(request.payload).toEqual({ destinationYardId: 'yard-c' });
    });

    it('rejects a no-op — 100 posted against a live 100.0000 is not a change', async () => {
      const { service } = build();
      await expect(
        service.submit({ rateId: 'rate-1', update: { rateValue: 100 } }),
      ).rejects.toThrow(/Nothing changed/);
    });

    it('refuses a rate that is not LIVE — those edit directly', async () => {
      const { service } = build({ rate: liveRate({ status: 'DRAFT' }) });
      await expect(
        service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
      ).rejects.toThrow(BadRequestException);
    });

    it('refuses a second pending change for the same rate', async () => {
      const { service } = build({
        pending: { id: 'req-0', status: 'PENDING' } as unknown as RateChangeRequest,
      });
      await expect(
        service.submit({ rateId: 'rate-1', update: { rateValue: 200 } }),
      ).rejects.toThrow(ConflictException);
    });

    it('validates up front so the requester hears about a bad patch, not the approver', async () => {
      const { service, rates } = build();
      rates.assertUpdateValid.mockRejectedValueOnce(
        new BadRequestException('Rate unit "PER_TON" is not valid for this rate.'),
      );
      await expect(
        service.submit({ rateId: 'rate-1', update: { rateUnit: 'PER_TON' } }),
      ).rejects.toThrow(/not valid for this rate/);
    });
  });

  describe('approve', () => {
    const pendingRequest = (): RateChangeRequest =>
      ({
        id: 'req-1',
        rateId: 'rate-1',
        payload: { rateValue: 200 },
        previousValues: { rateValue: '100.0000' },
        status: 'PENDING',
        requestedByUserId: 'staff-1',
      }) as unknown as RateChangeRequest;

    it('applies the change through RatesService and marks it approved', async () => {
      const { service, rates } = build({ pending: pendingRequest() });

      const decided = await service.approve('req-1', 'approver-1', 'Agreed');

      expect(rates.applyApprovedUpdate).toHaveBeenCalledWith('rate-1', { rateValue: 200 });
      expect(decided.status).toBe('APPROVED');
      expect(decided.decidedByUserId).toBe('approver-1');
      expect(decided.decisionNote).toBe('Agreed');
    });

    it('blocks the requester from approving their own change', async () => {
      const { service, rates } = build({ pending: pendingRequest() });
      await expect(service.approve('req-1', 'staff-1')).rejects.toThrow(ForbiddenException);
      expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
    });

    it('lets a super admin self-approve', async () => {
      const { service } = build({ pending: pendingRequest() });
      await expect(service.approve('req-1', 'staff-1', undefined, true)).resolves.toMatchObject({
        status: 'APPROVED',
      });
    });

    it('stays PENDING when applying now fails — never marks a change that did not land', async () => {
      const { service, pending, repo } = build({
        pending: pendingRequest(),
        applyThrows: new ConflictException('A rate for this exact combination already exists.'),
      });

      await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(/already exists/);
      // Apply runs first, so a failure leaves the request untouched and re-decidable.
      expect(pending!.status).toBe('PENDING');
      expect(repo.save).not.toHaveBeenCalled();
    });

    it('refuses to decide an already-decided request', async () => {
      const { service } = build({
        pending: { ...pendingRequest(), status: 'APPROVED' } as unknown as RateChangeRequest,
      });
      await expect(service.approve('req-1', 'approver-1')).rejects.toThrow(ConflictException);
    });
  });

  describe('reject', () => {
    it('never touches the rate — it simply keeps its current value', async () => {
      const { service, rates } = build({
        pending: {
          id: 'req-1',
          rateId: 'rate-1',
          payload: { rateValue: 200 },
          previousValues: { rateValue: '100.0000' },
          status: 'PENDING',
          requestedByUserId: 'staff-1',
        } as unknown as RateChangeRequest,
      });

      const decided = await service.reject('req-1', 'approver-1', 'Too steep');

      expect(decided.status).toBe('REJECTED');
      expect(decided.decisionNote).toBe('Too steep');
      expect(rates.applyApprovedUpdate).not.toHaveBeenCalled();
    });
  });
});
