import { ListingEntity } from '@modules/listings/domain/entities/listing.entity';
import { PropertyEntity, type PropertyProps } from '@modules/listings/domain/entities/property.entity';
import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository';
import { type IPropertyRepository } from '@modules/listings/domain/repositories/property.repository';
import { Address } from '@modules/listings/domain/value-objects/address.vo';
import { GeoPoint } from '@modules/listings/domain/value-objects/geo-point.vo';
import { Price } from '@modules/listings/domain/value-objects/price.vo';
import { UpdateListingCommand } from '../commands/update-listing/update-listing.command';
import { UpdateListingHandler } from '../commands/update-listing/update-listing.handler';

function createListing(
  id = 'listing-1',
  sellerId = 'seller-1',
  agentId: string | null = null,
  status: 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' = 'DRAFT',
): ListingEntity {
  const price = Price.create(2_000_000_000n).unwrap();
  const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80, agentId ?? undefined);
  if (status === 'PENDING_REVIEW') listing.submitForReview();
  if (status === 'ACTIVE') {
    listing.submitForReview();
    listing.approve();
  }
  listing.clearDomainEvents();
  return listing;
}

function createProperty(id = 'prop-1'): PropertyEntity {
  const address = Address.create('123 Đường Lê Lợi', 'Phường Bến Thành', 'Quận 1', 'Hồ Chí Minh').unwrap();
  const location = GeoPoint.create(10.7769, 106.7009).unwrap();
  const props: PropertyProps = {
    propertyType: 'APARTMENT',
    title: 'Căn hộ 3PN view sông',
    description: 'Căn hộ cao cấp nội thất đầy đủ',
    address,
    location,
    areaM2: 80,
    usableAreaM2: 72,
    bedrooms: 3,
    bathrooms: 2,
    floors: null,
    floor: 15,
    totalFloors: 30,
    direction: 'EAST',
    yearBuilt: 2020,
    legalStatus: 'Sổ hồng',
    amenities: ['Hồ bơi', 'Gym'],
    nearbyPOIs: null,
    metroDistanceM: 500,
    projectName: 'Vinhomes Central Park',
  };
  return PropertyEntity.createNew(id, props);
}

