// 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.Linq;
using System.Threading.Tasks;
using JetBrains.Annotations;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Configuration;
using osu.Game.Database;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Online.Spectator;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Ranking;

namespace osu.Game.Screens.Play
{
    /// <summary>
    /// A player instance which supports submitting scores to an online store.
    /// </summary>
    public abstract partial class SubmittingPlayer : Player
    {
        /// <summary>
        /// The token to be used for the current submission. This is fetched via a request created by <see cref="CreateTokenRequest"/>.
        /// </summary>
        private long? token;

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

        [Resolved]
        private SpectatorClient spectatorClient { get; set; }

        [Resolved]
        private SessionStatics statics { get; set; }

        [Resolved(canBeNull: true)]
        [CanBeNull]
        private UserStatisticsWatcher userStatisticsWatcher { get; set; }

        private readonly object scoreSubmissionLock = new object();
        private TaskCompletionSource<bool> scoreSubmissionSource;

        protected SubmittingPlayer(PlayerConfiguration configuration = null)
            : base(configuration)
        {
        }

        [BackgroundDependencyLoader]
        private void load()
        {
            if (DrawableRuleset == null)
            {
                // base load must have failed (e.g. due to an unknown mod); bail.
                return;
            }

            AddInternal(new PlayerTouchInputDetector());

            // We probably want to move this display to something more global.
            // Probably using the OSD somehow.
            AddInternal(new GameplayOffsetControl
            {
                Margin = new MarginPadding(20),
                Anchor = Anchor.CentreRight,
                Origin = Anchor.CentreRight,
            });
        }

        protected override GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart)
        {
            ShouldValidatePlaybackRate = true,
        };

        protected override void LoadAsyncComplete()
        {
            base.LoadAsyncComplete();
            handleTokenRetrieval();
        }

        private bool handleTokenRetrieval()
        {
            // Token request construction should happen post-load to allow derived classes to potentially prepare DI backings that are used to create the request.
            var tcs = new TaskCompletionSource<bool>();

            if (Mods.Value.Any(m => !m.UserPlayable))
            {
                handleTokenFailure(new InvalidOperationException("Non-user playable mod selected."));
                return false;
            }

            if (!api.IsLoggedIn)
            {
                handleTokenFailure(new InvalidOperationException("API is not online."));
                return false;
            }

            var req = CreateTokenRequest();

            if (req == null)
            {
                handleTokenFailure(new InvalidOperationException("Request could not be constructed."));
                return false;
            }

            req.Success += r =>
            {
                Logger.Log($"Score submission token retrieved ({r.ID})");
                token = r.ID;
                tcs.SetResult(true);
            };
            req.Failure += ex => handleTokenFailure(ex, displayNotification: true);

            api.Queue(req);

            // Generally a timeout would not happen here as APIAccess will timeout first.
            if (!tcs.Task.Wait(30000))
                req.TriggerFailure(new InvalidOperationException("Token retrieval timed out (request never run)"));

            return true;

            void handleTokenFailure(Exception exception, bool displayNotification = false)
            {
                tcs.SetResult(false);

                bool shouldExit = ShouldExitOnTokenRetrievalFailure(exception);

                if (displayNotification || shouldExit)
                {
                    string whatWillHappen = shouldExit
                        ? "Play in this state is not permitted."
                        : "Your score will not be submitted.";

                    if (string.IsNullOrEmpty(exception.Message))
                        Logger.Error(exception, $"Failed to retrieve a score submission token.\n\n{whatWillHappen}");
                    else
                        Logger.Log($"{getUserFacingAPIError(exception)}\n\n{whatWillHappen}", level: LogLevel.Important);
                }

                if (shouldExit)
                {
                    Schedule(() =>
                    {
                        ValidForResume = false;
                        this.Exit();
                    });
                }
            }
        }

        /// <summary>
        /// Called when a token could not be retrieved for submission.
        /// </summary>
        /// <param name="exception">The error causing the failure.</param>
        /// <returns>Whether gameplay should be immediately exited as a result. Returning false allows the gameplay session to continue. Defaults to true.</returns>
        protected virtual bool ShouldExitOnTokenRetrievalFailure(Exception exception) => true;

        public override bool AllowCriticalSettingsAdjustment
        {
            get
            {
                // General limitations to ensure players don't do anything too weird.
                // These match stable for now.

                // TODO: the blocking conditions should probably display a message.
                if (!IsBreakTime.Value && GameplayClockContainer.CurrentTime - GameplayClockContainer.GameplayStartTime > 10000)
                    return false;

                if (GameplayClockContainer.IsPaused.Value)
                    return false;

                return base.AllowCriticalSettingsAdjustment;
            }
        }

        protected override async Task PrepareScoreForResultsAsync(Score score)
        {
            await base.PrepareScoreForResultsAsync(score).ConfigureAwait(false);

            score.ScoreInfo.Date = DateTimeOffset.Now;

            await submitScore(score).ConfigureAwait(false);
            spectatorClient.EndPlaying(GameplayState);
            userStatisticsWatcher?.RegisterForStatisticsUpdateAfter(score.ScoreInfo);
        }

        [Resolved]
        private RealmAccess realm { get; set; }

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

