package mindustry.audio;

import arc.*;
import arc.audio.*;
import arc.audio.Filters.*;
import arc.files.*;
import arc.math.*;
import arc.math.geom.*;
import arc.struct.*;
import arc.util.*;
import mindustry.*;
import mindustry.content.*;
import mindustry.core.*;
import mindustry.game.EventType.*;
import mindustry.gen.*;

import java.util.concurrent.*;

import static mindustry.Vars.*;

/** Controls playback of multiple audio tracks.*/
public class SoundControl{
    public float finTime = 120f, foutTime = 120f, musicInterval = 3f * Time.toMinutes, musicChance = 0.8f, musicWaveChance = 0.46f;

    /** normal, ambient music, plays at any time */
    public Seq<Music> ambientMusic = Seq.with();
    /** darker music, used in times of conflict  */
    public Seq<Music> darkMusic = Seq.with();
    /** music used explicitly after boss spawns */
    public Seq<Music> bossMusic = Seq.with();

    public AudioBus uiBus = new AudioBus();

    protected Music lastRandomPlayed;
    protected Interval timer = new Interval(4);
    protected long lastPlayed;
    protected @Nullable Music current;
    protected float fade;
    protected boolean silenced, keepSilent;

    protected @Nullable AudioThread ambientThread;
    protected boolean launchingAmbientThread;
    protected Seq<SoundData> localData = new Seq<>();

    protected boolean wasPlaying;
    protected AudioFilter filter = new BiquadFilter(){{
        set(0, 500, 1);
    }};

    protected ObjectMap<Sound, SoundData> sounds = new ObjectMap<>();

    public SoundControl(){
        Events.on(ClientLoadEvent.class, e -> reload());

        //only run music 10 seconds after a wave spawns
        Events.on(WaveEvent.class, e -> Time.run(Mathf.random(8f, 15f) * 60f, () -> {
            if(state.rules.disableMusic) return;

            boolean boss = state.rules.spawns.contains(group -> group.getSpawned(state.wave - 2) > 0 && group.effect == StatusEffects.boss);

            if(boss){
                playOnce(getBossMusic().random(lastRandomPlayed));
            }else if(Mathf.chance(musicWaveChance)){
                playRandom();
            }
        }));

        setupFilters();

        Events.on(ResetEvent.class, e -> {
            lastPlayed = Time.millis();

            //stop all in-game voices
            Core.audio.soundBus.stop();
            Core.audio.soundBus.play();

            launchingAmbientThread = false;
            if(ambientThread != null){
                ambientThread.running = false;
                ambientThread.interrupt();
                ambientThread = null;
            }
        });
    }

    protected void setupFilters(){
        Core.audio.soundBus.setFilter(0, filter);
        Core.audio.soundBus.setFilterParam(0, Filters.paramWet, 0f);
    }

    protected void reload(){
        current = null;
        fade = 0f;
        ambientMusic = Seq.with(Musics.game1, Musics.game3, Musics.game6, Musics.game8, Musics.game9, Musics.fine);
        darkMusic = Seq.with(Musics.game2, Musics.game5, Musics.game7, Musics.game4);
        bossMusic = Seq.with(Musics.boss1, Musics.boss2, Musics.game2, Musics.game5);

        //setup UI bus for all sounds that are in the UI folder
        for(var sound : Core.assets.getAll(Sound.class, new Seq<>())){
            var file = Fi.get(Core.assets.getAssetFileName(sound));
            if(file.parent().name().equals("ui")){
                sound.setBus(uiBus);
            }
        }

        Events.fire(new MusicRegisterEvent());
    }

    public void stop(){
        silenced = true;
        if(current != null){
            current.stop();
            current = null;
            fade = 0f;
        }
    }

