// 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.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Graphics;
using osu.Game.Graphics.Containers;
using osu.Game.Graphics.Sprites;
using osu.Game.Rulesets.Osu;
using osu.Game.Screens.Play;
using osuTK.Graphics;

namespace osu.Game.Tests.Visual.UserInterface
{
    [TestFixture]
    public class TestSceneBeatSyncedContainer : OsuTestScene
    {
        private TestBeatSyncedContainer beatContainer;

        private MasterGameplayClockContainer gameplayClockContainer;

        [SetUpSteps]
        public void SetUpSteps()
        {
            AddStep("Set beatmap", () =>
            {
                Beatmap.Value = CreateWorkingBeatmap(new OsuRuleset().RulesetInfo);
            });

            AddStep("Create beat sync container", () =>
            {
                Children = new Drawable[]
                {
                    gameplayClockContainer = new MasterGameplayClockContainer(Beatmap.Value, 0)
                    {
                        Child = beatContainer = new TestBeatSyncedContainer
                        {
                            Anchor = Anchor.BottomCentre,
                            Origin = Anchor.BottomCentre,
                        },
                    }
                };
            });

            AddStep("Start playback", () => gameplayClockContainer.Start());
        }

        [TestCase(false)]
        [TestCase(true)]
        public void TestDisallowMistimedEventFiring(bool allowMistimed)
        {
            int? lastBeatIndex = null;
            double? lastActuationTime = null;
            TimingControlPoint lastTimingPoint = null;

            AddStep($"set mistimed to {(allowMistimed ? "allowed" : "disallowed")}", () => beatContainer.AllowMistimedEventFiring = allowMistimed);

            AddStep("Set time before zero", () =>
            {
                beatContainer.NewBeat = (i, timingControlPoint, _, _) =>
                {
                    lastActuationTime = gameplayClockContainer.CurrentTime;
                    lastTimingPoint = timingControlPoint;
                    lastBeatIndex = i;
                    beatContainer.NewBeat = null;
                };

                gameplayClockContainer.Seek(-1000);
            });

            AddUntilStep("wait for trigger", () => lastBeatIndex != null);

            if (!allowMistimed)
            {
                AddAssert("trigger is near beat length",
                    () => lastActuationTime != null && lastBeatIndex != null && Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength, lastActuationTime.Value,
                        BeatSyncedContainer.MISTIMED_ALLOWANCE));
            }
            else
            {
                AddAssert("trigger is not near beat length",
                    () => lastActuationTime != null && lastBeatIndex != null && !Precision.AlmostEquals(lastTimingPoint.Time + lastBeatIndex.Value * lastTimingPoint.BeatLength,
                        lastActuationTime.Value, BeatSyncedContainer.MISTIMED_ALLOWANCE));
            }
        }

