import { loggerTest, loggerDebug } from "@foxxmd/logging";
import chai, { assert, expect } from 'chai';
import asPromised from 'chai-as-promised';
import EventEmitter from "events";
import { after, before, describe, it } from 'mocha';
import pEvent from "p-event";
import clone from 'clone';
import { PlayObject } from "../../../core/Atomic.js";
import { generatePlay, generatePlayerStateData, generatePlays, normalizePlays } from "../../../core/PlayTestUtils.js";
import { TestMemoryPositionalSource, TestMemorySource, TestSource } from "./TestSource.js";
import spotifyPayload from '../plays/spotifyCurrentPlaybackState.json' with { type: "json" };
import SpotifySource from "../../sources/SpotifySource.js";
import MockDate from 'mockdate';
import dayjs, { Dayjs } from "dayjs";
import { REPORTED_PLAYER_STATUSES } from "../../common/infrastructure/Atomic.js";
import { SourceConfig } from "../../common/infrastructure/config/source/sources.js";
import MemorySource from "../../sources/MemorySource.js";
import { RT_TICK_DEFAULT, setRtTick } from "../../sources/PlayerState/RealtimePlayer.js";
import { sleep } from "../../utils.js";
import DeezerInternalSource from "../../sources/DeezerInternalSource.js";
import { DeezerInternalSourceOptions } from "../../common/infrastructure/config/source/deezer.js";
import { artistCreditsToNames } from "../../../core/StringUtils.js";

chai.use(asPromised);


