/**
 * GS25 클라이언트 테스트
 */

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
  attachDistanceToGs25Stores,
  calculateDistanceM,
  clearGs25StoresCache,
  extractGs25ProductCandidates,
  fetchGs25Stores,
  fetchGs25SearchProducts,
  filterGs25StoresByKeyword,
  geocodeGs25Address,
  sortGs25Stores,
} from '../../../src/services/gs25/client.js';
import { normalizeStore, toNumber } from '../../../src/services/gs25/storeUtils.js';

const mockFetch = vi.fn();

beforeEach(() => {
  mockFetch.mockReset();
  clearGs25StoresCache();
  vi.stubGlobal('fetch', mockFetch);
});

afterEach(() => {
  vi.restoreAllMocks();
});

describe('fetchGs25Stores', () => {
  it('일시적 GET 실패는 기본 재시도로 복구한다', async () => {
    mockFetch
      .mockResolvedValueOnce(
        new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' }),
      )
      .mockResolvedValueOnce(
        new Response(JSON.stringify({ stores: [{ storeCode: 'VE463', storeName: '강남역점' }] })),
      );

    const result = await fetchGs25Stores({ useCache: false });

    expect(result.stores[0].storeCode).toBe('VE463');
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });

  it('store/stock 403이면 Zyte fallback으로 매장을 조회한다', async () => {
    const zyteBody = Buffer.from(
      JSON.stringify({
        stores: [{ storeCode: 'VE463', storeName: '강남역점' }],
      }),
      'utf8',
    ).toString('base64');

    mockFetch
      .mockResolvedValueOnce(new Response('forbidden', { status: 403, statusText: 'Forbidden' }))
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            statusCode: 200,
            httpResponseBody: zyteBody,
          }),
        ),
      );

    const result = await fetchGs25Stores({ useCache: false }, { zyteApiKey: 'test-zyte-key' });

    expect(result.stores[0].storeCode).toBe('VE463');
    expect(mockFetch).toHaveBeenNthCalledWith(
      2,
      'https://api.zyte.com/v1/extract',
      expect.any(Object),
    );
  });

  it('store/stock 403이어도 Zyte 키가 없으면 원본 에러를 반환한다', async () => {
    mockFetch.mockResolvedValueOnce(
      new Response('forbidden', { status: 403, statusText: 'Forbidden' }),
    );

    await expect(fetchGs25Stores({ useCache: false })).rejects.toThrow(
      'API 요청 실패: 403 Forbidden - forbidden',
    );
  });

  it('GS25 매장 목록을 정규화해 반환한다', async () => {
    mockFetch.mockResolvedValue(
      new Response(
        JSON.stringify({
          stores: [
            {
              storeCode: 'VE463',
              storeName: '강남역점',
              storeAddress: '서울시 강남구',
              storeTelephoneNumber: '02-1234-5678',
              storeXCoordination: '127.0276',
              storeYCoordination: '37.4982',
              realStockQuantity: '3',
              pickupStkQty: 1,
              dlvyStkQty: 2,
              isSoldOutYn: 'N',
              searchItemName: '오리온)오감자50G',
              searchItemSellPrice: '1700',
              propertyList: [{ storePropertyCode: '49', storePropertyName: '반값택배픽업' }],
            },
          ],
        }),
      ),
    );

    const result = await fetchGs25Stores();

    expect(result.totalCount).toBe(1);
    expect(result.cacheHit).toBe(false);
    expect(mockFetch).toHaveBeenCalledWith(
      expect.any(String),
      expect.objectContaining({
        headers: expect.objectContaining({
          'Accept-Language': expect.stringContaining('ko-KR'),
          'User-Agent': expect.stringContaining('Android'),
          Origin: 'https://woodongs.com',
          Referer: 'https://woodongs.com/',
        }),
      }),
    );
    expect(result.stores[0]).toEqual(
      expect.objectContaining({
        storeCode: 'VE463',
        latitude: 37.4982,
        longitude: 127.0276,
        realStockQuantity: 3,
        searchItemName: '오리온)오감자50G',
      }),
    );
  });

  it('누락된 매장 필드를 기본값으로 정규화한다', async () => {
    mockFetch.mockResolvedValue(
      new Response(
        JSON.stringify({
          stores: [
            {
              storeCode: 'V000',
              propertyList: [
                { storePropertyCode: 'P1', storePropertyType: 'service' },
                { storePropertyName: '픽업' },
              ],
            },
          ],
        }),
      ),
    );

    const result = await fetchGs25Stores({ useCache: false });

    expect(result.stores[0]).toEqual(
      expect.objectContaining({
        storeCode: 'V000',
        storeName: '',
        address: '',
        phone: '',
        serviceCode: '',
        realStockQuantity: 0,
        searchItemSellPrice: null,
        propertyNames: ['픽업'],
      }),
    );
  });

  it('캐시가 있으면 재요청하지 않는다', async () => {
    mockFetch.mockResolvedValue(
      new Response(JSON.stringify({ stores: [{ storeCode: '1', storeName: 'A' }] })),
    );

    await fetchGs25Stores();
    const cached = await fetchGs25Stores();

    expect(cached.cacheHit).toBe(true);
    expect(mockFetch).toHaveBeenCalledTimes(1);
  });

  it('storeCode 파라미터를 포함해 요청할 수 있다', async () => {
    mockFetch.mockResolvedValue(
      new Response(JSON.stringify({ stores: [{ storeCode: 'VE463', storeName: 'A' }] })),
    );

    await fetchGs25Stores({ storeCode: 'VE463', useCache: false });

    const calledUrl = String(mockFetch.mock.calls[0][0]);
    expect(calledUrl).toContain('storeCode=VE463');
  });

  it('기본 store/stock 요청에는 차단되는 페이지 파라미터를 넣지 않는다', async () => {
    mockFetch.mockResolvedValue(new Response(JSON.stringify({ stores: [] })));

    await fetchGs25Stores({ itemCode: '8801056038861', latitude: 37.5, longitude: 127, useCache: false });

    const calledUrl = new URL(String(mockFetch.mock.calls[0][0]));
    expect(calledUrl.searchParams.has('pageNumber')).toBe(false);
    expect(calledUrl.searchParams.has('pageCount')).toBe(false);
    expect(calledUrl.searchParams.get('itemCode')).toBe('8801056038861');
  });

  it('명시한 경우에만 pageNumber와 pageCount를 전달한다', async () => {
    mockFetch.mockResolvedValue(new Response(JSON.stringify({ stores: [] })));

    await fetchGs25Stores({ pageNumber: 2, pageCount: 50, useCache: false });

    const calledUrl = new URL(String(mockFetch.mock.calls[0][0]));
    expect(calledUrl.searchParams.get('pageNumber')).toBe('2');
    expect(calledUrl.searchParams.get('pageCount')).toBe('50');
  });
});

