import dayjs from "dayjs";
import EventEmitter from "events";
import request, { Request, Response, SuperAgent } from 'superagent';
import { PlayObject, PlayObjectLifecycleless, SOURCE_SOT, TA_CLOSE, TA_DURING, TA_EXACT, TA_FUZZY, TemporalAccuracy } from "../../core/Atomic.js";
import { DEFAULT_RETRY_MULTIPLIER, FormatPlayObjectOptions, InternalConfig } from "../common/infrastructure/Atomic.js";
import { DeezerInternalSourceConfig, DeezerInternalTrackData, DeezerSourceConfig } from "../common/infrastructure/config/source/deezer.js";
import { TRANSFORM_HOOK } from "../common/infrastructure/Transform.js";
import { parseRetryAfterSecsFromObj, playObjDataMatch, sleep, sortByOldestPlayDate} from "../utils.js";
import AbstractSource, { RecentlyPlayedOptions } from "./AbstractSource.js";
import { CookieJar, Cookie } from 'tough-cookie';
import { MixedCookieAgent } from 'http-cookie-agent/http';
import MemorySource from "./MemorySource.js";
import { genericSourcePlayMatch } from "../utils/PlayComparisonUtils.js";
import { TemporalPlayComparisonOptions } from "../utils/TimeUtils.js";
import { findAsync, findIndexAsync } from "../utils/AsyncUtils.js";
import { baseFormatPlayObj } from "../utils/PlayTransformUtils.js";
import { UpstreamError } from "../common/errors/UpstreamError.js";

interface DeezerHistoryResponse {
    errors: []
    results: {
        data: DeezerInternalTrackData[]
        error: string[]
    }
}

interface DeezerAccountData {
    USER_ID: string,
    /** account name */
    BLOG_NAME: string,
    /** https://github.com/FoxxMD/multi-scrobbler/issues/344#issuecomment-3347915743 */
    EXTRA_FAMILY?: {
        /** if false then this account is private */
        IS_LOGGABLE_AS: boolean
        /** true if private? */
        IS_DELINKABLE: boolean
    }
}

interface DeezerAccountResponse {
    error?: {PERMISSION_ERROR: "No Permission"}
    results: DeezerAccountData[]
}

interface DeezerUserDataResponse {
    results: DeezerAuthedUserData & {
        checkForm: string
    }
}

interface DeezerAuthedUserData {
            USER: {
            USER_ID: string
            /** account name */
            BLOG_NAME: string
            MULTI_ACCOUNT: {
                /** true if its a sub account */
                IS_SUB_ACCOUNT: boolean
            }
        }
}

export default class DeezerInternalSource extends MemorySource {
    requiresAuth = true;
    requiresAuthInteraction = false;
    isSubAccount: boolean = false;

    authedAccount: DeezerAuthedUserData;

    accounts?: DeezerAccountData[] = []

    csrfToken?: string;

    agent: request.SuperAgentStatic & request.Request
    jar: CookieJar

    declare config: DeezerInternalSourceConfig;

    constructor(name: any, config: DeezerInternalSourceConfig, internal: InternalConfig, emitter: EventEmitter) {
        super('deezer', name, config, internal, emitter);
        const {
            data: {
                interval = 60,
                ...rest
            } = {},
        } = config;

        if (interval < 15) {
            this.logger.warn('Interval should be above 30 seconds...😬');
        }

        // @ts-expect-error not correct structure
        this.config.data = {
            ...rest,
            interval,
        };

        this.canPoll = true;
        this.canBacklog = true;
        this.supportsUpstreamRecentlyPlayed = true;
        this.playerSourceOfTruth = SOURCE_SOT.HISTORY;
        // https://developers.deezer.com/api/user/history
        // https://stackoverflow.com/a/19497151/1469797
        this.SCROBBLE_BACKLOG_COUNT = 50;

        this.jar = new CookieJar();
        const mixedAgent = new MixedCookieAgent({ cookies: { jar: this.jar } });
        // @ts-expect-error not correct structure
        this.agent = request.agent().use((req) => req.agent(mixedAgent));
    }

