// 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.

#nullable disable

using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Audio.Sample;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Input.Events;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Framework.Threading;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Extensions;
using osu.Game.Graphics.Containers;
using osu.Game.IO.Archives;
using osu.Game.Online.API;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.UI;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play.HUD;
using osu.Game.Screens.Ranking;
using osu.Game.Skinning;
using osu.Game.Users;
using osuTK.Graphics;

namespace osu.Game.Screens.Play
{
    [Cached]
    public abstract partial class Player : ScreenWithBeatmapBackground, ISamplePlaybackDisabler, ILocalUserPlayInfo
    {
        /// <summary>
        /// The delay upon completion of the beatmap before displaying the results screen.
        /// </summary>
        public const double RESULTS_DISPLAY_DELAY = 1000.0;

        /// <summary>
        /// Raised after <see cref="StartGameplay"/> is called.
        /// </summary>
        public event Action OnGameplayStarted;

        public override bool AllowBackButton => false; // handled by HoldForMenuButton

        protected override bool PlayExitSound => !isRestarting;

        protected override UserActivity InitialActivity => new UserActivity.InSoloGame(Beatmap.Value.BeatmapInfo, Ruleset.Value);

        public override float BackgroundParallaxAmount => 0.1f;

        public override bool HideOverlaysOnEnter => true;

        public override bool HideMenuCursorOnNonMouseInput => true;

        protected override OverlayActivation InitialOverlayActivationMode => OverlayActivation.UserTriggered;

        // We are managing our own adjustments (see OnEntering/OnExiting).
        public override bool? AllowTrackAdjustments => false;

        private readonly IBindable<bool> gameActive = new Bindable<bool>(true);

        private readonly Bindable<bool> samplePlaybackDisabled = new Bindable<bool>();

        /// <summary>
        /// Whether gameplay should pause when the game window focus is lost.
        /// </summary>
        protected virtual bool PauseOnFocusLost => true;

        public Action<bool> RestartRequested;

        private bool isRestarting;

        private Bindable<bool> mouseWheelDisabled;

        private readonly Bindable<bool> storyboardReplacesBackground = new Bindable<bool>();

        public IBindable<bool> LocalUserPlaying => localUserPlaying;

        private readonly Bindable<bool> localUserPlaying = new Bindable<bool>();

        public int RestartCount;

        /// <summary>
        /// Whether the <see cref="HUDOverlay"/> is currently visible.
        /// </summary>
        public IBindable<bool> ShowingOverlayComponents = new Bindable<bool>();

        [Resolved]
        private ScoreManager scoreManager { get; set; }

        [Resolved]
        private IAPIProvider api { get; set; }

        [Resolved]
        private MusicController musicController { get; set; }

        public GameplayState GameplayState { get; private set; }

        private Ruleset ruleset;

        private Sample sampleRestart;

        public BreakOverlay BreakOverlay;

        /// <summary>
        /// Whether the gameplay is currently in a break.
        /// </summary>
        public readonly IBindable<bool> IsBreakTime = new BindableBool();

        private BreakTracker breakTracker;

        private SkipOverlay skipIntroOverlay;
        private SkipOverlay skipOutroOverlay;

        protected ScoreProcessor ScoreProcessor { get; private set; }

        protected HealthProcessor HealthProcessor { get; private set; }

        protected DrawableRuleset DrawableRuleset { get; private set; }

        protected HUDOverlay HUDOverlay { get; private set; }

        public bool LoadedBeatmapSuccessfully => DrawableRuleset?.Objects.Any() == true;

        protected GameplayClockContainer GameplayClockContainer { get; private set; }

        public DimmableStoryboard DimmableStoryboard { get; private set; }

        /// <summary>
        /// Whether failing should be allowed.
        /// By default, this checks whether all selected mods allow failing.
        /// </summary>
        protected virtual bool CheckModsAllowFailure() => GameplayState.Mods.OfType<IApplicableFailOverride>().All(m => m.PerformFail());

        public readonly PlayerConfiguration Configuration;

        /// <summary>
        /// The score for the current play session.
        /// Available only after the player is loaded.
        /// </summary>
        public Score Score { get; private set; }

        /// <summary>
        /// Create a new player instance.
        /// </summary>
        protected Player(PlayerConfiguration configuration = null)
        {
            Configuration = configuration ?? new PlayerConfiguration();
        }

        private ScreenSuspensionHandler screenSuspension;

        private DependencyContainer dependencies;

        protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
            => dependencies = new DependencyContainer(base.CreateChildDependencies(parent));

        protected override void LoadComplete()
        {
            base.LoadComplete();

            if (!LoadedBeatmapSuccessfully)
                return;

            PrepareReplay();

            ScoreProcessor.NewJudgement += _ => ScoreProcessor.PopulateScore(Score.ScoreInfo);
            ScoreProcessor.OnResetFromReplayFrame += () => ScoreProcessor.PopulateScore(Score.ScoreInfo);

            gameActive.BindValueChanged(_ => updatePauseOnFocusLostState(), true);
        }

