import { Test } from '@nestjs/testing';
import { ConfigModule } from '@nestjs/config';
import {
  SyncService,
  shouldRunDelistDetection,
  shouldSkipStaleDeletion,
} from './sync.service';
import { SdsClientService } from './sds-client.service';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from '../product-families/family-recompute.service';

describe('SyncService', () => {
  let service: SyncService;
  let sds: jest.Mocked<SdsClientService>;
  let prisma: PrismaService;
  const createdSdsCategoryIds: string[] = [];
  const createdSdsGoodIds: string[] = [];

  beforeAll(async () => {
    const sdsMock: Partial<SdsClientService> = {
      fetchCategoryTree: jest.fn(),
      fetchProductsPage: jest.fn(),
      fetchProductDetail: jest.fn(async (goodId: string | number) => ({ id: goodId })),
    };
    const moduleRef = await Test.createTestingModule({
      imports: [ConfigModule.forRoot({ isGlobal: true })],
      providers: [
        SyncService,
        { provide: SdsClientService, useValue: sdsMock },
        { provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
        PrismaService,
      ],
    }).compile();
    service = moduleRef.get(SyncService);
    jest
      .spyOn(service, 'syncConfiguredProductDetails')
      .mockResolvedValue({ synced: 0, failed: 0 });
    sds = moduleRef.get(SdsClientService) as jest.Mocked<SdsClientService>;
    prisma = moduleRef.get(PrismaService);
    await prisma.onModuleInit();
  });

  afterAll(async () => {
    if (createdSdsCategoryIds.length) {
      await prisma.category.deleteMany({
        where: { sdsCategoryId: { in: createdSdsCategoryIds } },
      });
    }
    if (createdSdsGoodIds.length) {
      await prisma.originGood.deleteMany({
        where: { sdsGoodId: { in: createdSdsGoodIds } },
      });
    }
    await prisma.onModuleDestroy();
  });

  it('should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('flattenCategoryTree', () => {
    it('flattens a nested SDS tree and preserves parent linkage', () => {
      const tree = [
        {
          id: 1,
          name: 'Root',
          children: [
            { id: 11, name: 'Child A' },
            { id: 12, name: 'Child B', children: [{ id: 121, name: 'Leaf' }] },
          ],
        },
      ];
      const flat = service.flattenCategoryTree(tree);
      expect(flat).toHaveLength(4);
      const byId = Object.fromEntries(flat.map((n) => [n.sdsId, n]));
      expect(byId['1'].name).toBe('Root');
      expect(byId['1'].parentSdsId).toBeUndefined();
      expect(byId['11'].parentSdsId).toBe('1');
      expect(byId['12'].parentSdsId).toBe('1');
      expect(byId['121'].parentSdsId).toBe('12');
    });

    it('skips nodes without a usable id', () => {
      const flat = service.flattenCategoryTree([{ id: null, name: 'no-id' }]);
      expect(flat).toHaveLength(0);
    });
  });

  describe('syncCategories', () => {
    it('inserts + updates rows and links parents', async () => {
      const stamp = Date.now();
      sds.fetchCategoryTree.mockResolvedValueOnce([
        {
          id: `r-${stamp}`,
          name: `Root ${stamp}`,
          children: [{ id: `c-${stamp}`, name: `Child ${stamp}` }],
        },
      ]);

      const result = await service.syncCategories();
      expect(result.total).toBe(2);
      expect(result.inserted).toBe(2);
      expect(result.updated).toBe(0);
      createdSdsCategoryIds.push(`r-${stamp}`, `c-${stamp}`);

      const child = await prisma.category.findUnique({
        where: { sdsCategoryId: `c-${stamp}` },
      });
      const root = await prisma.category.findUnique({
        where: { sdsCategoryId: `r-${stamp}` },
      });
      expect(child?.parentCategoryId).toBe(root?.id);
    });

    it('marks SyncLog SUCCESS', async () => {
      const logs = await prisma.syncLog.findMany({
        where: { type: 'CATEGORIES' },
        orderBy: { startedAt: 'desc' },
        take: 1,
      });
      expect(logs[0]?.status).toBe('SUCCESS');
    });
  });

  describe('syncProducts', () => {
    beforeEach(() => {
      jest.clearAllMocks();
    });

    it('upserts origin goods by sdsGoodId and updates on re-run', async () => {
      // Seed a leaf category with sdsCategoryId so the sync has work.
      const stamp = Date.now();
      const leaf = await prisma.category.create({
        data: {
          sdsCategoryId: `leaf-${stamp}`,
          categoryName: `Leaf ${stamp}`,
        },
      });
      createdSdsCategoryIds.push(`leaf-${stamp}`);

      // Default mock returns a single page with 2 products, then
      // breaks the loop because content.length < 50.
      sds.fetchProductsPage.mockImplementation(async (categoryId) => {
        if (categoryId === `leaf-${stamp}`) {
          return {
            content: [
              { id: `p-${stamp}-1`, name: 'Product 1', price: 12.5, pic: 'http://x' },
              { id: `p-${stamp}-2`, name: 'Product 2', price: '99.00' },
            ],
          };
        }
        // For any other (already-existing) category, return empty
        // so the loop terminates immediately.
        return { content: [] };
      });

      const result1 = await service.syncProducts();
      expect(result1.inserted).toBeGreaterThanOrEqual(2);
      createdSdsGoodIds.push(`p-${stamp}-1`, `p-${stamp}-2`);

      // Re-run with updated name -> should be `updated`, not `inserted`.
      sds.fetchProductsPage.mockImplementation(async (categoryId) => {
        if (categoryId === `leaf-${stamp}`) {
          return {
            content: [
              { id: `p-${stamp}-1`, name: 'Product 1 renamed' },
            ],
          };
        }
        return { content: [] };
      });
      const result2 = await service.syncProducts();
      expect(result2.updated).toBeGreaterThanOrEqual(1);
      const row = await prisma.originGood.findUnique({
        where: { sdsGoodId: `p-${stamp}-1` },
      });
      expect(row?.goodName).toBe('Product 1 renamed');
    });
  });

  describe('sync guard thresholds', () => {
    describe('shouldSkipStaleDeletion', () => {
      it('skips stale deletion when the fetched count is below the hard floor', () => {
        expect(shouldSkipStaleDeletion(2, 226)).toBe(true);
        expect(shouldSkipStaleDeletion(9, 226)).toBe(true);
      });

      it('skips stale deletion when fetched is far smaller than existing (ratio guard)', () => {
        expect(shouldSkipStaleDeletion(100, 250)).toBe(true);
      });

      it('does NOT skip when fetched count is healthy', () => {
        expect(shouldSkipStaleDeletion(226, 226)).toBe(false);
        expect(shouldSkipStaleDeletion(200, 226)).toBe(false);
      });

      it('does NOT skip when there are no existing SDS categories', () => {
        expect(shouldSkipStaleDeletion(0, 0)).toBe(false);
        expect(shouldSkipStaleDeletion(2, 0)).toBe(false);
      });
    });

    describe('shouldRunDelistDetection', () => {
      it('skips delist detection when leaf categories are too few', () => {
        expect(shouldRunDelistDetection(2, 500)).toBe(false);
        expect(shouldRunDelistDetection(9, 500)).toBe(false);
      });

      it('skips delist detection when the seen product count is too small', () => {
        expect(shouldRunDelistDetection(148, 2)).toBe(false);
        expect(shouldRunDelistDetection(148, 49)).toBe(false);
      });

      it('runs delist detection only when both metrics are healthy', () => {
        expect(shouldRunDelistDetection(148, 500)).toBe(true);
        expect(shouldRunDelistDetection(10, 50)).toBe(true);
      });
    });

    it('category sync keeps existing SDS categories when upstream returns a degenerate tree', async () => {
      const stamp = Date.now();
      const keep = await prisma.category.create({
        data: { sdsCategoryId: `keep-${stamp}`, categoryName: `Keep ${stamp}` },
      });
      createdSdsCategoryIds.push(`keep-${stamp}`);

      sds.fetchCategoryTree.mockResolvedValueOnce([
        { id: `g-${stamp}-1`, name: 'Tiny 1' },
        { id: `g-${stamp}-2`, name: 'Tiny 2' },
      ]);
      createdSdsCategoryIds.push(`g-${stamp}-1`, `g-${stamp}-2`);

      const result = await service.syncCategories();
      expect(result.deletedStale).toBe(0);

      const still = await prisma.category.findUnique({ where: { id: keep.id } });
      expect(still).not.toBeNull();
    });

    it('product sync does NOT delist origin goods when it sees too few products', async () => {
      const stamp = Date.now();
      const leaf = await prisma.category.create({
        data: { sdsCategoryId: `leafguard-${stamp}`, categoryName: `LeafGuard ${stamp}` },
      });
      createdSdsCategoryIds.push(`leafguard-${stamp}`);

      const active = await prisma.originGood.create({
        data: { sdsGoodId: `active-${stamp}`, delisted: false, goodName: 'Active' },
      });
      createdSdsGoodIds.push(`active-${stamp}`);

      sds.fetchProductsPage.mockImplementation(async (categoryId) => {
        if (categoryId === `leafguard-${stamp}`) {
          return {
            content: [
              { id: `guardp-${stamp}-1`, name: 'P1' },
              { id: `guardp-${stamp}-2`, name: 'P2' },
            ],
          };
        }
        return { content: [] };
      });

      const result = await service.syncProducts();
      expect(result.delisted).toBe(0);

      const still = await prisma.originGood.findUnique({ where: { id: active.id } });
      expect(still?.delisted).toBe(false);
      createdSdsGoodIds.push(`guardp-${stamp}-1`, `guardp-${stamp}-2`);
    });
  });

  describe('getStatus', () => {
    it('returns recent logs ordered by startedAt desc', async () => {
      const logs = await service.getStatus(5);
      expect(Array.isArray(logs)).toBe(true);
      expect(logs.length).toBeGreaterThan(0);
    });
  });
});

