// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Linq;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Track;
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Audio;
using osu.Framework.Graphics.Containers;
using osu.Framework.Utils;
using osu.Framework.Threading;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Mods;

namespace osu.Game.Overlays
{
    /// <summary>
    /// Handles playback of the global music track.
    /// </summary>
    public class MusicController : CompositeDrawable
    {
        [Resolved]
        private BeatmapManager beatmaps { get; set; }

        public IBindableList<BeatmapSetInfo> BeatmapSets
        {
            get
            {
                if (LoadState < LoadState.Ready)
                    throw new InvalidOperationException($"{nameof(BeatmapSets)} should not be accessed before the music controller is loaded.");

                return beatmapSets;
            }
        }

        /// <summary>
        /// Point in time after which the current track will be restarted on triggering a "previous track" action.
        /// </summary>
        private const double restart_cutoff_point = 5000;

        private readonly BindableList<BeatmapSetInfo> beatmapSets = new BindableList<BeatmapSetInfo>();

        /// <summary>
        /// Whether the user has requested the track to be paused. Use <see cref="IsPlaying"/> to determine whether the track is still playing.
        /// </summary>
        public bool UserPauseRequested { get; private set; }

        /// <summary>
        /// Fired when the global <see cref="WorkingBeatmap"/> has changed.
        /// Includes direction information for display purposes.
        /// </summary>
        public event Action<WorkingBeatmap, TrackChangeDirection> TrackChanged;

        [Resolved]
        private IBindable<WorkingBeatmap> beatmap { get; set; }

        [Resolved]
        private IBindable<IReadOnlyList<Mod>> mods { get; set; }

        [NotNull]
        public DrawableTrack CurrentTrack { get; private set; } = new DrawableTrack(new TrackVirtual(1000));

        private IBindable<WeakReference<BeatmapSetInfo>> managerUpdated;
        private IBindable<WeakReference<BeatmapSetInfo>> managerRemoved;

        [BackgroundDependencyLoader]
        private void load()
        {
            managerUpdated = beatmaps.ItemUpdated.GetBoundCopy();
            managerUpdated.BindValueChanged(beatmapUpdated);
            managerRemoved = beatmaps.ItemRemoved.GetBoundCopy();
            managerRemoved.BindValueChanged(beatmapRemoved);

            beatmapSets.AddRange(beatmaps.GetAllUsableBeatmapSets(IncludedDetails.Minimal, true).OrderBy(_ => RNG.Next()));

            // Todo: These binds really shouldn't be here, but are unlikely to cause any issues for now.
            // They are placed here for now since some tests rely on setting the beatmap _and_ their hierarchies inside their load(), which runs before the MusicController's load().
            beatmap.BindValueChanged(beatmapChanged, true);
            mods.BindValueChanged(_ => ResetTrackAdjustments(), true);
        }

        /// <summary>
        /// Forcefully reload the current <see cref="WorkingBeatmap"/>'s track from disk.
        /// </summary>
        public void ReloadCurrentTrack() => changeTrack();

        /// <summary>
        /// Change the position of a <see cref="BeatmapSetInfo"/> in the current playlist.
        /// </summary>
        /// <param name="beatmapSetInfo">The beatmap to move.</param>
        /// <param name="index">The new position.</param>
        public void ChangeBeatmapSetPosition(BeatmapSetInfo beatmapSetInfo, int index)
        {
            beatmapSets.Remove(beatmapSetInfo);
            beatmapSets.Insert(index, beatmapSetInfo);
        }

        /// <summary>
        /// Returns whether the beatmap track is playing.
        /// </summary>
        public bool IsPlaying => CurrentTrack.IsRunning;

        /// <summary>
        /// Returns whether the beatmap track is loaded.
        /// </summary>
        public bool TrackLoaded => CurrentTrack.TrackLoaded;

        private void beatmapUpdated(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet)
        {
            if (weakSet.NewValue.TryGetTarget(out var set))
            {
                Schedule(() =>
                {
                    beatmapSets.Remove(set);
                    beatmapSets.Add(set);
                });
            }
        }

        private void beatmapRemoved(ValueChangedEvent<WeakReference<BeatmapSetInfo>> weakSet)
        {
            if (weakSet.NewValue.TryGetTarget(out var set))
            {
                Schedule(() =>
                {
                    beatmapSets.RemoveAll(s => s.ID == set.ID);
                });
            }
        }

        private ScheduledDelegate seekDelegate;

        public void SeekTo(double position)
        {
            seekDelegate?.Cancel();
            seekDelegate = Schedule(() =>
            {
                if (!beatmap.Disabled)
                    CurrentTrack.Seek(position);
            });
        }

