import chai, { assert, expect } from 'chai';
import {spy} from 'sinon';
import asPromised from 'chai-as-promised';
import clone from 'clone';
import dayjs from "dayjs";
import { after, describe, it } from 'mocha';
import { http, HttpResponse } from 'msw';
import pEvent from 'p-event';
import { CLIENT_INGRESS_QUEUE, type PlayObject, SOURCE_SOT } from "../../../core/Atomic.ts";
import { sleep, sortByOldestPlayDate } from "../../utils.ts";
import { genGroupIdStr } from '../../../core/PlayUtils.ts';
import mixedDuration from '../plays/mixedDuration.json' with { type: 'json' };
import withDuration from '../plays/withDuration.json' with { type: 'json' };
import { MockNetworkError, withRequestInterception } from "../utils/networking.ts";
import { generatePlay, generatePlayPlatformId, generatePlays, generateSourcePlayerObj, normalizePlays } from "../../../core/tests/utils/PlayTestUtils.ts";
import MockDate from 'mockdate';

import { NowPlayingScrobbler, TestAuthScrobbler, TestScrobbler } from "./TestScrobbler.ts";
import { type PaginatedTimeRangeOptions, REFRESH_STALE_DEFAULT } from '../../common/infrastructure/Atomic.ts';
import { type PlayPlatformId } from '../../../core/Atomic.ts';
import { shuffleArray } from '../../utils/DataUtils.ts';
import { DEFAULT_CONSOLIDATE_DURATION, groupPlaysToTimeRanges } from '../../utils/ListenFetchUtils.ts';
import { asPlay } from '../../../core/PlayMarshalUtils.ts';
import { getRoot } from '../../ioc.ts';
import { transientCache } from '../utils/TransientTestUtils.ts';
import { generateArray } from '../../../core/DataUtils.ts';
import { type RepositoryCreatePlayOpts } from '../../common/database/drizzle/repositories/PlayRepository.ts';
import { fixtureCreatePlay } from '../utils/databaseFixtures.ts';
import { isAbortError } from 'abort-controller-x';
import { artistNamesToCredits } from '../../../core/StringUtils.ts';

chai.use(asPromised);

const firstPlayDate = dayjs().subtract(1, 'hour');
const olderFirstPlayDate = dayjs().subtract(4, 'hour');

const withDurPlays = withDuration.map(asPlay);
// @ts-expect-error mixed duration is missing meta but not used for tests
const mixedDurPlays = mixedDuration.map(asPlay);
const normalizedWithDur = normalizePlays(withDurPlays, {initialDate: firstPlayDate});
const normalizedWithMixedDur = normalizePlays(mixedDurPlays, {initialDate: firstPlayDate});

const normalizedWithMixedDurOlder = normalizePlays(mixedDurPlays, {initialDate: olderFirstPlayDate});

const generateTestScrobbler = async () => {
    const testScrobbler = new TestScrobbler();
    testScrobbler.verboseOptions = {
        match: {
            onMatch: true,
            onNoMatch: true,
            confidenceBreakdown: true
        }
    };
    await testScrobbler.initialize();
    return testScrobbler;
}

describe('Networking', function () {

    describe('Authentication', function () {
        it('Should set as authenticated if doAuthentication does not throw and returns true',
            withRequestInterception(
                [
                    http.get('http://example.com', () => {
                            // https://github.com/mswjs/msw/issues/1819#issuecomment-1789364174
                            // already using DOM though, not sure why it doesn't fix itself
                            return new HttpResponse(null, {status: 200});
                        }
                    )
                ],
                async function() {
                    const authScrobbler = new TestAuthScrobbler();
                    await authScrobbler.testAuth();
                    assert.isFalse(authScrobbler.authGated());
                }
            ));

        it('Should set as unauthenticated with possibility to retry if error is network related',
            withRequestInterception(
                [
                    http.get('http://example.com', () => {
                            throw new MockNetworkError('EAI_AGAIN');
                        }
                    )
                ],
                async function() {
                    const authScrobbler = new TestAuthScrobbler();
                    try {
                        await authScrobbler.testAuth();
                    } catch (e) {

                    }
                    assert.isTrue(authScrobbler.authGated());
                    assert.isFalse(authScrobbler.authFailure);
                }
            ));

        it('Should set as unauthenticated with no possibility to retry if error is not network related',
            withRequestInterception(
                [
                    http.get('http://example.com', () => {
                            return HttpResponse.json({error: 'Invalid API Key'}, {status: 401});
                        }
                    )
                ],
                async function() {
                    const authScrobbler = new TestAuthScrobbler();
                    try {
                        await authScrobbler.testAuth();
                    } catch (e) {

                    }
                    assert.isTrue(authScrobbler.authGated());
                    assert.isTrue(authScrobbler.authFailure);
                }
            ));
    });
});