    /** Update and play the right music track.*/
    public void update(){
        boolean paused = state.isGame() && Core.scene.hasDialog();
        boolean playing = state.isGame();

        //check if current track is finished
        if(current != null && !current.isPlaying()){
            current = null;
            fade = 0f;
        }

        //fade the lowpass filter in/out, poll every 30 ticks just in case performance is an issue
        if(timer.get(1, 30f)){
            Core.audio.soundBus.fadeFilterParam(0, Filters.paramWet, paused ? 1f : 0f, 0.4f);
        }

        //play/stop ordinary effects
        if(playing != wasPlaying){
            wasPlaying = playing;

            if(playing){
                Core.audio.soundBus.play();
                setupFilters();
            }else{
                //stopping a single audio bus stops everything else, yay!
                Core.audio.soundBus.stop();
                //play music bus again, as it was stopped above
                Core.audio.musicBus.play();

                Core.audio.soundBus.play();
            }
        }

        Core.audio.setPaused(Core.audio.soundBus.id, state.isPaused());

        if(keepSilent){
            keepSilent = false;
            stop();
        }else if(state.isMenu()){
            silenced = false;
            if(ui.planet.isShown()){
                play(ui.planet.state.planet.launchMusic);
            }else if(ui.editor.isShown()){
                play(Musics.editor);
            }else{
                play(Musics.menu);
            }
        }else if(state.rules.editor){
            silenced = false;
            play(Musics.editor);
        }else{
            //this just fades out the last track to make way for ingame music
            silence();

            if(!state.rules.disableMusic){
                if(alwaysPlayMusic()){
                    if(current == null){
                        playRandom();
                    }
                }else if(Time.timeSinceMillis(lastPlayed) > 1000 * musicInterval / 60f){
                    //chance to play it per interval
                    if(Mathf.chance(musicChance)){
                        lastPlayed = Time.millis();
                        playRandom();
                    }
                }
            }
        }

        updateLoops();
    }

    public void keepSilent(){
        keepSilent = true;
    }

    public void playMusic(@Nullable Music music, boolean interrupt){
        if(interrupt && current != null){
            current.stop();
            current = null;
        }
        playOnce(music);
        if(current == music){
            silenced = true; //music played successfully, don't fade it out
        }
    }

    public boolean isPlaying(){
        return current != null && current.isPlaying();
    }

    public @Nullable Music getCurrent(){
        return current;
    }

    /** Plays a random track.*/
    public void playRandom(){
        if(state.boss() != null){
            playOnce(getBossMusic().random(lastRandomPlayed));
        }else if(isDark()){
            playOnce(getDarkMusic().random(lastRandomPlayed));
        }else{
            playOnce(getAmbientMusic().random(lastRandomPlayed));
        }
    }

    protected boolean alwaysPlayMusic(){
        return state.rules.alwaysPlayMusic || Core.settings.getBool("alwaysmusic") || (state.getPlanet() != null && state.getPlanet().alwaysPlayMusic);
    }

    protected Seq<Music> getBossMusic(){
        if(state.rules.darkMusic != null) return state.rules.darkMusic.map(MusicContainer::get).removeAll(m -> m == null);
        return state.getPlanet() != null && state.getPlanet().darkMusic != null ? state.getPlanet().darkMusic : bossMusic;
    }

    protected Seq<Music> getAmbientMusic(){
        if(state.rules.ambientMusic != null) return state.rules.ambientMusic.map(MusicContainer::get).removeAll(m -> m == null);
        return state.getPlanet() != null && state.getPlanet().ambientMusic != null ? state.getPlanet().ambientMusic : ambientMusic;
    }

    protected Seq<Music> getDarkMusic(){
        if(state.rules.darkMusic != null) return state.rules.darkMusic.map(MusicContainer::get).removeAll(m -> m == null);
        return state.getPlanet() != null && state.getPlanet().darkMusic != null ? state.getPlanet().darkMusic : darkMusic;
    }

    /** Whether to play dark music.*/
    protected boolean isDark(){
        if(player.team().data().hasCore() && player.team().data().core().healthf() < 0.85f){
            //core damaged -> dark
            return true;
        }

        //it may be dark based on wave
        if(Mathf.chance((float)(Math.log10((state.wave - 17f)/19f) + 1) / 4f)){
            return true;
        }

        //dark based on enemies
        return Mathf.chance(state.enemies / 70f + 0.1f);
    }

    protected float volumeMultiplier(){
        return Core.settings.getInt("musicvol") / 100f * Mathf.clamp(state.rules.musicVolume);
    }

