// Unit tests for the Enhancement Two algorithm modules.
// These run against pure functions/classes with no database, which is
// exactly why the algorithms were extracted into their own modules.
const mergeSort = require('../mergeSort');
const BoundedMinHeap = require('../minHeap');
const { tokenize, scoreTripAgainstQuery, similarityScore, parseStars } = require('../relevance');
const { encodeCursor, decodeCursor } = require('../cursor');

describe('mergeSort', () => {
    const numbers = (a, b) => a - b;

    test('sorts numbers ascending', () => {
        expect(mergeSort([5, 1, 4, 2, 3], numbers)).toEqual([1, 2, 3, 4, 5]);
    });

    test('does not mutate the input array', () => {
        const input = [3, 1, 2];
        mergeSort(input, numbers);
        expect(input).toEqual([3, 1, 2]);
    });

    test('handles empty and single-element arrays', () => {
        expect(mergeSort([], numbers)).toEqual([]);
        expect(mergeSort([7], numbers)).toEqual([7]);
    });

    test('is stable: equal keys keep their original order', () => {
        const trips = [
            { name: 'B', price: 100 },
            { name: 'A', price: 100 },
            { name: 'C', price: 50 }
        ];
        const sorted = mergeSort(trips, (a, b) => a.price - b.price);
        // C first (50), then B before A because both are 100 and B came first.
        expect(sorted.map((t) => t.name)).toEqual(['C', 'B', 'A']);
    });

    test('sorts a large shuffled array correctly', () => {
        const big = Array.from({ length: 5000 }, () => Math.floor(Math.random() * 100000));
        const sorted = mergeSort(big, numbers);
        for (let i = 1; i < sorted.length; i += 1) {
            expect(sorted[i]).toBeGreaterThanOrEqual(sorted[i - 1]);
        }
        expect(sorted).toHaveLength(big.length);
    });

    test('rejects non-array and non-function inputs', () => {
        expect(() => mergeSort('nope', numbers)).toThrow(TypeError);
        expect(() => mergeSort([1, 2], null)).toThrow(TypeError);
    });
});

describe('BoundedMinHeap', () => {
    const identity = (x) => x;

    test('never grows beyond its capacity', () => {
        const heap = new BoundedMinHeap(3, identity);
        [9, 1, 8, 2, 7, 3, 6].forEach((n) => heap.offer(n));
        expect(heap.size()).toBe(3);
    });

    test('retains the k largest values and drains them best-first', () => {
        const heap = new BoundedMinHeap(3, identity);
        [5, 12, 3, 9, 1, 15, 7].forEach((n) => heap.offer(n));
        expect(heap.drainDescending()).toEqual([15, 12, 9]);
    });

    test('rejects candidates that do not beat the current minimum', () => {
        const heap = new BoundedMinHeap(2, identity);
        heap.offer(10);
        heap.offer(20);
        expect(heap.offer(5)).toBe(false);
        expect(heap.drainDescending()).toEqual([20, 10]);
    });

    test('works with an external score function over objects', () => {
        const scores = new Map([['a', 1], ['b', 3], ['c', 2]]);
        const heap = new BoundedMinHeap(2, (item) => scores.get(item.code));
        heap.offer({ code: 'a' });
        heap.offer({ code: 'b' });
        heap.offer({ code: 'c' });
        expect(heap.drainDescending().map((i) => i.code)).toEqual(['b', 'c']);
    });

    test('rejects invalid capacities', () => {
        expect(() => new BoundedMinHeap(0, identity)).toThrow(RangeError);
        expect(() => new BoundedMinHeap(-1, identity)).toThrow(RangeError);
    });
});

describe('relevance scoring', () => {
    const trip = {
        name: 'Gale Reef',
        resort: 'Emerald Bay, 3 stars',
        description: 'A reef trip with guided dives.'
    };

    test('tokenizes to lowercase alphanumeric terms', () => {
        expect(tokenize('Gale  REEF, 3-star!')).toEqual(['gale', 'reef', '3', 'star']);
    });

    test('caps pathological query length instead of scanning it', () => {
        const huge = 'a'.repeat(100000);
        expect(tokenize(huge).join('').length).toBeLessThanOrEqual(100);
    });

    test('weights name over resort over description', () => {
        expect(scoreTripAgainstQuery(trip, ['reef']))
            .toBeGreaterThan(scoreTripAgainstQuery(trip, ['emerald']));
        expect(scoreTripAgainstQuery(trip, ['emerald']))
            .toBeGreaterThan(scoreTripAgainstQuery(trip, ['guided']));
    });

    test('returns 0 for non-matching queries', () => {
        expect(scoreTripAgainstQuery(trip, ['volcano'])).toBe(0);
    });

    test('prefix match on the name earns a bonus', () => {
        // Both tokens hit ONLY the name field; 'gale' is a prefix, 'ale' is not.
        const prefixScore = scoreTripAgainstQuery(trip, ['gale']);
        const midScore = scoreTripAgainstQuery(trip, ['ale']);
        expect(prefixScore).toBeGreaterThan(midScore);
    });

    test('parseStars reads star ratings and tolerates absence', () => {
        expect(parseStars('Emerald Bay, 3 stars')).toBe(3);
        expect(parseStars('Somewhere nice')).toBeNull();
    });

    test('similarityScore favors closer price and same rating', () => {
        const seed = { name: 'Reef A', resort: 'X, 4 stars', perPerson: '1000', length: '4 nights / 5 days', description: 'coral reef dives' };
        const close = { name: 'Reef B', resort: 'Y, 4 stars', perPerson: '1100', length: '4 nights / 5 days', description: 'coral reef snorkel' };
        const far = { name: 'City Tour', resort: 'Z, 2 stars', perPerson: '3000', length: '2 nights / 3 days', description: 'museums and dining' };
        expect(similarityScore(seed, close)).toBeGreaterThan(similarityScore(seed, far));
    });
});

describe('pagination cursors', () => {
    const context = { sortBy: 'price', order: 'asc', query: '' };

    test('round-trips the resume position', () => {
        const cursor = encodeCursor(context, { sortValue: 1199, code: 'DAWR210315' });
        expect(decodeCursor(cursor, context)).toEqual({ value: 1199, code: 'DAWR210315' });
    });

    test('rejects malformed cursors with a 400', () => {
        expect(() => decodeCursor('not-base64-json', context)).toThrow(/Malformed/);
        try {
            decodeCursor('%%%', context);
        } catch (error) {
            expect(error.statusCode).toBe(400);
        }
    });

    test('rejects a cursor replayed against different query parameters', () => {
        const cursor = encodeCursor(context, { sortValue: 1199, code: 'DAWR210315' });
        expect(() => decodeCursor(cursor, { sortBy: 'name', order: 'asc', query: '' }))
            .toThrow(/does not match/);
    });
});