describe('Detects duplicate and unique scrobbles from client recent history', function () {

    describe('When scrobble is unique', function () {

        it('It is not detected as duplicate when play date is newer than most recent', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

            const newScrobble = generatePlay({
                playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 1].data.playDate.add(70, 'seconds')
            });

            assert.isFalse((await testScrobbler.alreadyScrobbled(newScrobble))[0]);
            return;
        });

        it('It is not detected as duplicate when play date is close to an existing scrobble', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

            const newScrobble = generatePlay({
                playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
            });

            assert.isFalse((await testScrobbler.alreadyScrobbled(newScrobble))[0]);
        });

        it('It handles unique detection when no existing scrobble matches above a score of 0', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

                const uniquePlay = generatePlay({
                    artists: artistNamesToCredits(["２８１４"]),
                    track: "新宿ゴールデン街",
                    duration: 130,
                    playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(6, 'minutes')
                });

                await assert.isFulfilled( testScrobbler.alreadyScrobbled(uniquePlay))
                assert.isFalse((await testScrobbler.alreadyScrobbled(uniquePlay))[0])
        });
    });

    describe('When scrobble track/artist/album matches existing but is a new scrobble', function () {

        it('Is not detected as duplicate when artist is same, time is similar, but track is different', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

            const diffPlay = clone(normalizedWithMixedDur[1]);
            diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
            diffPlay.data.track = 'A Totally Different Track'

            assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
        });

        it('Is not detected as duplicate when track is same, time is similar, but artist is different', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

            const diffPlay = clone(normalizedWithMixedDur[1]);
            diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
            diffPlay.data.artists = artistNamesToCredits(['A Different Artist']);

            assert.isFalse((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
        });


        it('Is not detected as duplicate when play date is different by more than 10 seconds (high granularity source)', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

            const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
            timeOffPos.data.playDate = timeOffPos.data.playDate.add(11, 's');

            const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
            timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(11, 's');

            assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffPos))[0]);
            assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]);
        });

        it('Is not detected as duplicate when play date is different by more than 60 seconds (low granularity source)', async function () {

            const recent = normalizePlays(mixedDurPlays, {
                initialDate: firstPlayDate,
                defaultMeta: {source: 'subsonic'}
            });
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = recent;

            const timeOffPos = clone(recent[recent.length - 1]);
            timeOffPos.data.playDate = timeOffPos.data.playDate.add(61, 's');

            const timeOffNeg = clone(recent[recent.length - 1]);
            timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(61, 's');

            assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffPos))[0]);
            assert.isFalse((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]);
        });

        describe('When existing has duration', function () {

            it('A track with continuity to the previous track is not detected as a duplicate', async function () {

                await using testScrobbler = await generateTestScrobbler();
                testScrobbler.testRecentScrobbles = normalizedWithDur;

                const brickPt1 = normalizedWithDur.find(x => x.data.track.includes('Another Brick'));
                const brickPt2 = clone(brickPt1);
                brickPt2.data.track = 'Another Brick in the Wall, Pt. 2';
                brickPt2.data.playDate = brickPt1.data.playDate.add(brickPt1.data.duration + 1, 'seconds');
                assert.isFalse((await testScrobbler.alreadyScrobbled(brickPt2))[0]);

                const story1 = normalizedWithDur.find(x => x.data.track.includes('Da Art of'));
                const story2 = clone(story1);
                story2.data.track = `Da Art of Storytellin' (Pt. 2)`;
                story2.data.playDate = story2.data.playDate.add(story1.data.duration + 1, 'seconds');

                assert.isFalse((await testScrobbler.alreadyScrobbled(story2))[0]);

                const ballad1 = normalizedWithDur.find(x => x.data.track.includes('Ballade No. 1'));
                const ballad2 = clone(ballad1);
                ballad2.data.track = `Ballade No. 2 in G Minor, Op. 27`;
                ballad2.data.playDate = ballad2.data.playDate.add(ballad1.data.duration + 1, 'seconds');

                assert.isFalse((await testScrobbler.alreadyScrobbled(ballad2))[0]);
            });

            it('Is not detected as duplicate when play date matches fuzzy but play is marked as repeat', async function () {

                const recent = normalizePlays(normalizedWithDur, {
                    initialDate: firstPlayDate,
                    defaultMeta: {source: 'jellyfin'}
                });
                await using testScrobbler = await generateTestScrobbler();
                testScrobbler.testRecentScrobbles = recent;

                const repeatPlay = clone(recent[recent.length - 1]);
                repeatPlay.data.playDate = repeatPlay.data.playDate.add(repeatPlay.data.duration + 2, 's');

                assert.isTrue((await testScrobbler.alreadyScrobbled(repeatPlay))[0]);

                repeatPlay.data.repeat = true;
                assert.isFalse((await testScrobbler.alreadyScrobbled(repeatPlay))[0]);
            });

            it('Is not detected as duplicate when play date matches fuzzy and play source SOT is history', async function () {

                const play = generatePlay({
                    artists: artistNamesToCredits(['Nejad']), 
                    track: 'CODE', 
                    album: undefined, 
                    playDate: dayjs().subtract(179, 's'),
                    duration: 179
                });
                await using testScrobbler = await generateTestScrobbler();
                testScrobbler.testRecentScrobbles = [play];

                const newPlay = clone(play);
                newPlay.data.playDate = dayjs();
                newPlay.meta.sourceSOT = SOURCE_SOT.HISTORY;

                const res = await testScrobbler.existingScrobble(newPlay, [play]);
                expect(res.match).is.false;
            });
        });

    });

    describe('When scrobble is a duplicate (title/artists/album)', function () {

        it('Is detected as duplicate when an exact match', async function () {
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
            assert.isTrue((await testScrobbler.alreadyScrobbled(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]))[0]);
        });

        it('Is detected as duplicate when artist/title differences are whitespace or case', async function () {
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
            const ref = normalizedWithMixedDur[3];

            const diffPlay = clone(ref);
            diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');


            diffPlay.data.track = ref.data.track.toUpperCase();
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);

            diffPlay.data.track = `  ${ref.data.track} `;
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);

            diffPlay.data.track = ref.data.track.replaceAll(' ', '   ');
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);

            diffPlay.data.artists = ref.data.artists.map(x => ({...x, name: x.name.toUpperCase()}));
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);

            diffPlay.data.artists = ref.data.artists.map(x => ({...x, name: x.name.replaceAll(' ', '   ')}));
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
        });

        it('Is detected as duplicate when artist/title differences are from unicode normalization', async function () {
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
            const ref = normalizedWithMixedDur.find(x => x.data.track === 'Jimbó');

            const diffPlay = clone(ref);
            diffPlay.data.playDate = diffPlay.data.playDate.add(9, 's');
            diffPlay.data.track = 'Jimbo';
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);
        });

        it('Is detected as duplicate when play date is off by 10 seconds or less (high granularity source)', async function () {

            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;

            const timeOffPos = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
            timeOffPos.data.playDate = timeOffPos.data.playDate.add(10, 's');

            const timeOffNeg = clone(normalizedWithMixedDur[normalizedWithMixedDur.length - 1]);
            timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(10, 's');

            assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffPos))[0]);
            assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]);

            // 10 seconds fuzzy diff inclusive
            const son = normalizedWithMixedDurOlder.find(x => x.data.track === 'Sonora')
            son.data.playDate = dayjs().subtract(1, 'hour').set('minute', 26).set('second', 20);
            son.data.duration = 267;
            son.data.listenedFor = undefined;
            testScrobbler.testRecentScrobbles = normalizedWithMixedDurOlder.concat(son);

            const offSon = clone(son);
            offSon.data.playDate = dayjs().subtract(1, 'hour').set('minute', 30).set('second', 37);
            assert.isTrue((await testScrobbler.alreadyScrobbled(offSon))[0]);
        });

        it('Is detected as duplicate when play date is off by less than 60 seconds (low granularity source)', async function () {

            const recent = normalizePlays(mixedDurPlays, {
                initialDate: firstPlayDate,
                defaultMeta: {source: 'subsonic'}
            });
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = recent;

            const timeOffPos = clone(recent[recent.length - 1]);
            timeOffPos.data.playDate = timeOffPos.data.playDate.add(59, 's');

            const timeOffNeg = clone(recent[recent.length - 1]);
            timeOffNeg.data.playDate = timeOffNeg.data.playDate.subtract(59, 's');

            assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffPos))[0]);
            assert.isTrue((await testScrobbler.alreadyScrobbled(timeOffNeg))[0]);
        });

        it('Is detected as duplicate when title is exact, artist is similar, and time is similar', async function () {
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
            const ref = normalizedWithMixedDur[3];

            const diffPlay = clone(ref);
            diffPlay.data.playDate = diffPlay.data.playDate.add(3, 's');
            diffPlay.data.artists = [diffPlay.data.artists[0]]
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);

            diffPlay.data.artists = [ref.data.artists[1]]
            assert.isTrue((await testScrobbler.alreadyScrobbled(diffPlay))[0]);


            const son = normalizedWithMixedDur.find(x => x.data.track === 'Sonora')

            const sonDiffPlay = clone(son);
            sonDiffPlay.data.playDate = sonDiffPlay.data.playDate.subtract(son.data.duration + 1, 's');
            assert.isTrue((await testScrobbler.alreadyScrobbled(sonDiffPlay))[0]);
        });

        it('Is detected as duplicate when artists are included in joiner', async function () {
            const ref = normalizedWithMixedDurOlder.find(x => x.data.track === 'Freeze Tag');
            ref.data.playDate = dayjs().subtract(1, 'hour').set('minute', 29).set('second', 26)

            const spotifyPlay: PlayObject = {
                data: {
                    artists: artistNamesToCredits([
                        "Terrace Martin",
                        "Robert Glasper",
                        "9th Wonder",
                        "Kamasi Washington",
                        "Dinner Party",
                        "Cordae",
                        "Phoelix"
                    ]),
                    album: "Dinner Party: Dessert",
                    track: "Freeze Tag (feat. Cordae & Phoelix)",
                    "duration": 191.375,
                    "playDate": dayjs().subtract(1, 'hour').set('minute', 29).set('second', 27)
                },
                meta: {
                    source: 'Spotify'
                }
            }
            await using testScrobbler = await generateTestScrobbler();
            testScrobbler.testRecentScrobbles = normalizedWithMixedDurOlder.concat(ref);

            assert.isTrue((await testScrobbler.alreadyScrobbled(spotifyPlay))[0]);
        });

        describe('When at least one play has duration', function () {

            it('Is detected as duplicate when play date is close to the end of an existing scrobble', async function () {

                await using testScrobbler = await generateTestScrobbler();
                testScrobbler.testRecentScrobbles = normalizedWithDur;

                const timeEnd = clone(normalizedWithDur[normalizedWithMixedDur.length - 2]);
                timeEnd.data.playDate = timeEnd.data.playDate.add(timeEnd.data.duration, 's');

                assert.isTrue((await testScrobbler.alreadyScrobbled(timeEnd))[0]);

                // only one has duration
                timeEnd.data.duration = undefined;

                assert.isTrue((await testScrobbler.alreadyScrobbled(timeEnd))[0]);

                // only one has duration
                timeEnd.data.duration = undefined;

                assert.isTrue((await testScrobbler.alreadyScrobbled(timeEnd))[0]);
            });

        });
    });
});