    /** Plays and fades in a music track. This must be called every frame.
     * If something is already playing, fades out that track and fades in this new music.*/
    protected void play(@Nullable Music music){
        if(!shouldPlay()){
            if(current != null){
                current.setVolume(0);
            }

            fade = 0f;
            return;
        }

        //update volume of current track
        if(current != null){
            current.setVolume(fade * volumeMultiplier());
        }

        //do not update once the track has faded out completely, just stop
        if(silenced){
            return;
        }

        if(current == null && music != null){
            //begin playing in a new track
            current = music;
            current.setLooping(true);
            current.setVolume(fade = 0f);
            current.play();
            silenced = false;
        }else if(current == music && music != null){
            //fade in the playing track
            fade = Mathf.clamp(fade + Time.delta /finTime);
        }else if(current != null){
            //fade out the current track
            fade = Mathf.clamp(fade - Time.delta /foutTime);

            if(fade <= 0.01f){
                //stop current track when it hits 0 volume
                current.stop();
                current = null;
                silenced = true;
                if(music != null){
                    //play newly scheduled track
                    current = music;
                    current.setVolume(fade = 0f);
                    current.setLooping(true);
                    current.play();
                    silenced = false;
                }
            }
        }
    }

    /** Plays a music track once and only once. If something is already playing, does nothing.*/
    protected void playOnce(Music music){
        if(current != null || music == null || !shouldPlay()) return; //do not interrupt already-playing tracks

        //save last random track played to prevent duplicates
        lastRandomPlayed = music;

        //set fade to 1 and play it, stopping the current when it's done
        fade = 1f;
        current = music;
        current.setVolume(volumeMultiplier());
        current.setLooping(false);
        current.play();
    }

    protected boolean shouldPlay(){
        return Core.settings.getInt("musicvol") > 0;
    }

    /** Fades out the current track, unless it has already been silenced. */
    protected void silence(){
        play(null);
    }

    public static @Nullable Music findMusic(String name){
        if(name == null) return null;
        Music cached = Core.assets.getOrNull(name, Music.class);
        if(cached == null) cached = Core.assets.getOrNull(name + ".ogg", Music.class);
        if(cached == null) cached = Core.assets.getOrNull(name + ".mp3", Music.class);
        if(cached == null) cached = Core.assets.getOrNull("music/" + name + ".ogg", Music.class);
        if(cached == null) cached = Core.assets.getOrNull("music/" + name + ".mp3", Music.class);
        if(cached == null && Vars.mods.orderedMods().any()){ //try loading a modded file
            String path = FileTree.getAudioPath("music/" + name);
            if(path == null) return null;
            return Vars.tree.loadMusic(name);
        }
        return cached;
    }

    //loop system

    public void loop(Sound sound, float volume){
        if(Vars.headless) return;

        loop(sound, Core.camera.position, volume);
    }

    public void loop(Sound sound, Position pos, float volume){
        loop(sound, pos, volume, 1f);
    }

    public void loop(Sound sound, Position pos, float volume, float pitch){
        if(Vars.headless || sound == Sounds.none || volume <= 0.00001f) return;

        loop(sounds.get(sound, SoundData::new), sound, pos, volume, pitch);
    }

    static void loop(SoundData data, Sound sound, Position pos, float volume, float pitch){
        float baseVol = sound.calcFalloff(pos.getX(), pos.getY());
        float vol = baseVol * volume;

        data.volume += vol;
        data.pitch += pitch * vol;
        data.volume = Mathf.clamp(data.volume, 0f, 1f);
        data.total += baseVol;
        data.totalVolume += vol;
        data.sumX += pos.getX() * baseVol;
        data.sumY += pos.getY() * baseVol;
    }

    public void addAmbientSource(AmbientSource source){
        if(headless) return;

        if(launchingAmbientThread && ambientThread != null){
            ambientThread.sources.add(source); //directly add to buffer while thread is launching; this prevents a million add calls to the queue during map load
        }else if(ambientThread != null){
            ambientThread.inputSources.add(source);
        }else{
            launchingAmbientThread = true;
            ambientThread = new AudioThread();
            ambientThread.setDaemon(true);

            //start thread with all the sources that were added during launch
            Core.app.post(() -> {
                if(ambientThread != null){
                    if(!ambientThread.isAlive()) ambientThread.start();
                    launchingAmbientThread = false;
                }
            });
        }
    }

