import { Test, type TestingModule } from '@nestjs/testing';
import { NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import { getRepositoryToken } from '@nestjs/typeorm';
import { ClsService } from 'nestjs-cls';
import { StorageService } from '../../common/storage/storage.service';
import { ActivityTicketService } from './activity-ticket.service';
import { ActivityTicketBatchRepository } from './repositories/activity-ticket-batch.repository';
import { ActivityTicketItemRepository } from './repositories/activity-ticket-item.repository';
import { ActivityTicketJourneyRepository } from './repositories/activity-ticket-journey.repository';
import {
  ActivityTicketBatchEntity,
  TicketBatchStatus,
} from '../../entities/activity-ticket-batch.entity';
import {
  ActivityTicketItemEntity,
  TicketItemStatus,
} from '../../entities/activity-ticket-item.entity';
import { TenantEntity } from '../../entities/tenant.entity';
import { BookingActivityItemEntity } from '../../entities/booking-activity-item.entity';

// assign/unassign now run inside batchRepo.manager.transaction with a
// row lock, using the transaction-scoped entity manager. These fakes
// stand in for that manager: `query` returns the locked batch row and
// `getRepository` hands back the right per-entity fake.
const itemEmRepo = { findOne: jest.fn(), update: jest.fn(), find: jest.fn() };
const batchEmRepo = { update: jest.fn() };
// Dashboard line (booking activity rows) — ticket status follows assignment.
const lineEmRepo = { findOne: jest.fn(), find: jest.fn(), update: jest.fn() };
const mockEm = {
  query: jest.fn(),
  getRepository: jest.fn((entity: unknown) =>
    entity === ActivityTicketItemEntity
      ? itemEmRepo
      : entity === BookingActivityItemEntity
        ? lineEmRepo
        : batchEmRepo,
  ),
};

const mockBatchRepo = {
  findAllPaginated: jest.fn(),
  findById: jest.fn(),
  findByIdWithItems: jest.fn(),
  findActiveBatchesForActivity: jest.fn(),
  save: jest.fn(),
  update: jest.fn(),
  softDelete: jest.fn(),
  currentTenantId: jest.fn().mockReturnValue('tenant-uuid'),
  manager: { transaction: jest.fn((cb: (em: unknown) => unknown) => cb(mockEm)) },
};

const mockItemRepo = {
  findById: jest.fn(),
  findAvailableInBatch: jest.fn(),
  findByBookingActivityItem: jest.fn(),
  countByBatchAndStatus: jest.fn(),
  save: jest.fn(),
  update: jest.fn(),
};

const mockJourneyRepo = {
  save: jest.fn(),
  update: jest.fn(),
  findByItemId: jest.fn(),
  logEvent: jest.fn().mockResolvedValue(undefined),
};

const mockCls = { get: jest.fn().mockReturnValue('user-uuid') };

// Storage is only exercised when a ticket carries a document_url; these
// tests use null URLs, so the fakes just need to exist for DI.
const mockStorage = {
  save: jest.fn(),
  move: jest.fn().mockResolvedValue(undefined),
  delete: jest.fn(),
  exists: jest.fn(),
  url: jest.fn(),
  keyFromUrl: jest.fn(),
};

const mockTenantRepo = {
  findOne: jest.fn().mockResolvedValue({ subdomain: 'mellow' }),
};

describe('ActivityTicketService', () => {
  let service: ActivityTicketService;

  beforeEach(async () => {
    const module: TestingModule = await Test.createTestingModule({
      providers: [
        ActivityTicketService,
        { provide: ActivityTicketBatchRepository, useValue: mockBatchRepo },
        { provide: ActivityTicketItemRepository, useValue: mockItemRepo },
        { provide: ActivityTicketJourneyRepository, useValue: mockJourneyRepo },
        { provide: ClsService, useValue: mockCls },
        { provide: StorageService, useValue: mockStorage },
        { provide: getRepositoryToken(TenantEntity), useValue: mockTenantRepo },
      ],
    }).compile();

    service = module.get<ActivityTicketService>(ActivityTicketService);
  });

  afterEach(() => {
    jest.clearAllMocks();
    mockStorage.keyFromUrl.mockReset();
    mockStorage.url.mockReset();
    mockStorage.move.mockReset().mockResolvedValue(undefined);
    mockTenantRepo.findOne.mockResolvedValue({ subdomain: 'mellow' });
  });

  describe('findBatchById', () => {
    it('should return batch when found', async () => {
      const batch = { id: 'batch-1', ticket_type: 'VIP' };
      mockBatchRepo.findByIdWithItems.mockResolvedValue(batch);
      expect(await service.findBatchById('batch-1')).toEqual(batch);
    });

    it('should throw NotFoundException when batch not found', async () => {
      mockBatchRepo.findByIdWithItems.mockResolvedValue(null);
      await expect(service.findBatchById('missing')).rejects.toThrow(NotFoundException);
    });
  });

  describe('createBatch', () => {
    it('should create one batch per unique ticket_type+age_group combination', async () => {
      mockBatchRepo.save.mockResolvedValue({ id: 'batch-1' });
      mockItemRepo.save.mockResolvedValue({});
      const dto = {
        activity_id: 'act-1',
        items: [
          { ticket_type: 'Standard', age_group: 'ADULT', ticket_ref_no: 'TK001' },
          { ticket_type: 'Standard', age_group: 'ADULT', ticket_ref_no: 'TK002' },
          { ticket_type: 'Standard', age_group: 'CHILD', ticket_ref_no: 'TK003' },
        ],
      };
      const result = await service.createBatch(dto);
      // Two groups → two batches
      expect(mockBatchRepo.save).toHaveBeenCalledTimes(2);
      expect(mockItemRepo.save).toHaveBeenCalledTimes(3);
      expect(result.batches).toHaveLength(2);
    });

    it('should set total_qty from items per group', async () => {
      mockBatchRepo.save.mockResolvedValue({ id: 'batch-1' });
      mockItemRepo.save.mockResolvedValue({});
      const dto = {
        activity_id: 'act-1',
        items: [
          { ticket_type: 'VIP', age_group: 'ADULT', ticket_ref_no: 'TK001' },
          { ticket_type: 'VIP', age_group: 'ADULT', ticket_ref_no: 'TK002' },
        ],
      };
      await service.createBatch(dto);
      expect(mockBatchRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({ total_qty: 2, available_qty: 2 }),
      );
    });

    it('should park an uploaded ticket PDF in the unused/ folder', async () => {
      mockBatchRepo.save.mockResolvedValue({ id: 'batch-1' });
      mockItemRepo.save.mockResolvedValue({});
      mockTenantRepo.findOne.mockResolvedValue({ subdomain: 'mellow' });
      // Freshly uploaded, not yet foldered: {subdomain}/{file}
      mockStorage.keyFromUrl.mockReturnValue('mellow/tk.pdf');
      mockStorage.url.mockReturnValue('/api/uploads/mellow/unused/tk.pdf');
      const dto = {
        activity_id: 'act-1',
        items: [
          {
            ticket_type: 'Standard',
            age_group: 'ADULT',
            ticket_ref_no: 'TK001',
            document_url: '/api/uploads/mellow/tk.pdf',
          },
        ],
      };
      await service.createBatch(dto);
      expect(mockStorage.move).toHaveBeenCalledWith('mellow/tk.pdf', 'mellow/unused/tk.pdf');
      expect(mockItemRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({ document_url: '/api/uploads/mellow/unused/tk.pdf' }),
      );
    });

    it('should drop a document_url that belongs to another tenant', async () => {
      mockBatchRepo.save.mockResolvedValue({ id: 'batch-1' });
      mockItemRepo.save.mockResolvedValue({});
      mockTenantRepo.findOne.mockResolvedValue({ subdomain: 'mellow' });
      mockStorage.keyFromUrl.mockReturnValue('victim/tk.pdf');
      const dto = {
        activity_id: 'act-1',
        items: [
          {
            ticket_type: 'Standard',
            age_group: 'ADULT',
            ticket_ref_no: 'TK001',
            document_url: '/api/uploads/victim/tk.pdf',
          },
        ],
      };
      await service.createBatch(dto);
      expect(mockStorage.move).not.toHaveBeenCalled();
      expect(mockItemRepo.save).toHaveBeenCalledWith(
        expect.objectContaining({ document_url: null }),
      );
    });
  });

  describe('topUpBatch', () => {
    it('should add tickets and increment qty', async () => {
      mockBatchRepo.findById.mockResolvedValue({
        id: 'b1',
        total_qty: 5,
        available_qty: 2,
        status: TicketBatchStatus.ACTIVE,
      });
      mockItemRepo.save.mockResolvedValue({});
      const result = await service.topUpBatch('b1', {
        items: [
          { ticket_type: 'Standard', age_group: 'ADULT', ticket_ref_no: 'TK010' },
          { ticket_type: 'Standard', age_group: 'ADULT', ticket_ref_no: 'TK011' },
        ],
      });
      expect(mockBatchRepo.update).toHaveBeenCalledWith(
        'b1',
        expect.objectContaining({ total_qty: 7, available_qty: 4 }),
      );
      expect(result.message).toContain('2 ticket(s)');
    });

    it('should throw BadRequestException on cancelled batch', async () => {
      mockBatchRepo.findById.mockResolvedValue({ status: TicketBatchStatus.CANCELLED });
      await expect(
        service.topUpBatch('b1', {
          items: [{ ticket_type: 'Standard', age_group: 'ADULT', ticket_ref_no: 'TK010' }],
        }),
      ).rejects.toThrow(BadRequestException);
    });
  });

  describe('assignTicket', () => {
    it('should assign next available ticket and decrement qty', async () => {
      // Locked batch row (from FOR UPDATE query).
      mockEm.query.mockResolvedValue([{ available_qty: 3, status: TicketBatchStatus.ACTIVE }]);
      itemEmRepo.findOne.mockResolvedValue({
        id: 'item-1',
        batch_id: 'b1',
        ticket_ref_no: 'TK001',
        document_url: null,
      });
      const result = await service.assignTicket({
        batch_id: 'b1',
        booking_activity_item_id: 'bai-1',
      });
      expect(result.ticket_ref_no).toBe('TK001');
      expect(batchEmRepo.update).toHaveBeenCalledWith(
        expect.objectContaining({ id: 'b1' }),
        expect.objectContaining({ available_qty: 2 }),
      );
    });

    it('should set batch to EXHAUSTED when last ticket assigned', async () => {
      mockEm.query.mockResolvedValue([{ available_qty: 1, status: TicketBatchStatus.ACTIVE }]);
      itemEmRepo.findOne.mockResolvedValue({
        id: 'item-1',
        batch_id: 'b1',
        ticket_ref_no: 'TK001',
        document_url: null,
      });
      await service.assignTicket({ batch_id: 'b1', booking_activity_item_id: 'bai-1' });
      expect(batchEmRepo.update).toHaveBeenCalledWith(
        expect.objectContaining({ id: 'b1' }),
        expect.objectContaining({ status: TicketBatchStatus.EXHAUSTED }),
      );
    });

    it('should throw BadRequestException when batch has no available tickets', async () => {
      mockEm.query.mockResolvedValue([{ available_qty: 0, status: TicketBatchStatus.EXHAUSTED }]);
      await expect(
        service.assignTicket({ batch_id: 'b1', booking_activity_item_id: 'bai-1' }),
      ).rejects.toThrow(BadRequestException);
    });

    it('should throw NotFoundException when the batch row is missing', async () => {
      mockEm.query.mockResolvedValue([]);
      await expect(
        service.assignTicket({ batch_id: 'missing', booking_activity_item_id: 'bai-1' }),
      ).rejects.toThrow(NotFoundException);
    });
  });

  describe('line ticket status', () => {
    const line = {
      id: 'bai-1',
      booking_id: 'bk-1',
      activity_id: 'act-1',
      ticket_id: null,
      date: '2026-11-12',
      age_group: 'adult',
      quantity: 1,
      adults_override: null,
      children_override: null,
      ticket_status: 'Pending',
    };
    const child = { ...line, id: 'bai-2', age_group: 'child' };

    beforeEach(() => {
      itemEmRepo.findOne.mockResolvedValue({
        id: 'item-1',
        batch_id: 'b1',
        ticket_ref_no: 'TK001',
        document_url: null,
        status: TicketItemStatus.ASSIGNED,
        booking_activity_item_id: 'bai-1',
      });
    });

    it('books the line as soon as a ticket is assigned', async () => {
      mockEm.query.mockResolvedValue([{ available_qty: 3, status: TicketBatchStatus.ACTIVE }]);
      lineEmRepo.findOne.mockResolvedValue(line);
      lineEmRepo.find.mockResolvedValue([line, child]);
      itemEmRepo.find.mockResolvedValue([{ batch_id: 'b1' }]);
      await service.assignTicket({ batch_id: 'b1', booking_activity_item_id: 'bai-1' });
      expect(lineEmRepo.update).toHaveBeenCalledWith(
        expect.objectContaining({ tenant_id: 'tenant-uuid' }),
        expect.objectContaining({ ticket_status: 'Booked' }),
      );
      expect(batchEmRepo.update).not.toHaveBeenCalledWith(
        expect.objectContaining({ tenant_id: 'tenant-uuid' }),
        expect.objectContaining({ ticket_status: 'Booked' }),
      );
    });

    it('returns a Booked line to Pending when the last ticket is taken back', async () => {
      mockEm.query.mockResolvedValue([{ available_qty: 0, status: TicketBatchStatus.EXHAUSTED }]);
      const booked = { ...line, ticket_status: 'Booked' };
      lineEmRepo.findOne.mockResolvedValue(booked);
      lineEmRepo.find.mockResolvedValue([booked, { ...child, ticket_status: 'Booked' }]);
      // After unassign there are no ASSIGNED tickets left on the line.
      itemEmRepo.find.mockResolvedValue([]);
      await service.unassignTicket('item-1');
      expect(lineEmRepo.update).toHaveBeenCalledWith(
        expect.anything(),
        expect.objectContaining({ ticket_status: 'Pending' }),
      );
    });
  });

  describe('unassignTicket', () => {
    it('should return ticket to batch, increment qty, and revive an EXHAUSTED batch', async () => {
      itemEmRepo.findOne.mockResolvedValue({
        id: 'item-1',
        status: TicketItemStatus.ASSIGNED,
        batch_id: 'b1',
        document_url: null,
        booking_activity_item_id: 'bai-1',
      });
      // Locked batch was EXHAUSTED → returning a ticket revives it.
      mockEm.query.mockResolvedValue([{ available_qty: 0, status: TicketBatchStatus.EXHAUSTED }]);
      await service.unassignTicket('item-1');
      expect(itemEmRepo.update).toHaveBeenCalledWith(
        expect.objectContaining({ id: 'item-1' }),
        expect.objectContaining({ status: TicketItemStatus.AVAILABLE }),
      );
      expect(batchEmRepo.update).toHaveBeenCalledWith(
        expect.objectContaining({ id: 'b1' }),
        expect.objectContaining({ available_qty: 1, status: TicketBatchStatus.ACTIVE }),
      );
    });

    it('should throw BadRequestException if ticket is not ASSIGNED', async () => {
      itemEmRepo.findOne.mockResolvedValue({ status: TicketItemStatus.AVAILABLE });
      await expect(service.unassignTicket('item-1')).rejects.toThrow(BadRequestException);
    });
  });

  // Tenant isolation — cross-tenant query must never succeed
  describe('tenant isolation', () => {
    it('findBatchById should return null for a different tenant batch', async () => {
      mockBatchRepo.findByIdWithItems.mockResolvedValue(null);
      await expect(service.findBatchById('other-tenant-batch')).rejects.toThrow(NotFoundException);
    });
  });

  describe('deleteBatch', () => {
    it('should throw ConflictException when batch has assigned tickets', async () => {
      mockBatchRepo.findById.mockResolvedValue({ id: 'b1', total_qty: 5, available_qty: 3 });
      await expect(service.deleteBatch('b1')).rejects.toThrow(ConflictException);
    });

    it('should soft-delete when all tickets are available', async () => {
      mockBatchRepo.findById.mockResolvedValue({ id: 'b1', total_qty: 5, available_qty: 5 });
      await service.deleteBatch('b1');
      expect(mockBatchRepo.softDelete).toHaveBeenCalledWith('b1');
    });
  });
});