        /// <summary>
        /// Run any recording / playback setup for replays.
        /// </summary>
        protected virtual void PrepareReplay()
        {
            DrawableRuleset.SetRecordTarget(Score);
        }

        [BackgroundDependencyLoader(true)]
        private void load(AudioManager audio, OsuConfigManager config, OsuGameBase game, CancellationToken cancellationToken)
        {
            var gameplayMods = Mods.Value.Select(m => m.DeepClone()).ToArray();

            if (gameplayMods.Any(m => m is UnknownMod))
            {
                Logger.Log("Gameplay was started with an unknown mod applied.", level: LogLevel.Important);
                return;
            }

            if (Beatmap.Value is DummyWorkingBeatmap)
                return;

            IBeatmap playableBeatmap = loadPlayableBeatmap(gameplayMods, cancellationToken);

            if (playableBeatmap == null)
                return;

            sampleRestart = audio.Samples.Get(@"Gameplay/restart");

            mouseWheelDisabled = config.GetBindable<bool>(OsuSetting.MouseDisableWheel);

            if (game != null)
                gameActive.BindTo(game.IsActive);

            if (game is OsuGame osuGame)
                LocalUserPlaying.BindTo(osuGame.LocalUserPlaying);

            DrawableRuleset = ruleset.CreateDrawableRulesetWith(playableBeatmap, gameplayMods);
            dependencies.CacheAs(DrawableRuleset);

            ScoreProcessor = ruleset.CreateScoreProcessor();
            ScoreProcessor.Mods.Value = gameplayMods;
            ScoreProcessor.ApplyBeatmap(playableBeatmap);

            dependencies.CacheAs(ScoreProcessor);

            HealthProcessor = ruleset.CreateHealthProcessor(playableBeatmap.HitObjects[0].StartTime);
            HealthProcessor.ApplyBeatmap(playableBeatmap);

            dependencies.CacheAs(HealthProcessor);

            if (!ScoreProcessor.Mode.Disabled)
                config.BindWith(OsuSetting.ScoreDisplayMode, ScoreProcessor.Mode);

            InternalChild = GameplayClockContainer = CreateGameplayClockContainer(Beatmap.Value, DrawableRuleset.GameplayStartTime);

            AddInternal(screenSuspension = new ScreenSuspensionHandler(GameplayClockContainer));

            Score = CreateScore(playableBeatmap);

            // ensure the score is in a consistent state with the current player.
            Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo;
            Score.ScoreInfo.Ruleset = ruleset.RulesetInfo;
            Score.ScoreInfo.Mods = gameplayMods;

            dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score, ScoreProcessor));