        /// <summary>
        /// Ensures music is playing, no matter what, unless the user has explicitly paused.
        /// This means that if the current beatmap has a virtual track (see <see cref="TrackVirtual"/>) a new beatmap will be selected.
        /// </summary>
        public void EnsurePlayingSomething()
        {
            if (UserPauseRequested) return;

            if (CurrentTrack.IsDummyDevice || beatmap.Value.BeatmapSetInfo.DeletePending)
            {
                if (beatmap.Disabled)
                    return;

                NextTrack();
            }
            else if (!IsPlaying)
            {
                Play();
            }
        }

        /// <summary>
        /// Start playing the current track (if not already playing).
        /// </summary>
        /// <param name="restart">Whether to restart the track from the beginning.</param>
        /// <param name="requestedByUser">
        /// Whether the request to play was issued by the user rather than internally.
        /// Specifying <c>true</c> will ensure that other methods like <see cref="EnsurePlayingSomething"/>
        /// will resume music playback going forward.
        /// </param>
        /// <returns>Whether the operation was successful.</returns>
        public bool Play(bool restart = false, bool requestedByUser = false)
        {
            if (requestedByUser)
                UserPauseRequested = false;

            if (restart)
                CurrentTrack.Restart();
            else if (!IsPlaying)
                CurrentTrack.Start();

            return true;
        }

        /// <summary>
        /// Stop playing the current track and pause at the current position.
        /// </summary>
        /// <param name="requestedByUser">
        /// Whether the request to stop was issued by the user rather than internally.
        /// Specifying <c>true</c> will ensure that other methods like <see cref="EnsurePlayingSomething"/>
        /// will not resume music playback until the next explicit call to <see cref="Play"/>.
        /// </param>
        public void Stop(bool requestedByUser = false)
        {
            UserPauseRequested |= requestedByUser;
            if (CurrentTrack.IsRunning)
                CurrentTrack.Stop();
        }

        /// <summary>
        /// Toggle pause / play.
        /// </summary>
        /// <returns>Whether the operation was successful.</returns>
        public bool TogglePause()
        {
            if (CurrentTrack.IsRunning)
                Stop(true);
            else
                Play(requestedByUser: true);

            return true;
        }

        /// <summary>
        /// Play the previous track or restart the current track if it's current time below <see cref="restart_cutoff_point"/>.
        /// </summary>
        /// <param name="onSuccess">Invoked when the operation has been performed successfully.</param>
        public void PreviousTrack(Action<PreviousTrackResult> onSuccess = null) => Schedule(() =>
        {
            PreviousTrackResult res = prev();
            if (res != PreviousTrackResult.None)
                onSuccess?.Invoke(res);
        });

        /// <summary>
        /// Play the previous track or restart the current track if it's current time below <see cref="restart_cutoff_point"/>.
        /// </summary>
        /// <returns>The <see cref="PreviousTrackResult"/> that indicate the decided action.</returns>
        private PreviousTrackResult prev()
        {
            if (beatmap.Disabled)
                return PreviousTrackResult.None;

            var currentTrackPosition = CurrentTrack.CurrentTime;

            if (currentTrackPosition >= restart_cutoff_point)
            {
                SeekTo(0);
                return PreviousTrackResult.Restart;
            }

            queuedDirection = TrackChangeDirection.Prev;

            var playable = BeatmapSets.TakeWhile(i => i.ID != current.BeatmapSetInfo.ID).LastOrDefault() ?? BeatmapSets.LastOrDefault();

            if (playable != null)
            {
                changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value));
                restartTrack();
                return PreviousTrackResult.Previous;
            }