const emitter = new EventEmitter();
const generateSource = async () => {
    const source = new TestSource('spotify', 'test-basic', {}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'},  emitter);
    await source.initialize();
    return source;
}
const generateMemorySource = async (config: SourceConfig = {}) => {
    const s = new TestMemorySource('spotify', 'test-memory', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'},  emitter);
    await s.initialize();
   // s.buildTransformRules();
    s.scheduler.stop();
    return s;
}

const generateMemoryPositionalSource = async (config: SourceConfig = {}) => {
    const s = new TestMemoryPositionalSource('spotify', 'test-positional', config, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'},  emitter);
    await s.initialize();
    //s.buildTransformRules();
    s.scheduler.stop();
    return s;
}

describe('Sources use transform plays correctly', function () {

    it('Transforms play on preCompare', async function() {
        await using source = await generateSource();
        source.config.options = {
            playTransform: {
                preCompare: {
                    title: [
                        {
                            search: 'cool',
                            replace: 'fun'
                        }
                    ]
                }
            }
        };
        source.buildTransformRules();
        const newScrobble = generatePlay({
            track: 'my cool track'
        });
        const discovered = await source.discover([newScrobble])
        expect(discovered.length).eq(1);
        expect(discovered[0].data.track).is.eq('my fun track');
    });

    it('Transforms play on postCompare', async function() {
        await using source = await generateSource();
        source.config.options = {
            playTransform: {
                postCompare: {
                    title: [
                        {
                            search: 'cool',
                            replace: 'fun'
                        }
                    ]
                }
            }
        };
        source.buildTransformRules();
        const newScrobble = generatePlay({
            track: 'my cool track'
        });
        const discovered = await source.discover([newScrobble])
        expect(discovered.length).eq(1);
        expect(discovered[0].data.track).is.eq('my cool track');

        const pAwaiter =  pEvent(source.emitter, 'discoveredToScrobble') as Promise<{data: [PlayObject] }>;
        source.handle(discovered);
        const e = await pAwaiter;
        expect(e.data.length).is.eq(1);
        expect(e.data[0].data.track).is.eq('my fun track');
    });

    it('Transforms play existing comparison', async function() {
        await using source = await generateSource();
        source.config.options = {
            playTransform: {
                compare: {
                    existing: {
                        title: [
                            {
                                search: 'hugely cool and very different track',
                                replace: 'fun'
                            }
                        ]
                    }
                }
            }
        };
        source.buildTransformRules();
        const newScrobble = generatePlay({
            track: 'my hugely cool and very different track title',
        });
        const discovered = await source.discover([newScrobble])
        expect(discovered.length).eq(1);
        expect(discovered[0].data.track).is.eq('my hugely cool and very different track title');

        expect((await source.discover([newScrobble])).length).is.eq(1);
    });

    it('Transforms play candidate comparison', async function() {
        await using source = await generateSource();
        source.config.options = {
            playTransform: {
                compare: {
                    candidate: {
                        title: [
                            {
                                search: 'hugely cool and very different track',
                                replace: 'fun'
                            }
                        ]
                    }
                }
            }
        };
        source.buildTransformRules();
        const newScrobble = generatePlay({
            track: 'my hugely cool and very different track title',
        });
        const discovered = await source.discover([newScrobble])
        expect(discovered.length).eq(1);
        expect(discovered[0].data.track).is.eq('my hugely cool and very different track title');

        expect((await source.discover([newScrobble])).length).is.eq(1);
    });
})


describe('Sources correctly parse incoming payloads', function () {

    it('Spotify parses payload with no album artists correctly', function() {
        const noAAPayload = clone(spotifyPayload)
        noAAPayload.item.album.artists = [];
        const play = SpotifySource.formatPlayObj(noAAPayload as SpotifyApi.CurrentPlaybackResponse);
        expect(play.data.track).eq('The Sandpits Of Zonhoven');
        expect(play.data.album).eq('Bloodbags And Downtube Shifters');
        expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']);
        expect(play.data.albumArtists).to.be.empty;
    });

    it('Spotify parses payload with different album artists correctly', function() {
        const play = SpotifySource.formatPlayObj(spotifyPayload as SpotifyApi.CurrentPlaybackResponse);
        expect(play.data.track).eq('The Sandpits Of Zonhoven');
        expect(play.data.album).eq('Bloodbags And Downtube Shifters');
        expect(artistCreditsToNames(play.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']);
        expect(artistCreditsToNames(play.data.albumArtists)).eql(['Dubmood']);
    });

    it('Spotify parses payload with identical album artists correctly', function() {
        const identicalArtistsPayload = clone(spotifyPayload)
        identicalArtistsPayload.item.album.artists = identicalArtistsPayload.item.artists;
        const identicalArtistsPlay = SpotifySource.formatPlayObj(identicalArtistsPayload as SpotifyApi.CurrentPlaybackResponse);
        expect(identicalArtistsPlay.data.track).eq('The Sandpits Of Zonhoven');
        expect(identicalArtistsPlay.data.album).eq('Bloodbags And Downtube Shifters');
        expect(artistCreditsToNames(identicalArtistsPlay.data.artists)).eql(['Dubmood', 'MASTER BOOT RECORD']);
        expect(artistCreditsToNames(identicalArtistsPlay.data.albumArtists)).to.be.empty;
    });
});

describe('Player Cleanup', function () {

    this.afterEach(() => {
        MockDate.reset();
        setRtTick(RT_TICK_DEFAULT);
    });
    this.beforeEach(() => {
        setRtTick(1);
    });

    const cleanedUpDuration = async (generateSource: (config: SourceConfig) => Promise<MemorySource>) => {
        await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
        const initialDate = dayjs();
        const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
        expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);

        let position = 0;
        let timeSince = 0;

        // simulate polling playing source for 30 seconds, 10 second interval
        for(let i = 0; i < 3; i++) {
            position += 10;
            timeSince += 10;
            MockDate.set(initialDate.add(position, 'seconds').toDate());
            await sleep(1);
            const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
            expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
        }

        // simulate polling another 20 seconds without any updates from the Source
        for(let i = 0; i < 2; i++) {
            timeSince += 10;
            MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
            await sleep(1);
            expect((await source.processRecentPlays([])).length).to.be.eq(0);
        }

        MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
        await sleep(1);
        const discoveredPlays = await source.processRecentPlays([]);
        // cleanup should discover stale play
        expect(discoveredPlays.length).to.be.eq(1);
        expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);
    } 

    it('Discovers cleaned up Play with correct duration (Non Positional Source)', async function () {
        await cleanedUpDuration(generateMemorySource);
    });

    it('Discovers cleaned up Play with correct duration (Positional Source)', async function () {
        await cleanedUpDuration(generateMemoryPositionalSource);
    });

    const noScrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => Promise<MemorySource>) => {

        await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
        const initialDate = dayjs();
        const initialState = generatePlayerStateData({position: 0, playData: {duration: 50}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
        expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);

        let position = 0;
        let timeSince = 0;

        // simulate polling playing source for 30 seconds, 10 second interval
        for(let i = 0; i < 3; i++) {
            position += 10;
            timeSince += 10;
            MockDate.set(initialDate.add(position, 'seconds').toDate());
            await sleep(1);
            const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
            expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
        }

        // simulate polling another 20 seconds without any updates from the Source
        for(let i = 0; i < 2; i++) {
            timeSince += 10;
            MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
            await sleep(1);
            expect((await source.processRecentPlays([])).length).to.be.eq(0);
        }

        timeSince += 2;

        MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
        await sleep(1);
        const discoveredPlays = await source.processRecentPlays([]);
        // cleanup should discover stale play
        expect(discoveredPlays.length).to.be.eq(1);
        expect(discoveredPlays[0].data.listenedFor).closeTo(30, 2);

        timeSince += 10;

        position -= 9;
        // simulate polling another 20 seconds with active source again
        for(let i = 0; i < 2; i++) {
            timeSince += 10;
            MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
            await sleep(1);
            const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
            expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
        }

        timeSince += 10;
        MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
        await sleep(1);
        // new Play
        const advancedState = generatePlayerStateData({stateUpdatedAt: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
        // should not return play because it has only been played for ~20 seconds, less than 50% of duration
        const plays = await source.processRecentPlays([advancedState])
        expect(plays.length).to.be.eq(0);
    }


    it('Does not discover same Play after becoming active again (Non Positional Source)', async function () {
        await noScrobbleRediscoveryOnActive(generateMemorySource);
    });

    it('Does not discover same Play after becoming active again (Positional Source)', async function () {
        await noScrobbleRediscoveryOnActive(generateMemoryPositionalSource);
    });

    const noScrobbleStale = async (generateSource: (config: SourceConfig) => Promise<MemorySource>) => {

        await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
        const initialDate = dayjs();

        // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
        const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
        expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);

        let position = 0;
        let timeSince = 0;

        // simulate polling playing source for 30 seconds, 10 second interval
        for(let i = 0; i < 3; i++) {
            position += 10;
            timeSince += 10;
            MockDate.set(initialDate.add(position, 'seconds').toDate());
            await sleep(1);
            const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: initialDate, position, status: REPORTED_PLAYER_STATUSES.playing});
            expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
        }

        // simulate polling another 20 seconds without any updates from the Source
        for(let i = 0; i < 2; i++) {
            timeSince += 10;
            MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
            await sleep(1);
            expect((await source.processRecentPlays([])).length).to.be.eq(0);
        }

        MockDate.set(initialDate.add(timeSince + 2, 'seconds').toDate());
        const discoveredPlays = await source.processRecentPlays([]);
        // cleanup should not discover stale play
        expect(discoveredPlays.length).to.be.eq(0);

    }

    it('Does not discover cleaned up Play that did not meet threshold (Non Positional Source)', async function () {
        await noScrobbleStale(generateMemorySource);
    });

    it('Does not discover cleaned up Play that did not meet threshold (Positional Source)', async function () {
        await noScrobbleStale(generateMemoryPositionalSource);
    });

    const scrobbleRediscoveryOnActive = async (generateSource: (config: SourceConfig) => Promise<MemorySource>) => {

        await using source = await generateSource({data: {staleAfter: 21, orphanedAfter: 40}, options: {}});
        const initialDate = dayjs();

        // if player incorrectly counted stale time then 30s of actual play + 20s of stale time > scrobble threshold of 50% of 90s
        const initialState = generatePlayerStateData({position: 0, playData: {duration: 90}, stateUpdatedAt: initialDate, status: REPORTED_PLAYER_STATUSES.playing});
        expect((await source.processRecentPlays([initialState])).length).to.be.eq(0);

        let position = 0;
        let timeSince = 0;

        // simulate polling playing source for 30 seconds, 10 second interval
        for(let i = 0; i < 3; i++) {
            position += 10;
            timeSince += 10;
            MockDate.set(initialDate.add(position, 'seconds').toDate());
            await sleep(1);
            const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
            expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
        }

        // simulate polling another 20 seconds without any updates from the Source
        for(let i = 0; i < 2; i++) {
            timeSince += 10;
            MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
            await sleep(1);
            expect((await source.processRecentPlays([])).length).to.be.eq(0);
        }

        timeSince += 2;

        MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
        await sleep(1);
        const discoveredPlays = await source.processRecentPlays([]);
        // cleanup should not discover stale play
        expect(discoveredPlays.length).to.be.eq(0);

        // so that loop starts 1 second after "paused" position
        position -= 9;

        // simulate ~50 seconds of listening (enough for scrobble)
        MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
        for(let i = 0; i < 5; i++) {
            position += 10;
            timeSince += 10;
            MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
            await sleep(1);
            const advancedState = generatePlayerStateData({play: initialState.play, stateUpdatedAt: dayjs(), position, status: REPORTED_PLAYER_STATUSES.playing});
            expect((await source.processRecentPlays([advancedState])).length).to.be.eq(0);
        }

        timeSince += 10;
        MockDate.set(initialDate.add(timeSince, 'seconds').toDate());
        await sleep(1);
        // new Play
        const advancedState = generatePlayerStateData({stateUpdatedAt: dayjs(), position: 0, status: REPORTED_PLAYER_STATUSES.playing});
        // should return discovered play with ~90 seconds of duration
        const plays = await source.processRecentPlays([advancedState])
        expect(plays.length).to.be.eq(1);
        expect(plays[0].data.duration).to.be.closeTo(90, 2);

    }

    it('Does discover Play after becoming active again (Non Positional Source)', async function () {
        await scrobbleRediscoveryOnActive(generateMemorySource);
    });

    it('Does discover Play after becoming active again (Positional Source)', async function () {
        await scrobbleRediscoveryOnActive(generateMemoryPositionalSource);
    });
});

class DeezerTestSource extends DeezerInternalSource {
    protected async doCheckConnection(): Promise<true | string | undefined> {
        return;
    }
    doAuthentication = async () => {
        return true;
    }
}

const generateDeezerSource = async (options: DeezerInternalSourceOptions = {}) => {
    const source = new DeezerTestSource('test', {data: {arl: 'test'}, options}, {localUrl: new URL('https://example.com'), configDir: 'fake', logger: loggerTest, version: 'test'},  emitter);
    await source.initialize();
    return source;
}
const firstPlayDate = dayjs().subtract(1, 'hour');
const normalizedPlays = normalizePlays(generatePlays(6), {initialDate: firstPlayDate});
const lastPlay = normalizedPlays[normalizePlays.length - 1];

describe('Deezer Internal Source', function() {

    describe('When fuzzyDiscoveryIgnore is not defined or false', function () {

        it('discovers fuzzy play', async function() {
            const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
            const targetPlay = normalizedPlays[normalizedPlays.length - 2]
            const fuzzyPlay = clone(targetPlay);
            fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');

            const source = await generateDeezerSource();
            source.discover([...normalizedPlays, interimPlay]);

            const discovered = await source.discover([fuzzyPlay]);

            expect(discovered.length).to.eq(1);
        });
    });

    describe('When fuzzyDiscoveryIgnore is true', function () {

        it('does not discover fuzzy play with interim plays', async function() {
            const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
            const targetPlay = normalizedPlays[normalizedPlays.length - 2]
            const fuzzyPlay = clone(targetPlay);
            fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');

            await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: true});
            await source.discover([...normalizedPlays, interimPlay]);

            const discovered = await source.discover([fuzzyPlay]);

            expect(discovered.length).to.eq(0);
        });

        it('discovers fuzzy play when it is the last play ', async function() {
            const targetPlay = normalizedPlays[normalizedPlays.length - 1]
            const fuzzyPlay = clone(targetPlay);
            fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');

            await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: true});
            await source.discover(normalizedPlays);

            const discovered = await source.discover([fuzzyPlay]);

            expect(discovered.length).to.eq(1);
        });

        it('discovers fuzzy play when it is played consecutively', async function() {
            const targetPlay = normalizedPlays[normalizedPlays.length - 1]
            const fuzzyPlay = clone(targetPlay);
            fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
            const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});

            await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: true});
            const discovered = await source.discover(morePlays);

            expect(discovered.length).to.eq(morePlays.length);
        });
    });

        describe('When fuzzyDiscoveryIgnore is aggressive', function () {

            it('does not discover fuzzy play with interim plays', async function() {
                const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
                const targetPlay = normalizedPlays[normalizedPlays.length - 2]
                const fuzzyPlay = clone(targetPlay);
                fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');

                await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
                await source.discover([...normalizedPlays, interimPlay]);

                const discovered = await source.discover([fuzzyPlay]);

                expect(discovered.length).to.eq(0);
            });

            it('does not discover play found during duration of previous', async function() {
                const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
                const targetPlay = normalizedPlays[normalizedPlays.length - 2]
                const duringPlay = clone(targetPlay);
                duringPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration * 0.5, 's');

                await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
                await source.discover([...normalizedPlays, interimPlay]);

                const discovered = await source.discover([duringPlay]);

                expect(discovered.length).to.eq(0);
            });

            it('does not discover fuzzy play with delay of up to 40 seconds', async function() {
                const interimPlay = generatePlay({playDate: lastPlay.data.playDate.add(15, 's'), duration: 80});
                const targetPlay = normalizedPlays[normalizedPlays.length - 2]
                const fuzzyPlay = clone(targetPlay);
                fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration + 39, 's');

                await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
                await source.discover([...normalizedPlays, interimPlay]);

                const discovered = await source.discover([fuzzyPlay]);

                expect(discovered.length).to.eq(0);
            });

            it('it does not discover fuzzy play when it is the last play ', async function() {
                const targetPlay = normalizedPlays[normalizedPlays.length - 1]
                const fuzzyPlay = clone(targetPlay);
                fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');

                await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
                await source.discover(normalizedPlays);

                const discovered = await source.discover([fuzzyPlay]);

                expect(discovered.length).to.eq(0);
            });

            it('does not discover fuzzy play when it is played consecutively', async function() {
                const targetPlay = normalizedPlays[normalizedPlays.length - 1]
                const fuzzyPlay = clone(targetPlay);
                fuzzyPlay.data.playDate = targetPlay.data.playDate.add(targetPlay.data.duration, 's');
                const morePlays = normalizePlays([...normalizedPlays, fuzzyPlay, ...generatePlays(2)], {initialDate: firstPlayDate});

                await using source = await generateDeezerSource({fuzzyDiscoveryIgnore: 'aggressive'});
                const discovered = await source.discover(morePlays);

                expect(discovered.length).to.eq(morePlays.length - 1);
            });

        });
});