    static formatPlayObj(obj: DeezerInternalTrackData, options: FormatPlayObjectOptions = {}): PlayObject {
        const {newFromSource = false} = options;
        const play: PlayObjectLifecycleless = {
            data: {
                artists: [obj.ART_NAME],
                album: obj.ALB_TITLE,
                track: obj.SNG_TITLE,
                duration: obj.DURATION,
                playDate: dayjs(obj.TS * 1000),
            },
            meta: {
                source: 'Deezer',
                trackId: obj.SNG_ID,
                newFromSource,
                url: {
                    web: `https://www.deezer.com/track/${obj.SNG_ID}`
                },
                musicService: 'Deezer',
            }
        };
        if(obj.ALB_PICTURE !== undefined && obj.ALB_PICTURE !== '') {
            play.meta.art = {
                album: `https://cdn-images.dzcdn.net/images/cover/${obj.ALB_PICTURE}/500x500-000000-80-0-0.jpg`
            }
        }
        return baseFormatPlayObj(obj, play);
    }

    protected async doBuildInitData(): Promise<true | string | undefined> {
        this.logger.warn('This Source uses unofficial methods to access Deezer data that are likely against Deezer\'s TOS. Deezer may change or remove these methods at any time breaking functionality as well as revoke access to your account. Use this Source at your own risk.');
        if (this.config.data.arl === undefined) {
            throw new Error('arl must be defined in configuration');
        }
        this.jar.setCookie(`arl=${this.config.data.arl}; comeback=1`, 'https://www.deezer.com');
        return true;
    }

    protected async doCheckConnection(): Promise<true | string | undefined> {
        try {
            await request.get('https://deezer.com');
            return true;
        } catch (e) {
            throw e;
        }
    }

    doAuthentication = async () => {
        try {
            const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
            .query({
                method: 'deezer.getUserData'
            })
            const resp = (await this.callApi(req)) as DeezerUserDataResponse;
            this.authedAccount = resp.results;
            this.logger.verbose(`Authenticated for User ${resp.results.USER.BLOG_NAME}`);
            const enumerated = await this.enumerateChildAccounts();

            // still a bit unsure about this
            // https://github.com/FoxxMD/multi-scrobbler/issues/344#issuecomment-3357187332
            // but it seems like if the authed account is not the *main* account in the family then it is always considered private?
            if(resp.results.USER.MULTI_ACCOUNT.IS_SUB_ACCOUNT) {
                this.logger.verbose('This account is a child account, will not enumerate other accounts');
                this.isSubAccount = true;
                if(this.config.data.accountId !== undefined) {
                    this.logger.warn('Cannot use accountId when authenticated account is a child account!');
                }
            } else {
                const enumerated = await this.enumerateChildAccounts();
                if(this.config.data.accountId !== undefined) {
                    if(!enumerated) {
                        this.logger.warn('Unable to verify if account history is available for accountId due to enumeration issue.');
                    } else {
                        const requestedAccount = this.accounts.find(x => x.USER_ID === this.config.data.accountId);
                        if(requestedAccount === undefined) {
                            this.logger.warn(`Could not find a linked account matching ${this.config.data.accountId}. History fetching may fail.`);
                        } else {
                            const authedAccount = this.accounts.find(x => x.USER_ID === this.authedAccount.USER.USER_ID);
                            if(!authedAccount.EXTRA_FAMILY.IS_LOGGABLE_AS && this.config.data.accountId !== this.authedAccount.USER.USER_ID) {
                                this.logger.warn(`Authed Account (${this.authedAccount.USER.USER_ID}) is private and specified accountId is not the same (${this.config.data.accountId}), likely history returned will not be correct.`);
                            } else if(!requestedAccount.EXTRA_FAMILY.IS_LOGGABLE_AS) {
                                this.logger.warn('Account specified by accountId is private, likely returned will not be correct!');
                            }
                        }
                    }
                    this.jar.setCookie(`account_id=${this.config.data.accountId}`, 'https://www.deezer.com');
                    this.logger.verbose(`Set account_id=${this.config.data.accountId}`);
                }
            }
            return true;
        } catch (e) {
            throw e;
        }
    }