describe('Detects duplicate and unique scrobbles using actively tracked scrobbles', function() {

    it('Detects a unique play', async function() {
        const newScrobble = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });

        await using testScrobbler = await generateTestScrobbler();
        testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
        const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(newScrobble);

        assert.isUndefined(matchedPlay);
        assert.isEmpty(matchedData);
    });

    it('Detects an exact duplicate', async function() {
        const newScrobble = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });
        await using testScrobbler = await generateTestScrobbler();
        await testScrobbler.playRepoTest.createPlays([{play: newScrobble, state: 'scrobbled', input: {}}])
        testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
        //testScrobbler.addScrobbledTrack(newScrobble, newScrobble);

        const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(newScrobble);

        assert.isDefined(matchedPlay);
        assert.isNotEmpty(matchedData);
    });

    it('Detects a duplicate with close time', async function() {
        const newScrobble = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });
        await using testScrobbler = await generateTestScrobbler();
        //testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
        await testScrobbler.playRepoTest.createPlays([{play: newScrobble, state: 'scrobbled', input: {}}])
        //testScrobbler.addScrobbledTrack(newScrobble, newScrobble);

        const dupScrobble = clone(newScrobble);
        dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');

        const [matchedPlay, matchedData] = await testScrobbler.findExistingSubmittedPlayObj(dupScrobble);

        assert.isDefined(matchedPlay);
        assert.isNotEmpty(matchedData);
    });
});

