/**
 * costMonitor.ts 단위 테스트
 *
 * 테스트 대상:
 *  - trackApiUsage(): type별 Firestore 필드 increment
 *  - trackApiUsage() query 타입: hourlyQueryCounts 함께 증가
 *  - getDailyUsage(): 문서 있을 때 / 없을 때
 *  - getUsageSummary(): daily/weekly/monthly 날짜 범위 조회
 *  - estimateQueryCost(): 캐시 히트/미스에 따른 비용 계산
 */

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

// ─────────────────────────────────────────────
// vi.hoisted()를 사용해 hoisting 시 참조 가능한 mock 변수 선언
// ─────────────────────────────────────────────

const { mockSet, mockGet, mockDoc, mockCollection, mockFirestore } = vi.hoisted(() => {
    const mockSet = vi.fn().mockResolvedValue(undefined);
    const mockGet = vi.fn();
    const mockDoc = vi.fn().mockReturnValue({ set: mockSet, get: mockGet });
    const mockCollection = vi.fn().mockReturnValue({ doc: mockDoc });
    const mockFirestore = vi.fn().mockReturnValue({ collection: mockCollection });
    return { mockSet, mockGet, mockDoc, mockCollection, mockFirestore };
});

vi.mock('@/lib/firebase-admin', () => ({
    getFirebaseAdmin: vi.fn().mockReturnValue({
        firestore: mockFirestore,
    }),
}));

vi.mock('firebase-admin/firestore', () => ({
    FieldValue: {
        increment: vi.fn((n: number) => ({ _increment: n })),
        serverTimestamp: vi.fn(() => ({ _serverTimestamp: true })),
    },
}));

vi.mock('server-only', () => ({}));

import { trackApiUsage, getDailyUsage, getUsageSummary, estimateQueryCost, getTodayUsage } from '../costMonitor';

// ─────────────────────────────────────────────
// beforeEach: mock 초기화
// ─────────────────────────────────────────────

beforeEach(() => {
    vi.clearAllMocks();
    mockSet.mockResolvedValue(undefined);
    mockDoc.mockReturnValue({ set: mockSet, get: mockGet });
    mockCollection.mockReturnValue({ doc: mockDoc });
    mockFirestore.mockReturnValue({ collection: mockCollection });
});

// ─────────────────────────────────────────────
// trackApiUsage 테스트
// ─────────────────────────────────────────────

describe('trackApiUsage', () => {
    it('firestoreRead 타입은 firestoreReads 필드를 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'firestoreRead' });

        expect(mockSet).toHaveBeenCalledOnce();
        const [payload, options] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('firestoreReads');
        expect((payload.firestoreReads as any)._increment).toBe(1);
        expect(options).toEqual({ merge: true });
    });

    it('firestoreWrite 타입은 firestoreWrites 필드를 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'firestoreWrite' });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('firestoreWrites');
        expect((payload.firestoreWrites as any)._increment).toBe(1);
    });

    it('geminiEmbedding 타입은 geminiEmbeddingCalls 필드를 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'geminiEmbedding' });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('geminiEmbeddingCalls');
        expect((payload.geminiEmbeddingCalls as any)._increment).toBe(1);
    });

    it('geminiGeneration 타입은 geminiGenerationCalls 필드를 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'geminiGeneration' });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('geminiGenerationCalls');
        expect((payload.geminiGenerationCalls as any)._increment).toBe(1);
    });

    it('cacheHit 타입은 cacheHits 필드를 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'cacheHit' });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('cacheHits');
        expect((payload.cacheHits as any)._increment).toBe(1);
    });

    it('cacheMiss 타입은 cacheMisses 필드를 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'cacheMiss' });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('cacheMisses');
        expect((payload.cacheMisses as any)._increment).toBe(1);
    });

    it('count 파라미터를 전달하면 해당 값만큼 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'firestoreRead', count: 5 });

        const [payload] = mockSet.mock.calls[0];
        expect((payload.firestoreReads as any)._increment).toBe(5);
    });

    it('query 타입은 queryCalls + hourlyQueryCounts 필드도 함께 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'query' });

        expect(mockSet).toHaveBeenCalledOnce();
        const [payload] = mockSet.mock.calls[0];

        // queryCalls 필드 확인
        expect(payload).toHaveProperty('queryCalls');
        expect((payload.queryCalls as any)._increment).toBe(1);

        // hourlyQueryCounts.HH 필드 확인 (현재 UTC 시간대)
        const hourKey = new Date().getUTCHours().toString().padStart(2, '0');
        const hourlyField = `hourlyQueryCounts.${hourKey}`;
        expect(payload).toHaveProperty(hourlyField);
        expect((payload[hourlyField] as any)._increment).toBe(1);
    });

    it('query 타입이 아닌 경우 hourlyQueryCounts 필드가 없어야 한다', async () => {
        await trackApiUsage({ type: 'firestoreRead' });

        const [payload] = mockSet.mock.calls[0];
        const keys = Object.keys(payload);
        const hasHourly = keys.some((k: string) => k.startsWith('hourlyQueryCounts.'));
        expect(hasHourly).toBe(false);
    });

    it('estimatedCostUsd도 함께 increment 해야 한다', async () => {
        await trackApiUsage({ type: 'geminiEmbedding', count: 1 });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('estimatedCostUsd');
        // geminiEmbedding cost: 0.000001 * 1 = 0.000001
        expect((payload.estimatedCostUsd as any)._increment).toBeCloseTo(0.000001);
    });

    it('updatedAt은 serverTimestamp이어야 한다', async () => {
        await trackApiUsage({ type: 'firestoreRead' });

        const [payload] = mockSet.mock.calls[0];
        expect(payload).toHaveProperty('updatedAt');
        expect((payload.updatedAt as any)._serverTimestamp).toBe(true);
    });
});

