import { apiResponse, getCachedApiResponse, setCachedApiResponse, getApiPromise } from "./api";
import { defaultOptions } from "../options";
import { getCache, setCache } from "../utils/cache";
import { setVisitorId } from "../utils/visitorId";
import type { componentInterface } from "../factory";
import * as hashModule from "../utils/hash";
import * as stringifyModule from "../utils/stableStringify";

const options = {
    ...defaultOptions,
    cache_lifetime_in_ms: 100,
}

const apiResponse = {
    identifier: 'test'
} as apiResponse;

describe('setCachedApiResponse', () => {
    beforeEach(() => {
        localStorage.clear();
    })

    test('it should write the apiResponse to the cache if options allow that', () => {
        setCachedApiResponse(options, apiResponse);
        expect(getCache(options).apiResponse).toEqual(apiResponse);
    });

    test('it should not write if cache if off', () => {
        setCachedApiResponse({
            ...options,
            cache_api_call: false,
        }, apiResponse);

        expect(getCache(options).apiResponse).not.toBeDefined();
        expect(getCache(options).apiResponseExpiry).not.toBeDefined();
    });

    test('it should not write if lifetime is 0', () => {
        setCachedApiResponse({
            ...options,
            cache_lifetime_in_ms: 0
        }, apiResponse);

        expect(getCache(options).apiResponse).not.toBeDefined();
        expect(getCache(options).apiResponseExpiry).not.toBeDefined();
    });
})

describe('getCachedApiResponse', () => {
    beforeEach(() => {
        localStorage.clear();
    })

    test('it should get from the cache if a value exists there', () => {
        setCache(options, {
            apiResponseExpiry: Date.now() + 2000000,
            apiResponse,
        });

        const cached = getCachedApiResponse(options);
        expect(cached).toBeDefined();
        expect(cached).toEqual(apiResponse);
    });

    test('it should not return an expiried cache', () => {
        setCache(options, {
            apiResponseExpiry: Date.now() - 2000,
            apiResponse,
        });

        const cached = getCachedApiResponse(options);
        expect(cached).not.toBeDefined();
    })

})