describe('Upstream Scrobbles', function() {

    afterEach(function () {
        MockDate.reset();
        const root = getRoot();
        root.upsert({ cache: () => transientCache });
    });

    it('Calls timerange func to get SOT scrobbles when none exists', async function() {
        const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        await using scrobbler = await generateTestScrobbler();
        scrobbler.testRecentScrobbles = [];
        await scrobbler.initialize();
        scrobbler.testRecentScrobbles = existingPlays;

        const sp = spy(scrobbler, 'getScrobblesForTimeRange');

        const play = generatePlay({playDate: dayjs().subtract(60, 's')});
        await scrobbler.queueScrobble(play, 'test');
        const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
        scrobbler.startScrobbling(new AbortController().signal).then(() => null);
        await emptied;
        scrobbler.tryStopScrobbling().then(() => null);
        expect(sp.called).is.true;
        return;
    });

    it('Uses cached timerange for closely grouped scrobbles', async function() {
        const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        await using scrobbler = await generateTestScrobbler();
        scrobbler.testRecentScrobbles = [];
        await scrobbler.initialize();
        scrobbler.testRecentScrobbles = existingPlays;

        const sp = spy(scrobbler, 'getScrobblesForTimeRange');

        const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')});
        const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
        await scrobbler.queueScrobble([play1, play2], 'test');
        const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
        scrobbler.startScrobbling(new AbortController().signal).then(() => null);
        await emptied;
        scrobbler.tryStopScrobbling().then(() => null);
        expect(sp.callCount).to.eq(1);
        return;
    });

    it('Uses separate timerange calls when scrobbles are not closely grouped', async function() {
        const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        await using scrobbler = await generateTestScrobbler();
        scrobbler.testRecentScrobbles = [];
        await scrobbler.initialize();
        scrobbler.testRecentScrobbles = existingPlays;

        const sp = spy(scrobbler, 'getScrobblesForTimeRange');

        const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')});
        const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
        const play3 = generatePlay({playDate: dayjs().subtract(DEFAULT_CONSOLIDATE_DURATION.add(4, 'm'))});
        await scrobbler.queueScrobble([play1, play2, play3], 'test');
        const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
        scrobbler.startScrobbling(new AbortController().signal).then(() => null);
        await emptied;
        scrobbler.tryStopScrobbling().then(() => null);
        expect(sp.callCount).to.eq(2);
        return;
    });

    it('Gets fresh timerange if TTL of staleAfter has passed', async function() {
        const existingPlays = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        await using scrobbler = await generateTestScrobbler();
        scrobbler.testRecentScrobbles = [];
        await scrobbler.initialize();
        scrobbler.testRecentScrobbles = existingPlays;

        const sp = spy(scrobbler, 'getScrobblesForTimeRange');

        const play1 = generatePlay({playDate: dayjs().subtract(3, 'm')});
        const play2 = generatePlay({playDate: dayjs().subtract(1, 'm')});
        await scrobbler.queueScrobble([play1], 'test');
        const emptied = pEvent(scrobbler.emitter, 'queueEmptied');
        scrobbler.startScrobbling(new AbortController().signal).then(() => null);
        await emptied;
        expect(sp.calledOnce).is.true;

        MockDate.set(dayjs().add(REFRESH_STALE_DEFAULT + 1, 's').toDate());
        const emptied2 = pEvent(scrobbler.emitter, 'queueEmptied');
        await scrobbler.queueScrobble([play2], 'test');
        await emptied2;
        scrobbler.tryStopScrobbling().then(() => null);
        expect(sp.calledTwice).is.true;
        return;
    });

});

