/* eslint-disable no-case-declarations */
import { childLogger, Logger } from '@foxxmd/logging';
import dayjs, { Dayjs } from "dayjs";
import { PlayObject, SourcePlayerObj } from "../../core/Atomic.js";
import { ConfigMeta, InternalConfig, InternalConfigOptional, isClientType, SourceIdentifier } from "../common/infrastructure/Atomic.js";
import { clientTypes, ClientType } from '../common/infrastructure/config/client/clients.js';
import { AIOConfig } from "../common/infrastructure/config/aioConfig.js";
import { ClientAIOConfig, ClientConfig } from "../common/infrastructure/config/client/clients.js";
import { LastfmClientConfig, LastfmData } from "../common/infrastructure/config/client/lastfm.js";
import { ListenBrainzClientConfig, ListenBrainzData } from "../common/infrastructure/config/client/listenbrainz.js";
import { MalojaClientConfig, MalojaData } from "../common/infrastructure/config/client/maloja.js";
import { WildcardEmitter } from "../common/WildcardEmitter.js";
import { Notifiers } from "../notifier/Notifiers.js";
import { isDebugMode, nonEmptyObj, parseBool, removeUndefinedKeys } from "../utils.js";
import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.js';
import { validateJson } from "../utils/ValidationUtils.js";
import AbstractScrobbleClient from "./AbstractScrobbleClient.js";
import { KoitoClientConfig, KoitoData } from '../common/infrastructure/config/client/koito.js';
import { TealClientConfig, TealData } from '../common/infrastructure/config/client/tealfm.js';
import { RockSkyClientConfig, RockSkyData } from '../common/infrastructure/config/client/rocksky.js';
import { CommonClientOptions } from '../common/infrastructure/config/client/index.js';
import { ExternalMetadataTerm, PlayTransformHooks } from '../common/infrastructure/Transform.js';
import { LibrefmClientConfig, LibrefmData } from '../common/infrastructure/config/client/librefm.js';
import clone from 'clone';
import { DiscordClientConfig, DiscordData } from '../common/infrastructure/config/client/discord.js';

type groupedNamedConfigs = {[key: string]: ParsedConfig[]};

type ParsedConfig = ClientAIOConfig & ConfigMeta;

export default class ScrobbleClients {

    clients: AbstractScrobbleClient[] = [];
    logger: Logger;

    internalConfig: InternalConfig;

    emitter: WildcardEmitter;

    sourceEmitter: WildcardEmitter;

    constructor(emitter: WildcardEmitter, sourceEmitter: WildcardEmitter, internal: InternalConfigOptional, parentLogger: Logger) {
        this.emitter = emitter;
        this.sourceEmitter = sourceEmitter;
        this.logger = childLogger(parentLogger, 'Scrobblers'); // winston.loggers.get('app').child({labels: ['Scrobblers']}, mergeArr);
        this.internalConfig = {
            ...internal,
            logger: this.logger
        }

        this.sourceEmitter.on('playerUpdate', async (payload: { data: SourcePlayerObj & { options: { scrobbleTo: string[] } }} & SourceIdentifier) => {
            // agressively update Now Playing so scrobblers that display based on duration are mostly synced
            // but aggressively *stop* updating if state becomes stale/orphaned
            this.playingNow(payload.data, {...payload.data.options, scrobbleFrom: { type: payload.type, name: payload.name}});
        });

        this.sourceEmitter.on('discoveredToScrobble', async (payload: { data: (PlayObject | PlayObject[]), options: { forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string } }) => {
            await this.scrobble(payload.data, payload.options);
        });
    }

    getByName = (name: any, safe: boolean = false) => this.clients.find(x => (safe ? x.getSafeExternalName() : x.name) === name)

    getByType = (type: any) => this.clients.filter(x => x.type === type)

    getByNameAndType = (name: string, type: ClientType, safe: boolean = false) => this.clients.find(x => (safe ? x.getSafeExternalName() : x.name) === name && x.type === type)

    async getStatusSummary(type?: string, name?: string): Promise<[boolean, string[]]> {
        let clients: AbstractScrobbleClient[] = [];

        const messages: string[] = [];
        let clientsReady = true;

        if(type !== undefined) {
            clients = this.getByType(type);
        } else if(name !== undefined) {
            const clientByName = this.getByName(name);
            if(clientByName !== undefined) {
                clients = [clientByName];
            }
        } else {
            clients = this.clients;
        }

        for(const client of clients) {
            if(!(await client.isReady())) {
                clientsReady = false;
                messages.push(`Client ${client.type} - ${client.name} is not ready.`);
            }
        }

        return [clientsReady, messages];
    }