            // User expectation is that last played should be updated when entering the gameplay loop
            // from multiplayer / playlists / solo.
            realm.WriteAsync(r =>
            {
                var realmBeatmap = r.Find<BeatmapInfo>(Beatmap.Value.BeatmapInfo.ID);
                if (realmBeatmap != null)
                    realmBeatmap.LastPlayed = DateTimeOffset.Now;
            });

            spectatorClient.BeginPlaying(token, GameplayState, Score);
        }

        public override bool Pause()
        {
            bool wasPaused = GameplayClockContainer.IsPaused.Value;

            bool paused = base.Pause();

            if (!wasPaused && paused)
                Score.ScoreInfo.Pauses.Add((int)Math.Round(GameplayClockContainer.CurrentTime));

            return paused;
        }

        protected override void ConcludeFailedScore(Score score)
        {
            base.ConcludeFailedScore(score);
            submitFromFailOrQuit(score);
        }

        public override bool OnExiting(ScreenExitEvent e)
        {
            bool exiting = base.OnExiting(e);
            submitFromFailOrQuit(Score);
            statics.SetValue(Static.LastLocalUserScore, Score?.ScoreInfo.DeepClone());
            return exiting;
        }

        private void submitFromFailOrQuit(Score score)
        {
            if (LoadedBeatmapSuccessfully)
            {
                // compare: https://github.com/ppy/osu/blob/ccf1acce56798497edfaf92d3ece933469edcf0a/osu.Game/Screens/Play/Player.cs#L848-L851
                var scoreCopy = score.DeepClone();

                Task.Run(async () =>
                {
                    await submitScore(scoreCopy).ConfigureAwait(false);
                    spectatorClient.EndPlaying(GameplayState);
                }).FireAndForget();
            }
        }

        /// <summary>
        /// Construct a request to be used for retrieval of the score token.
        /// Can return null, at which point <see cref="ShouldExitOnTokenRetrievalFailure"/> will be fired.
        /// </summary>
        [CanBeNull]
        protected abstract APIRequest<APIScoreToken> CreateTokenRequest();

        /// <summary>
        /// Construct a request to submit the score.
        /// Will only be invoked if the request constructed via <see cref="CreateTokenRequest"/> was successful.
        /// </summary>
        /// <param name="score">The score to be submitted.</param>
        /// <param name="token">The submission token.</param>
        protected abstract APIRequest<MultiplayerScore> CreateSubmissionRequest(Score score, long token);

        private Task submitScore(Score score)
        {
            var masterClock = GameplayClockContainer as MasterGameplayClockContainer;

            if (masterClock?.PlaybackRateValid.Value != true)
            {
                Logger.Log("Score submission cancelled due to audio playback rate discrepancy.");
                return Task.CompletedTask;
            }

            // token may be null if the request failed but gameplay was still allowed (see HandleTokenRetrievalFailure).
            if (token == null)
            {
                Logger.Log("No token, skipping score submission");
                return Task.CompletedTask;
            }

            // if the user never hit anything, this score should not be counted in any way.
            if (!score.ScoreInfo.Statistics.Any(s => s.Key.IsHit() && s.Value > 0))
            {
                Logger.Log("No hits registered, skipping score submission");
                return Task.CompletedTask;
            }

            // zero scores should also never be submitted.
            if (score.ScoreInfo.TotalScore == 0)
            {
                Logger.Log("Zero score, skipping score submission");
                return Task.CompletedTask;
            }

            // mind the timing of this.
            // once `scoreSubmissionSource` is created, it is presumed that submission is taking place in the background,
            // so all exceptional circumstances that would disallow submission must be handled above.
            lock (scoreSubmissionLock)
            {
                if (scoreSubmissionSource != null)
                    return scoreSubmissionSource.Task;

                scoreSubmissionSource = new TaskCompletionSource<bool>();
            }

            Logger.Log($"Beginning score submission (token:{token.Value})...");
            var request = CreateSubmissionRequest(score, token.Value);

            request.Success += s =>
            {
                score.ScoreInfo.OnlineID = s.ID;
                score.ScoreInfo.Position = s.Position;

                scoreSubmissionSource.SetResult(true);
                Logger.Log($"Score submission completed! (token:{token.Value} id:{s.ID})");
            };

            request.Failure += e =>
            {
                Logger.Error(e, $"{getUserFacingAPIError(e)}\n\nScore was not submitted (id: {token.Value})");
                scoreSubmissionSource.SetResult(false);
            };

            api.Queue(request);
            return scoreSubmissionSource.Task;
        }

        private static string getUserFacingAPIError(Exception exception)
        {
            switch (exception.Message)
            {
                case @"missing token header":
                case @"invalid client hash":
                case @"invalid verification hash":
                case @"invalid token":
                case @"outdated client":
                    return "Please ensure that you are using the latest version of the official game releases.";

                case @"invalid or missing beatmap_hash":
                    return "This beatmap does not match the online version. Please update or redownload it.";

                case @"expired token":
                    return "Your system clock is set incorrectly. Please check your system time, date and timezone.";

                default:
                    return exception.Message;
            }
        }

        protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score)
        {
            AllowRetry = true,
            IsLocalPlay = true,
        };
    }
}