            var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin);

            // load the skinning hierarchy first.
            // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources.
            GameplayClockContainer.Add(rulesetSkinProvider);

            if (cancellationToken.IsCancellationRequested)
                return;

            rulesetSkinProvider.AddRange(new Drawable[]
            {
                failAnimationLayer = new FailAnimation(DrawableRuleset)
                {
                    OnComplete = onFailComplete,
                    Children = new[]
                    {
                        // underlay and gameplay should have access to the skinning sources.
                        createUnderlayComponents(),
                        createGameplayComponents(Beatmap.Value)
                    }
                },
                FailOverlay = new FailOverlay
                {
                    SaveReplay = prepareAndImportScore,
                    OnRetry = () => Restart(),
                    OnQuit = () => PerformExit(true),
                },
                new HotkeyExitOverlay
                {
                    Action = () =>
                    {
                        if (!this.IsCurrentScreen()) return;

                        fadeOut(true);
                        PerformExit(false);
                    },
                },
            });

            if (cancellationToken.IsCancellationRequested)
                return;

            if (Configuration.AllowRestart)
            {
                rulesetSkinProvider.Add(new HotkeyRetryOverlay
                {
                    Action = () =>
                    {
                        if (!this.IsCurrentScreen()) return;

                        fadeOut(true);
                        Restart(true);
                    },
                });
            }

            // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components.
            // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.)
            // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there.
            failAnimationLayer.Add(createOverlayComponents(Beatmap.Value));

            if (!DrawableRuleset.AllowGameplayOverlays)
            {
                HUDOverlay.ShowHud.Value = false;
                HUDOverlay.ShowHud.Disabled = true;
                BreakOverlay.Hide();
            }

            DrawableRuleset.FrameStableClock.WaitingOnFrames.BindValueChanged(waiting =>
            {
                if (waiting.NewValue)
                    GameplayClockContainer.Stop();
                else
                    GameplayClockContainer.Start();
            });

            DrawableRuleset.IsPaused.BindValueChanged(_ =>
            {
                updateGameplayState();
                updateSampleDisabledState();
            });

            DrawableRuleset.FrameStableClock.IsCatchingUp.BindValueChanged(_ => updateSampleDisabledState());

            DrawableRuleset.HasReplayLoaded.BindValueChanged(_ => updateGameplayState());

            // bind clock into components that require it
            ((IBindable<bool>)DrawableRuleset.IsPaused).BindTo(GameplayClockContainer.IsPaused);

            DrawableRuleset.NewResult += r =>
            {
                HealthProcessor.ApplyResult(r);
                ScoreProcessor.ApplyResult(r);
                GameplayState.ApplyResult(r);
            };

            DrawableRuleset.RevertResult += r =>
            {
                HealthProcessor.RevertResult(r);
                ScoreProcessor.RevertResult(r);
            };

            DimmableStoryboard.HasStoryboardEnded.ValueChanged += storyboardEnded =>
            {
                if (storyboardEnded.NewValue)
                    progressToResults(true);
            };

            // Bind the judgement processors to ourselves
            ScoreProcessor.HasCompleted.BindValueChanged(scoreCompletionChanged);
            HealthProcessor.Failed += onFail;

            // Provide judgement processors to mods after they're loaded so that they're on the gameplay clock,
            // this is required for mods that apply transforms to these processors.
            ScoreProcessor.OnLoadComplete += _ =>
            {
                foreach (var mod in gameplayMods.OfType<IApplicableToScoreProcessor>())
                    mod.ApplyToScoreProcessor(ScoreProcessor);
            };

            HealthProcessor.OnLoadComplete += _ =>
            {
                foreach (var mod in gameplayMods.OfType<IApplicableToHealthProcessor>())
                    mod.ApplyToHealthProcessor(HealthProcessor);
            };

            IsBreakTime.BindTo(breakTracker.IsBreakTime);
            IsBreakTime.BindValueChanged(onBreakTimeChanged, true);

            if (Configuration.AutomaticallySkipIntro)
                skipIntroOverlay.SkipWhenReady();

            loadLeaderboard();
        }

        protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart);

        private Drawable createUnderlayComponents() =>
            DimmableStoryboard = new DimmableStoryboard(Beatmap.Value.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both };

        private Drawable createGameplayComponents(IWorkingBeatmap working) => new ScalingContainer(ScalingMode.Gameplay)
        {
            Children = new Drawable[]
            {
                DrawableRuleset.With(r =>
                    r.FrameStableComponents.Children = new Drawable[]
                    {
                        ScoreProcessor,
                        HealthProcessor,
                        new ComboEffects(ScoreProcessor),
                        breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor)
                        {
                            Breaks = working.Beatmap.Breaks
                        }
                    }),
            }
        };

        private Drawable createOverlayComponents(IWorkingBeatmap working)
        {
            var container = new Container
            {
                RelativeSizeAxes = Axes.Both,
                Children = new[]
                {
                    DimmableStoryboard.OverlayLayerContainer.CreateProxy(),
                    BreakOverlay = new BreakOverlay(working.Beatmap.BeatmapInfo.LetterboxInBreaks, ScoreProcessor)
                    {
                        Clock = DrawableRuleset.FrameStableClock,
                        ProcessCustomClock = false,
                        Breaks = working.Beatmap.Breaks
                    },
                    // display the cursor above some HUD elements.
                    DrawableRuleset.Cursor?.CreateProxy() ?? new Container(),
                    DrawableRuleset.ResumeOverlay?.CreateProxy() ?? new Container(),
                    HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard)
                    {
                        HoldToQuit =
                        {
                            Action = () => PerformExit(true),
                            IsPaused = { BindTarget = GameplayClockContainer.IsPaused }
                        },
                        KeyCounter =
                        {
                            AlwaysVisible = { BindTarget = DrawableRuleset.HasReplayLoaded },
                            IsCounting = false
                        },
                        Anchor = Anchor.Centre,
                        Origin = Anchor.Centre
                    },
                    skipIntroOverlay = new SkipOverlay(DrawableRuleset.GameplayStartTime)
                    {
                        RequestSkip = performUserRequestedSkip
                    },
                    skipOutroOverlay = new SkipOverlay(Beatmap.Value.Storyboard.LatestEventTime ?? 0)
                    {
                        RequestSkip = () => progressToResults(false),
                        Alpha = 0
                    },
                    PauseOverlay = new PauseOverlay
                    {
                        OnResume = Resume,
                        Retries = RestartCount,
                        OnRetry = () => Restart(),
                        OnQuit = () => PerformExit(true),
                    },
                },
            };

            if (!Configuration.AllowSkipping || !DrawableRuleset.AllowGameplayOverlays)
            {
                skipIntroOverlay.Expire();
                skipOutroOverlay.Expire();
            }

            if (GameplayClockContainer is MasterGameplayClockContainer master)
                HUDOverlay.PlayerSettingsOverlay.PlaybackSettings.UserPlaybackRate.BindTarget = master.UserPlaybackRate;

            return container;
        }

        private void onBreakTimeChanged(ValueChangedEvent<bool> isBreakTime)
        {
            updateGameplayState();
            updatePauseOnFocusLostState();
            HUDOverlay.KeyCounter.IsCounting = !isBreakTime.NewValue;
        }

        private void updateGameplayState()
        {
            bool inGameplay = !DrawableRuleset.HasReplayLoaded.Value && !DrawableRuleset.IsPaused.Value && !breakTracker.IsBreakTime.Value && !GameplayState.HasFailed;
            OverlayActivationMode.Value = inGameplay ? OverlayActivation.Disabled : OverlayActivation.UserTriggered;
            localUserPlaying.Value = inGameplay;
        }

        private void updateSampleDisabledState()
        {
            samplePlaybackDisabled.Value = DrawableRuleset.FrameStableClock.IsCatchingUp.Value || GameplayClockContainer.IsPaused.Value;
        }

        private void updatePauseOnFocusLostState()
        {
            if (!PauseOnFocusLost || !pausingSupportedByCurrentState || breakTracker.IsBreakTime.Value)
                return;

            if (gameActive.Value == false)
            {
                bool paused = Pause();

                // if the initial pause could not be satisfied, the pause cooldown may be active.
                // reschedule the pause attempt until it can be achieved.
                if (!paused)
                    Scheduler.AddOnce(updatePauseOnFocusLostState);
            }
        }

        private IBeatmap loadPlayableBeatmap(Mod[] gameplayMods, CancellationToken cancellationToken)
        {
            IBeatmap playable;

            try
            {
                if (Beatmap.Value.Beatmap == null)
                    throw new InvalidOperationException("Beatmap was not loaded");

                var rulesetInfo = Ruleset.Value ?? Beatmap.Value.BeatmapInfo.Ruleset;
                ruleset = rulesetInfo.CreateInstance();

                if (ruleset == null)
                    throw new RulesetLoadException("Instantiation failure");

                try
                {
                    playable = Beatmap.Value.GetPlayableBeatmap(ruleset.RulesetInfo, gameplayMods, cancellationToken);
                }
                catch (BeatmapInvalidForRulesetException)
                {
                    // A playable beatmap may not be creatable with the user's preferred ruleset, so try using the beatmap's default ruleset
                    rulesetInfo = Beatmap.Value.BeatmapInfo.Ruleset;
                    ruleset = rulesetInfo.CreateInstance();

                    playable = Beatmap.Value.GetPlayableBeatmap(rulesetInfo, gameplayMods, cancellationToken);
                }

                if (playable.HitObjects.Count == 0)
                {
                    Logger.Log("Beatmap contains no hit objects!", level: LogLevel.Error);
                    return null;
                }
            }
            catch (OperationCanceledException)
            {
                // Load has been cancelled. No logging is required.
                return null;
            }
            catch (Exception e)
            {
                Logger.Error(e, "Could not load beatmap successfully!");
                //couldn't load, hard abort!
                return null;
            }

            return playable;
        }

        /// <summary>
        /// Attempts to complete a user request to exit gameplay.
        /// </summary>
        /// <remarks>
        /// <list type="bullet">
        /// <item>This should only be called in response to a user interaction. Exiting is not guaranteed.</item>
        /// <item>This will interrupt any pending progression to the results screen, even if the transition has begun.</item>
        /// </list>
        /// </remarks>
        /// <param name="showDialogFirst">
        /// Whether the pause or fail dialog should be shown before performing an exit.
        /// If <see langword="true"/> and a dialog is not yet displayed, the exit will be blocked and the relevant dialog will display instead.
        /// </param>
        protected void PerformExit(bool showDialogFirst)
        {
            // there is a chance that an exit request occurs after the transition to results has already started.
            // even in such a case, the user has shown intent, so forcefully return to this screen (to proceed with the upwards exit process).
            if (!this.IsCurrentScreen())
            {
                ValidForResume = false;

                // in the potential case that this instance has already been exited, this is required to avoid a crash.
                if (this.GetChildScreen() != null)
                    this.MakeCurrent();
                return;
            }

            bool pauseOrFailDialogVisible =
                PauseOverlay.State.Value == Visibility.Visible || FailOverlay.State.Value == Visibility.Visible;

            if (showDialogFirst && !pauseOrFailDialogVisible)
            {
                // if the fail animation is currently in progress, accelerate it (it will show the pause dialog on completion).
                if (ValidForResume && GameplayState.HasFailed)
                {
                    failAnimationLayer.FinishTransforms(true);
                    return;
                }

                // even if this call has requested a dialog, there is a chance the current player mode doesn't support pausing.
                if (pausingSupportedByCurrentState)
                {
                    // in the case a dialog needs to be shown, attempt to pause and show it.
                    // this may fail (see internal checks in Pause()) but the fail cases are temporary, so don't fall through to Exit().
                    Pause();
                    return;
                }
            }

            // if an exit has been requested, cancel any pending completion (the user has shown intention to exit).
            resultsDisplayDelegate?.Cancel();

            // The actual exit is performed if
            // - the pause / fail dialog was not requested
            // - the pause / fail dialog was requested but is already displayed (user showing intention to exit).
            // - the pause / fail dialog was requested but couldn't be displayed due to the type or state of this Player instance.
            this.Exit();
        }

        private void performUserRequestedSkip()
        {
            // user requested skip
            // disable sample playback to stop currently playing samples and perform skip
            samplePlaybackDisabled.Value = true;

            (GameplayClockContainer as MasterGameplayClockContainer)?.Skip();

            // return samplePlaybackDisabled.Value to what is defined by the beatmap's current state
            updateSampleDisabledState();
        }

        /// <summary>
        /// Seek to a specific time in gameplay.
        /// </summary>
        /// <param name="time">The destination time to seek to.</param>
        public void Seek(double time) => GameplayClockContainer.Seek(time);

        private ScheduledDelegate frameStablePlaybackResetDelegate;

        /// <summary>
        /// Specify and seek to a custom start time from which gameplay should be observed.
        /// </summary>
        /// <remarks>
        /// This performs a non-frame-stable seek. Intermediate hitobject judgements may not be applied or reverted correctly during this seek.
        /// </remarks>
        /// <param name="time">The destination time to seek to.</param>
        protected void SetGameplayStartTime(double time)
        {
            if (frameStablePlaybackResetDelegate?.Cancelled == false && !frameStablePlaybackResetDelegate.Completed)
                frameStablePlaybackResetDelegate.RunTask();

            bool wasFrameStable = DrawableRuleset.FrameStablePlayback;
            DrawableRuleset.FrameStablePlayback = false;

            GameplayClockContainer.Reset(time);

            // Delay resetting frame-stable playback for one frame to give the FrameStabilityContainer a chance to seek.
            frameStablePlaybackResetDelegate = ScheduleAfterChildren(() => DrawableRuleset.FrameStablePlayback = wasFrameStable);
        }

        /// <summary>
        /// Restart gameplay via a parent <see cref="PlayerLoader"/>.
        /// <remarks>This can be called from a child screen in order to trigger the restart process.</remarks>
        /// </summary>
        /// <param name="quickRestart">Whether a quick restart was requested (skipping intro etc.).</param>
        public void Restart(bool quickRestart = false)
        {
            if (!Configuration.AllowRestart)
                return;

            isRestarting = true;

            // at the point of restarting the track should either already be paused or the volume should be zero.
            // stopping here is to ensure music doesn't become audible after exiting back to PlayerLoader.
            musicController.Stop();

            sampleRestart?.Play();
            RestartRequested?.Invoke(quickRestart);

            PerformExit(false);
        }

        /// <summary>
        /// This delegate, when set, means the results screen has been queued to appear.
        /// The display of the results screen may be delayed by any work being done in <see cref="PrepareScoreForResultsAsync"/>.
        /// </summary>
        /// <remarks>
        /// Once set, this can *only* be cancelled by rewinding, ie. if <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="false"/>.
        /// Even if the user requests an exit, it will forcefully proceed to the results screen (see special case in <see cref="OnExiting"/>).
        /// </remarks>
        private ScheduledDelegate resultsDisplayDelegate;

        /// <summary>
        /// A task which asynchronously prepares a completed score for display at results.
        /// This may include performing net requests or importing the score into the database, generally to ensure things are in a sane state for the play session.
        /// </summary>
        private Task<ScoreInfo> prepareScoreForDisplayTask;

        /// <summary>
        /// Handles changes in player state which may progress the completion of gameplay / this screen's lifetime.
        /// </summary>
        /// <exception cref="InvalidOperationException">Thrown if this method is called more than once without changing state.</exception>
        private void scoreCompletionChanged(ValueChangedEvent<bool> completed)
        {
            // If this player instance is in the middle of an exit, don't attempt any kind of state update.
            if (!this.IsCurrentScreen())
                return;

            // Special case to handle rewinding post-completion. This is the only way already queued forward progress can be cancelled.
            // TODO: Investigate whether this can be moved to a RewindablePlayer subclass or similar.
            // Currently, even if this scenario is hit, prepareScoreForDisplay has already been queued (and potentially run).
            // In scenarios where rewinding is possible (replay, spectating) this is a non-issue as no submission/import work is done,
            // but it still doesn't feel right that this exists here.
            if (!completed.NewValue)
            {
                resultsDisplayDelegate?.Cancel();
                resultsDisplayDelegate = null;

                GameplayState.HasPassed = false;
                ValidForResume = true;
                skipOutroOverlay.Hide();
                return;
            }

            // Only show the completion screen if the player hasn't failed
            if (HealthProcessor.HasFailed)
                return;

            GameplayState.HasPassed = true;

            // Setting this early in the process means that even if something were to go wrong in the order of events following, there
            // is no chance that a user could return to the (already completed) Player instance from a child screen.
            ValidForResume = false;

            // Ensure we are not writing to the replay any more, as we are about to consume and store the score.
            DrawableRuleset.SetRecordTarget(null);

            if (!Configuration.ShowResults)
                return;

            prepareScoreForDisplayTask ??= Task.Run(prepareAndImportScore);

            bool storyboardHasOutro = DimmableStoryboard.ContentDisplayed && !DimmableStoryboard.HasStoryboardEnded.Value;

            if (storyboardHasOutro)
            {
                // if the current beatmap has a storyboard, the progression to results will be handled by the storyboard ending
                // or the user pressing the skip outro button.
                skipOutroOverlay.Show();
                return;
            }

            progressToResults(true);
        }

        /// <summary>
        /// Asynchronously run score preparation operations (database import, online submission etc.).
        /// </summary>
        /// <returns>The final score.</returns>
        private async Task<ScoreInfo> prepareAndImportScore()
        {
            var scoreCopy = Score.DeepClone();

            try
            {
                await PrepareScoreForResultsAsync(scoreCopy).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                Logger.Error(ex, @"Score preparation failed!");
            }

            try
            {
                await ImportScore(scoreCopy).ConfigureAwait(false);
            }
            catch (Exception ex)
            {
                Logger.Error(ex, @"Score import failed!");
            }

            return scoreCopy.ScoreInfo;
        }

        /// <summary>
        /// Queue the results screen for display.
        /// </summary>
        /// <remarks>
        /// A final display will only occur once all work is completed in <see cref="PrepareScoreForResultsAsync"/>. This means that even after calling this method, the results screen will never be shown until <see cref="JudgementProcessor.HasCompleted">ScoreProcessor.HasCompleted</see> becomes <see langword="true"/>.
        /// </remarks>
        /// <param name="withDelay">Whether a minimum delay (<see cref="RESULTS_DISPLAY_DELAY"/>) should be added before the screen is displayed.</param>
        private void progressToResults(bool withDelay)
        {
            resultsDisplayDelegate?.Cancel();

            double delay = withDelay ? RESULTS_DISPLAY_DELAY : 0;

            resultsDisplayDelegate = new ScheduledDelegate(() =>
            {
                if (prepareScoreForDisplayTask?.IsCompleted != true)
                    // If the asynchronous preparation has not completed, keep repeating this delegate.
                    return;

                resultsDisplayDelegate?.Cancel();

                if (!this.IsCurrentScreen())
                    // This player instance may already be in the process of exiting.
                    return;

                this.Push(CreateResults(prepareScoreForDisplayTask.GetResultSafely()));
            }, Time.Current + delay, 50);

            Scheduler.Add(resultsDisplayDelegate);
        }

        protected override bool OnScroll(ScrollEvent e)
        {
            // During pause, allow global volume adjust regardless of settings.
            if (GameplayClockContainer.IsPaused.Value)
                return false;

            // Block global volume adjust if the user has asked for it (special case when holding "Alt").
            return mouseWheelDisabled.Value && !e.AltPressed;
        }

        #region Gameplay leaderboard

        protected readonly Bindable<bool> LeaderboardExpandedState = new BindableBool();

        private void loadLeaderboard()
        {
            HUDOverlay.HoldingForHUD.BindValueChanged(_ => updateLeaderboardExpandedState());
            LocalUserPlaying.BindValueChanged(_ => updateLeaderboardExpandedState(), true);

            var gameplayLeaderboard = CreateGameplayLeaderboard();

            if (gameplayLeaderboard != null)
            {
                LoadComponentAsync(gameplayLeaderboard, leaderboard =>
                {
                    if (!LoadedBeatmapSuccessfully)
                        return;

                    leaderboard.Expanded.BindTo(LeaderboardExpandedState);

                    AddLeaderboardToHUD(leaderboard);
                });
            }
        }

        [CanBeNull]
        protected virtual GameplayLeaderboard CreateGameplayLeaderboard() => null;

        protected virtual void AddLeaderboardToHUD(GameplayLeaderboard leaderboard) => HUDOverlay.LeaderboardFlow.Add(leaderboard);

        private void updateLeaderboardExpandedState() =>
            LeaderboardExpandedState.Value = !LocalUserPlaying.Value || HUDOverlay.HoldingForHUD.Value;

        #endregion

        #region Fail Logic

        protected FailOverlay FailOverlay { get; private set; }

        private FailAnimation failAnimationLayer;

        private bool onFail()
        {
            // Failing after the quit sequence has started may cause weird side effects with the fail animation / effects.
            if (GameplayState.HasQuit)
                return false;

            if (!CheckModsAllowFailure())
                return false;

            Debug.Assert(!GameplayState.HasFailed);
            Debug.Assert(!GameplayState.HasPassed);
            Debug.Assert(!GameplayState.HasQuit);

            GameplayState.HasFailed = true;

            updateGameplayState();

            // There is a chance that we could be in a paused state as the ruleset's internal clock (see FrameStabilityContainer)
            // could process an extra frame after the GameplayClock is stopped.
            // In such cases we want the fail state to precede a user triggered pause.
            if (PauseOverlay.State.Value == Visibility.Visible)
                PauseOverlay.Hide();

            failAnimationLayer.Start();

            if (GameplayState.Mods.OfType<IApplicableFailOverride>().Any(m => m.RestartOnFail))
                Restart(true);

            return true;
        }

        /// <summary>
        /// Invoked when the fail animation has finished.
        /// </summary>
        private void onFailComplete()
        {
            // fail completion is a good point to mark a score as failed,
            // since the last judgement that caused the fail only applies to score processor after onFail.
            // todo: this should probably be handled better.
            ScoreProcessor.FailScore(Score.ScoreInfo);

            GameplayClockContainer.Stop();

            FailOverlay.Retries = RestartCount;
            FailOverlay.Show();
        }

        #endregion

        #region Pause Logic

        public bool IsResuming { get; private set; }

        /// <summary>
        /// The amount of gameplay time after which a second pause is allowed.
        /// </summary>
        private const double pause_cooldown = 1000;

        protected PauseOverlay PauseOverlay { get; private set; }

        private double? lastPauseActionTime;

        protected bool PauseCooldownActive =>
            lastPauseActionTime.HasValue && GameplayClockContainer.CurrentTime < lastPauseActionTime + pause_cooldown;

        /// <summary>
        /// A set of conditionals which defines whether the current game state and configuration allows for
        /// pausing to be attempted via <see cref="Pause"/>. If false, the game should generally exit if a user pause
        /// is attempted.
        /// </summary>
        private bool pausingSupportedByCurrentState =>
            // must pass basic screen conditions (beatmap loaded, instance allows pause)
            LoadedBeatmapSuccessfully && Configuration.AllowPause && ValidForResume
            // replays cannot be paused and exit immediately
            && !DrawableRuleset.HasReplayLoaded.Value
            // cannot pause if we are already in a fail state
            && !GameplayState.HasFailed;

        private bool canResume =>
            // cannot resume from a non-paused state
            GameplayClockContainer.IsPaused.Value
            // cannot resume if we are already in a fail state
            && !GameplayState.HasFailed
            // already resuming
            && !IsResuming;

        public bool Pause()
        {
            if (!pausingSupportedByCurrentState) return false;

            if (!IsResuming && PauseCooldownActive)
                return false;

            if (IsResuming)
            {
                DrawableRuleset.CancelResume();
                IsResuming = false;
            }

            GameplayClockContainer.Stop();
            PauseOverlay.Show();
            lastPauseActionTime = GameplayClockContainer.CurrentTime;
            return true;
        }

        public void Resume()
        {
            if (!canResume) return;

            IsResuming = true;
            PauseOverlay.Hide();

            // breaks and time-based conditions may allow instant resume.
            if (breakTracker.IsBreakTime.Value)
                completeResume();
            else
                DrawableRuleset.RequestResume(completeResume);

            void completeResume()
            {
                GameplayClockContainer.Start();
                IsResuming = false;
            }
        }

        #endregion

        #region Screen Logic

        public override void OnEntering(ScreenTransitionEvent e)
        {
            base.OnEntering(e);

            if (!LoadedBeatmapSuccessfully)
                return;

            Alpha = 0;
            this
                .ScaleTo(0.7f)
                .ScaleTo(1, 750, Easing.OutQuint)
                .Delay(250)
                .FadeIn(250);

            ApplyToBackground(b =>
            {
                b.IgnoreUserSettings.Value = false;
                b.BlurAmount.Value = 0;
                b.FadeColour(Color4.White, 250);

                // bind component bindables.
                b.IsBreakTime.BindTo(breakTracker.IsBreakTime);

                b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground);

                failAnimationLayer.Background = b;
            });

            HUDOverlay.IsPlaying.BindTo(localUserPlaying);
            ShowingOverlayComponents.BindTo(HUDOverlay.ShowHud);

            DimmableStoryboard.IsBreakTime.BindTo(breakTracker.IsBreakTime);

            DimmableStoryboard.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground);

            storyboardReplacesBackground.Value = Beatmap.Value.Storyboard.ReplacesBackground && Beatmap.Value.Storyboard.HasDrawable;

            foreach (var mod in GameplayState.Mods.OfType<IApplicableToPlayer>())
                mod.ApplyToPlayer(this);

            foreach (var mod in GameplayState.Mods.OfType<IApplicableToHUD>())
                mod.ApplyToHUD(HUDOverlay);

            foreach (var mod in GameplayState.Mods.OfType<IApplicableToTrack>())
                mod.ApplyToTrack(GameplayClockContainer.AdjustmentsFromMods);

            updateGameplayState();

            GameplayClockContainer.FadeInFromZero(750, Easing.OutQuint);

            StartGameplay();
            OnGameplayStarted?.Invoke();
        }

        /// <summary>
        /// Called to trigger the starting of the gameplay clock and underlying gameplay.
        /// This will be called on entering the player screen once. A derived class may block the first call to this to delay the start of gameplay.
        /// </summary>
        protected virtual void StartGameplay()
        {
            if (GameplayClockContainer.IsRunning)
                throw new InvalidOperationException($"{nameof(StartGameplay)} should not be called when the gameplay clock is already running");

            GameplayClockContainer.Reset(startClock: true);
        }

        public override void OnSuspending(ScreenTransitionEvent e)
        {
            screenSuspension?.RemoveAndDisposeImmediately();

            fadeOut();
            base.OnSuspending(e);
        }

        public override bool OnExiting(ScreenExitEvent e)
        {
            screenSuspension?.RemoveAndDisposeImmediately();
            failAnimationLayer?.RemoveFilters();

            if (LoadedBeatmapSuccessfully)
            {
                if (!GameplayState.HasPassed && !GameplayState.HasFailed)
                    GameplayState.HasQuit = true;

                // if arriving here and the results screen preparation task hasn't run, it's safe to say the user has not completed the beatmap.
                if (prepareScoreForDisplayTask == null)
                    ScoreProcessor.FailScore(Score.ScoreInfo);
            }

            // GameplayClockContainer performs seeks / start / stop operations on the beatmap's track.
            // as we are no longer the current screen, we cannot guarantee the track is still usable.
            (GameplayClockContainer as MasterGameplayClockContainer)?.StopUsingBeatmapClock();

            musicController.ResetTrackAdjustments();

            fadeOut();

            return base.OnExiting(e);
        }

        /// <summary>
        /// Creates the player's <see cref="Scoring.Score"/>.
        /// </summary>
        /// <param name="beatmap"></param>
        /// <returns>The <see cref="Scoring.Score"/>.</returns>
        protected virtual Score CreateScore(IBeatmap beatmap) => new Score
        {
            ScoreInfo = new ScoreInfo { User = api.LocalUser.Value },
        };

        /// <summary>
        /// Imports the player's <see cref="Scoring.Score"/> to the local database.
        /// </summary>
        /// <param name="score">The <see cref="Scoring.Score"/> to import.</param>
        /// <returns>The imported score.</returns>
        protected virtual Task ImportScore(Score score)
        {
            // Replays are already populated and present in the game's database, so should not be re-imported.
            if (DrawableRuleset.ReplayScore != null)
                return Task.CompletedTask;

            LegacyByteArrayReader replayReader = null;

            if (score.ScoreInfo.Ruleset.IsLegacyRuleset())
            {
                using (var stream = new MemoryStream())
                {
                    new LegacyScoreEncoder(score, GameplayState.Beatmap).Encode(stream);
                    replayReader = new LegacyByteArrayReader(stream.ToArray(), "replay.osr");
                }
            }

            // the import process will re-attach managed beatmap/rulesets to this score. we don't want this for now, so create a temporary copy to import.
            var importableScore = score.ScoreInfo.DeepClone();

            // For the time being, online ID responses are not really useful for anything.
            // In addition, the IDs provided via new (lazer) endpoints are based on a different autoincrement from legacy (stable) scores.
            //
            // Until we better define the server-side logic behind this, let's not store the online ID to avoid potential unique constraint
            // conflicts across various systems (ie. solo and multiplayer).
            importableScore.OnlineID = -1;

            var imported = scoreManager.Import(importableScore, replayReader);

            imported.PerformRead(s =>
            {
                // because of the clone above, it's required that we copy back the post-import hash/ID to use for availability matching.
                score.ScoreInfo.Hash = s.Hash;
                score.ScoreInfo.ID = s.ID;
            });

            return Task.CompletedTask;
        }

        /// <summary>
        /// Prepare the <see cref="Scoring.Score"/> for display at results.
        /// </summary>
        /// <param name="score">The <see cref="Scoring.Score"/> to prepare.</param>
        /// <returns>A task that prepares the provided score. On completion, the score is assumed to be ready for display.</returns>
        protected virtual Task PrepareScoreForResultsAsync(Score score) => Task.CompletedTask;

        /// <summary>
        /// Creates the <see cref="ResultsScreen"/> for a <see cref="ScoreInfo"/>.
        /// </summary>
        /// <param name="score">The <see cref="ScoreInfo"/> to be displayed in the results screen.</param>
        /// <returns>The <see cref="ResultsScreen"/>.</returns>
        protected virtual ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score, true)
        {
            ShowUserStatistics = true
        };

        private void fadeOut(bool instant = false)
        {
            float fadeOutDuration = instant ? 0 : 250;
            this.FadeOut(fadeOutDuration);

            ApplyToBackground(b => b.IgnoreUserSettings.Value = true);
            storyboardReplacesBackground.Value = false;
        }

        #endregion

        IBindable<bool> ISamplePlaybackDisabler.SamplePlaybackDisabled => samplePlaybackDisabled;

        IBindable<bool> ILocalUserPlayInfo.IsPlaying => LocalUserPlaying;
    }
}