// ─────────────────────────────────────────────
// getDailyUsage 테스트
// ─────────────────────────────────────────────

describe('getDailyUsage', () => {
    it('문서가 있을 때 올바른 ApiUsageDaily 객체를 반환해야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: true,
            data: () => ({
                date: '2026-03-01',
                firestoreReads: 100,
                firestoreWrites: 20,
                geminiEmbeddingCalls: 50,
                geminiGenerationCalls: 10,
                queryCalls: 30,
                cacheHits: 15,
                cacheMisses: 15,
                estimatedCostUsd: 0.05,
                hourlyQueryCounts: { '09': 10, '10': 20 },
                updatedAt: { toDate: () => new Date() },
            }),
        });

        const result = await getDailyUsage('2026-03-01');

        expect(result).not.toBeNull();
        expect(result!.date).toBe('2026-03-01');
        expect(result!.firestoreReads).toBe(100);
        expect(result!.firestoreWrites).toBe(20);
        expect(result!.geminiEmbeddingCalls).toBe(50);
        expect(result!.queryCalls).toBe(30);
        expect(result!.cacheHits).toBe(15);
        expect(result!.cacheMisses).toBe(15);
        expect(result!.estimatedCostUsd).toBe(0.05);
        expect(result!.hourlyQueryCounts).toEqual({ '09': 10, '10': 20 });
    });

    it('문서가 없을 때 null을 반환해야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: false,
            data: () => undefined,
        });

        const result = await getDailyUsage('2026-03-01');
        expect(result).toBeNull();
    });

    it('누락된 필드는 기본값(0)으로 채워야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: true,
            data: () => ({
                date: '2026-03-01',
                // 나머지 필드 모두 누락
            }),
        });

        const result = await getDailyUsage('2026-03-01');

        expect(result).not.toBeNull();
        expect(result!.firestoreReads).toBe(0);
        expect(result!.firestoreWrites).toBe(0);
        expect(result!.estimatedCostUsd).toBe(0);
        expect(result!.hourlyQueryCounts).toEqual({});
    });
});

// ─────────────────────────────────────────────
// getUsageSummary 테스트
// ─────────────────────────────────────────────

