import { type ListingDocument, type SearchParams } from '../../domain/repositories/search.repository';
import { TypesenseSearchRepository } from '../services/typesense-search.repository';

function makeDocument(overrides?: Partial<ListingDocument>): ListingDocument {
  return {
    id: 'listing-1',
    listingId: 'listing-1',
    propertyId: 'prop-1',
    title: 'Test Apartment',
    description: 'A great place',
    propertyType: 'APARTMENT',
    transactionType: 'SALE',
    priceVND: 5000000000,
    pricePerM2: 50,
    areaM2: 80,
    bedrooms: 2,
    bathrooms: 1,
    floors: 1,
    direction: 'EAST',
    address: '123 Street',
    ward: 'Ward 1',
    district: 'District 1',
    city: 'HCMC',
    location: [10.776, 106.700],
    agentId: 'agent-1',
    sellerId: 'seller-1',
    status: 'ACTIVE',
    publishedAt: 1700000000,
    viewCount: 10,
    saveCount: 5,
    projectName: null,
    legalStatus: null,
    amenities: ['parking'],
    titleNormalized: 'test apartment',
    descriptionNormalized: 'a great place',
    addressNormalized: '123 street',
    wardNormalized: 'ward 1',
    districtNormalized: 'district 1',
    cityNormalized: 'hcmc',
    projectNameNormalized: null,
    ...overrides,
  };
}