describe('Dead Scrobbles', function() {

    it('Processes all dead scrobbles', async function () {
        await using testScrobbler = await generateTestScrobbler();
        await testScrobbler.initialize();
        testScrobbler.testRecentScrobbles = [];

        const queuedPlayed = await testScrobbler.playRepoTest.createPlays(generateArray<RepositoryCreatePlayOpts>(3, () => ({ ...fixtureCreatePlay(), state: 'queued', input: {} })))

        for(const dead of queuedPlayed) {
            await testScrobbler.addDeadLetterScrobble(dead);
        }

        await testScrobbler.processDeadLetterQueue();
        await pEvent(testScrobbler.emitter, 'queueState');

        expect(testScrobbler.deadLetterQueued).eq(0);
    });

});

const normalizedScrobbler = async () => {
    await using testScrobbler = await generateTestScrobbler();
    await testScrobbler.initialize();
    testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
    testScrobbler.scrobbleSleep = 500;
    testScrobbler.scrobbleDelay = 0;
    //testScrobbler.lastScrobbleCheck = dayjs().subtract(60, 'seconds');
    testScrobbler.config.options = {};
    return testScrobbler;
}

describe('Scrobble client uses transform plays correctly', function() {

    // TODO need to find a better way to detect this
    // since we are now doing it in the processing loop instead of before queue
    
    // it('Transforms play before queue when preCompare is present', async function() {
    //     testScrobbler.config.options = {
    //         playTransform: {
    //             preCompare: {
    //                 title: [
    //                     'cool'
    //                 ]
    //             }
    //         }
    //     }
    //     testScrobbler.buildTransformRules();
    //     const newScrobble = generatePlay({
    //         track: 'my cool track'
    //     });
    //     testScrobbler.queueScrobble(newScrobble, 'test');
    //     expect(testScrobbler.queuedScrobbles[0].play.data.track).is.eq('my  track');
    // });

    it('Transforms play on scrobble when postCompare is present', async function() {
        await using testScrobbler = await normalizedScrobbler();
        testScrobbler.config.options = {
            playTransform: {
                postCompare: {
                    title: [
                        'cool'
                    ]
                }
            }
        }
        testScrobbler.buildTransformRules();
        const newScrobble = generatePlay({
            track: 'my cool track'
        });
        await testScrobbler.queueScrobble(newScrobble, 'test');
        const queuedPlayedData = await testScrobbler.playRepoTest.getQueued(CLIENT_INGRESS_QUEUE);
        expect(queuedPlayedData.data[0].play.data.track).is.eq('my cool track');
        testScrobbler.scrobbleSleep = 100;
        testScrobbler.initScrobbleMonitoring().catch(console.error);

        const e = (await pEvent(testScrobbler.emitter, 'scrobble')) as {data: {play: PlayObject }};
        expect(e.data.play.data.track).is.eq('my  track');
    });

    it('Transforms candidate play on comparison', async function() {
        await using testScrobbler = await normalizedScrobbler();
        testScrobbler.config.options = {
            playTransform: {
                compare: {
                    candidate: {
                        title: [
                            'hugely cool and very different track'
                        ]
                    }
                }
            }
        }
        const newScrobble = generatePlay({
            track: 'my hugely cool and very different track title'
        });

        testScrobbler.testRecentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate});
        testScrobbler.buildTransformRules();

        expect((await testScrobbler.alreadyScrobbled(newScrobble))[0]).is.false;
    });

    it('Transforms existing play on comparison', async function() {
        await using testScrobbler = await normalizedScrobbler();
        testScrobbler.config.options = {
            playTransform: {
                compare: {
                    existing: {
                        title: [
                            'hugely cool and very different track'
                        ]
                    }
                }
            }
        }
        const newScrobble = generatePlay({
            track: 'my hugely cool and very different track title'
        });

        testScrobbler.testRecentScrobbles = normalizePlays([newScrobble, ...withDurPlays], {initialDate: firstPlayDate});
        testScrobbler.buildTransformRules();

        expect((await testScrobbler.alreadyScrobbled(newScrobble))[0]).is.false;
    });

    afterEach(async function () {
        this.timeout(3500);
        //await testScrobbler.tryStopScrobbling()
    });

});