describe('getUsageSummary', () => {
    function makeExistingSnap(date: string, cost: number) {
        return {
            exists: true,
            id: date,
            data: () => ({
                date,
                firestoreReads: 0,
                firestoreWrites: 0,
                geminiEmbeddingCalls: 0,
                geminiGenerationCalls: 0,
                queryCalls: 0,
                cacheHits: 0,
                cacheMisses: 0,
                estimatedCostUsd: cost,
                hourlyQueryCounts: {},
            }),
        };
    }

    it('daily period는 1개의 날짜 문서만 조회해야 한다', async () => {
        mockGet.mockResolvedValueOnce(makeExistingSnap('2026-03-01', 0.10));

        const result = await getUsageSummary({ period: 'daily', date: '2026-03-01' });

        // doc() 호출 횟수 = 1
        expect(mockDoc).toHaveBeenCalledTimes(1);
        expect(result.data).toHaveLength(1);
        expect(result.totalCost).toBeCloseTo(0.10);
        expect(result.avgDailyCost).toBeCloseTo(0.10);
    });

    it('weekly period는 7개의 날짜 문서를 조회해야 한다', async () => {
        // 7개 중 3개만 문서가 있다고 가정
        for (let i = 0; i < 7; i++) {
            if (i < 3) {
                mockGet.mockResolvedValueOnce(makeExistingSnap(`2026-0${i + 1}-01`, 0.05));
            } else {
                mockGet.mockResolvedValueOnce({ exists: false, id: `x${i}`, data: () => undefined });
            }
        }

        const result = await getUsageSummary({ period: 'weekly', date: '2026-03-01' });

        expect(mockDoc).toHaveBeenCalledTimes(7);
        expect(result.data).toHaveLength(3);
        expect(result.totalCost).toBeCloseTo(0.15);
        expect(result.avgDailyCost).toBeCloseTo(0.05);
    });

    it('monthly period는 30개의 날짜 문서를 조회해야 한다', async () => {
        for (let i = 0; i < 30; i++) {
            mockGet.mockResolvedValueOnce({ exists: false, id: `x${i}`, data: () => undefined });
        }

        await getUsageSummary({ period: 'monthly', date: '2026-03-01' });

        expect(mockDoc).toHaveBeenCalledTimes(30);
    });

    it('데이터가 없으면 totalCost=0, avgDailyCost=0을 반환해야 한다', async () => {
        mockGet.mockResolvedValue({ exists: false, id: 'x', data: () => undefined });

        const result = await getUsageSummary({ period: 'daily', date: '2026-03-01' });

        expect(result.data).toHaveLength(0);
        expect(result.totalCost).toBe(0);
        expect(result.avgDailyCost).toBe(0);
    });
});

// ─────────────────────────────────────────────
// estimateQueryCost 테스트
// ─────────────────────────────────────────────

describe('estimateQueryCost', () => {
    it('캐시 미스 시 임베딩 + 읽기 + 생성 비용이 모두 포함되어야 한다', async () => {
        const result = await estimateQueryCost({
            embeddingCalls: 1,
            firestoreReads: 10,
            generationCalls: 1,
            isCacheHit: false,
        });

        // embeddingCost: 1 * 0.000001 = 0.000001
        // readCost: 10 * 0.00000036 = 0.0000036
        // generationCost: 1 * 0.0000075 = 0.0000075
        expect(result.breakdown.embeddingCost).toBeCloseTo(0.000001);
        expect(result.breakdown.firestoreReadCost).toBeCloseTo(0.0000036);
        expect(result.breakdown.generationCost).toBeCloseTo(0.0000075);
        expect(result.costUsd).toBeCloseTo(0.000001 + 0.0000036 + 0.0000075);
    });

    it('캐시 히트 시 생성 비용이 0이어야 한다', async () => {
        const result = await estimateQueryCost({
            embeddingCalls: 1,
            firestoreReads: 5,
            generationCalls: 1,
            isCacheHit: true,
        });

        expect(result.breakdown.generationCost).toBe(0);
        // embeddingCost: 0.000001, readCost: 5 * 0.00000036 = 0.0000018
        expect(result.costUsd).toBeCloseTo(0.000001 + 0.0000018);
    });

    it('breakdown에 embeddingCost, firestoreReadCost, generationCost 키가 있어야 한다', async () => {
        const result = await estimateQueryCost({
            embeddingCalls: 0,
            firestoreReads: 0,
            generationCalls: 0,
            isCacheHit: false,
        });

        expect(result.breakdown).toHaveProperty('embeddingCost');
        expect(result.breakdown).toHaveProperty('firestoreReadCost');
        expect(result.breakdown).toHaveProperty('generationCost');
    });

    it('모든 입력이 0이면 costUsd=0이어야 한다', async () => {
        const result = await estimateQueryCost({
            embeddingCalls: 0,
            firestoreReads: 0,
            generationCalls: 0,
            isCacheHit: false,
        });

        expect(result.costUsd).toBe(0);
    });
});

