import { faker, fakerEL, FakerError } from '@faker-js/faker';
import dayjs, { Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration.js";
import isBetween from "dayjs/plugin/isBetween.js";
import relativeTime from "dayjs/plugin/relativeTime.js";
import timezone from "dayjs/plugin/timezone.js";
import utc from "dayjs/plugin/utc.js";
import { ArtistCredit, BrainzMeta, FEAT, JOINERS, JOINERS_FINAL, JsonPlayObject, MBID, MissingMbidType, ObjectPlayData, PlayMeta, PlayObject, SourcePlayerObj } from "./Atomic.js";
import { genGroupIdStr } from './PlayUtils.js';
import { sortByNewestPlayDate } from './PlayUtils.js';
import { CALCULATED_PLAYER_STATUSES, NO_DEVICE, NO_USER, PlayerStateDataMaybePlay, PlayPlatformId, REPORTED_PLAYER_STATUSES, SINGLE_USER_PLATFORM_ID } from '../backend/common/infrastructure/Atomic.js';
import { arrayListAnd, artistNamesToCredits } from './StringUtils.js';
import { findDelimiters } from "./StringUtils.js";
import { ListRecord, ScrobbleRecord } from '../backend/common/infrastructure/config/client/tealfm.js';
import { nanoid } from 'nanoid';
import { LastFMTrackObject } from '../backend/common/vendor/LastfmApiClient.js';
import { MarkOptional } from 'ts-essentials';
import { defaultLifecycle } from '../backend/utils/PlayTransformUtils.js';
import clone from 'clone';
import { removeUndefinedKeys } from '../backend/utils.js';

dayjs.extend(utc)
dayjs.extend(isBetween);
dayjs.extend(relativeTime);
dayjs.extend(duration);
dayjs.extend(timezone);

export const normalizePlays = (plays: PlayObject[],
                               options?: {
                                   //sortFunc?: (a: PlayObject, b: PlayObject) => 0 | 1 | -1
                                   initialDate?: Dayjs,
                                   endDate?: Dayjs
                                   defaultDuration?: number,
                                   defaultData?: ObjectPlayData,
                                   defaultMeta?: MarkOptional<PlayMeta, 'lifecycle'>
                               }
): PlayObject[] => {
    const {
        initialDate,
        endDate,
        defaultDuration = 180,
        defaultData = {},
        defaultMeta = {},
        //sortFunc = sortByNewestPlayDate
    } = options || {};

    const normalizedPlays: PlayObject[] = [];

    let actualInitialDate: Dayjs | undefined = initialDate;

    if(endDate !== undefined && actualInitialDate !== undefined) {

        // need to redefine durations and listenedFor, if present
        const dur = dayjs.duration(endDate.diff(actualInitialDate));
        let remaining = plays.length;

        let index = 0;
        let lastDate = endDate;
        let remainingTime = dur.asSeconds()
        for(const play of plays) {

            const cleanPlay = {...play};

            const remainingAvgTime = remainingTime/remaining;
            cleanPlay.data.playDate = lastDate;
            cleanPlay.data.duration = faker.number.int({min: 30, max: remainingAvgTime});
            if(cleanPlay.data.listenedFor !== undefined) {
                cleanPlay.data.listenedFor = faker.number.int({min: Math.floor(cleanPlay.data.duration * 0.9), max: cleanPlay.data.duration});
            }
            cleanPlay.data = {
                ...cleanPlay.data,
                ...defaultData
            }
            cleanPlay.meta = {
                lifecycle: defaultLifecycle(),
                ...cleanPlay.meta,
                ...defaultMeta
            }

            if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) {
                cleanPlay.meta.lifecycle.original.data.playDate = lastDate;
            }
            if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) {
                cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's');
            }

            if(index + 1 <= plays.length - 1) {
                const listenTime = (plays[index+1].data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2});
                lastDate = cleanPlay.data.playDate.subtract(listenTime, 'seconds');
            }
            remaining--;
            remainingTime -= cleanPlay.data.duration;
            index++;
        }

    } else {
        const progressDirection: 'newer' | 'older' = endDate !== undefined ? 'older' : 'newer';
        if(progressDirection === 'newer' && actualInitialDate === undefined) {
            const firstDatedPlay = plays.find(x => x.data.playDate !== undefined);
            if (firstDatedPlay !== undefined) {
                actualInitialDate = firstDatedPlay.data.playDate;
            } else {
                throw new Error('No initial date specified and no play had a defined date');
            }
        }

        let lastDate: Dayjs = progressDirection === 'newer' ? actualInitialDate : endDate;
        let index = 0;
        for (const play of plays) {

            const cleanPlay = {...play};

            cleanPlay.data.playDate = lastDate;
            cleanPlay.data = {
                ...cleanPlay.data,
                ...defaultData
            }
            cleanPlay.meta = {
                ...cleanPlay.meta,
                ...defaultMeta
            }

            if(cleanPlay.meta.lifecycle?.original?.data?.playDate !== undefined) {
                cleanPlay.meta.lifecycle.original.data.playDate = lastDate;
            }
            if(cleanPlay.meta.lifecycle?.original?.data?.playDateCompleted !== undefined) {
                cleanPlay.meta.lifecycle.original.data.playDate = lastDate.add(cleanPlay.data.listenedFor ?? cleanPlay.data.duration, 's');
            }

            if(progressDirection === 'newer') {
                const listenTime = (cleanPlay.data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2});
                lastDate = cleanPlay.data.playDate.add(listenTime, 'seconds');
            } else if(index + 1 <= plays.length - 1) {
                const listenTime = (plays[index+1].data.duration ?? defaultDuration) + faker.number.int({min: 0, max: 2});
                lastDate = cleanPlay.data.playDate.subtract(listenTime, 'seconds');
            }

            normalizedPlays.push(cleanPlay);
            index++;
        }

        if(progressDirection === 'older') {
            normalizedPlays.sort(sortByNewestPlayDate);
        }
    }

    return normalizedPlays;
}