const normalizedMonitoringScrobbler = async () => {
    const testScrobbler = await generateTestScrobbler();
    await testScrobbler.initialize();
    testScrobbler.testRecentScrobbles = normalizedWithMixedDur;
    testScrobbler.scrobbleSleep = 100;
    testScrobbler.scrobbleDelay = 0;
    testScrobbler.initScrobbleMonitoring().catch((e) => {
        if(!isAbortError(e)) {
            console.error(e);
        }
    });
    return testScrobbler;
}

describe('Manages scrobble queue', function() {

    it('Scrobbles a uniquely queued play', async function() {
        await using testScrobbler = await normalizedMonitoringScrobbler();
        const newScrobble = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });
        await testScrobbler.queueScrobble(newScrobble, 'test');
        const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(3000)]);

        assert.isDefined(res);
        assert.isDefined(res.data);
    });

    it('Does not Scrobble a duplicate play queued after original is scrobbled', async function() {
        await using testScrobbler = await normalizedMonitoringScrobbler();
        this.timeout(20000);

        const newScrobble = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });
        const dupScrobble = clone(newScrobble);
        dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');

        await testScrobbler.queueScrobble(newScrobble, 'test');
        const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);

        assert.isDefined(res);
        assert.isDefined(res.data);

        await testScrobbler.queueScrobble(dupScrobble, 'test');
        const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);

        assert.isUndefined(resDup);
    });

    it('Does not Scrobble a duplicate play queued before original is scrobbled', async function() {
        await using testScrobbler = await normalizedMonitoringScrobbler();
        this.timeout(3500);

        const newScrobble = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });
        const dupScrobble = clone(newScrobble);
        dupScrobble.data.playDate = newScrobble.data.playDate.add(2, 'seconds');

        await testScrobbler.queueScrobble(newScrobble, 'test');
        await testScrobbler.queueScrobble(dupScrobble, 'test');
        const res = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1500)]);

        assert.isDefined(res);
        assert.isDefined(res.data);

        const resDup = await Promise.race([pEvent(testScrobbler.emitter, 'scrobble'), sleep(1100)]);

        assert.isUndefined(resDup);
    });

    it('Delays scrobbles when many are queued', async function () {
        await using testScrobbler = await normalizedMonitoringScrobbler();
        this.timeout(3500);

        const newScrobble1 = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(3, 'seconds')
        });
        const newScrobble2 = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(80, 'seconds')
        });
        const newScrobble3 = generatePlay({
            playDate: normalizedWithMixedDur[normalizedWithMixedDur.length - 3].data.playDate.add(160, 'seconds')
        });

        testScrobbler.scrobbleDelay = 600;

        await testScrobbler.queueScrobble([newScrobble1, newScrobble2, newScrobble3], 'test');
        await pEvent(testScrobbler.emitter, 'scrobble');
        const initial = dayjs();
        await pEvent(testScrobbler.emitter, 'scrobble');
        await pEvent(testScrobbler.emitter, 'scrobble');

        const end = dayjs();

        // roughly...
        assert.closeTo(end.diff(initial, 'ms'), 1200, 200);
    });

    after(async function () {
       this.timeout(3500);
    });
});