describe('UpdateListingHandler', () => {
  let handler: UpdateListingHandler;
  let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
  let mockPropertyRepo: { [K in keyof IPropertyRepository]: ReturnType<typeof vi.fn> };
  let mockEventBus: { publish: ReturnType<typeof vi.fn> };
  let mockCache: {
    invalidate: ReturnType<typeof vi.fn>;
    invalidateByPrefix: ReturnType<typeof vi.fn>;
  };
  let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; log: ReturnType<typeof vi.fn> };

  beforeEach(() => {
    mockListingRepo = {
      findById: vi.fn(),
      findByIdWithProperty: vi.fn(),
      save: vi.fn(),
      update: vi.fn().mockResolvedValue(undefined),
      search: vi.fn(),
      findByStatus: vi.fn(),
      findBySellerId: vi.fn(),
    };

    mockPropertyRepo = {
      findById: vi.fn(),
      save: vi.fn(),
      update: vi.fn().mockResolvedValue(undefined),
      addMedia: vi.fn(),
      findMediaByPropertyId: vi.fn(),
      deleteMedia: vi.fn(),
      countMediaByPropertyId: vi.fn(),
      updateMediaOrder: vi.fn().mockResolvedValue(undefined),
    };

    mockEventBus = { publish: vi.fn() };
    mockCache = {
      invalidate: vi.fn().mockResolvedValue(undefined),
      invalidateByPrefix: vi.fn().mockResolvedValue(undefined),
    };
    mockLogger = {
      error: vi.fn(),
      warn: vi.fn(),
      log: vi.fn(),
    };

    handler = new UpdateListingHandler(
      mockListingRepo as any,
      mockPropertyRepo as any,
      mockEventBus as any,
      mockCache as any,
      mockLogger as any,
    );
  });

  describe('ownership checks', () => {
    it('allows the seller to update their listing', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới');
      const result = await handler.execute(command);

      expect(result.listingId).toBe('listing-1');
      expect(result.updatedFields).toContain('title');
    });

    it('allows the assigned agent to update the listing', async () => {
      const listing = createListing('listing-1', 'seller-1', 'agent-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'agent-1', 'Tiêu đề mới');
      const result = await handler.execute(command);

      expect(result.listingId).toBe('listing-1');
    });

    it('rejects update from unauthorized user', async () => {
      const listing = createListing('listing-1', 'seller-1');
      mockListingRepo.findById.mockResolvedValue(listing);

      const command = new UpdateListingCommand('listing-1', 'stranger', 'Tiêu đề mới');

      await expect(handler.execute(command)).rejects.toThrow(/người bán|môi giới|quản trị/);
    });

    it('allows an admin to update any listing', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1',
        'admin-user',
        'Tiêu đề do admin sửa',
        undefined,
        undefined,
        undefined,
        undefined,
        undefined,
        'ADMIN',
      );
      const result = await handler.execute(command);

      expect(result.listingId).toBe('listing-1');
      expect(result.updatedFields).toContain('title');
    });
  });

  describe('content updates', () => {
    it('updates title on the property entity', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới nhất');
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('title');
      expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1);
    });

    it('updates description on the property entity', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'seller-1', undefined,
        'Mô tả mới chi tiết hơn cho căn hộ',
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('description');
      expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1);
    });

    it('updates priceVND on the listing entity', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'seller-1', undefined, undefined,
        3_000_000_000n,
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('priceVND');
      expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
      // property should NOT be updated when only listing fields change
      expect(mockPropertyRepo.update).not.toHaveBeenCalled();
    });

    it('updates amenities on the property entity', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'seller-1', undefined, undefined,
        undefined, undefined, ['Hồ bơi', 'Gym', 'Sân tennis'],
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('amenities');
      expect(mockPropertyRepo.update).toHaveBeenCalledTimes(1);
    });

    it('updates media order via property repository', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);
      mockPropertyRepo.updateMediaOrder.mockResolvedValue(undefined);

      const command = new UpdateListingCommand(
        'listing-1', 'seller-1', undefined, undefined,
        undefined, undefined, undefined,
        [{ mediaId: 'media-1', order: 1 }, { mediaId: 'media-2', order: 0 }],
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('mediaOrder');
      expect(mockPropertyRepo.updateMediaOrder).toHaveBeenCalledWith('prop-1', [
        { mediaId: 'media-1', order: 1 },
        { mediaId: 'media-2', order: 0 },
      ]);
    });

    it('rejects update with no fields provided', async () => {
      const listing = createListing('listing-1', 'seller-1');
      mockListingRepo.findById.mockResolvedValue(listing);

      const command = new UpdateListingCommand('listing-1', 'seller-1');

      await expect(handler.execute(command)).rejects.toThrow(/không có trường/i);
    });
  });

  describe('moderation re-submission', () => {
    it('transitions ACTIVE listing to PENDING_REVIEW on content edit', async () => {
      const listing = createListing('listing-1', 'seller-1', null, 'ACTIVE');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới');
      const result = await handler.execute(command);

      expect(result.status).toBe('PENDING_REVIEW');
      expect(result.resubmittedForModeration).toBe(true);
    });

    it('does NOT transition DRAFT listing status on content edit', async () => {
      const listing = createListing('listing-1', 'seller-1', null, 'DRAFT');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới');
      const result = await handler.execute(command);

      expect(result.status).toBe('DRAFT');
      expect(result.resubmittedForModeration).toBe(false);
    });
  });

  describe('events and caching', () => {
    it('publishes domain events after update', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới');
      await handler.execute(command);

      expect(mockEventBus.publish).toHaveBeenCalled();
    });

    it('invalidates listing cache after update', async () => {
      const listing = createListing('listing-1', 'seller-1');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand('listing-1', 'seller-1', 'Tiêu đề mới');
      await handler.execute(command);

      expect(mockCache.invalidate).toHaveBeenCalled();
      expect(mockCache.invalidateByPrefix).toHaveBeenCalled();
    });
  });

  it('throws NotFoundException for non-existent listing', async () => {
    mockListingRepo.findById.mockResolvedValue(null);

    const command = new UpdateListingCommand('nonexistent', 'seller-1', 'Tiêu đề mới');

    await expect(handler.execute(command)).rejects.toThrow('Listing');
  });

  // ─────────────────────────────────────────────────────────────────────
  // TEC-2928 — Ownership transfer permission matrix
  // Args (positional): listingId, userId, title, description, priceVND,
  //   rentPriceMonthly, amenities, mediaOrder, userRole, extras, agentId
  // ─────────────────────────────────────────────────────────────────────
  describe('ownership transfer (agentId)', () => {
    it('1) rejects ownership transfer from non-owner non-admin (403)', async () => {
      // Listing owned by seller-1, agent agent-A. Stranger tries to transfer.
      const listing = createListing('listing-1', 'seller-1', 'agent-A');
      mockListingRepo.findById.mockResolvedValue(listing);

      const command = new UpdateListingCommand(
        'listing-1', 'stranger',
        undefined, undefined, undefined, undefined, undefined, undefined,
        'USER', undefined, 'agent-B',
      );

      // Caught at primary ownership check (not seller, not agent, not admin)
      await expect(handler.execute(command)).rejects.toThrow(
        /người bán|môi giới|quản trị/,
      );
      expect(mockEventBus.publish).not.toHaveBeenCalled();
    });

    it('2) rejects ownership transfer attempted by the seller (not admin, not current agent)', async () => {
      // Seller IS the listing owner so passes step 2, but is NOT admin / current
      // owner-agent so the ownership-transfer guard at step 2b must reject.
      const listing = createListing('listing-1', 'seller-1', 'agent-A');
      mockListingRepo.findById.mockResolvedValue(listing);

      const command = new UpdateListingCommand(
        'listing-1', 'seller-1',
        undefined, undefined, undefined, undefined, undefined, undefined,
        'USER', undefined, 'agent-B',
      );

      await expect(handler.execute(command)).rejects.toThrow(
        /quản trị viên|môi giới hiện tại/,
      );
      expect(mockEventBus.publish).not.toHaveBeenCalled();
    });

    it('3) admin can transfer to another agent — event + audit fields populated', async () => {
      const listing = createListing('listing-1', 'seller-1', 'agent-A');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'admin-1',
        undefined, undefined, undefined, undefined, undefined, undefined,
        'ADMIN', undefined, 'agent-B',
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('agentId');
      expect(listing.agentId).toBe('agent-B');

      // The transfer event should be among the published events.
      const publishedEvents = mockEventBus.publish.mock.calls.map((c: any[]) => c[0]);
      const transferEvent = publishedEvents.find(
        (e: any) => e?.eventName === 'listing.ownership_transferred',
      );
      expect(transferEvent).toBeDefined();
      expect(transferEvent.fromAgentId).toBe('agent-A');
      expect(transferEvent.toAgentId).toBe('agent-B');
      expect(transferEvent.byUserId).toBe('admin-1');
      expect(transferEvent.byRole).toBe('ADMIN');
    });

    it('4) current owner-agent can transfer to another agent', async () => {
      const listing = createListing('listing-1', 'seller-1', 'agent-A');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'agent-A',
        undefined, undefined, undefined, undefined, undefined, undefined,
        'AGENT', undefined, 'agent-B',
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('agentId');
      expect(listing.agentId).toBe('agent-B');

      const publishedEvents = mockEventBus.publish.mock.calls.map((c: any[]) => c[0]);
      const transferEvent = publishedEvents.find(
        (e: any) => e?.eventName === 'listing.ownership_transferred',
      );
      expect(transferEvent).toBeDefined();
      expect(transferEvent.byUserId).toBe('agent-A');
    });

    it('5) agentId equal to current owner-agent is a no-op (no event, no field, no re-moderation)', async () => {
      const listing = createListing('listing-1', 'seller-1', 'agent-A', 'ACTIVE');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      // Same agentId + a content update so the empty-update guard doesn't fire.
      const command = new UpdateListingCommand(
        'listing-1', 'agent-A',
        'Tiêu đề mới',
        undefined, undefined, undefined, undefined, undefined,
        'AGENT', undefined, 'agent-A',
      );
      const result = await handler.execute(command);

      // No agentId in updated fields — domain method returned false.
      expect(result.updatedFields).not.toContain('agentId');

      const publishedEvents = mockEventBus.publish.mock.calls.map((c: any[]) => c[0]);
      const transferEvent = publishedEvents.find(
        (e: any) => e?.eventName === 'listing.ownership_transferred',
      );
      expect(transferEvent).toBeUndefined();
    });

    it('6) agentId === undefined → normal flow, no ownership change', async () => {
      const listing = createListing('listing-1', 'seller-1', 'agent-A');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'seller-1',
        'Tiêu đề mới',
        // …agentId omitted entirely
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).not.toContain('agentId');
      expect(listing.agentId).toBe('agent-A');

      const publishedEvents = mockEventBus.publish.mock.calls.map((c: any[]) => c[0]);
      const transferEvent = publishedEvents.find(
        (e: any) => e?.eventName === 'listing.ownership_transferred',
      );
      expect(transferEvent).toBeUndefined();
    });

    it('7) admin un-assigns (agentId === null) → event with toAgentId null', async () => {
      const listing = createListing('listing-1', 'seller-1', 'agent-A');
      const property = createProperty('prop-1');
      mockListingRepo.findById.mockResolvedValue(listing);
      mockPropertyRepo.findById.mockResolvedValue(property);

      const command = new UpdateListingCommand(
        'listing-1', 'admin-1',
        undefined, undefined, undefined, undefined, undefined, undefined,
        'ADMIN', undefined, null,
      );
      const result = await handler.execute(command);

      expect(result.updatedFields).toContain('agentId');
      expect(listing.agentId).toBeNull();

      const publishedEvents = mockEventBus.publish.mock.calls.map((c: any[]) => c[0]);
      const transferEvent = publishedEvents.find(
        (e: any) => e?.eventName === 'listing.ownership_transferred',
      );
      expect(transferEvent).toBeDefined();
      expect(transferEvent.fromAgentId).toBe('agent-A');
      expect(transferEvent.toAgentId).toBeNull();
      expect(transferEvent.byRole).toBe('ADMIN');
    });
  });
});