describe('TypesenseSearchRepository', () => {
  let repo: TypesenseSearchRepository;
  let mockClient: {
    collections: ReturnType<typeof vi.fn>;
  };
  let collectionOps: {
    retrieve: ReturnType<typeof vi.fn>;
    delete: ReturnType<typeof vi.fn>;
    documents: ReturnType<typeof vi.fn>;
    synonyms: ReturnType<typeof vi.fn>;
  };
  let documentOps: {
    upsert: ReturnType<typeof vi.fn>;
    import: ReturnType<typeof vi.fn>;
    search: ReturnType<typeof vi.fn>;
    delete: ReturnType<typeof vi.fn>;
  };
  let createFn: ReturnType<typeof vi.fn>;
  let mockTypesenseClientService: { getClient: ReturnType<typeof vi.fn> };
  let mockLogger: {
    log: ReturnType<typeof vi.fn>;
    warn: ReturnType<typeof vi.fn>;
    error: ReturnType<typeof vi.fn>;
  };

  beforeEach(() => {
    documentOps = {
      upsert: vi.fn().mockResolvedValue({}),
      import: vi.fn().mockResolvedValue([]),
      search: vi.fn(),
      delete: vi.fn().mockResolvedValue({}),
    };
    collectionOps = {
      retrieve: vi.fn(),
      delete: vi.fn().mockResolvedValue({}),
      documents: vi.fn().mockReturnValue(documentOps),
      synonyms: vi.fn().mockReturnValue({ upsert: vi.fn().mockResolvedValue({}) }),
    };
    createFn = vi.fn().mockResolvedValue({});
    mockClient = {
      collections: vi.fn().mockImplementation((name?: string) =>
        name ? collectionOps : { create: createFn },
      ),
    };
    mockTypesenseClientService = {
      getClient: vi.fn().mockReturnValue(mockClient),
    };
    mockLogger = {
      log: vi.fn(),
      warn: vi.fn(),
      error: vi.fn(),
    };
    repo = new TypesenseSearchRepository(
      mockTypesenseClientService as any,
      mockLogger as any,
    );
  });

  it('ensureCollection does not create collection when it already exists', async () => {
    collectionOps.retrieve.mockResolvedValue({ name: 'listings' });

    await repo.ensureCollection();

    expect(collectionOps.retrieve).toHaveBeenCalled();
    expect(createFn).not.toHaveBeenCalled();
    expect(mockLogger.log).toHaveBeenCalled();
  });

  it('ensureCollection creates collection when retrieve throws', async () => {
    collectionOps.retrieve.mockRejectedValue(new Error('Not found'));

    await repo.ensureCollection();

    expect(createFn).toHaveBeenCalled();
    expect(mockLogger.log).toHaveBeenCalled();
  });

  it('dropCollection deletes the collection', async () => {
    collectionOps.delete.mockResolvedValue({});

    await repo.dropCollection();

    expect(collectionOps.delete).toHaveBeenCalled();
    expect(mockLogger.log).toHaveBeenCalled();
  });

  it('dropCollection handles missing collection gracefully', async () => {
    collectionOps.delete.mockRejectedValue(new Error('Not found'));

    await expect(repo.dropCollection()).resolves.not.toThrow();
    expect(mockLogger.warn).toHaveBeenCalled();
  });

  it('indexDocument upserts the document', async () => {
    const doc = makeDocument();

    await repo.indexDocument(doc);

    expect(mockClient.collections).toHaveBeenCalledWith('listings');
    expect(collectionOps.documents).toHaveBeenCalled();
    expect(documentOps.upsert).toHaveBeenCalledWith(doc);
  });

  it('indexDocuments skips empty array without calling import', async () => {
    await repo.indexDocuments([]);

    expect(documentOps.import).not.toHaveBeenCalled();
  });

  it('removeDocument deletes the document by id', async () => {
    const docOpsWithId = { delete: vi.fn().mockResolvedValue({}) };
    collectionOps.documents.mockReturnValue(docOpsWithId);

    await repo.removeDocument('listing-1');

    expect(collectionOps.documents).toHaveBeenCalledWith('listing-1');
    expect(docOpsWithId.delete).toHaveBeenCalled();
  });

  it('removeDocument handles missing document gracefully', async () => {
    const docOpsWithId = { delete: vi.fn().mockRejectedValue(new Error('Not found')) };
    collectionOps.documents.mockReturnValue(docOpsWithId);

    await expect(repo.removeDocument('missing-id')).resolves.not.toThrow();
    expect(mockLogger.warn).toHaveBeenCalled();
  });

  it('search returns formatted results', async () => {
    const mockHit = { document: makeDocument() };
    documentOps.search.mockResolvedValue({
      hits: [mockHit],
      found: 1,
      search_time_ms: 3,
    });

    const params: SearchParams = { query: 'apartment', page: 1, perPage: 20 };
    const result = await repo.search(params);

    expect(result.hits).toHaveLength(1);
    expect(result.totalFound).toBe(1);
    expect(result.page).toBe(1);
    expect(result.perPage).toBe(20);
    expect(result.totalPages).toBe(1);
    expect(result.searchTimeMs).toBe(3);
  });

  it('search applies geo filter when geoPoint is provided', async () => {
    documentOps.search.mockResolvedValue({ hits: [], found: 0, search_time_ms: 2 });

    const params: SearchParams = {
      query: '*',
      geoPoint: { lat: 10.776, lng: 106.700 },
      geoRadiusKm: 5,
    };
    await repo.search(params);

    const searchCall = documentOps.search.mock.calls[0]![0];
    expect(searchCall.filter_by).toContain('location:(10.776, 106.7, 5 km)');
    expect(searchCall.sort_by).toContain('location(10.776, 106.7):asc');
  });

  it('search queries both original and normalized fields', async () => {
    documentOps.search.mockResolvedValue({ hits: [], found: 0, search_time_ms: 1 });

    const params: SearchParams = { query: 'căn hộ', page: 1, perPage: 20 };
    await repo.search(params);

    const searchCall = documentOps.search.mock.calls[0]![0];
    expect(searchCall.query_by).toContain('titleNormalized');
    expect(searchCall.query_by).toContain('addressNormalized');
    expect(searchCall.num_typos).toBe('2');
    // Query should include both original Vietnamese and normalized ASCII
    expect(searchCall.q).toContain('căn hộ');
    expect(searchCall.q).toContain('can ho');
  });

  it('ensureCollection upserts Vietnamese synonyms', async () => {
    collectionOps.retrieve.mockResolvedValue({ name: 'listings' });
    const upsertSpy = vi.fn().mockResolvedValue({});
    collectionOps.synonyms.mockReturnValue({ upsert: upsertSpy });

    await repo.ensureCollection();

    expect(upsertSpy).toHaveBeenCalled();
    // Verify at least the HCM synonym was upserted
    expect(upsertSpy).toHaveBeenCalledWith('hcm', expect.objectContaining({
      synonyms: expect.arrayContaining(['hcm', 'ho chi minh']),
    }));
  });
});