describe('Now Playing', function() {

    describe('Filtering Aggregated Updates', function() {

           it('When no Now Playing exists, chooses play based on sorted platform id', async function () {

                await using npScrobbler = new NowPlayingScrobbler();
                await npScrobbler.initialize();

                const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
                const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];

                const pt = dayjs().subtract(15, 's');

                await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}) , {type: 'spotify', name: 'test'});
                await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});

                const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);

                expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));

            });

            it('When Now Playing platform does not exist in queued plays, chooses play based on sorted platform id', async function () {

                await using npScrobbler = new NowPlayingScrobbler();
                await npScrobbler.initialize();

                const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
                const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];

                npScrobbler.nowPlayingLastPlay = generateSourcePlayerObj({play: generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});

                const pt = dayjs().subtract(15, 's');

                await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})}), {type: 'spotify', name: 'test'});
                await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});

                const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);

                expect(toReport.play.meta.deviceId).eq(genGroupIdStr(firstPlatform));

            });

            it('Chooses play based on existing Now Playing', async function () {

                await using npScrobbler = new NowPlayingScrobbler();
                await npScrobbler.initialize();

                const firstPlatform: PlayPlatformId = ['aaa', 'NO_USER'];
                const secondPlatform: PlayPlatformId = ['bbbb', 'NO_USER'];

                const pt = dayjs().subtract(15, 's');

                const stickyNp = generateSourcePlayerObj({play: generatePlay({playDate: pt}, {deviceId: genGroupIdStr(secondPlatform)})});

                npScrobbler.nowPlayingLastPlay = stickyNp
            

                await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({playDate: pt}, {deviceId: genGroupIdStr(firstPlatform)})}), {type: 'spotify', name: 'test'});
                await npScrobbler.queuePlayingNow(stickyNp, {type: 'spotify', name: 'test'});

                const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);

                expect(toReport.play.meta.deviceId).eq(genGroupIdStr(secondPlatform));

            });

             it('Sorts Sources alphabetically when using default Source sorting', async function () {

                await using npScrobbler = new NowPlayingScrobbler();
                await npScrobbler.initialize();

                const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
                const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});

                await npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})
                await npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})

                const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);

                expect(toReport.play.meta.deviceId).eq(a.play.meta.deviceId);

            });

            it('Sorts Sources based on user config', async function () {

                await using npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
                await npScrobbler.initialize();

                const a = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
                const b = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});

                await npScrobbler.queuePlayingNow(a, {type: 'subsonic', name: 'atest'})
                await npScrobbler.queuePlayingNow(b, {type: 'jellyfin', name: 'btest'})

                const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);

                expect(toReport.play.meta.deviceId).eq(b.play.meta.deviceId);

            });

            it('Does not report if source is not in user config', async function () {

                await using npScrobbler = new NowPlayingScrobbler({name: 'test', options: {nowPlaying: ['btest', 'atest']}});
                await npScrobbler.initialize();

                const c = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});

                await npScrobbler.queuePlayingNow(c, {type: 'jellyfin', name: 'ctest'})

                const toReport = npScrobbler.nowPlayingFilter(npScrobbler.nowPlayingQueue);

                expect(toReport).to.be.undefined;

            });
    });

    describe('Updating', function () {

        it('Should update if no existing Now Playing', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            await npScrobbler.initialize();

            const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0];
            expect(res).to.be.true;
        });

        it('Should update if previous Now Playing matches updated and last updated diff is greater than upper limit', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            await npScrobbler.initialize();

            const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
            npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) + 1, 's');
            npScrobbler.nowPlayingLastPlay = lastUpdate;

            const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0];
            expect(res).to.be.true;
        });

         it('Should NOT update if previous Now Playing matches updated and last updated diff is less than upper limit', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            await npScrobbler.initialize();

            const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
            npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMaxThreshold(lastUpdate.play) - 1, 's');
            npScrobbler.nowPlayingLastPlay = lastUpdate;

            const res = npScrobbler.shouldUpdatePlayingNow(lastUpdate)[0];
            expect(res).to.be.false;
        });

        it('Should update if previous Now Playing does NOT match updated and last updated diff is greater than lower limit', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            await npScrobbler.initialize();

            const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
            npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) + 1, 's');
            npScrobbler.nowPlayingLastPlay = lastUpdate;

            const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0];
            expect(res).to.be.true;
        });

        it('Should NOT update if previous Now Playing does NOT match updated and last updated diff is less than than lower limit', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            await npScrobbler.initialize();

            const lastUpdate = generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})});
            npScrobbler.nowPlayingLastUpdated = dayjs().subtract(npScrobbler.nowPlayingMinThreshold(lastUpdate.play) - 1, 's');
            npScrobbler.nowPlayingLastPlay = lastUpdate;

            const res = npScrobbler.shouldUpdatePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}))[0];
            expect(res).to.be.false;
        });

    });

    describe('Scheduling', function () {

        this.afterEach(() => {
            MockDate.reset();
        });

        it('Should update when no existing Now Playing', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            npScrobbler.nowPlayingTaskInterval = 10;
            await npScrobbler.initialize();
            npScrobbler.initTasks();

            await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});

            const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(12)]);

            expect(res).is.not.undefined;
        });

        it('Should update when updated does not match Now Playing', async function () {

            await using npScrobbler = new NowPlayingScrobbler();
            npScrobbler.nowPlayingTaskInterval = 10;
            await npScrobbler.initialize();
            npScrobbler.initTasks();

            const now = dayjs();

            await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});

            const res = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]);

            expect(res).is.not.undefined;

            MockDate.set(now.add(npScrobbler.nowPlayingMinThreshold() + 3, 's').toDate());

            await npScrobbler.queuePlayingNow(generateSourcePlayerObj({play:generatePlay({}, {deviceId: genGroupIdStr(generatePlayPlatformId())})}), {type: 'jellyfin', name: 'test'});

            const resUpdate = await Promise.race([pEvent(npScrobbler.emitter, 'nowPlayingUpdated'), sleep(20)]);

            expect(resUpdate).is.not.undefined;
        });

    });

});