    getUpstreamRecentlyPlayed = async (options: RecentlyPlayedOptions = {}): Promise<PlayObject[]> => this.getRecentlyPlayed(options)

    getRecentlyPlayed = async (options: RecentlyPlayedOptions = {}) => {

        try {
            const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
                .query({
                    method: 'user.getSongsHistory'
                })
                .set('Content-Type', 'application/json')
                .send({
                    nb: 30,
                    start: 0
                });
            // returns listening history in descending order (newest to oldest)
            const resp = (await this.callApi(req)) as DeezerHistoryResponse;
            let errList: string[] = [];
            if('error' in resp.results) {
                errList = resp.results.error;
            } else if('errors' in resp) {
                errList = resp.errors;
            }
            for (const e of errList) {
                this.logger.warn(`Error returned in history response: ${e}`);
            }
            const nonSong = resp.results.data.filter(x => x.__TYPE__ !== 'song');
            if (nonSong.length > 0) {
                const nonSongTypes = [];
                for (const n of nonSong) {
                    if (!nonSongTypes.includes(n.__TYPE__)) {
                        nonSongTypes.push(n.__TYPE__);
                    }
                }
                this.logger.debug(`Ignoring ${nonSong.length} entries in history with types of ${nonSongTypes.join(',')}`);
            }
            return resp.results.data.filter(x => x.__TYPE__ === 'song').map(x => DeezerInternalSource.formatPlayObj(x)).sort(sortByOldestPlayDate);
        } catch (e) {
            throw new Error('Failed to get recently played tracks', {cause: e});
        }
    }

    enumerateChildAccounts = async (): Promise<boolean> => {
        try {
            const resp = await this.getChildAccounts();
            this.accounts = resp;
            const accountSummaries: string[] = [];
            for(const a of this.accounts) {
                accountSummaries.push(`Name: ${a.BLOG_NAME} | ID: ${a.USER_ID} | Private?: ${a.EXTRA_FAMILY.IS_LOGGABLE_AS ? 'No' : 'Yes'}`);
            }
            this.logger.verbose(`Linked Accounts:\n${accountSummaries.join('\n')}`)
            return true;
        } catch (e) {
            if(this.config.data.accountId !== undefined) {
                this.logger.warn(new Error(`Could not fetch child accounts, likely using 'accountId' will not work!`));
            } else {
                this.logger.warn(new Error('Could not enumerate child accounts. You can ignore this if there is no family account or accountId being used.', {cause: e}));
            }
            return false;
        }
    }

    getChildAccounts = async () => {
        try {
            const req = this.agent.post('https://www.deezer.com/ajax/gw-light.php')
                .query({
                    method: 'deezer.getChildAccounts'
                })
                .set('Content-Type', 'application/json')
                .send({
                    nb: 30,
                    start: 0
                });
            const resp = (await this.callApi(req)) as DeezerAccountResponse;
            return resp.results;
        } catch (e) {
            throw new UpstreamError('Unable to get child accounts', {cause: e});
        }
    }

    callApi = async (req: request.SuperAgentRequest, retries = 0) => {
        const {
            maxRequestRetries = 1,
            retryMultiplier = DEFAULT_RETRY_MULTIPLIER
        } = this.config.options;

        req.query({
            input: 3,
            api_version: '1.0',
            api_token: this.csrfToken ?? ''
           });
        setRequestHeaders(req);
        try {
            const resp = await req;
            const {
                body: {
                    error,
                    results
                } = {}
            } = resp;
            if('checkForm' in results) {
                this.csrfToken = results.checkForm;
            }
            if (error !== undefined && error.length > 0) {
                const err = new Error((error as string[]).join(' | '));
                throw  err;
            }
            return resp.body;
        } catch (e) {
            if(retries < maxRequestRetries) {
                const retryAfter = parseRetryAfterSecsFromObj(e) ?? (retryMultiplier * (retries + 1));
                this.logger.warn(`Request failed but retries (${retries}) less than max (${maxRequestRetries}), retrying request after ${retryAfter} seconds...`);
                await sleep(retryAfter * 1000);
                return await this.callApi(req, retries + 1)
            }
            const {
                message,
                response,
            } = e;
            const msg = response !== undefined ? `API Call failed: Server Response => ${response}` : `API Call failed: ${message}`;
            throw new Error(msg, {cause: e});
        }
    }