        [Test]
        public void TestNegativeBeatsStillUsingBeatmapTiming()
        {
            int? lastBeatIndex = null;
            double? lastBpm = null;

            AddStep("Set time before zero", () =>
            {
                beatContainer.NewBeat = (i, timingControlPoint, _, _) =>
                {
                    lastBeatIndex = i;
                    lastBpm = timingControlPoint.BPM;
                };

                gameplayClockContainer.Seek(-1000);
            });

            AddUntilStep("wait for trigger", () => lastBpm != null);
            AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128));
            AddAssert("beat index is less than zero", () => lastBeatIndex < 0);
        }

        [Test]
        public void TestIdleBeatOnPausedClock()
        {
            double? lastBpm = null;

            AddStep("bind event", () =>
            {
                beatContainer.NewBeat = (_, timingControlPoint, _, _) => lastBpm = timingControlPoint.BPM;
            });

            AddUntilStep("wait for trigger", () => lastBpm != null);
            AddAssert("bpm is from beatmap", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 128));

            AddStep("pause gameplay clock", () =>
            {
                lastBpm = null;
                gameplayClockContainer.Stop();
            });

            AddUntilStep("wait for trigger", () => lastBpm != null);
            AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60));
        }

        [TestCase(true)]
        [TestCase(false)]
        public void TestEarlyActivationEffectPoint(bool earlyActivating)
        {
            double earlyActivationMilliseconds = earlyActivating ? 100 : 0;
            ControlPoint actualEffectPoint = null;

            AddStep($"set early activation to {earlyActivationMilliseconds}", () => beatContainer.EarlyActivationMilliseconds = earlyActivationMilliseconds);

            AddStep("seek before kiai effect point", () =>
            {
                ControlPoint expectedEffectPoint = Beatmap.Value.Beatmap.ControlPointInfo.EffectPoints.First(ep => ep.KiaiMode);
                actualEffectPoint = null;
                beatContainer.AllowMistimedEventFiring = false;

                beatContainer.NewBeat = (_, _, effectControlPoint, _) =>
                {
                    if (Precision.AlmostEquals(gameplayClockContainer.CurrentTime + earlyActivationMilliseconds, expectedEffectPoint.Time, BeatSyncedContainer.MISTIMED_ALLOWANCE))
                        actualEffectPoint = effectControlPoint;
                };

                gameplayClockContainer.Seek(expectedEffectPoint.Time - earlyActivationMilliseconds);
            });

            AddUntilStep("wait for effect point", () => actualEffectPoint != null);

            AddAssert("effect has kiai", () => actualEffectPoint != null && ((EffectControlPoint)actualEffectPoint).KiaiMode);
        }

        private class TestBeatSyncedContainer : BeatSyncedContainer
        {
            private const int flash_layer_height = 150;

            public new bool AllowMistimedEventFiring
            {
                get => base.AllowMistimedEventFiring;
                set => base.AllowMistimedEventFiring = value;
            }

            public new double EarlyActivationMilliseconds
            {
                get => base.EarlyActivationMilliseconds;
                set => base.EarlyActivationMilliseconds = value;
            }

            private readonly InfoString timingPointCount;
            private readonly InfoString currentTimingPoint;
            private readonly InfoString beatCount;
            private readonly InfoString currentBeat;
            private readonly InfoString beatsPerMinute;
            private readonly InfoString adjustedBeatLength;
            private readonly InfoString timeUntilNextBeat;
            private readonly InfoString timeSinceLastBeat;
            private readonly InfoString currentTime;

            private readonly Box flashLayer;

            public TestBeatSyncedContainer()
            {
                RelativeSizeAxes = Axes.X;
                AutoSizeAxes = Axes.Y;
                Children = new Drawable[]
                {
                    new Container
                    {
                        Name = @"Info Layer",
                        Anchor = Anchor.BottomLeft,
                        Origin = Anchor.BottomLeft,
                        AutoSizeAxes = Axes.Both,
                        Margin = new MarginPadding { Bottom = flash_layer_height },
                        Children = new Drawable[]
                        {
                            new Box
                            {
                                RelativeSizeAxes = Axes.Both,
                                Colour = Color4.Black.Opacity(150),
                            },
                            new FillFlowContainer
                            {
                                Anchor = Anchor.BottomLeft,
                                Origin = Anchor.BottomLeft,
                                AutoSizeAxes = Axes.Both,
                                Direction = FillDirection.Vertical,
                                Children = new Drawable[]
                                {
                                    currentTime = new InfoString(@"Current time"),
                                    timingPointCount = new InfoString(@"Timing points amount"),
                                    currentTimingPoint = new InfoString(@"Current timing point"),
                                    beatCount = new InfoString(@"Beats amount (in the current timing point)"),
                                    currentBeat = new InfoString(@"Current beat"),
                                    beatsPerMinute = new InfoString(@"BPM"),
                                    adjustedBeatLength = new InfoString(@"Adjusted beat length"),
                                    timeUntilNextBeat = new InfoString(@"Time until next beat"),
                                    timeSinceLastBeat = new InfoString(@"Time since last beat"),
                                }
                            }
                        }
                    },
                    new Container
                    {
                        Name = @"Color indicator",
                        Anchor = Anchor.BottomCentre,
                        Origin = Anchor.BottomCentre,
                        RelativeSizeAxes = Axes.X,
                        Height = flash_layer_height,
                        Children = new Drawable[]
                        {
                            new Box
                            {
                                RelativeSizeAxes = Axes.Both,
                                Colour = Color4.Black,
                            },
                            flashLayer = new Box
                            {
                                RelativeSizeAxes = Axes.Both,
                                Colour = Color4.White,
                                Alpha = 0,
                            }
                        }
                    }
                };
            }

            private List<TimingControlPoint> timingPoints => BeatSyncSource.ControlPoints?.TimingPoints.ToList();

            private TimingControlPoint getNextTimingPoint(TimingControlPoint current)
            {
                if (ReferenceEquals(timingPoints[^1], current))
                    return current;

                int index = timingPoints.IndexOf(current); // -1 means that this is a "default beat"

                return index == -1 ? current : timingPoints[index + 1];
            }

            private int calculateBeatCount(TimingControlPoint current)
            {
                if (timingPoints.Count == 0) return 0;

                if (ReferenceEquals(timingPoints[^1], current))
                {
                    Debug.Assert(BeatSyncSource.Clock != null);

                    return (int)Math.Ceiling((BeatSyncSource.Clock.CurrentTime - current.Time) / current.BeatLength);
                }

                return (int)Math.Ceiling((getNextTimingPoint(current).Time - current.Time) / current.BeatLength);
            }

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

                Debug.Assert(BeatSyncSource.Clock != null);

                timeUntilNextBeat.Value = TimeUntilNextBeat;
                timeSinceLastBeat.Value = TimeSinceLastBeat;
                currentTime.Value = BeatSyncSource.Clock.CurrentTime;
            }

            public Action<int, TimingControlPoint, EffectControlPoint, ChannelAmplitudes> NewBeat;

            protected override void OnNewBeat(int beatIndex, TimingControlPoint timingPoint, EffectControlPoint effectPoint, ChannelAmplitudes amplitudes)
            {
                base.OnNewBeat(beatIndex, timingPoint, effectPoint, amplitudes);

                timingPointCount.Value = timingPoints.Count;
                currentTimingPoint.Value = timingPoints.IndexOf(timingPoint);
                beatCount.Value = calculateBeatCount(timingPoint);
                currentBeat.Value = beatIndex;
                beatsPerMinute.Value = 60000 / timingPoint.BeatLength;
                adjustedBeatLength.Value = timingPoint.BeatLength;

                flashLayer.FadeOutFromOne(timingPoint.BeatLength / 4);

                NewBeat?.Invoke(beatIndex, timingPoint, effectPoint, amplitudes);
            }
        }

        private class InfoString : FillFlowContainer
        {
            private const int text_size = 20;
            private const int margin = 7;

            private readonly OsuSpriteText valueText;

            public double Value
            {
                set => valueText.Text = $"{value:0.##}";
            }

            public InfoString(string header)
            {
                AutoSizeAxes = Axes.Both;
                Direction = FillDirection.Horizontal;
                Add(new OsuSpriteText { Text = header + @": ", Font = OsuFont.GetFont(size: text_size) });
                Add(valueText = new OsuSpriteText { Font = OsuFont.GetFont(size: text_size) });
                Margin = new MarginPadding(margin);
            }
        }
    }
}