describe('Scrobble Temporal Grouping', function () {

    it('Groups into separate groups when not within duration', function() {
        const plays1 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        plays1.sort(sortByOldestPlayDate);
        const oldest1 = plays1[0].data.playDate.unix();
        const newest1 = plays1[plays1.length - 1].data.playDate.unix();

        const plays2 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(2, 'hour')});
        plays2.sort(sortByOldestPlayDate);
        const oldest2 = plays2[0].data.playDate.unix();
        const newest2 = plays2[plays2.length - 1].data.playDate.unix();

        const plays = [...plays1, ...plays2];
        shuffleArray(plays);

        const ranges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: dayjs.duration(1, 's')});
        expect(ranges.length).eq(2);
        expect(ranges.some(x => x.from < oldest1 && x.to > newest1)).is.true;
        expect(ranges.some(x => x.from < oldest2 && x.to > newest2)).is.true;
    });

    it('Groups into existing time range', function() {
        const plays1 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        plays1.sort(sortByOldestPlayDate);
        const oldest1 = plays1[0].data.playDate;
        const newest1 = plays1[plays1.length - 1].data.playDate;

        const plays2 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(2, 'hour')});
        plays2.sort(sortByOldestPlayDate);
        const oldest2 = plays2[0].data.playDate;
        const newest2 = plays2[plays2.length - 1].data.playDate;

        const plays = [...plays1, ...plays2];
        shuffleArray(plays);

        const existing: PaginatedTimeRangeOptions = {from: oldest1.subtract(10, 's').unix(), to: newest1.add(10, 's').unix()};

        const ranges = groupPlaysToTimeRanges(plays, [existing], {consolidateDuration: dayjs.duration(1, 's')});
        expect(ranges.length).eq(2);
        expect(ranges.some(x => x.from < oldest1.unix() && x.to > newest1.unix())).is.true;
        expect(ranges.some(x => x.from < oldest2.unix() && x.to > newest2.unix())).is.true;
        expect(ranges.some(x => x.to === existing.to && x.from && existing.from));
    });

        it('Consolidates time ranges', function() {
        const plays1 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(1, 'hour')});
        plays1.sort(sortByOldestPlayDate);

        const plays2 = normalizePlays(generatePlays(3), {initialDate: dayjs().subtract(5, 'hour')});
        plays2.sort(sortByOldestPlayDate);

        const singlePlay1 = generatePlay({playDate: dayjs().subtract(9, 'h')})
        const singlePlay2 = generatePlay({playDate: dayjs().subtract(9, 'h').subtract(20, 'm')});
        const singlePlay3 = generatePlay({playDate: dayjs().subtract(9, 'h').subtract(40, 'm')});

        const plays = [...plays1, ...plays2, singlePlay1, singlePlay2, singlePlay3];
        shuffleArray(plays);

        const ranges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: dayjs.duration(1, 's')});
        expect(ranges.length).eq(5);

        const consolidatedRanges = groupPlaysToTimeRanges(plays, [], {consolidateDuration: DEFAULT_CONSOLIDATE_DURATION});
        expect(consolidatedRanges.length).eq(3);
    });
})