describe('getApiPromise timeout behavior', () => {
    let mockFetch: jest.Mock;
    const testOptions = {
        ...defaultOptions,
        api_key: 'test-api-key',
        timeout: 100, // Short timeout for tests
        cache_lifetime_in_ms: 1000,
    };

    const testComponents: componentInterface = {
        userAgent: 'test-agent',
        screen: { width: 1920, height: 1080 },
    };

    beforeAll(() => {
        // Polyfill TextEncoder for jsdom environment
        if (typeof TextEncoder === 'undefined') {
            const util = require('util');
            global.TextEncoder = util.TextEncoder;
            global.TextDecoder = util.TextDecoder;
        }

        // Mock hash and stableStringify to avoid TextEncoder issues
        jest.spyOn(hashModule, 'hash').mockReturnValue('mocked-hash');
        jest.spyOn(stringifyModule, 'stableStringify').mockReturnValue('{"mocked":"data"}');

        jest.useFakeTimers();
    });

    afterAll(() => {
        jest.useRealTimers();
    });

    beforeEach(async () => {
        // Clear all pending timers and promises from previous tests
        jest.clearAllTimers();

        localStorage.clear();
        mockFetch = jest.fn();
        global.fetch = mockFetch;

        // Re-mock hash and stableStringify for each test
        jest.spyOn(hashModule, 'hash').mockReturnValue('mocked-hash');
        jest.spyOn(stringifyModule, 'stableStringify').mockReturnValue('{"mocked":"data"}');
    });

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

    test('returns fetch response when fetch completes before timeout', async () => {
        // Use options without caching to avoid state interference
        const noCacheOptions = {
            ...testOptions,
            cache_lifetime_in_ms: 0, // Disable caching
        };

        // Mock fetch to resolve quickly
        mockFetch.mockResolvedValueOnce({
            ok: true,
            status: 200,
            json: async () => ({
                version: '1.2.3',
                visitorId: 'test-visitor-id',
                thumbmark: 'test-thumbmark',
                requestId: 'test-request-id',
                info: {
                    ip_address: {
                        ip_address: '1.2.3.4',
                        ip_identifier: 'abc123',
                        autonomous_system_number: 12345,
                        ip_version: 'v4' as const,
                    }
                }
            })
        });

        const promise = getApiPromise(noCacheOptions, testComponents);

        // Fast forward time, but fetch completes before timeout
        await jest.runAllTimersAsync();
        const result = await promise;

        expect(result).toBeDefined();
        expect(result?.version).toBe('1.2.3');
        expect(result?.info?.timed_out).toBeUndefined();
        expect(result?.thumbmark).toBe('test-thumbmark');
        expect(result?.requestId).toBe('test-request-id');
    });

    test('returns expired cache when timeout occurs and cache exists', async () => {
        // Disable caching to test timeout fallback without request deduplication
        const noCacheOptions = {
            ...testOptions,
            cache_api_call: false,
        };

        // Set up expired cache
        const expiredCachedValue = {
            apiResponseExpiry: Date.now() - 100, // Expired
            apiResponse: {
                version: '1.2.3',
                thumbmark: 'cached-thumbmark',
                info: {
                    ip_address: {
                        ip_address: '1.2.3.4',
                        ip_identifier: 'abc123',
                        autonomous_system_number: 12345,
                        ip_version: 'v4' as const,
                    }
                }
            }
        };
        setCache(noCacheOptions, expiredCachedValue);

        // Mock fetch to never resolve (hanging request)
        mockFetch.mockReturnValueOnce(new Promise(() => { }));

        const promise = getApiPromise(noCacheOptions, testComponents);

        // Advance timers to trigger timeout
        await jest.runAllTimersAsync();
        const result = await promise;

        // Should return expired cache, not timeout response
        expect(result).toBeDefined();
        expect(result?.version).toBe('1.2.3');
        expect(result?.thumbmark).toBe('cached-thumbmark');
        expect(result?.info?.timed_out).toBeUndefined();
    });

    test('returns timeout response when timeout occurs and no cache exists', async () => {
        const noCacheOptions = {
            ...testOptions,
            cache_api_call: false,
        };

        expect(getCache(noCacheOptions)).toEqual({});

        mockFetch.mockReturnValueOnce(new Promise(() => { }));

        const promise = getApiPromise(noCacheOptions, testComponents);

        await jest.runAllTimersAsync();
        const result = await promise;

        expect(result).toBeDefined();
        expect(result?.info?.timed_out).toBe(true);

        const cached = getCache(noCacheOptions);
        expect(cached.apiResponse).toBeUndefined();
    });

    test('returns timeout response without visitorId when no cache and no stored visitorId', async () => {
        const noCacheOptions = {
            ...testOptions,
            cache_api_call: false,
        };

        // Ensure no cache and no visitor ID
        expect(getCache(noCacheOptions)).toEqual({});
        expect(localStorage.getItem('thumbmark_visitor_id')).toBeNull();

        mockFetch.mockReturnValueOnce(new Promise(() => { }));

        const promise = getApiPromise(noCacheOptions, testComponents);

        await jest.runAllTimersAsync();
        const result = await promise;

        expect(result).toBeDefined();
        expect(result?.info?.timed_out).toBe(true);
        expect(result?.visitorId).toBeUndefined();
    });

    test('returns timeout response with visitorId when no cache but visitorId exists in localStorage', async () => {
        const noCacheOptions = {
            ...testOptions,
            cache_api_call: false,
        };

        // Set up visitor ID in localStorage (no cache)
        const storedVisitorId = 'stored-visitor-id-123';
        setVisitorId(storedVisitorId, noCacheOptions);

        // Verify setup: no cache, but visitor ID exists
        expect(getCache(noCacheOptions)).toEqual({});
        expect(localStorage.getItem('thumbmark_visitor_id')).toBe(storedVisitorId);

        mockFetch.mockReturnValueOnce(new Promise(() => { }));

        const promise = getApiPromise(noCacheOptions, testComponents);

        await jest.runAllTimersAsync();
        const result = await promise;

        expect(result).toBeDefined();
        expect(result?.info?.timed_out).toBe(true);
        expect(result?.visitorId).toBe(storedVisitorId);
    });

    test('retries on network error and succeeds on second attempt', async () => {
        jest.useRealTimers();
        const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };

        mockFetch
            .mockRejectedValueOnce(new TypeError('Failed to fetch'))
            .mockResolvedValueOnce({
                ok: true, status: 200,
                json: async () => ({ version: '1.0', thumbmark: 'retry-ok' }),
            });

        const result = await getApiPromise(noCacheOptions, testComponents);

        expect(mockFetch).toHaveBeenCalledTimes(2);
        expect(result?.thumbmark).toBe('retry-ok');
        jest.useFakeTimers();
    });

    test('does not retry on 500 server error', async () => {
        jest.useRealTimers();
        const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };

        mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });

        await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('HTTP error! status: 500');
        expect(mockFetch).toHaveBeenCalledTimes(1);
        jest.useFakeTimers();
    });

    test('does not retry on 403 auth error', async () => {
        jest.useRealTimers();
        const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };

        mockFetch.mockResolvedValueOnce({ ok: false, status: 403 });

        await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('HTTP error! status: 403');
        expect(mockFetch).toHaveBeenCalledTimes(1);
        jest.useFakeTimers();
    });

    test('network errors exhaust retries then throw', async () => {
        jest.useRealTimers();
        const noCacheOptions = { ...testOptions, cache_api_call: false, timeout: 5000 };

        mockFetch.mockRejectedValue(new TypeError('Failed to fetch'));

        await expect(getApiPromise(noCacheOptions, testComponents)).rejects.toThrow('Failed to fetch');
        expect(mockFetch).toHaveBeenCalledTimes(3);
        jest.useFakeTimers();
    });

    test('returns full cached response (not just visitorId) when cache exists on timeout', async () => {
        const noCacheOptions = {
            ...testOptions,
            cache_api_call: false,
        };

        // Set up both: expired cache AND visitor ID in localStorage
        const storedVisitorId = 'stored-visitor-id-456';
        setVisitorId(storedVisitorId, noCacheOptions);

        const expiredCachedValue = {
            apiResponseExpiry: Date.now() - 100, // Expired
            apiResponse: {
                version: '1.2.3',
                thumbmark: 'cached-thumbmark',
                visitorId: 'cached-visitor-id',
                info: {
                    uniqueness: { score: 0.95 }
                }
            }
        };
        setCache(noCacheOptions, expiredCachedValue);

        mockFetch.mockReturnValueOnce(new Promise(() => { }));

        const promise = getApiPromise(noCacheOptions, testComponents);

        await jest.runAllTimersAsync();
        const result = await promise;

        // Should return full cached response, not just visitor ID fallback
        expect(result).toBeDefined();
        expect(result?.info?.timed_out).toBeUndefined();
        expect(result?.thumbmark).toBe('cached-thumbmark');
        expect(result?.visitorId).toBe('cached-visitor-id'); // From cache, not localStorage
        expect(result?.version).toBe('1.2.3');
    });
})