describe('fetchGs25SearchProducts', () => {
  it('읽기성 POST 상품 검색은 allowlist로 재시도한다', async () => {
    mockFetch
      .mockResolvedValueOnce(
        new Response('origin timeout', { status: 522, statusText: 'Origin Timeout' }),
      )
      .mockResolvedValueOnce(
        new Response(
          JSON.stringify({
            SearchQueryResult: {
              Collection: [
                {
                  Documentset: {
                    Document: [
                      { field: { itemCode: '8801', itemName: '콜라', stockCheckYn: 'Y' } },
                    ],
                  },
                },
              ],
            },
          }),
        ),
      );

    const result = await fetchGs25SearchProducts('콜라');

    expect(result[0].itemCode).toBe('8801');
    expect(mockFetch).toHaveBeenCalledTimes(2);
  });
});

describe('유틸 함수', () => {
  it('normalizeStore는 비정상 숫자 필드를 안전한 기본값으로 정규화한다', () => {
    const store = normalizeStore({
      storeCode: '',
      storeName: '',
      storeAddress: '',
      storeTelephoneNumber: '',
      storeXCoordination: Number.NaN,
      storeYCoordination: { value: 37.5 } as never,
      serviceCode: '',
      realStockQuantity: Number.POSITIVE_INFINITY,
      pickupStkQty: 'bad',
      dlvyStkQty: { value: 1 } as never,
      isSoldOutYn: true as never,
      searchItemName: null as never,
      searchItemSellPrice: 'bad',
      propertyList: [{ storePropertyName: '', storePropertyImage: 'icon.png' }],
    });

    expect(store).toEqual(
      expect.objectContaining({
        storeCode: '',
        storeName: '',
        longitude: 0,
        latitude: 0,
        realStockQuantity: 0,
        pickupStockQuantity: 0,
        deliveryStockQuantity: 0,
        isSoldOut: true,
        searchItemName: '',
        searchItemSellPrice: 0,
        properties: [],
      }),
    );
    expect(toNumber('not-a-number')).toBe(0);
  });

  it('calculateDistanceM는 동일 좌표에서 0을 반환한다', () => {
    expect(calculateDistanceM(37.5, 127.0, 37.5, 127.0)).toBe(0);
  });

  it('filterGs25StoresByKeyword는 이름/주소를 기준으로 필터한다', () => {
    const stores = [
      {
        storeCode: '1',
        storeName: '강남역점',
        address: '서울 강남구',
        propertyNames: [],
      },
      {
        storeCode: '2',
        storeName: '홍대점',
        address: '서울 마포구',
        propertyNames: [],
      },
    ] as never;

    const filtered = filterGs25StoresByKeyword(stores, '강남');
    expect(filtered).toHaveLength(1);
    expect(filtered[0].storeCode).toBe('1');
  });

  it('filterGs25StoresByKeyword는 공백이 다른 매장명도 매칭한다', () => {
    const stores = [
      {
        storeCode: '1',
        storeName: '안산주은점',
        address: '경기 안산시 단원구 고잔1길 69',
        propertyNames: [],
      },
    ] as never;

    const filtered = filterGs25StoresByKeyword(stores, '안산 주은점');
    expect(filtered).toHaveLength(1);
    expect(filtered[0].storeCode).toBe('1');
  });

  it('filterGs25StoresByKeyword는 역명 키워드를 점포명 변형과 매칭한다', () => {
    const stores = [
      {
        storeCode: '1',
        storeName: 'GS25 안산중앙점',
        address: '경기 안산시 단원구 중앙대로 907',
        propertyNames: [],
      },
    ] as never;

    const filtered = filterGs25StoresByKeyword(stores, '안산 중앙역');
    expect(filtered).toHaveLength(1);
    expect(filtered[0].storeCode).toBe('1');
  });

  it('filterGs25StoresByKeyword는 다중 토큰을 모두 포함하는 매장을 찾는다', () => {
    const stores = [
      {
        storeCode: '1',
        storeName: '중앙점',
        address: '경기 안산시 단원구',
        propertyNames: [],
      },
      {
        storeCode: '2',
        storeName: '중앙점',
        address: '서울 중구',
        propertyNames: [],
      },
    ] as never;

    const filtered = filterGs25StoresByKeyword(stores, '안산 단원구');
    expect(filtered.map((store) => store.storeCode)).toEqual(['1']);
  });

  it('attachDistanceToGs25Stores는 거리 값을 추가한다', () => {
    const stores = [
      {
        storeCode: '1',
        storeName: 'A',
        address: '',
        phone: '',
        latitude: 37.5,
        longitude: 127,
        serviceCode: '01',
        realStockQuantity: 0,
        pickupStockQuantity: 0,
        deliveryStockQuantity: 0,
        isSoldOut: false,
        searchItemName: '',
        searchItemSellPrice: null,
        propertyNames: [],
        properties: [],
        distanceM: null,
      },
    ];

    const withDistance = attachDistanceToGs25Stores(stores, 37.5, 127);
    expect(withDistance[0].distanceM).toBe(0);
  });

  it('sortGs25Stores는 거리 우선으로 정렬한다', () => {
    const sorted = sortGs25Stores([
      {
        storeCode: '1',
        storeName: 'B',
        distanceM: 500,
        realStockQuantity: 0,
      },
      {
        storeCode: '2',
        storeName: 'A',
        distanceM: 100,
        realStockQuantity: 0,
      },
    ] as never);

    expect(sorted[0].storeCode).toBe('2');
  });

  it('sortGs25Stores는 거리/재고가 같으면 이름순으로 정렬한다', () => {
    const sorted = sortGs25Stores([
      {
        storeCode: '1',
        storeName: '나',
        distanceM: 100,
        realStockQuantity: 1,
      },
      {
        storeCode: '2',
        storeName: '가',
        distanceM: 100,
        realStockQuantity: 1,
      },
    ] as never);

    expect(sorted[0].storeCode).toBe('2');
  });

  it('sortGs25Stores는 거리가 같으면 재고 많은 매장을 우선한다', () => {
    const sorted = sortGs25Stores([
      {
        storeCode: '1',
        storeName: 'A',
        distanceM: 100,
        realStockQuantity: 1,
      },
      {
        storeCode: '2',
        storeName: 'B',
        distanceM: 100,
        realStockQuantity: 5,
      },
    ] as never);

    expect(sorted[0].storeCode).toBe('2');
  });

  it('sortGs25Stores는 거리 미상 매장을 뒤로 보낸다', () => {
    const sorted = sortGs25Stores([
      {
        storeCode: '1',
        storeName: 'A',
        distanceM: null,
        realStockQuantity: 10,
      },
      {
        storeCode: '2',
        storeName: 'B',
        distanceM: 500,
        realStockQuantity: 0,
      },
    ] as never);

    expect(sorted[0].storeCode).toBe('2');
  });

  it('sortGs25Stores는 거리 미상 매장끼리도 재고순으로 정렬한다', () => {
    const sorted = sortGs25Stores([
      {
        storeCode: '1',
        storeName: 'A',
        distanceM: null,
        realStockQuantity: 1,
      },
      {
        storeCode: '2',
        storeName: 'B',
        distanceM: null,
        realStockQuantity: 5,
      },
    ] as never);

    expect(sorted[0].storeCode).toBe('2');
  });

  it('extractGs25ProductCandidates는 상품 후보를 집계한다', () => {
    const products = extractGs25ProductCandidates([
      { searchItemName: '오감자', searchItemSellPrice: 1700, realStockQuantity: 3 },
      { searchItemName: '오감자', searchItemSellPrice: 1700, realStockQuantity: 0 },
      { searchItemName: '포카칩', searchItemSellPrice: 1800, realStockQuantity: 1 },
    ] as never);

    expect(products).toHaveLength(2);
    expect(products[0]).toEqual(
      expect.objectContaining({
        name: '오감자',
        matchedStoreCount: 2,
        inStockStoreCount: 1,
        totalStockQuantity: 3,
      }),
    );
  });

  it('extractGs25ProductCandidates는 동일 재고 조건에서 이름순 정렬한다', () => {
    const products = extractGs25ProductCandidates([
      { searchItemName: '나상품', searchItemSellPrice: 1000, realStockQuantity: 1 },
      { searchItemName: '가상품', searchItemSellPrice: 1000, realStockQuantity: 1 },
    ] as never);

    expect(products[0].name).toBe('가상품');
  });

  it('extractGs25ProductCandidates는 빈 상품명과 재고 매장 수 정렬을 처리한다', () => {
    const products = extractGs25ProductCandidates([
      { searchItemName: '', searchItemSellPrice: 1000, realStockQuantity: 100 },
      { searchItemName: '재고많음', searchItemSellPrice: 1000, realStockQuantity: 3 },
      { searchItemName: '재고적음', searchItemSellPrice: 1000, realStockQuantity: 1 },
      { searchItemName: '재고많음', searchItemSellPrice: 1000, realStockQuantity: 2 },
    ] as never);

    expect(products.map((product) => product.name)).toEqual(['재고많음', '재고적음']);
    expect(products[0].inStockStoreCount).toBe(2);
  });

  it('extractGs25ProductCandidates는 가격 null 상품을 별도 후보로 묶는다', () => {
    const products = extractGs25ProductCandidates([
      { searchItemName: '콜라', searchItemSellPrice: null, realStockQuantity: 1 },
      { searchItemName: '콜라', searchItemSellPrice: 2000, realStockQuantity: 1 },
    ] as never);

    expect(products).toHaveLength(2);
  });
});