            return PreviousTrackResult.None;
        }

        /// <summary>
        /// Play the next random or playlist track.
        /// </summary>
        /// <param name="onSuccess">Invoked when the operation has been performed successfully.</param>
        /// <returns>A <see cref="ScheduledDelegate"/> of the operation.</returns>
        public void NextTrack(Action onSuccess = null) => Schedule(() =>
        {
            bool res = next();
            if (res)
                onSuccess?.Invoke();
        });

        private bool next()
        {
            if (beatmap.Disabled)
                return false;

            queuedDirection = TrackChangeDirection.Next;

            var playable = BeatmapSets.SkipWhile(i => i.ID != current.BeatmapSetInfo.ID).ElementAtOrDefault(1) ?? BeatmapSets.FirstOrDefault();

            if (playable != null)
            {
                changeBeatmap(beatmaps.GetWorkingBeatmap(playable.Beatmaps.First(), beatmap.Value));
                restartTrack();
                return true;
            }

            return false;
        }

        private void restartTrack()
        {
            // if not scheduled, the previously track will be stopped one frame later (see ScheduleAfterChildren logic in GameBase).
            // we probably want to move this to a central method for switching to a new working beatmap in the future.
            Schedule(() => CurrentTrack.Restart());
        }

        private WorkingBeatmap current;

        private TrackChangeDirection? queuedDirection;

        private void beatmapChanged(ValueChangedEvent<WorkingBeatmap> beatmap) => changeBeatmap(beatmap.NewValue);

        private void changeBeatmap(WorkingBeatmap newWorking)
        {
            // This method can potentially be triggered multiple times as it is eagerly fired in next() / prev() to ensure correct execution order
            // (changeBeatmap must be called before consumers receive the bindable changed event, which is not the case when the local beatmap bindable is updated directly).
            if (newWorking == current)
                return;

            var lastWorking = current;

            TrackChangeDirection direction = TrackChangeDirection.None;

            bool audioEquals = newWorking?.BeatmapInfo?.AudioEquals(current?.BeatmapInfo) ?? false;

            if (current != null)
            {
                if (audioEquals)
                    direction = TrackChangeDirection.None;
                else if (queuedDirection.HasValue)
                {
                    direction = queuedDirection.Value;
                    queuedDirection = null;
                }
                else
                {
                    // figure out the best direction based on order in playlist.
                    var last = BeatmapSets.TakeWhile(b => b.ID != current.BeatmapSetInfo?.ID).Count();
                    var next = newWorking == null ? -1 : BeatmapSets.TakeWhile(b => b.ID != newWorking.BeatmapSetInfo?.ID).Count();

                    direction = last > next ? TrackChangeDirection.Prev : TrackChangeDirection.Next;
                }
            }

            current = newWorking;

            if (!audioEquals || CurrentTrack.IsDummyDevice)
            {
                changeTrack();
            }
            else
            {
                // transfer still valid track to new working beatmap
                current.TransferTrack(lastWorking.Track);
            }

            TrackChanged?.Invoke(current, direction);

            ResetTrackAdjustments();

            queuedDirection = null;

            // this will be a noop if coming from the beatmapChanged event.
            // the exception is local operations like next/prev, where we want to complete loading the track before sending out a change.
            if (beatmap.Value != current && beatmap is Bindable<WorkingBeatmap> working)
                working.Value = current;
        }

        private void changeTrack()
        {
            var lastTrack = CurrentTrack;

            var queuedTrack = new DrawableTrack(current.LoadTrack());
            queuedTrack.Completed += () => onTrackCompleted(current);

            CurrentTrack = queuedTrack;

            // At this point we may potentially be in an async context from tests. This is extremely dangerous but we have to make do for now.
            // CurrentTrack is immediately updated above for situations where a immediate knowledge about the new track is required,
            // but the mutation of the hierarchy is scheduled to avoid exceptions.
            Schedule(() =>
            {
                lastTrack.VolumeTo(0, 500, Easing.Out).Expire();

                if (queuedTrack == CurrentTrack)
                {
                    AddInternal(queuedTrack);
                    queuedTrack.VolumeTo(0).Then().VolumeTo(1, 300, Easing.Out);
                }
                else
                {
                    // If the track has changed since the call to changeTrack, it is safe to dispose the
                    // queued track rather than consume it.
                    queuedTrack.Dispose();
                }
            });
        }

        private void onTrackCompleted(WorkingBeatmap workingBeatmap)
        {
            // the source of track completion is the audio thread, so the beatmap may have changed before firing.
            if (current != workingBeatmap)
                return;

            if (!CurrentTrack.Looping && !beatmap.Disabled)
                NextTrack();
        }

        private bool allowRateAdjustments;

        /// <summary>
        /// Whether mod rate adjustments are allowed to be applied.
        /// </summary>
        public bool AllowRateAdjustments
        {
            get => allowRateAdjustments;
            set
            {
                if (allowRateAdjustments == value)
                    return;

                allowRateAdjustments = value;
                ResetTrackAdjustments();
            }
        }

        /// <summary>
        /// Resets the speed adjustments currently applied on <see cref="CurrentTrack"/> and applies the mod adjustments if <see cref="AllowRateAdjustments"/> is <c>true</c>.
        /// </summary>
        /// <remarks>
        /// Does not reset speed adjustments applied directly to the beatmap track.
        /// </remarks>
        public void ResetTrackAdjustments()
        {
            CurrentTrack.ResetSpeedAdjustments();

            if (allowRateAdjustments)
            {
                foreach (var mod in mods.Value.OfType<IApplicableToTrack>())
                    mod.ApplyToTrack(CurrentTrack);
            }
        }
    }

    public enum TrackChangeDirection
    {
        None,
        Next,
        Prev
    }

    public enum PreviousTrackResult
    {
        None,
        Restart,
        Previous
    }
}