package com.shadorc.shadbot.music;

import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.AudioSourceManagers;
import com.sedmelluq.discord.lavaplayer.source.youtube.YoutubeAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.track.playback.NonAllocatingAudioFrameBuffer;
import com.sedmelluq.lava.extensions.youtuberotator.YoutubeIpRotatorSetup;
import com.sedmelluq.lava.extensions.youtuberotator.planner.AbstractRoutePlanner;
import com.sedmelluq.lava.extensions.youtuberotator.planner.RotatingNanoIpRoutePlanner;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block;
import com.shadorc.shadbot.command.CommandException;
import com.shadorc.shadbot.data.Config;
import com.shadorc.shadbot.data.credential.Credential;
import com.shadorc.shadbot.data.credential.CredentialManager;
import com.shadorc.shadbot.db.DatabaseManager;
import com.shadorc.shadbot.db.guilds.entity.DBGuild;
import com.shadorc.shadbot.db.guilds.entity.Settings;
import com.shadorc.shadbot.listener.music.TrackEventListener;
import discord4j.common.util.Snowflake;
import discord4j.core.GatewayDiscordClient;
import discord4j.core.object.entity.channel.VoiceChannel;
import discord4j.voice.AudioProvider;
import discord4j.voice.VoiceConnection;
import discord4j.voice.retry.VoiceGatewayException;
import reactor.core.publisher.Mono;
import reactor.util.Logger;
import reactor.util.Loggers;

import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicBoolean;

public class MusicManager {

    public static final Logger LOGGER = Loggers.getLogger("shadbot.music");

    private static MusicManager instance;

    static {
        MusicManager.instance = new MusicManager();
    }

    private final AudioPlayerManager audioPlayerManager;
    private final Map<Snowflake, GuildMusic> guildMusics;
    private final Map<Snowflake, AtomicBoolean> guildJoining;

    private MusicManager() {
        this.audioPlayerManager = new DefaultAudioPlayerManager();
        this.audioPlayerManager.getConfiguration().setFrameBufferFactory(NonAllocatingAudioFrameBuffer::new);
        this.audioPlayerManager.getConfiguration().setFilterHotSwapEnabled(true);
        AudioSourceManagers.registerRemoteSources(this.audioPlayerManager);
        this.guildMusics = new ConcurrentHashMap<>();
        this.guildJoining = new ConcurrentHashMap<>();

        //IPv6 rotation config
        final String ipv6Block = CredentialManager.getInstance().get(Credential.IPV6_BLOCK);
        if (!Config.IS_SNAPSHOT && ipv6Block != null && !ipv6Block.isBlank()) {
            LOGGER.info("Configuring YouTube IP rotator");
            final List<IpBlock> blocks = Collections.singletonList(new Ipv6Block(ipv6Block));
            final AbstractRoutePlanner planner = new RotatingNanoIpRoutePlanner(blocks);

            new YoutubeIpRotatorSetup(planner)
                    .forSource(this.audioPlayerManager.source(YoutubeAudioSourceManager.class))
                    .setup();
        }
    }

    /**
     * Schedules loading a track or playlist with the specified identifier. Items loaded with the same
     * guild ID are handled sequentially in the order of calls to this method.
     *
     * @return A future for this operation.
     */
    protected Future<Void> loadItemOrdered(long guildId, String identifier, AudioLoadResultHandler listener) {
        return this.audioPlayerManager.loadItemOrdered(guildId, identifier, listener);
    }

    /**
     * Gets the {@link GuildMusic} corresponding to the provided {@code guildId}. If there is none,
     * a new one is created and a request to join the {@link VoiceChannel} corresponding to the provided
     * {@code voiceChannelId} is sent.
     */
    public Mono<GuildMusic> getOrCreate(GatewayDiscordClient gateway, Snowflake guildId, Snowflake voiceChannelId) {
        return Mono.justOrEmpty(this.getGuildMusic(guildId))
                .switchIfEmpty(Mono.defer(() -> {
                    final AudioPlayer audioPlayer = this.audioPlayerManager.createPlayer();
                    audioPlayer.addListener(new TrackEventListener(guildId));
                    final LavaplayerAudioProvider audioProvider = new LavaplayerAudioProvider(audioPlayer);

                    return this.joinVoiceChannel(gateway, guildId, voiceChannelId, audioProvider)
                            .flatMap(ignored -> DatabaseManager.getGuilds().getDBGuild(guildId))
                            .map(DBGuild::getSettings)
                            .map(Settings::getDefaultVol)
                            .map(volume -> new TrackScheduler(audioPlayer, volume))
                            .map(trackScheduler -> new GuildMusic(gateway, guildId, trackScheduler))
                            .doOnNext(guildMusic -> {
                                this.guildMusics.put(guildId, guildMusic);
                                LOGGER.debug("{Guild ID: {}} Guild music created", guildId.asLong());
                            });
                }));
    }

    /**
     * Requests to join a voice channel.
     */
    private Mono<VoiceConnection> joinVoiceChannel(GatewayDiscordClient gateway, Snowflake guildId, Snowflake voiceChannelId,
                                                   AudioProvider audioProvider) {

        // Do not join the voice channel if the bot is already joining one
        if (this.guildJoining.computeIfAbsent(guildId, id -> new AtomicBoolean()).getAndSet(true)) {
            return Mono.empty();
        }

        final Mono<Boolean> isDisconnected = gateway.getVoiceConnectionRegistry()
                .getVoiceConnection(guildId)
                .flatMapMany(VoiceConnection::stateEvents)
                .next()
                .map(VoiceConnection.State.DISCONNECTED::equals)
                .defaultIfEmpty(true);

        return gateway.getChannelById(voiceChannelId)
                .cast(VoiceChannel.class)
                // Do not join the voice channel if the current voice connection is in not disconnected
                .filterWhen(ignored -> isDisconnected)
                .doOnNext(ignored -> LOGGER.info("{Guild ID: {}} Joining voice channel...", guildId.asLong()))
                .flatMap(voiceChannel -> voiceChannel.join(spec -> spec.setProvider(audioProvider)))
                .doOnError(VoiceGatewayException.class, err -> LOGGER.warn(err.getMessage()))
                .onErrorMap(VoiceGatewayException.class,
                        err -> new CommandException("An unknown error occurred while joining the voice channel, please try again."))
                .doOnTerminate(() -> this.guildJoining.remove(guildId));
    }

    public Mono<Void> destroyConnection(Snowflake guildId) {
        final GuildMusic guildMusic = this.guildMusics.remove(guildId);
        if (guildMusic != null) {
            guildMusic.destroy();
            LOGGER.debug("{Guild ID: {}} Guild music destroyed", guildId.asLong());
        }

        return Mono.justOrEmpty(guildMusic)
                .map(GuildMusic::getGateway)
                .map(GatewayDiscordClient::getVoiceConnectionRegistry)
                .flatMap(registry -> registry.getVoiceConnection(guildId))
                .flatMap(VoiceConnection::disconnect);
    }

    public Optional<GuildMusic> getGuildMusic(Snowflake guildId) {
        final GuildMusic guildMusic = this.guildMusics.get(guildId);
        if (LOGGER.isTraceEnabled()) {
            LOGGER.trace("{Guild ID: {}} Guild music request: {}", guildId.asLong(), guildMusic);
        }
        return Optional.ofNullable(guildMusic);
    }

    public static MusicManager getInstance() {
        return MusicManager.instance;
    }
}