// ─────────────────────────────────────────────
// getTodayUsage 테스트
// ─────────────────────────────────────────────

describe('getTodayUsage', () => {
    // MAX_DAILY_COST_USD 환경변수를 각 테스트 후 초기화
    afterEach(() => {
        delete process.env.MAX_DAILY_COST_USD;
        vi.resetModules();
    });

    it('문서가 있을 때 올바른 DailyUsageBudget 객체를 반환해야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: true,
            data: () => ({
                date: new Date().toISOString().split('T')[0],
                firestoreReads: 50,
                firestoreWrites: 10,
                geminiEmbeddingCalls: 20,
                geminiGenerationCalls: 5,
                queryCalls: 30,
                cacheHits: 12,
                cacheMisses: 18,
                estimatedCostUsd: 0.03,
                hourlyQueryCounts: { '10': 15, '11': 15 },
                updatedAt: { toDate: () => new Date() },
            }),
        });

        const result = await getTodayUsage();

        expect(result).toHaveProperty('totalQueries', 30);
        expect(result).toHaveProperty('estimatedCostUsd', 0.03);
        expect(result).toHaveProperty('isOverBudget');
    });

    it('estimatedCostUsd가 MAX_DAILY_COST_USD 이상이면 isOverBudget=true여야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: true,
            data: () => ({
                date: new Date().toISOString().split('T')[0],
                queryCalls: 100,
                estimatedCostUsd: 5.0, // 기본 MAX_DAILY_COST_USD=5 와 동일 → 이상(>=) 이므로 true
                hourlyQueryCounts: {},
            }),
        });

        const result = await getTodayUsage();

        expect(result.estimatedCostUsd).toBe(5.0);
        expect(result.isOverBudget).toBe(true);
    });

    it('estimatedCostUsd가 MAX_DAILY_COST_USD 미만이면 isOverBudget=false여야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: true,
            data: () => ({
                date: new Date().toISOString().split('T')[0],
                queryCalls: 10,
                estimatedCostUsd: 4.99, // 기본 MAX_DAILY_COST_USD=5 미만
                hourlyQueryCounts: {},
            }),
        });

        const result = await getTodayUsage();

        expect(result.estimatedCostUsd).toBe(4.99);
        expect(result.isOverBudget).toBe(false);
    });

    it('문서가 없으면 기본값(totalQueries:0, estimatedCostUsd:0, isOverBudget:false)을 반환해야 한다', async () => {
        mockGet.mockResolvedValue({
            exists: false,
            data: () => undefined,
        });

        const result = await getTodayUsage();

        expect(result.totalQueries).toBe(0);
        expect(result.estimatedCostUsd).toBe(0);
        expect(result.isOverBudget).toBe(false);
    });

    it('MAX_DAILY_COST_USD 환경변수가 설정되면 해당 값을 기준으로 isOverBudget을 판단해야 한다', async () => {
        // 환경변수를 미리 설정한 후 모듈을 동적으로 재로드하여 parseFloat이 새 값을 읽도록 한다
        process.env.MAX_DAILY_COST_USD = '2';

        vi.resetModules();

        // mock 설정은 vi.mock hoisting으로 유지되므로 재설정 불필요
        // 단, resetModules 후 재로드된 모듈에서 firebase-admin mock이 동작해야 하므로
        // mockGet을 통한 반환값을 다시 지정한다
        mockGet.mockResolvedValue({
            exists: true,
            data: () => ({
                date: new Date().toISOString().split('T')[0],
                queryCalls: 5,
                estimatedCostUsd: 2.5, // MAX_DAILY_COST_USD=2 기준으로 초과 → true
                hourlyQueryCounts: {},
            }),
        });

        // 재로드된 모듈에서 getTodayUsage 재import
        const { getTodayUsage: getTodayUsageReloaded } = await import('../costMonitor');
        const result = await getTodayUsageReloaded();

        expect(result.estimatedCostUsd).toBe(2.5);
        expect(result.isOverBudget).toBe(true);
    });
});
