import { sameValueZeroEqual } from 'fast-equals';
import microMemoize from 'micro-memoize';
import moize from '../src';

import type { Moized } from '../index.d';

const foo = 'foo';
const bar = 'bar';
const _default = 'default';

const method = jest.fn(function (one: string, two: string) {
    return { one, two };
});

const methodDefaulted = jest.fn(function (one: string, two = _default) {
    return { one, two };
});

const memoized = moize(method);
const memoizedDefaulted = moize(methodDefaulted);

describe('moize', () => {
    afterEach(() => {
        jest.clearAllMocks();

        memoized.clear();
        memoized.clearStats();

        memoizedDefaulted.clear();
        memoizedDefaulted.clearStats();

        moize.collectStats(false);
    });

    describe('main', () => {
        it('should handle a standard use-case', () => {
            const result = memoized(foo, bar);

            expect(result).toEqual({ one: foo, two: bar });

            expect(method).toHaveBeenCalled();

            method.mockClear();

            let newResult;

            for (let index = 0; index < 10; index++) {
                newResult = memoized(foo, bar);

                expect(newResult).toEqual({ one: foo, two: bar });
                expect(method).not.toHaveBeenCalled();
            }
        });

        it('should handle default parameters', () => {
            const result = memoizedDefaulted(foo);

            expect(result).toEqual({ one: foo, two: _default });

            expect(methodDefaulted).toHaveBeenCalled();

            methodDefaulted.mockClear();

            let newResult;

            for (let index = 0; index < 10; index++) {
                newResult = memoizedDefaulted(foo);

                expect(newResult).toEqual({ one: foo, two: _default });
                expect(methodDefaulted).not.toHaveBeenCalled();
            }
        });

        it('should handle a curried call of options creation', () => {
            const moizer = moize({ isSerialized: true })({ maxSize: 5 })({
                maxAge: 1000,
            });

            expect(moizer).toBeInstanceOf(Function);

            const moized = moizer(jest.fn());

            expect(moized.options).toEqual(
                expect.objectContaining({
                    isSerialized: true,
                    maxAge: 1000,
                    maxSize: 5,
                })
            );
        });

        it('should handle moizing an already-moized function with additional options', () => {
            const moized = moize(memoized, { maxSize: 5 });

            expect(moized.originalFunction).toBe(memoized.originalFunction);
            expect(moized.options).toEqual({
                ...memoized.options,
                maxSize: 5,
            });
        });

        it('should copy static properties from the source function', () => {
            const fn = (a: any, b: any) => [a, b];

            fn.foo = 'bar';

            const memoized = moize(fn);

            expect(memoized.foo).toBe(fn.foo);
        });
    });

    describe('cache manipulation', () => {
        it('should add an entry to cache if it does not exist', () => {
            memoized(foo, bar);

            const value = 'something else';

            memoized.set([bar, foo], value);

            expect(memoized.cacheSnapshot).toEqual({
                keys: [[bar, foo]],
                size: 1,
                values: [value],
            });
        });

        it('should add an entry to cache and remove the oldest one', () => {
            const singleMemoized = moize(method);

            singleMemoized(foo, bar);

            const value = 'something else';

            singleMemoized.set([bar, foo], value);

            expect(singleMemoized.cacheSnapshot).toEqual({
                keys: [[bar, foo]],
                size: 1,
                values: [value],
            });
        });

        it('should notify of cache manipulation when adding', () => {
            // eslint-disable-next-line prefer-const
            let withNotifiers: Moized<typeof memoized>;

            const onCacheOperation = jest.fn(function (cache, options, moized) {
                expect(cache).toBe(withNotifiers.cache);
                expect(options).toBe(withNotifiers.options);
                expect(moized).toBe(withNotifiers);
            });

            withNotifiers = moize(memoized, {
                onCacheAdd: onCacheOperation,
                onCacheChange: onCacheOperation,
            });

            withNotifiers(foo, bar);

            const value = 'something else';

            withNotifiers.set([bar, foo], value);

            expect(withNotifiers.cacheSnapshot).toEqual({
                keys: [[bar, foo]],
                size: 1,
                values: [value],
            });

            expect(withNotifiers.options.onCacheAdd).toHaveBeenCalled();
            expect(withNotifiers.options.onCacheChange).toHaveBeenCalled();
        });

        it('should update an entry to cache if it exists', () => {
            memoized(foo, bar);

            const value = 'something else';

            memoized.set([foo, bar], value);

            expect(memoized.cacheSnapshot).toEqual({
                keys: [[foo, bar]],
                size: 1,
                values: [value],
            });
        });

        it('should notify of cache manipulation when updating', () => {
            const withNotifiers = moize(memoized, {
                onCacheChange: jest.fn(function (cache, options, moized) {
                    expect(cache).toBe(withNotifiers.cache);
                    expect(options).toBe(withNotifiers.options);
                    expect(moized).toBe(withNotifiers);
                }),
            });

            withNotifiers(foo, bar);

            const value = 'something else';

            withNotifiers.set([foo, bar], value);

            expect(withNotifiers.cacheSnapshot).toEqual({
                keys: [[foo, bar]],
                size: 1,
                values: [value],
            });

            expect(withNotifiers.options.onCacheChange).toHaveBeenCalled();
        });

        it('should get the entry in cache if it exists', () => {
            const result = memoized(foo, bar);

            expect(memoized.get([foo, bar])).toBe(result);
            expect(memoized.get([bar, foo])).toBe(undefined);
        });

        it('should correctly identify the entry in cache if it exists', () => {
            memoized(foo, bar);

            expect(memoized.has([foo, bar])).toBe(true);
            expect(memoized.has([bar, foo])).toBe(false);
        });

        it('should remove the entry in cache if it exists', () => {
            memoized(foo, bar);

            expect(memoized.has([foo, bar])).toBe(true);

            const result = memoized.remove([foo, bar]);

            expect(memoized.has([foo, bar])).toBe(false);
            expect(result).toBe(true);
        });

        it('should not remove the entry in cache if does not exist', () => {
            memoized(foo, bar);

            expect(memoized.has([bar, foo])).toBe(false);

            const result = memoized.remove([bar, foo]);

            expect(memoized.has([bar, foo])).toBe(false);
            expect(memoized.has([foo, bar])).toBe(true);
            expect(result).toBe(false);
        });

        it('should notify of cache change on removal and clear the expiration', () => {
            const expiringMemoized = moize(method, {
                maxAge: 2000,
                onCacheChange: jest.fn(),
            });

            expiringMemoized(foo, bar);

            expect(expiringMemoized.has([foo, bar])).toBe(true);
            expect(expiringMemoized.expirations.length).toBe(1);

            const result = expiringMemoized.remove([foo, bar]);

            expect(expiringMemoized.has([foo, bar])).toBe(false);
            expect(result).toBe(true);

            expect(expiringMemoized.options.onCacheChange).toHaveBeenCalledWith(
                expiringMemoized.cache,
                expiringMemoized.options,
                expiringMemoized
            );

            expect(expiringMemoized.expirations.length).toBe(0);
        });

        it('should clear the cache', () => {
            memoized(foo, bar);

            expect(memoized.has([foo, bar])).toBe(true);

            const result = memoized.clear();

            expect(memoized.cache.size).toBe(0);

            expect(memoized.has([foo, bar])).toBe(false);
            expect(result).toBe(true);
        });

        it('should notify of the cache change on clear', () => {
            const changeMemoized = moize(method, {
                onCacheChange: jest.fn(),
            });

            changeMemoized(foo, bar);

            expect(changeMemoized.has([foo, bar])).toBe(true);

            const result = changeMemoized.clear();

            expect(memoized.cache.size).toBe(0);

            expect(changeMemoized.has([foo, bar])).toBe(false);
            expect(result).toBe(true);

            expect(changeMemoized.options.onCacheChange).toHaveBeenCalledWith(
                changeMemoized.cache,
                changeMemoized.options,
                changeMemoized
            );
        });

        it('should have the keys and values from cache', () => {
            memoized(foo, bar);

            const cache = memoized.cacheSnapshot;

            expect(memoized.keys()).toEqual(cache.keys);
            expect(memoized.values()).toEqual(cache.values);
        });

        it('should allow stats management of the method', () => {
            moize.collectStats();

            const profiled = moize(memoized, { profileName: 'profiled' });

            profiled(foo, bar);
            profiled(foo, bar);
            profiled(foo, bar);
            profiled(foo, bar);

            expect(profiled.getStats()).toEqual({
                calls: 4,
                hits: 3,
                usage: '75.0000%',
            });

            profiled.clearStats();

            expect(profiled.getStats()).toEqual({
                calls: 0,
                hits: 0,
                usage: '0.0000%',
            });
        });
    });

    describe('properties', () => {
        it('should have the micro-memoize options', () => {
            const mmResult = microMemoize(method, { maxSize: 1 });

            const { isEqual, ...options } = memoized._microMemoizeOptions;
            const { isEqual: _isEqualIgnored, ...resultOptions } =
                mmResult.options;

            expect(options).toEqual(resultOptions);
            expect(isEqual).toBe(sameValueZeroEqual);
        });

        it('should have cache and cacheSnapshot', () => {
            memoized(foo, bar);

            expect(memoized.cache).toEqual(
                expect.objectContaining({
                    keys: [[foo, bar]],
                    values: [{ one: foo, two: bar }],
                })
            );
            expect(memoized.cache.size).toBe(1);

            expect(memoized.cacheSnapshot).toEqual(
                expect.objectContaining({
                    keys: [[foo, bar]],
                    values: [{ one: foo, two: bar }],
                })
            );
            expect(memoized.cacheSnapshot.size).toBe(1);
        });

        it('should have expirations and expirationsSnapshot', () => {
            const expiringMemoized = moize(method, {
                maxAge: 2000,
            });

            expiringMemoized(foo, bar);

            expect(expiringMemoized.expirations).toEqual([
                expect.objectContaining({
                    expirationMethod: expect.any(Function),
                    key: [foo, bar],
                    timeoutId: expect.any(Number),
                }),
            ]);

            expect(expiringMemoized.expirationsSnapshot).toEqual([
                expect.objectContaining({
                    expirationMethod: expect.any(Function),
                    key: [foo, bar],
                    timeoutId: expect.any(Number),
                }),
            ]);
        });

        it('should have the original function', () => {
            expect(memoized.originalFunction).toBe(method);
        });
    });

    describe('edge cases', () => {
        it('should have a self-referring `default` property for mixed ESM/CJS environments', () => {
            // @ts-ignore - `default` is not surfaced because it exists invisibly for edge-case import cross-compatibility
            expect(moize.default).toBe(moize);
        });

        it('should prefer the `profileName` when provided', () => {
            function myNamedFunction() {}

            const memoized = moize(myNamedFunction, {
                profileName: 'custom profile name',
            });

            expect(memoized.name).toBe('moized(custom profile name)');
        });

        it('should wrap the original function name', () => {
            function myNamedFunction() {}

            const memoized = moize(myNamedFunction);

            expect(memoized.name).toBe('moized(myNamedFunction)');
        });

        it('should have an ultimate fallback for an anonymous function', () => {
            const memoized = moize(() => {});

            expect(memoized.name).toBe('moized(anonymous)');
        });
    });
});