    protected getBackloggedPlays = async (options: RecentlyPlayedOptions = {}) => await this.getRecentlyPlayed({formatted: true, ...options})


    existingDiscovered = async (play: PlayObject, opts: {checkAll?: boolean} = {}): Promise<PlayObject | undefined> => {
        const lists: PlayObject[][] = this.getExistingDiscoveredLists(play, opts);
        const candidate = await this.transformPlay(play, TRANSFORM_HOOK.candidate);
        for(const list of lists) {
            const existing = await findAsync(list, async x => {
                const e = await this.transformPlay(x, TRANSFORM_HOOK.existing);
                return genericSourcePlayMatch(e, candidate);
            });
            if(existing) {
                return existing;
            }
            if(this.config.options?.fuzzyDiscoveryIgnore === true || this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
                const fuzzyIndex = await findIndexAsync(list, async x => {
                    const e = await this.transformPlay(x, TRANSFORM_HOOK.existing);
                    let temporalOptions: TemporalPlayComparisonOptions = {};
                    const temporalAccuracy: TemporalAccuracy[] = [TA_EXACT, TA_CLOSE, TA_FUZZY];
                    if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
                        temporalOptions = {
                            fuzzyDiffThreshold: Math.max(100, x.data.duration * 0.5),
                            duringReferences: ['duration', 'listenedFor', 'range']
                        }
                        temporalAccuracy.push(TA_DURING);
                    }
                    return genericSourcePlayMatch(e, candidate, temporalAccuracy, temporalOptions);
                });
                if(fuzzyIndex !== -1) {
                    if(this.config.options?.fuzzyDiscoveryIgnore === 'aggressive') {
                        // always return fuzzy match as existing
                        // likely will make MS miss scrobbles for repeated plays
                        return list[fuzzyIndex];
                    }
                    if(fuzzyIndex + 1 === list.length || playObjDataMatch(list[fuzzyIndex], list[fuzzyIndex + 1])) {
                        // last discovered play was this one, or next played play was also this one
                        // so we'll assume this means the play is on repeat, don't count as existing
                        return undefined;
                    }
                    // next played play was *not* this one (Deezer reports play between candidate TS and fuzzy match)
                    // so this is likely a duplicate deezer should not have reported
                    return list[fuzzyIndex];
                }
            }
        }
        return undefined;
    }
}

const setRequestHeaders = (req: Request, userAgent: string = 'Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0') => {
    req
    .set('Pragma', 'no-cache')
    .set('Origin', 'https://www.deezer.com')
    .set('Accept-Encoding', 'gzip, deflate, br')
    .set('Accept-Language', 'en-US,en;q=0.9')
    .set('User-Agent', userAgent)
    .set('Accept', '*/*')
    .set('Cache-Control', 'no-cache')
    .set('X-Requested-With', 'XMLHttpRequest')
    .set('Connection', 'keep-alive')
    .set('Referer',  'https://www.deezer.com/login')
    .set('DNT', '1')

    const ct = req.get('Content-Type');
    if(ct === '' || ct === null || ct === undefined) {
            req.set('Content-Type', 'application/x-www-form-urlencoded; charset=UTF-8');
    }
}

const buildInternalUrl = (method: string, token: string = ''): URL => {
    const params = new URLSearchParams([
        ['api_version', '1.0'],
        ['input', '3']
    ]);
    params.append('method', method);
    params.append('api_token', token);

    const u = new URL(`https://www.deezer.com/ajax/gw-light.php?${params.toString()}`);

    return u;
}