    protected void updateLoops(){
        //clear loops when in menu
        if(!state.isGame()){
            sounds.clear();
            return;
        }

        if(state.isPaused()) return;

        float avol = Core.settings.getInt("ambientvol", 100) / 100f;

        sounds.each((sound, data) -> {
            data.curVolume = Mathf.lerpDelta(data.curVolume, data.volume * avol, 0.11f);

            boolean play = data.curVolume > 0.01f;
            float pan = Mathf.zero(data.total, 0.0001f) ? 0f : sound.calcPan(data.sumX / data.total, data.sumY / data.total);
            float pitch = Mathf.zero(data.totalVolume, 0.0001f) ? 1f : data.pitch / data.totalVolume;
            if(data.soundID <= 0 || !Core.audio.isPlaying(data.soundID)){
                if(play){
                    data.soundID = sound.loop(data.curVolume, pitch, pan);
                    Core.audio.protect(data.soundID, true);
                }
            }else{
                if(data.curVolume <= 0.001f){
                    sound.stop();
                    data.soundID = -1;
                    return;
                }
                Core.audio.set(data.soundID, pan, data.curVolume);
                if(!Mathf.equal(pitch, 1f, 0.001f)){
                    Core.audio.setPitch(data.soundID, pitch);
                }
            }

            data.pitch = 0f;
            data.volume = 0f;
            data.total = 0f;
            data.totalVolume = 0f;
            data.sumX = 0f;
            data.sumY = 0f;
        });

        //grab data from ambient thread
        if(ambientThread != null){
            synchronized(ambientThread.outputData){
                localData.set(ambientThread.outputData);
            }

            for(var data : localData){
                var target = sounds.get(data.sound, SoundData::new);

                target.pitch = data.pitch;
                target.volume = data.volume;
                target.total = data.total;
                target.totalVolume = data.totalVolume;
                target.sumX = data.sumX;
                target.sumY = data.sumY;
            }
        }
    }

    protected static class SoundData implements Cloneable{
        float volume, pitch;
        float total;
        float sumX, sumY;

        int soundID;
        float curVolume, totalVolume;
        Sound sound;

        @Override
        public SoundData clone(){
            try{
                return (SoundData)super.clone();
            }catch(CloneNotSupportedException e){
                throw new AssertionError("death");
            }
        }
    }

    static class AudioThread extends Thread{
        static final int targetFps = 20;
        static final long targetNanos = Time.millisToNanos(1000) / targetFps;

        volatile boolean running = true;
        Seq<AmbientSource> sources = new Seq<>(false, 16, AmbientSource.class);

        LinkedBlockingQueue<AmbientSource> inputSources = new LinkedBlockingQueue<>();
        ObjectMap<Sound, SoundData> sounds = new ObjectMap<>();

        //buffer that is built up on the audio thread
        final Seq<SoundData> localData = new Seq<>();
        //buffer for output sound data
        final Seq<SoundData> outputData = new Seq<>();

        void doLoop(){
            var items = sources.items;
            int size = sources.size;
            localData.size = 0;

            AmbientSource source;
            while((source = inputSources.poll()) != null){
                sources.add(source);
            }

            for(int i = 0; i < size; i++){
                var item = items[i];
                if(item.isValid()){
                    if(item.shouldAmbientSound()){
                        float volume = item.getAmbientVolume();
                        if(volume > 0.00001f){

                            Sound sound = item.getAmbientSound();
                            var data = sounds.get(sound, SoundData::new);
                            data.sound = sound;

                            boolean silent = data.volume == 0f;

                            loop(data, sound, item, volume, 1f);

                            if(silent && data.volume > 0f){
                                localData.add(data);
                            }
                        }
                    }
                }else{
                    sources.remove(i); //unordered swap
                    i --;
                    size --;
                }
            }

            //copy built-up data to output buffer
            synchronized(outputData){
                outputData.size = 0;
                for(var data : localData){

                    //this allocates, which is bad, but it only happens at 20fps with a few audio types at most
                    outputData.add(data.clone());
                }
            }

            //reset data for next iteration
            sounds.each((sound, data) -> {
                data.pitch = 0f;
                data.volume = 0f;
                data.total = 0f;
                data.totalVolume = 0f;
                data.sumX = 0f;
                data.sumY = 0f;
            });
        }

        @Override
        public void run(){
            try{
                while(running){
                    long nanos = Time.nanos();

                    if(state.isMenu()) break;
                    if(state.isPlaying()){
                        doLoop();
                    }

                    long elapsed = Time.timeSinceNanos(nanos);
                    if(elapsed < targetNanos){
                        long remaining = targetNanos - elapsed;
                        Thread.sleep(remaining / Time.nanosPerMilli, (int)(remaining % Time.nanosPerMilli));
                    }
                }
            }catch(InterruptedException e){
                //exit
            }
        }
    }
}