describe('SyncService product detail scopes', () => {
  const originGoods = [
    { id: 1n, sdsGoodId: 'all-1' },
    { id: 2n, sdsGoodId: 'all-2' },
  ];

  function createService() {
    const prisma = {
      originGood: { findMany: jest.fn().mockResolvedValue(originGoods) },
    } as unknown as PrismaService;
    const sds = {
      fetchProductDetail: jest.fn(async (goodId: string) => ({ id: goodId })),
    } as unknown as SdsClientService;
    const familyRecompute = {
      enqueue: jest.fn(),
    } as unknown as FamilyRecomputeService;
    const scopedService = new SyncService(prisma, sds, familyRecompute);
    jest
      .spyOn(scopedService as any, 'persistProductDetail')
      .mockResolvedValue(undefined);
    return { scopedService, prisma, sds };
  }

  it('manual detail sync selects every active origin product', async () => {
    const { scopedService, prisma, sds } = createService();
    const result = await scopedService.syncAllProductDetails();

    expect(prisma.originGood.findMany).toHaveBeenCalledWith(
      expect.objectContaining({ where: { delisted: false, source: 'SDS' } }),
    );
    expect(sds.fetchProductDetail).toHaveBeenCalledTimes(2);
    expect(result).toEqual({ total: 2, synced: 2, failed: 0 });
  });

  it('hourly detail refresh remains limited to configured products', async () => {
    const { scopedService, prisma } = createService();
    await scopedService.syncConfiguredProductDetails();

    expect(prisma.originGood.findMany).toHaveBeenCalledWith(
      expect.objectContaining({
        where: { delisted: false, source: 'SDS', goods: { some: {} } },
      }),
    );
  });

  it('keeps hourly category/product sync separate from the daily detail sync', async () => {
    const { scopedService } = createService();
    const categories = jest.spyOn(scopedService, 'syncCategories').mockResolvedValue({
      inserted: 0, updated: 0, total: 0, deletedStale: 0,
    });
    const products = jest.spyOn(scopedService, 'syncProducts').mockResolvedValue({
      inserted: 0, updated: 0, total: 0, leafCategories: 0, delisted: 0,
    });
    const details = jest.spyOn(scopedService, 'syncProductDetails').mockResolvedValue({
      total: 0, synced: 0, failed: 0,
    });

    await scopedService.hourlyCron();
    expect(categories).toHaveBeenCalledTimes(1);
    expect(products).toHaveBeenCalledTimes(1);
    expect(details).not.toHaveBeenCalled();

    await scopedService.dailyProductDetailCron();
    expect(details).toHaveBeenCalledTimes(1);
  });
});