describe('geocodeGs25Address', () => {
  it('Google Geocoding 응답을 좌표로 변환한다', async () => {
    mockFetch.mockResolvedValue(
      new Response(
        JSON.stringify({
          status: 'OK',
          results: [{ geometry: { location: { lat: 37.3172, lng: 126.8354 } } }],
        }),
      ),
    );

    const result = await geocodeGs25Address('경기도 안산시 단원구 중앙대로 885', {
      googleMapsApiKey: 'test-key',
    });

    expect(result).toEqual({ latitude: 37.3172, longitude: 126.8354 });
  });

  it('api key가 없으면 null을 반환한다', async () => {
    const result = await geocodeGs25Address('서울 강남구');
    expect(result).toBeNull();
  });

  it('주소가 비어 있으면 null을 반환한다', async () => {
    const result = await geocodeGs25Address('   ', {
      googleMapsApiKey: 'test-key',
    });
    expect(result).toBeNull();
  });

  it('status가 OK가 아니면 null을 반환한다', async () => {
    mockFetch.mockResolvedValue(
      new Response(JSON.stringify({ status: 'ZERO_RESULTS', results: [] })),
    );

    const result = await geocodeGs25Address('없는 주소', {
      googleMapsApiKey: 'test-key',
    });
    expect(result).toBeNull();
  });

  it('결과 location이 없으면 null을 반환한다', async () => {
    mockFetch.mockResolvedValue(new Response(JSON.stringify({ status: 'OK', results: [{}] })));

    const result = await geocodeGs25Address('서울 강남구', {
      googleMapsApiKey: 'test-key',
    });
    expect(result).toBeNull();
  });

  it('좌표가 0이면 null을 반환한다', async () => {
    mockFetch.mockResolvedValue(
      new Response(
        JSON.stringify({
          status: 'OK',
          results: [{ geometry: { location: { lat: 0, lng: 0 } } }],
        }),
      ),
    );

    const result = await geocodeGs25Address('서울 강남구', {
      googleMapsApiKey: 'test-key',
    });
    expect(result).toBeNull();
  });
});
