import {
    Configuration,
    deserializeArray,
    EventType,
    KeyValueStore,
    ProxyConfiguration,
    Request,
    RequestList,
} from '@crawlee/core';
import type { gotScraping } from '@crawlee/utils';
import { sleep } from '@crawlee/utils';
import { beforeAll, type MockedFunction } from 'vitest';

import log from '@apify/log';

import { MemoryStorageEmulator } from '../shared/MemoryStorageEmulator';

/**
 * Stand-in for underscore.js shuffle (weird, but how else?)
 */
function shuffle(array: unknown[]): unknown[] {
    const out = [...array];
    for (let i = out.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [out[i], out[j]] = [out[j], out[i]];
    }
    return out;
}

vitest.mock('@crawlee/utils/src/internals/gotScraping', async () => {
    return {
        gotScraping: vitest.fn(),
    };
});

let gotScrapingSpy: MockedFunction<typeof gotScraping>;

beforeAll(async () => {
    // @ts-ignore for some reason, this fails when the project is not built :/
    const { gotScraping } = await import('@crawlee/utils');
    gotScrapingSpy = vitest.mocked(gotScraping);
});

describe('RequestList', () => {
    let ll: number;
    const emulator = new MemoryStorageEmulator();
    const events = Configuration.getEventManager();

    beforeAll(() => {
        ll = log.getLevel();
        log.setLevel(log.LEVELS.ERROR);
    });

    beforeEach(async () => {
        await emulator.init();
        vitest.restoreAllMocks();
    });

    afterAll(async () => {
        log.setLevel(ll);
        await emulator.destroy();
    });

    test('should not accept to pages with same uniqueKey', async () => {
        const requestList = await RequestList.open(null, [
            { url: 'https://example.com/1' },
            { url: 'https://example.com/1#same' },
        ]);

        expect(await requestList.isEmpty()).toBe(false);

        const req = await requestList.fetchNextRequest();

        expect(req!.url).toBe('https://example.com/1');
        expect(await requestList.isEmpty()).toBe(true);
        expect(await requestList.isFinished()).toBe(false);
        expect(await requestList.fetchNextRequest()).toBe(null);

        await requestList.markRequestHandled(req!);

        expect(await requestList.isEmpty()).toBe(true);
        expect(await requestList.isFinished()).toBe(true);
    });

    test('must be initialized before using any of the methods', async () => {
        // @ts-expect-error private constructor
        const requestList = new RequestList({ sources: [{ url: 'https://example.com' }] });
        const requestObj = new Request({ url: 'https://example.com' });

        await expect(requestList.isEmpty()).rejects.toThrow();
        await expect(requestList.isFinished()).rejects.toThrow();
        expect(() => requestList.getState()).toThrowError();
        await expect(requestList.markRequestHandled(requestObj)).rejects.toThrow();
        await expect(requestList.reclaimRequest(requestObj)).rejects.toThrow();
        await expect(requestList.fetchNextRequest()).rejects.toThrow();

        await requestList.initialize();

        await expect(requestList.isEmpty()).resolves.not.toThrow();
        await expect(requestList.isFinished()).resolves.not.toThrow();
        expect(() => requestList.getState()).not.toThrowError();
        await expect(requestList.fetchNextRequest()).resolves.not.toThrow();
        await expect(requestList.reclaimRequest(requestObj)).resolves.not.toThrow();
        await expect(requestList.fetchNextRequest()).resolves.not.toThrow();
        await expect(requestList.markRequestHandled(requestObj)).resolves.not.toThrow();
    });

    test('should correctly initialize itself', async () => {
        const sources = [
            { url: 'https://example.com/1' },
            { url: 'https://example.com/2' },
            { url: 'https://example.com/3' },
            { url: 'https://example.com/4' },
            { url: 'https://example.com/5' },
            { url: 'https://example.com/6' },
            { url: 'https://example.com/7' },
            { url: 'https://example.com/8' },
        ];
        const sourcesCopy = JSON.parse(JSON.stringify(sources));

        const originalList = await RequestList.open(null, sources);

        const r1 = await originalList.fetchNextRequest(); // 1
        const r2 = await originalList.fetchNextRequest(); // 2
        await originalList.fetchNextRequest(); // 3
        const r4 = await originalList.fetchNextRequest(); // 4
        const r5 = await originalList.fetchNextRequest(); // 5
        await originalList.fetchNextRequest(); // 6

        await originalList.markRequestHandled(r1!);
        await originalList.markRequestHandled(r2!);
        await originalList.markRequestHandled(r4!);
        await originalList.reclaimRequest(r5!);

        const newList = await RequestList.open({
            sources: sourcesCopy,
            state: originalList.getState(),
        });

        expect(await newList.isEmpty()).toBe(false);
        expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/3');
        expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/5');
        expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/6');
        expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/7');
        expect((await newList.fetchNextRequest())!.url).toBe('https://example.com/8');
        expect(await newList.isEmpty()).toBe(true);
    });

    test('`RequestList` is `for .. await` iterable', async () => {
        const sources = [
            'https://example.com/1',
            'https://example.com/2',
            'https://example.com/3',
            'https://example.com/4',
            'https://example.com/5',
            'https://example.com/6',
            'https://example.com/7',
            'https://example.com/8',
        ];
        const requestList = await RequestList.open(null, sources);

        for await (const request of requestList) {
            expect(request?.url).toBe(sources.shift());
        }
    });

    test('should correctly load list from hosted files in correct order', async () => {
        const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
        const list1 = ['https://example.com', 'https://google.com', 'https://wired.com'];
        const list2 = ['https://another.com', 'https://page.com'];
        spy.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve(list1) as any, 100)) as any);
        spy.mockResolvedValueOnce(list2);

        const requestList = await RequestList.open({
            sources: [
                { method: 'GET', requestsFromUrl: 'http://example.com/list-1' },
                { method: 'POST', requestsFromUrl: 'http://example.com/list-2' },
            ],
        });

        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list1[0] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list1[1] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list1[2] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'POST', url: list2[0] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'POST', url: list2[1] });

        expect(spy).toBeCalledTimes(2);
        expect(spy).toBeCalledWith({ url: 'http://example.com/list-1', urlRegExp: undefined });
        expect(spy).toBeCalledWith({ url: 'http://example.com/list-2', urlRegExp: undefined });
    });

    test('should use regex parameter to parse urls', async () => {
        const listStr = 'kjnjkn"https://example.com/a/b/c?q=1#abc";,"HTTP://google.com/a/b/c";dgg:dd';
        const listArr = ['https://example.com', 'HTTP://google.com'];
        gotScrapingSpy.mockResolvedValue({ body: listStr } as any);

        const regex = /(https:\/\/example.com|HTTP:\/\/google.com)/g;
        const requestList = await RequestList.open({
            sources: [
                {
                    method: 'GET',
                    requestsFromUrl: 'http://example.com/list-1',
                    regex,
                },
            ],
        });

        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: listArr[0] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: listArr[1] });

        expect(gotScrapingSpy).toBeCalledWith({ url: 'http://example.com/list-1', encoding: 'utf8' });
    });

    test('should fix gdoc sharing url in `requestsFromUrl` automatically (GH issue #639)', async () => {
        const list = ['https://example.com', 'https://google.com', 'https://wired.com'];
        const wrongUrls = [
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU',
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/',
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/edit?usp=sharing',
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/123123132',
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/?q=blablabla',
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/edit#gid=0',
        ];
        const correctUrl =
            'https://docs.google.com/spreadsheets/d/11UGSBOSXy5Ov2WEP9nr4kSIxQJmH18zh-5onKtBsovU/gviz/tq?tqx=out:csv';

        gotScrapingSpy.mockResolvedValue({ body: JSON.stringify(list) } as any);

        const requestList = await RequestList.open({
            sources: wrongUrls.map((requestsFromUrl) => ({ requestsFromUrl })),
        });

        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list[0] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list[1] });
        expect(await requestList.fetchNextRequest()).toMatchObject({ method: 'GET', url: list[2] });

        expect(gotScrapingSpy).toBeCalledWith({ url: correctUrl, encoding: 'utf8' });
    });

    test('should handle requestsFromUrl with no URLs', async () => {
        const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
        spy.mockResolvedValueOnce([]);

        const requestList = await RequestList.open({
            sources: [
                {
                    method: 'GET',
                    requestsFromUrl: 'http://example.com/list-1',
                },
            ],
        });

        expect(await requestList.fetchNextRequest()).toBe(null);

        expect(spy).toBeCalledTimes(1);
        expect(spy).toBeCalledWith({ url: 'http://example.com/list-1', urlRegExp: undefined });
    });

    test('should use the defined proxy server when using `requestsFromUrl`', async () => {
        const proxyUrls = ['http://proxyurl.usedforthe.download', 'http://another.proxy.url'];

        const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
        spy.mockResolvedValue([]);

        const proxyConfiguration = new ProxyConfiguration({
            proxyUrls,
        });

        const requestList = await RequestList.open({
            sources: [
                { requestsFromUrl: 'http://example.com/list-1' },
                { requestsFromUrl: 'http://example.com/list-2' },
                { requestsFromUrl: 'http://example.com/list-3' },
            ],
            proxyConfiguration,
        });

        expect(spy).not.toBeCalledWith(expect.not.objectContaining({ proxyUrl: expect.any(String) }));
    });

    test('should correctly handle reclaimed pages', async () => {
        const requestList = await RequestList.open({
            sources: [
                { url: 'https://example.com/1' },
                { url: 'https://example.com/2' },
                { url: 'https://example.com/3' },
                { url: 'https://example.com/4' },
                { url: 'https://example.com/5' },
                { url: 'https://example.com/6' },
            ],
        });

        //
        // Fetch first 5 urls
        //

        const request1 = await requestList.fetchNextRequest();
        const request2 = await requestList.fetchNextRequest();
        const request3 = await requestList.fetchNextRequest();
        const request4 = await requestList.fetchNextRequest();
        const request5 = await requestList.fetchNextRequest();

        expect(request1!.url).toBe('https://example.com/1');
        expect(request2!.url).toBe('https://example.com/2');
        expect(request3!.url).toBe('https://example.com/3');
        expect(request4!.url).toBe('https://example.com/4');
        expect(request5!.url).toBe('https://example.com/5');
        expect(requestList.getState()).toEqual({
            inProgress: [
                'https://example.com/1',
                'https://example.com/2',
                'https://example.com/3',
                'https://example.com/4',
                'https://example.com/5',
            ],
            nextIndex: 5,
            nextUniqueKey: 'https://example.com/6',
        });
        expect(await requestList.isEmpty()).toBe(false);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress.size).toBe(5);
        expect(requestList.reclaimed.size).toBe(0);

        //
        // Mark 1st, 2nd handled
        // Reclaim 3rd 4th
        //

        await requestList.markRequestHandled(request1!);
        await requestList.markRequestHandled(request2!);
        await requestList.reclaimRequest(request3!);
        await requestList.reclaimRequest(request4!);

        expect(requestList.getState()).toEqual({
            inProgress: ['https://example.com/3', 'https://example.com/4', 'https://example.com/5'],
            nextIndex: 5,
            nextUniqueKey: 'https://example.com/6',
        });
        expect(await requestList.isEmpty()).toBe(false);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Mark 5th handled
        //

        await requestList.markRequestHandled(request5!);

        expect(requestList.getState()).toEqual({
            inProgress: ['https://example.com/3', 'https://example.com/4'],
            nextIndex: 5,
            nextUniqueKey: 'https://example.com/6',
        });
        expect(await requestList.isEmpty()).toBe(false);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Fetch 3rd and 4th
        // Mark 4th handled
        //

        const reclaimed3 = await requestList.fetchNextRequest();
        expect(reclaimed3!.url).toBe('https://example.com/3');
        const reclaimed4 = await requestList.fetchNextRequest();
        expect(reclaimed4!.url).toBe('https://example.com/4');
        await requestList.markRequestHandled(request4!);

        expect(requestList.getState()).toEqual({
            inProgress: ['https://example.com/3'],
            nextIndex: 5,
            nextUniqueKey: 'https://example.com/6',
        });
        expect(await requestList.isEmpty()).toBe(false);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Mark 3rd handled
        //

        await requestList.markRequestHandled(request3!);

        expect(requestList.getState()).toEqual({
            inProgress: [],
            nextIndex: 5,
            nextUniqueKey: 'https://example.com/6',
        });
        expect(await requestList.isEmpty()).toBe(false);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Fetch 6th
        //

        const request6 = await requestList.fetchNextRequest();

        expect(request6!.url).toBe('https://example.com/6');
        expect(await requestList.fetchNextRequest()).toBe(null);
        expect(requestList.getState()).toEqual({
            inProgress: ['https://example.com/6'],
            nextIndex: 6,
            nextUniqueKey: null,
        });
        expect(await requestList.isEmpty()).toBe(true);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Reclaim 6th
        //

        await requestList.reclaimRequest(request6!);

        expect(requestList.getState()).toEqual({
            inProgress: ['https://example.com/6'],
            nextIndex: 6,
            nextUniqueKey: null,
        });
        expect(await requestList.isEmpty()).toBe(false);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Fetch 6th
        //

        const reclaimed6 = await requestList.fetchNextRequest();

        expect(reclaimed6!.url).toBe('https://example.com/6');
        expect(requestList.getState()).toEqual({
            inProgress: ['https://example.com/6'],
            nextIndex: 6,
            nextUniqueKey: null,
        });
        expect(await requestList.isEmpty()).toBe(true);
        expect(await requestList.isFinished()).toBe(false);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));

        //
        // Mark 6th handled
        //

        await requestList.markRequestHandled(reclaimed6!);

        expect(requestList.getState()).toEqual({
            inProgress: [],
            nextIndex: 6,
            nextUniqueKey: null,
        });
        expect(await requestList.isEmpty()).toBe(true);
        expect(await requestList.isFinished()).toBe(true);
        expect(requestList.inProgress).toEqual(expect.objectContaining(requestList.reclaimed));
    });

    test('should correctly persist its state when persistStateKey is set', async () => {
        const PERSIST_STATE_KEY = 'some-key';
        const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
        const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');

        getValueSpy.mockResolvedValueOnce(null);

        const opts = {
            sources: [
                { url: 'https://example.com/1' },
                { url: 'https://example.com/2' },
                { url: 'https://example.com/3' },
            ],
            persistStateKey: PERSIST_STATE_KEY,
        };
        const optsCopy = JSON.parse(JSON.stringify(opts));

        const requestList = await RequestList.open(opts);
        expect(requestList.isStatePersisted).toBe(true);

        // Fetch one request and check that state is not persisted.
        const request1 = await requestList.fetchNextRequest();
        expect(requestList.isStatePersisted).toBe(false);

        // Persist state.
        setValueSpy.mockResolvedValueOnce();
        events.emit(EventType.PERSIST_STATE);
        await sleep(20);
        expect(requestList.isStatePersisted).toBe(true);

        // Do some other changes and persist it again.
        const request2 = await requestList.fetchNextRequest();
        expect(requestList.isStatePersisted).toBe(false);
        await requestList.markRequestHandled(request2!);
        expect(requestList.isStatePersisted).toBe(false);
        setValueSpy.mockResolvedValueOnce();
        events.emit(EventType.PERSIST_STATE);
        await sleep(20);
        expect(requestList.isStatePersisted).toBe(true);

        // Reclaim event doesn't change the state.
        await requestList.reclaimRequest(request1!);
        expect(requestList.isStatePersisted).toBe(true);

        // Now initiate new request list from saved state and check that it's same as state
        // of original request list.
        getValueSpy.mockResolvedValueOnce(requestList.getState());
        const requestList2 = await RequestList.open(optsCopy);
        expect(requestList2.getState()).toEqual(requestList.getState());
    });

    test('should correctly persist its sources when persistRequestsKey is set', async () => {
        const PERSIST_REQUESTS_KEY = 'some-key';
        const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
        const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');

        let persistedRequests;

        const opts = {
            sources: [
                { url: 'https://example.com/1' },
                { url: 'https://example.com/2' },
                { url: 'https://example.com/3' },
            ],
            persistRequestsKey: PERSIST_REQUESTS_KEY,
        };

        // Expect an attempt to load sources.
        getValueSpy.mockResolvedValueOnce(null);

        // Expect persist sources.
        setValueSpy.mockImplementationOnce(async (_key, value) => {
            persistedRequests = value;
        });

        const requestList = await RequestList.open(opts);
        expect(requestList.areRequestsPersisted).toBe(true);

        const opts2 = {
            sources: [{ url: 'https://test.com/1' }, { url: 'https://test.com/2' }, { url: 'https://test.com/3' }],
            persistRequestsKey: PERSIST_REQUESTS_KEY,
        };

        getValueSpy.mockResolvedValueOnce(persistedRequests);

        const requestList2 = await RequestList.open(opts2);
        expect(requestList2.areRequestsPersisted).toBe(true);
        expect(requestList2.requests).toEqual(requestList.requests);
    });

    test('should correctly persist sources from requestsFromUrl if persistRequestsKey is set', async () => {
        const PERSIST_REQUESTS_KEY = 'some-key';
        const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
        const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');
        const spy = vitest.spyOn(RequestList.prototype as any, '_downloadListOfUrls');
        let persistedRequests: any;

        const opts = {
            sources: [
                { url: 'https://example.com/1' },
                { url: 'https://example.com/2' },
                { requestsFromUrl: 'http://example.com/list-urls.txt', userData: { isFromUrl: true } },
                { url: 'https://example.com/5' },
            ],
            persistRequestsKey: PERSIST_REQUESTS_KEY,
        };

        const urlsFromTxt = ['http://example.com/3', 'http://example.com/4'];
        spy.mockResolvedValueOnce(urlsFromTxt);

        getValueSpy.mockResolvedValueOnce(null);
        setValueSpy.mockImplementationOnce(async (_key, value) => {
            persistedRequests = value;
        });

        const requestList = await RequestList.open(opts);
        expect(requestList.areRequestsPersisted).toBe(true);
        const requests = await deserializeArray(persistedRequests);
        expect(requestList.requests).toHaveLength(5);
        expect(requests).toEqual(requestList.requests);

        expect(spy).toBeCalledTimes(1);
        expect(spy).toBeCalledWith({ url: 'http://example.com/list-urls.txt', urlRegExp: undefined });
    });

    test('handles correctly inconsistent inProgress fields in state', async () => {
        // NOTE: This is a test for the deleteFromInProgress hotfix - see RequestList.initialize()

        const sources = [
            { url: 'https://www.ams360.com' },
            { url: 'https://www.anybus.com' },
            { url: 'https://www.anychart.com' },
            { url: 'https://www.example.com' },
        ];

        const state = {
            nextIndex: 2,
            nextUniqueKey: 'https://www.anychart.com',
            inProgress: ['https://www.ams360.com', 'https://www.anybus.com', 'https://www.anychart.com'],
        };

        const requestList = await RequestList.open({
            sources,
            state,
        });

        // Get requests from list
        let reqs: Request[] = [];
        for (let i = 0; i < 5; i++) {
            const request = await requestList.fetchNextRequest();
            if (!request) break;
            reqs.push(request);
        }

        reqs = shuffle(reqs) as typeof reqs;

        for (let i = 0; i < reqs.length; i++) {
            await requestList.reclaimRequest(reqs[i]);
        }
    });

    test('it gets correct length()', async () => {
        const sources = [
            { url: 'https://www.example.com' },
            { url: 'https://www.ams360.com' },
            { url: 'https://www.anybus.com' },
            { url: 'https://www.anychart.com' },
            { url: 'https://www.example.com' },
        ];

        const requestList = await RequestList.open({
            sources,
        });

        expect(requestList.length()).toBe(4);
    });

    test('it gets correct handledCount()', async () => {
        const sources = [
            { url: 'https://www.example.com' },
            { url: 'https://www.ams360.com' },
            { url: 'https://www.anybus.com' },
            { url: 'https://www.anychart.com' },
            { url: 'https://www.example.com' },
        ];

        const requestList = await RequestList.open({
            sources,
        });

        const req1 = await requestList.fetchNextRequest();
        const req2 = await requestList.fetchNextRequest();
        const req3 = await requestList.fetchNextRequest();
        expect(requestList.handledCount()).toBe(0);

        await requestList.markRequestHandled(req2!);
        expect(requestList.handledCount()).toBe(1);

        await requestList.markRequestHandled(req3!);
        expect(requestList.handledCount()).toBe(2);

        await requestList.reclaimRequest(req1!);
        expect(requestList.handledCount()).toBe(2);
    });

    test('should correctly keep duplicate URLs while keepDuplicateUrls is set', async () => {
        const sources = [
            { url: 'https://www.example.com' },
            { url: 'https://www.example.com' },
            { url: 'https://www.example.com' },
            { url: 'https://www.ex2mple.com' },
        ];
        const sourcesCopy = JSON.parse(JSON.stringify(sources));

        let requestList = await RequestList.open({
            sources,
            keepDuplicateUrls: true,
        });

        expect(requestList.length()).toBe(4);

        log.setLevel(log.LEVELS.INFO);
        const warnSpy = vitest.spyOn(console, 'warn').mockImplementation(() => {});

        requestList = await RequestList.open({
            sources: sourcesCopy.concat([
                { url: 'https://www.example.com', uniqueKey: '123' },
                { url: 'https://www.example.com', uniqueKey: '123' },
                { url: 'https://www.example.com', uniqueKey: '456' },
                { url: 'https://www.ex2mple.com', uniqueKey: '456' },
            ]),
            keepDuplicateUrls: true,
        });

        expect(requestList.length()).toBe(6);
        expect(warnSpy).toBeCalled();
        expect(warnSpy.mock.calls[0][0]).toMatch(`Check your sources' unique keys.`);

        log.setLevel(log.LEVELS.ERROR);
    });

    describe('Apify.RequestList.open()', () => {
        test('should work', async () => {
            const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
            const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');

            const name = 'xxx';
            const SDK_KEY = `SDK_${name}`;
            const sources = [{ url: 'https://example.com' }];

            const rl = await RequestList.open(name, sources);
            expect(rl).toBeInstanceOf(RequestList);
            // @ts-expect-error accessing private var
            expect(rl.persistStateKey.startsWith(SDK_KEY)).toBe(true);
            // @ts-expect-error accessing private var
            expect(rl.persistRequestsKey.startsWith(SDK_KEY)).toBe(true);
            // @ts-expect-error accessing private var
            expect(rl.sources).toEqual([]);
            // @ts-expect-error accessing private var
            expect(rl.isInitialized).toBe(true);

            expect(getValueSpy).toBeCalledTimes(2);
            expect(setValueSpy).toBeCalledTimes(1);
        });

        test('should work with string sources', async () => {
            const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
            const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');

            const name = 'xxx';
            const SDK_KEY = `SDK_${name}`;
            const sources = ['https://example.com'];
            const requests = sources.map((url) => ({ url, uniqueKey: url }));

            const rl = await RequestList.open(name, sources);
            expect(rl).toBeInstanceOf(RequestList);
            // @ts-expect-error accessing private var
            expect(rl.persistStateKey.startsWith(SDK_KEY)).toBe(true);
            // @ts-expect-error accessing private var
            expect(rl.persistRequestsKey.startsWith(SDK_KEY)).toBe(true);
            expect(rl.requests).toEqual(requests);
            // @ts-expect-error accessing private var
            expect(rl.isInitialized).toBe(true);

            expect(getValueSpy).toBeCalledTimes(2);
            expect(setValueSpy).toBeCalledTimes(1);
        });

        test('should correctly pass options', async () => {
            const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
            const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');

            const name = 'xxx';
            const SDK_KEY = `SDK_${name}`;
            let counter = 0;
            const sources = [{ url: 'https://example.com' }];
            const requests = sources.map(({ url }) => ({ url, uniqueKey: `${url}-${counter++}` }));
            const options = {
                keepDuplicateUrls: true,
                persistStateKey: 'yyy',
            };

            const rl = await RequestList.open(name, sources, options);
            expect(rl).toBeInstanceOf(RequestList);
            // @ts-expect-error accessing private var
            expect(rl.persistStateKey.startsWith(SDK_KEY)).toBe(true);
            // @ts-expect-error accessing private var
            expect(rl.persistRequestsKey.startsWith(SDK_KEY)).toBe(true);
            expect(rl.requests).toEqual(requests);
            // @ts-expect-error accessing private var
            expect(rl.isInitialized).toBe(true);
            // @ts-expect-error accessing private var
            expect(rl.keepDuplicateUrls).toBe(true);

            expect(getValueSpy).toBeCalledTimes(2);
            expect(setValueSpy).toBeCalledTimes(1);
        });

        test('should work with null name', async () => {
            const getValueSpy = vitest.spyOn(KeyValueStore.prototype, 'getValue');
            const setValueSpy = vitest.spyOn(KeyValueStore.prototype, 'setValue');

            const name: string | null = null;
            const sources = [{ url: 'https://example.com' }];
            const requests = sources.map(({ url }) => ({ url, uniqueKey: url }));

            const rl = await RequestList.open(name, sources);
            expect(rl).toBeInstanceOf(RequestList);
            // @ts-expect-error accessing private var
            expect(rl.persistStateKey == null).toBe(true);
            // @ts-expect-error accessing private var
            expect(rl.persistRequestsKey == null).toBe(true);
            expect(rl.requests).toEqual(requests);
            // @ts-expect-error accessing private var
            expect(rl.isInitialized).toBe(true);

            expect(getValueSpy).not.toBeCalled();
            expect(setValueSpy).not.toBeCalled();
        });

        test('should throw on invalid parameters', async () => {
            const args = [[], ['x', {}], ['x', 6, {}], ['x', [], []]] as const;
            for (const arg of args) {
                try {
                    // @ts-ignore
                    await RequestList.open(...arg);
                    throw new Error('wrong error');
                } catch (err) {
                    const e = err as Error;
                    expect(e.message).not.toBe('wrong error');
                    if (e.message.match('argument to be of type `string`')) {
                        expect(e.message).toMatch('received type `undefined`');
                    } else if (e.message.match('argument to be of type `array`')) {
                        const isMatched =
                            e.message.match('received type `Object`') ||
                            e.message.match('received type `number`') ||
                            e.message.match('received type `undefined`');
                        expect(isMatched).toBeTruthy();
                    } else if (e.message.match('argument to be of type `null`')) {
                        expect(e.message).toMatch('received type `undefined`');
                    }
                }
            }
        });
    });

    // This test is here to run locally. It would take too long
    // when running a test suite and in CI with large source arrays
    // and would be flaky with small source arrays, so manual inspection
    // looks like the best idea, since multiple runs with various values
    // need to be tested and compared (read: I'm too lazy to automate this)

    // test('memory consumption does not spike', async () => {
    //     function getMemoryInMbytes() {
    //         const memory = process.memoryUsage();
    //         return (memory.heapUsed + memory.external) / 1024 / 1024;
    //     }
    //     const sources = [];
    //     for (let i = 0; i < 1e6; i++) {
    //         sources.push({ url: `https://example.com?page=${i}` });
    //     }
    //     const startingMemory = getMemoryInMbytes();
    //     console.log(startingMemory, 'MB');
    //
    //     process.env.APIFY_LOCAL_STORAGE_DIR = 'tmp';
    //     const rl = await RequestList.open({ sources, persistRequestsKey: null });
    //     const instanceMemory = getMemoryInMbytes();
    //     console.log(instanceMemory, 'MB');
    //
    //     const initMemory = getMemoryInMbytes();
    //     console.log(initMemory, 'MB');
    // });
});