    private getSchemaByType = (type: ClientType): string => {
            switch(type) {
                case 'maloja':
                    return "MalojaClientConfig";
                case 'lastfm':
                    return "LastfmClientConfig";
                case 'librefm':
                    return "LibrefmClientConfig";
                case 'listenbrainz':
                    return "ListenBrainzClientConfig";
                case 'koito':
                    return "KoitoClientConfig";
                case 'tealfm':
                    return "TealClientConfig";
                case 'rocksky':
                    return "RockSkyClientConfig";
                case 'discord':
                    return 'DiscordClientConfig';
            }
    }

    buildClientsFromConfig = async (notifier: Notifiers) => {
        const configs: ParsedConfig[] = [];

        let configFile;
        try {
            configFile = await readJson(`${this.internalConfig.configDir}/config.json`, {throwOnNotFound: false, logger: childLogger(this.logger, `Secrets`)});
        } catch (e) {
            // think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore
            throw new Error('config.json could not be parsed');
        }

        let clientDefaults = {};
        if (configFile !== undefined) {
            const aioConfig = await validateJson<AIOConfig>('client', configFile, 'AIOClientRelaxedConfig', this.logger);
            const {
                clients: mainConfigClientConfigs = [],
                clientDefaults: cd = {},
                database: {
                    retention
                } = {},
            } = aioConfig;
            clientDefaults = {retention, ...cd};
            for (const [index, c] of mainConfigClientConfigs.entries()) {
                const {name = 'unnamed'} = c;
                if(c.type === undefined) {
                    const invalidMsgType = `Client config ${index + 1} (${name}) in config.json does not have a "type" property! "type": "[clientType]" must be one of ${clientTypes.join(' | ')}`;
                    this.logger.error(invalidMsgType);
                    continue;
                }
                if(!isClientType(c.type.toLocaleLowerCase())) {
                    const invalidTypeMsg = `Client config ${index + 1} (${name}) in config.json has an invalid client type of '${c.type}'. Must be one of ${clientTypes.join(' | ')}`;
                    //this.emitter.emit('error', new Error(invalidTypeMsg));
                    this.logger.error(invalidTypeMsg);
                    continue;
                }
                if(c.configureAs === 'source') {
                       this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`);
                       continue;
                }
                try {
                    await validateJson<AIOConfig>('client', c, this.getSchemaByType(c.type.toLocaleLowerCase() as ClientType), this.logger);
                } catch (e) {
                    const err = new Error(`Client config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e});
                    this.emitter.emit('error', err);
                    this.logger.error(err);
                    continue;
                }
                configs.push({...c,
                    name,
                    source: 'config.json',
                    configureAs: 'client', //override user value
                });
            }
        }

        for (const clientType of clientTypes) {
            const defaultConfigureAs = 'client';
            switch (clientType) {
                case 'maloja': {
                    // env builder for single user mode
                    const data = removeUndefinedKeys<MalojaData>({
                        url: process.env.MALOJA_URL,
                        apiKey: process.env.MALOJA_API_KEY
                    }, false);
                    const p = getCommonComponentEnvConfig('MALOJA');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'maloja',
                            name: 'unnamed-mlj',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('MALOJA')
                        })
                    }
                }  break;
                case 'lastfm': {
                    const data = removeUndefinedKeys<LastfmData>({
                        apiKey: process.env.LASTFM_API_KEY,
                        secret: process.env.LASTFM_SECRET,
                        redirectUri: process.env.LASTFM_REDIRECT_URI,
                        session: process.env.LASTFM_SESSION
                    }, false);
                    const p = getCommonComponentEnvConfig('LASTFM');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'lastfm',
                            name: 'unnamed-lfm',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('LASTFM')
                        })
                    }
                }    break;
                case 'librefm': {
                    const data = removeUndefinedKeys<LibrefmData>({
                        apiKey: process.env.LIBREFM_API_KEY,
                        secret: process.env.LIBREFM_SECRET,
                        redirectUri: process.env.LIBREFM_REDIRECT_URI,
                        session: process.env.LIBREFM_SESSION,
                        urlBase: process.env.LIBREFM_URLBASE,
                    }, false);
                    const p = getCommonComponentEnvConfig('LIBREFM');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'librefm',
                            name: 'unnamed-librefm',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('LIBREFM')
                        })
                    }
                }    break;
                case 'listenbrainz': {
                    const data = removeUndefinedKeys<ListenBrainzData>({
                        url: process.env.LZ_URL,
                        token: process.env.LZ_TOKEN,
                        username: process.env.LZ_USER
                    }, false);
                    const p = getCommonComponentEnvConfig('LZ');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'listenbrainz',
                            name: 'unnamed-lz',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('LZ')
                        })
                    }
                }    break;
                case 'koito': {
                    const data = removeUndefinedKeys<KoitoData>({
                        url: process.env.KOITO_URL,
                        token: process.env.KOITO_TOKEN,
                        username: process.env.KOITO_USER
                    }, false);
                    const p = getCommonComponentEnvConfig('KOITO');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'koito',
                            name: 'unnamed-koito',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('KOITO')
                        })
                    }
                }    break;
                case 'tealfm': {
                    const data: TealData = removeUndefinedKeys<TealData>({
                        identifier: process.env.TEALFM_IDENTIFIER,
                        appPassword: process.env.TEALFM_APP_PW,
                    }, false);
                    const p = getCommonComponentEnvConfig('TEALFM');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'tealfm',
                            name: 'unnamed-tealfm',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('TEALFM')
                        })
                    }
                }    break;
                case 'rocksky': {
                    const data: RockSkyData = removeUndefinedKeys<RockSkyData>({
                        key: process.env.ROCKSKY_KEY,
                        token: process.env.ROCKSKY_TOKEN,
                        handle: process.env.ROCKSKY_HANDLE
                    }, false);
                    const p = getCommonComponentEnvConfig('ROCKSKY');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'rocksky',
                            name: 'unnamed-rocksky',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('ROCKSKY')
                        })
                    }
                }   break;
                case 'discord': {
                    const data: DiscordData = removeUndefinedKeys<DiscordData>({
                        token: process.env.DISCORD_TOKEN,
                        artwork: process.env.DISCORD_ARTWORK,
                        applicationId: process.env.DISCORD_APPLICATION_ID,
                        ipcLocations: process.env.DISCORD_IPC_LOCATIONS,
                        artworkDefaultUrl: process.env.DISCORD_ARTWORK_DEFAULT_URL,
                        statusOverrideAllow: process.env.DISCORD_STATUS_OVERRIDE_ALLOW,
                        listeningActivityAllow: process.env.DISCORD_LISTENING_ACTIVITY_ALLOW
                    }, false);
                    const p = getCommonComponentEnvConfig('DISCORD');
                    if (nonEmptyObj(data) || nonEmptyObj(p)) {
                        configs.push({
                            type: 'discord',
                            name: 'unnamed-discord',
                            source: 'ENV',
                            mode: 'single',
                            configureAs: 'client',
                            data: data,
                            ...p,
                            options: transformPresetEnv('DISCORD')
                        })
                    }
                }   break;
                default:
                    break;
            }
            let rawClientConfigs;
            try {
                rawClientConfigs = await readJson(`${this.internalConfig.configDir}/${clientType}.json`, {throwOnNotFound: false, logger: childLogger(this.logger, `${clientType} Secrets`)});
            } catch (e) {
                const errMsg = `${clientType}.json config file could not be parsed`;
                this.emitter.emit('error', errMsg);
                this.logger.error(errMsg);
                continue;
            }
            if (rawClientConfigs !== undefined) {
                let clientConfigs: ParsedConfig[] = [];
                if (Array.isArray(rawClientConfigs)) {
                    clientConfigs = rawClientConfigs;
                } else if(rawClientConfigs === null) {
                    this.logger.error(`${clientType}.json contained no data`);
                    continue;
                } else if(typeof rawClientConfigs === 'object') {
                    clientConfigs = [rawClientConfigs];
                } else {
                    this.logger.error(`All top level data from ${clientType}.json must be an object or an array of objects, will not parse configs from file`);
                    continue;
                }
                for(const [i,rawConf] of rawClientConfigs.entries()) {
                    if(rawConf.configureAs === 'source') 
                    {
                        this.logger.debug(`Skipping config ${i + 1} from ${clientType}.json because it is configured as a source.`);
                       continue;
                    }
                    try {
                        const validConfig = await validateJson<ClientConfig>('client', rawConf, this.getSchemaByType(clientType), this.logger);
                        const {configureAs = defaultConfigureAs} = validConfig;
                        if (configureAs === 'client') {
                            const parsedConfig: ParsedConfig = {
                                ...rawConf,
                                source: `${clientType}.json`,
                                type: clientType
                            }
                            configs.push(parsedConfig);
                        }
                    } catch (e: any) {
                        const configErr = new Error(`The config entry at index ${i} from ${clientType}.json was not valid`, {cause: e});
                        this.emitter.emit('error', configErr);
                        this.logger.error(configErr);
                    }
                }
            }
        }

        // all client configs are minimally valid
        // now check that names are unique
        const nameGroupedConfigs = configs.reduce((acc: groupedNamedConfigs, curr: ParsedConfig) => {
            const {name = 'unnamed'} = curr;
            const {[name]: n = []} = acc;
            return {...acc, [name]: [...n, curr]};
        }, {});
        let noConflictConfigs: ParsedConfig[] = [];
        for (const [name, configs] of Object.entries(nameGroupedConfigs)) {
            if (configs.length > 1) {
                const sources = configs.map((c: any) => `Config object from ${c.source} of type [${c.type}]`);
                this.logger.error(`The following clients will not be built because of config naming conflicts (they have the same name of "${name}"): 
${sources.join('\n')}`);
                if (name === 'unnamed') {
                    this.logger.info('HINT: "unnamed" configs occur when using ENVs, if a multi-user mode config does not have a "name" property, or if a config is built in single-user mode');
                }
            } else {
                noConflictConfigs = [...noConflictConfigs, ...configs];
            }
        }

        // finally! all configs are valid, structurally, and can now be passed to addClient
        // just need to re-map unnnamed to default
        const finalConfigs: ParsedConfig[] = noConflictConfigs.map(({name = 'unnamed', ...x}) => ({
            ...x,
            name
        }));
        for (const c of finalConfigs) {
            try {
                await this.addClient(c, clientDefaults, notifier);
            } catch(e) {
                const addError = new Error(`Client ${c.name} from ${c.source} was not added because it had unrecoverable errors`, {cause: e});
                this.emitter.emit('error', addError);
                this.logger.error(addError);
            }
        }
    }

    addClient = async (clientConfig: ParsedConfig, defaults = {}, notifier: Notifiers) => {
/*        const isValidConfig = isValidConfigStructure(clientConfig, {name: true, data: true, type: true});
        if (isValidConfig !== true) {
            throw new Error(`Config object from ${clientConfig.source || 'unknown'} with name [${clientConfig.name || 'unnamed'}] of type [${clientConfig.type || 'unknown'}] has errors: ${isValidConfig.join(' | ')}`)
        }*/
        const {type, name, enable = true, source, data: d = {}, options = {}} = clientConfig;

        if(enable === false) {
            this.logger.warn({labels: [`${type} - ${name}`]}, `Client from ${source} was disabled by config`);
            return;
        }

        // add defaults
        const compositeOptions = {...defaults, ...options};
        let newClient;
        this.logger.debug({labels: [`${type} - ${name}`]}, `Constructing Client from ${source}`);
        switch (type) {
            case 'maloja':
                const MalojaScrobbler = (await import('./MalojaScrobbler.js')).default;
                newClient = new MalojaScrobbler(name, ({...clientConfig, data: d, options: compositeOptions} as unknown as MalojaClientConfig), notifier, this.emitter, this.logger);
                break;
            case 'lastfm':
                const LastfmScrobbler = (await import('./LastfmScrobbler.js')).default;
                newClient = new LastfmScrobbler(name, {...clientConfig, data: d, options: compositeOptions } as unknown as LastfmClientConfig, this.internalConfig, notifier, this.emitter, this.logger);
                break;
            case 'librefm':
                const LibrefmScrobbler = (await import('./LibrefmScrobbler.js')).default;
                newClient = new LibrefmScrobbler(name, {...clientConfig, data: d, options: compositeOptions } as unknown as LibrefmClientConfig, this.internalConfig, notifier, this.emitter, this.logger);
                break;
            case 'listenbrainz':
                const ListenbrainzScrobbler = (await import('./ListenbrainzScrobbler.js')).default;
                newClient = new ListenbrainzScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as ListenBrainzClientConfig, {}, notifier, this.emitter, this.logger);
                break;
            case 'koito':
                const KoitoScrobbler = (await import('./KoitoScrobbler.js')).default;
                newClient = new KoitoScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as KoitoClientConfig, {}, notifier, this.emitter, this.logger);
                break;
            case 'tealfm':
                const TealScrobbler = (await import('./TealfmScrobbler.js')).default;
                newClient = new TealScrobbler(name, {...clientConfig, data: d, options: compositeOptions} as unknown as TealClientConfig, this.internalConfig, notifier, this.emitter, this.logger);
                break;
            case 'rocksky':
                const RockskyScrobbler = (await import('./RockskyScrobbler.js')).default;
                newClient = new RockskyScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as RockSkyClientConfig, this.internalConfig, notifier, this.emitter, this.logger);
                break;
            case 'discord':
                const DiscordScrobbler = (await import('./DiscordScrobbler.js')).default;
                newClient = new DiscordScrobbler(name, {...clientConfig, data: {configDir: this.internalConfig.configDir, ...d}, options: compositeOptions } as unknown as DiscordClientConfig, {}, notifier, this.emitter, this.logger);
                break;                
            default:
                break;
        }

        if(newClient === undefined) {
            // really shouldn't get here!
            throw new Error(`Client of type ${type} from ${source} was not recognized??`);
        }
        newClient.logger.info(`Client Added from ${source}`);
        this.clients.push(newClient);
    }

    playingNow = async (data: SourcePlayerObj, options: {scrobbleTo: string[], scrobbleFrom: SourceIdentifier}) => {
        const playObjs = Array.isArray(data) ? data : [data];
        const {
            scrobbleTo = [],
            scrobbleFrom,
        } = options;

        if (this.clients.length === 0) {
            this.logger.warn('Cannot update Now Playing! No clients are configured.');
        }

        for (const client of this.clients) {
            if(!client.supportsNowPlaying || !client.nowPlayingEnabled) {
                continue;
            }
            if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
                if(isDebugMode()) {
                    client.logger.debug(`Client was filtered out by Source '${scrobbleFrom.type} - ${scrobbleFrom.name}'`);
                }
                continue;
            }
            for (const playObj of playObjs) {
                await client.queuePlayingNow(playObj, scrobbleFrom);
            }
        }
    }

    getPlayingNow = (source: string, scrobbleTo: string[]): PlayObject[] => {
        let playingNow = [];
        for (const client of this.clients) {
            if(!client.supportsNowPlaying || !client.nowPlayingEnabled) {
                continue;
            }
            if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
                continue;
            }
            if(client.nowPlayingSourceAllowed(source) && client.nowPlayingLastPlay !== undefined) {
                playingNow.push(client.nowPlayingLastPlay.play);
            }
        }
        return playingNow.filter(x => x !== undefined);
    }

    scrobble = async (data: (PlayObject | PlayObject[]), options: {forceRefresh?: boolean, checkTime?: Dayjs, scrobbleTo?: string[], scrobbleFrom?: string} = {}) => {
        const playObjs = Array.isArray(data) ? data : [data];
        const {
            forceRefresh = false,
            checkTime = dayjs(),
            scrobbleTo = [],
            scrobbleFrom = 'source',
        } = options;

        if (this.clients.length === 0) {
            this.logger.warn('Cannot scrobble! No clients are configured.');
        }

        for (const client of this.clients) {
            if (scrobbleTo.length > 0 && !scrobbleTo.includes(client.name)) {
                client.logger.debug(`Client was filtered out by Source '${scrobbleFrom}'`);
                continue;
            }
            for (const playObj of playObjs) {
                await client.queueScrobble(clone(playObj), scrobbleFrom);
            }
        }
    }
}

const transformPresetEnv = <T extends CommonClientOptions = CommonClientOptions>(prefix: string, existing: T = undefined): undefined | T => {

    const env = process.env[`${prefix}_TRANSFORMS`];
    if(env === undefined || env.trim() === '') {
        return existing;
    }

    const popts: PlayTransformHooks<ExternalMetadataTerm> = {
        preCompare: [
        ]
    }
    for(const p of env.split(',').map(x => x.trim().toLocaleLowerCase())) {
        switch(p) {
            case 'native':
                popts.preCompare.push({type: 'native'});
                break;
            case 'musicbrainz':
                popts.preCompare.push({type: 'musicbrainz'});
                break;
        }
    }

    // @ts-ignore
    return {
        ...(existing || {}),
        playTransform: popts
    };
}