export const generatePlayerStateData = (options: Omit<PlayerStateDataMaybePlay, 'platformId'> & {playData?: ObjectPlayData, playMeta?: PlayMeta, platformId?: PlayPlatformId} = {}): PlayerStateDataMaybePlay => {
    let play: PlayObject = options.play ?? generatePlay(options.playData, options.playMeta);
    if(options.position !== undefined) {
        play.meta.trackProgressPosition = options.position;
    }
    return {
        platformId: options.platformId ?? [NO_DEVICE, NO_USER],
        sessionId: options.sessionId,
        play,
        status: options.status,
        position: options.position,
        stateUpdatedAt: options.stateUpdatedAt ?? dayjs()
    }
}

export interface GeneratePlayOpts {
    playDateCompleted?: boolean,
    listenRanges?: boolean
}
export const generatePlay = (data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject => {
    const {
        playDateCompleted = false,
        listenRanges = false,
    } = opts;

    const duration = faker.number.int({min: 30, max: 300});
    const play: PlayObject = {
        data: {
            track: faker.music.songName(),
            artists: artistNamesToCredits(faker.helpers.multiple(faker.music.artist, {count: {min: 1, max: 3}})),
            duration,
            playDate: dayjs().subtract(faker.number.int({min: 1, max: 800})),
            album: faker.music.album(),
            listenedFor: faker.number.int({min: duration * 0.5, max: duration}),
            ...data
        },
        meta: {
            source: ['Spotify', 'Listenbrainz', 'Lastfm', 'Jellyfin', 'Plex'][faker.number.int({min: 0, max: 4})],
            seenAt: dayjs(),
            ...meta,
            lifecycle: {
                original: {
                    data: {},
                    meta: {}
                },
                steps: []
            },
            url: {
                web: 'https://example.com',
                origin: 'https://example.com'
            },
        }
    }

    if(play.data.playDateCompleted === undefined && playDateCompleted) {
        play.data.playDateCompleted = play.data.playDate.add(play.data.duration)
    }

    if(listenRanges) {
        play.data.listenRanges = [];
        const lf = play.data.listenedFor;
        const sessions = faker.number.int({min: 1, max: 3});
        const sessionTime = lf / sessions;
        let nextTime = play.data.playDate;
        switch(faker.number.int({min: 1, max: 2})) {
            case 1:
                // timestamps only
                for(let i = 0; i < sessions; i++) {
                    const newTime = nextTime.add(sessionTime, 's');
                    play.data.listenRanges.push({start: {timestamp: clone(nextTime)}, end: {timestamp: newTime}});
                    nextTime = newTime;
                }
                break;
            case 2:
                // timestamps + position
                let position = 0;
                for(let i = 0; i < sessions; i++) {
                    const newTime = nextTime.add(sessionTime, 's');
                    const nextPosition = position + sessionTime;
                    play.data.listenRanges.push({start: {timestamp: clone(nextTime), position}, end: {timestamp: newTime, position: nextPosition}});
                    nextTime = newTime;
                    position = nextPosition;
                }
                break;
        }
    }

    return play;
}

export const generateJsonPlay = (...args: Parameters<typeof generatePlay>): JsonPlayObject => {
    const play = generatePlay(...args);
    return JSON.parse(JSON.stringify(play));
}

export const generateJsonPlays = (...args: Parameters<typeof generatePlays>): JsonPlayObject[] => {
    const plays = generatePlays(...args);
    return JSON.parse(JSON.stringify(plays));
}

export interface WithBrainzOptions {
    include: ('track' | 'artist' | 'album' | 'recording')[]
}
export const generateBrainz = (play: PlayObject, opts: WithBrainzOptions): BrainzMeta => {
    const {include} = opts;
    const brainz: BrainzMeta = {};
    for(const i of include) {
        switch(i) {
            case 'track':
                if(play.data.meta?.brainz?.track === undefined) {
                    brainz.track = generateMbid();
                }
                break;
            case 'recording':
                if(play.data.meta?.brainz?.recording === undefined) {
                    brainz.recording = generateMbid();
                }
                break;
            case 'album':
                if(play.data.meta?.brainz?.album === undefined && play.data.album !== undefined) {
                    brainz.album = generateMbid();
                }
                break;
            case 'artist':
                if(play.data.meta?.brainz?.artist === undefined && (play.data.artists ?? []).length > 0) {
                    const artistMbids = play.data.artists.map(x => generateMbid());
                    brainz.artist = artistMbids;
                }
                break;
        }
    }

    return brainz
}

export const withBrainz = (play: PlayObject, opts: WithBrainzOptions): PlayObject => {
    const brainz = generateBrainz(play, opts);
    play.data.meta = {
        ...(play.data.meta ?? {}),
        brainz : {
            ...(play.data.meta?.brainz ?? {}),
            ...brainz
        }
    }
    return play;
}

export const generatePlayPlatformId = (deviceId?: string, userId?: string): PlayPlatformId => {
    const did = deviceId ?? [NO_DEVICE, faker.hacker.noun(), faker.hacker.noun()][faker.number.int({min: 0, max: 2})];
    const uid = userId ?? [NO_USER, faker.internet.username(), faker.internet.username()][faker.number.int({min: 0, max: 2})];
    return [did, uid];
}

export const generatePlays = (numberOfPlays: number, data: ObjectPlayData = {}, meta: MarkOptional<PlayMeta, 'lifecycle'> = {}, opts: GeneratePlayOpts = {}): PlayObject[] => {
    return Array.from(Array(numberOfPlays), () => generatePlay(data, meta, opts));
}

export const generateArtist = () => faker.music.artist;

export interface ArtistGenerationOptions
{
    ambiguousJoinedNames?: boolean, 
    trailingAmpersand?: boolean
}

export const generateArtists = (num?: number, max: number = 3, opts: ArtistGenerationOptions = {}) => {
    if(num === 0 || max === 0) {
        return [];
    }
    let artists = faker.helpers.multiple(faker.music.artist, {count: {min: num ?? 1, max: num ?? max}});

    const {
        trailingAmpersand = false,
        ambiguousJoinedNames = false
    } = opts;

    if(!trailingAmpersand) {
        // its really hard to parse an artist name that contains an '&' when it comes at the end of a list
        // because its ambigious if the list is joining the list with & or if & is part of the artist name
        // so by default don't generate these (we test for specific scenarios in playParsing.test.ts)
        while(artists[artists.length - 1].includes('&')) {
            artists = artists.slice(0, artists.length - 1).concat(faker.music.artist());
        }
    }
    if(!ambiguousJoinedNames) {
        artists = artists.map(x => {
            let a = x;
            let foundDelims = findDelimiters(a);
            while(foundDelims !== undefined && foundDelims.length > 0 && !(foundDelims.length === 1 && foundDelims[0] === '&')) {
                a = faker.music.artist();
                foundDelims = findDelimiters(a);
            }
            return a;
        });
    }
    return artists;
}

export const generateArtistCredit = (name: string = faker.music.artist(), mbidVal: MBID | boolean = true): ArtistCredit => {
    let mbid: MBID;
    if(mbidVal === true) {
        mbid = generateMbid();
    } else if(typeof mbidVal === 'string') {
        mbid = mbidVal as MBID;
    }
    return removeUndefinedKeys<ArtistCredit>({name, mbid});
}

export const generateArtistCredits = (num?: number, max?: number, opts: ArtistGenerationOptions & {mbidVal?: boolean} = {}): ArtistCredit[] => {
    const artistNames = generateArtists(num, max, opts);
    return artistNames.map(x => generateArtistCredit(x, opts.mbidVal));
}

export interface ArtistGenerateOptions extends ArtistGenerationOptions {
    num?: number
    max?: number
    joiner?: string
    finalJoiner?: false | string
    spacedJoiners?: boolean
}

export interface SecondaryArtistGenerateOptions extends ArtistGenerateOptions {
    ft?: string
    ftWrap?: boolean
}

export interface CompoundArtistGenerateOptions {
    primary?: number | ArtistGenerateOptions
    secondary?: number | SecondaryArtistGenerateOptions
}

export const generateArtistsStr = (options: CompoundArtistGenerateOptions = {}): [string, string[], string[]] => {

    const {primary = {}, secondary = {}} = options;

    const primaryOpts: ArtistGenerateOptions = typeof primary === 'number' ? {num: primary} : primary;
    const secondaryOpts: SecondaryArtistGenerateOptions = typeof secondary === 'number' ? {num: secondary} : secondary;

    const primaryArt = generateArtists(primaryOpts.num, primaryOpts.max)
    const secondaryArt = generateArtists(secondaryOpts.num, secondaryOpts.max);


    const joinerPrimary: string = primaryOpts.joiner ?? faker.helpers.arrayElement(JOINERS);
    let finalJoinerPrimary: string = joinerPrimary;
    if(primaryOpts.finalJoiner !== false) {
        if(primaryOpts.finalJoiner === undefined) {
            if(joinerPrimary === ',' && !primaryArt.some(x => x.includes('&'))) {
                finalJoinerPrimary = faker.helpers.arrayElement(JOINERS_FINAL);
            }
        
        } else {
            finalJoinerPrimary = primaryOpts.finalJoiner;
        }
    }

    const primaryStr = arrayListAnd(primaryArt, joinerPrimary, finalJoinerPrimary, primaryOpts.spacedJoiners);

    if(secondaryArt.length === 0) {
        return [primaryStr, primaryArt, []];
    }

    const joinerSecondary: string = secondaryOpts.joiner ?? faker.helpers.arrayElement(JOINERS);
    let finalJoinerSecondary: string = joinerSecondary;
    if(secondaryOpts.finalJoiner !== false) {
        if(secondaryOpts.finalJoiner === undefined) {
            if(joinerSecondary === ',' && !secondaryArt.some(x => x.includes('&'))) {
                finalJoinerSecondary = faker.helpers.arrayElement(JOINERS_FINAL);
            }
        } else {
            finalJoinerSecondary = secondaryOpts.finalJoiner;
        }
    }

    const secondaryStr = arrayListAnd(secondaryArt, joinerSecondary, finalJoinerSecondary, secondaryOpts.spacedJoiners);
    const ft = secondaryOpts.ft ?? faker.helpers.arrayElement(FEAT);
    let sec = `${ft} ${secondaryStr}`;
    let wrap: boolean;
    if(secondaryOpts.ftWrap !== undefined) {
        wrap = secondaryOpts.ftWrap;
    } else {
        wrap = faker.datatype.boolean();
    }
    if(wrap) {
        sec = `(${sec})`;
    }
    const artistStr = `${primaryStr} ${sec}`;

    return [artistStr, primaryArt, secondaryArt];
}

export const generateLastfmTrackObject = (): LastFMTrackObject => {
    const now = dayjs();
    const artist = faker.music.artist();
    return {
        album: {
            '#text': faker.music.album(),
            mbid: ""
        },
        artist: {
            '#text': artist,
            mbid: "",
            name: artist
        },
        date: {
            uts: now.unix().toString(),
            '#text': now.format('DD MMM YYYY, HH:ss')
        },
        name: faker.music.songName(),
        url: "https://www.last.fm/music/test/_/test",
        '@attr': {
            nowplaying: 'false'
        },
        mbid: ""
    }
}

export const generateMbid = (): MBID => {
    return [
        faker.string.alphanumeric({length: 8}),
        faker.string.alphanumeric({length: 4}),
        faker.string.alphanumeric({length: 4}),
        faker.string.alphanumeric({length: 4}),
        faker.string.alphanumeric({length: 12})
    ].join('-') as MBID;
}

export const generateTealPlayRecord = (opts: {
    withMbids?: boolean,
    withIsrc?: boolean
} = {}): [ListRecord<ScrobbleRecord>, { did: string, tid: string }] => {
    const {
        withMbids = true,
        withIsrc = true,
    } = opts;

    const now = dayjs();
    const artists = generateArtists(2);

    const did = nanoid(12);
    const tid = nanoid(10);

    const rec: ListRecord<ScrobbleRecord> = {
        uri: `at://did:plc:${did}/fm.teal.alpha.feed.play/${tid}`,
        cid: nanoid(12),
        value: {
            '$type': 'fm.teal.alpha.feed.play',
            artists: artists.map(x => withMbids ? { artistName: x, artistMbId: generateMbid() } : { artistName: x }),
            releaseName: faker.music.album(),
            trackName: faker.music.songName(),
            playedTime: now.toISOString(),
            submissionClientAgent: 'test',
            duration: faker.number.int({ min: 1, max: 300 })
        }
    }

    if (withMbids) {
        rec.value.releaseMbId = generateMbid();
        rec.value.recordingMbId = generateMbid();
    }

    if (withIsrc) {
        rec.value.isrc = nanoid(12);
    }

    return [rec, { did, tid }];
}

export interface GenerateSourcePlayerObjOptions {
    playOpts?: Parameters<typeof generatePlay>[0]
    play?: PlayObject
    playPlatform?: string, 
    sourcePlayOpts?: Partial<SourcePlayerObj>
}

export const generateSourcePlayerObj = (opts: GenerateSourcePlayerObjOptions): SourcePlayerObj => {
    let play: PlayObject;
    let platformId: string;
    if(opts.play !== undefined) {
        platformId = opts.play.meta.deviceId;
    } else {
        platformId = opts.playPlatform ?? genGroupIdStr(SINGLE_USER_PLATFORM_ID);
    }
    play = opts.play ?? generatePlay(opts.playOpts, {deviceId:platformId});

    const {
        status = {},
        play: oPlay,
        platformId: oplat,
        ...rest
    } = opts.sourcePlayOpts ?? {};

    return {
        platformId,
        play,
        listenedDuration: 60,
        playerLastUpdatedAt: dayjs().toISOString(),
        status: {
            reported: REPORTED_PLAYER_STATUSES.playing,
            calculated: CALCULATED_PLAYER_STATUSES.playing,
            stale: false,
            orphaned: false,
            ...status
        },
        ...rest
    }
}