// 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.Diagnostics;
using System.IO;
using System.Linq;
using NUnit.Framework;
using osu.Game.Audio;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy;
using osu.Game.Beatmaps.Timing;
using osu.Game.IO;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Catch.Beatmaps;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Beatmaps;
using osu.Game.Rulesets.Taiko;
using osu.Game.Skinning;
using osu.Game.Tests.Resources;
using osuTK;
using osuTK.Graphics;

namespace osu.Game.Tests.Beatmaps.Formats
{
    [TestFixture]
    public class LegacyBeatmapDecoderTest
    {
        [Test]
        public void TestDecodeBeatmapVersion()
        {
            using (var resStream = TestResources.OpenResource("beatmap-version-6.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoder = Decoder.GetDecoder<Beatmap>(stream);
                var working = new TestWorkingBeatmap(decoder.Decode(stream));

                Assert.AreEqual(6, working.BeatmapInfo.BeatmapVersion);
                Assert.AreEqual(6, working.Beatmap.BeatmapInfo.BeatmapVersion);
                Assert.AreEqual(6, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty<Mod>()).BeatmapInfo.BeatmapVersion);
            }
        }

        [TestCase(false)]
        [TestCase(true)]
        public void TestPreviewPointWithOffsets(bool applyOffsets)
        {
            using (var resStream = TestResources.OpenResource("beatmap-version-4.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoder = Decoder.GetDecoder<Beatmap>(stream);
                ((LegacyBeatmapDecoder)decoder).ApplyOffsets = applyOffsets;
                var working = new TestWorkingBeatmap(decoder.Decode(stream));

                Assert.AreEqual(4, working.BeatmapInfo.BeatmapVersion);
                Assert.AreEqual(4, working.Beatmap.BeatmapInfo.BeatmapVersion);
                Assert.AreEqual(4, working.GetPlayableBeatmap(new OsuRuleset().RulesetInfo, Array.Empty<Mod>()).BeatmapInfo.BeatmapVersion);

                Assert.AreEqual(-1, working.BeatmapInfo.Metadata.PreviewTime);
            }
        }

        [Test]
        public void TestDecodeBeatmapGeneral()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var beatmapInfo = beatmap.BeatmapInfo;
                var metadata = beatmap.Metadata;

                Assert.AreEqual("03. Renatus - Soleily 192kbps.mp3", metadata.AudioFile);
                Assert.AreEqual(0, beatmap.AudioLeadIn);
                Assert.AreEqual(164471, metadata.PreviewTime);
                Assert.AreEqual(0.7f, beatmap.StackLeniency);
                Assert.IsTrue(beatmapInfo.Ruleset.OnlineID == 0);
                Assert.IsFalse(beatmap.LetterboxInBreaks);
                Assert.IsFalse(beatmap.SpecialStyle);
                Assert.IsFalse(beatmap.WidescreenStoryboard);
                Assert.IsFalse(beatmap.SamplesMatchPlaybackRate);
                Assert.AreEqual(CountdownType.None, beatmap.Countdown);
                Assert.AreEqual(0, beatmap.CountdownOffset);
            }
        }

        [Test]
        public void TestDecodeBeatmapEditor()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);

                int[] expectedBookmarks =
                {
                    11505, 22054, 32604, 43153, 53703, 64252, 74802, 85351,
                    95901, 106450, 116999, 119637, 130186, 140735, 151285,
                    161834, 164471, 175020, 185570, 196119, 206669, 209306
                };
                Assert.AreEqual(expectedBookmarks.Length, beatmap.Bookmarks.Length);
                for (int i = 0; i < expectedBookmarks.Length; i++)
                    Assert.AreEqual(expectedBookmarks[i], beatmap.Bookmarks[i]);
                Assert.AreEqual(1.8, beatmap.DistanceSpacing);
                Assert.AreEqual(4, beatmap.BeatmapInfo.BeatDivisor);
                Assert.AreEqual(4, beatmap.GridSize);
                Assert.AreEqual(2, beatmap.TimelineZoom);
            }
        }

        [Test]
        public void TestDecodeBeatmapMetadata()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var beatmapInfo = beatmap.BeatmapInfo;
                var metadata = beatmap.Metadata;

                Assert.AreEqual("Renatus", metadata.Title);
                Assert.AreEqual("Renatus", metadata.TitleUnicode);
                Assert.AreEqual("Soleily", metadata.Artist);
                Assert.AreEqual("Soleily", metadata.ArtistUnicode);
                Assert.AreEqual("Gamu", metadata.Author.Username);
                Assert.AreEqual("Insane", beatmapInfo.DifficultyName);
                Assert.AreEqual(string.Empty, metadata.Source);
                Assert.AreEqual("MBC7 Unisphere 地球ヤバイEP Chikyu Yabai", metadata.Tags);
                Assert.AreEqual(557821, beatmapInfo.OnlineID);
                Assert.AreEqual(241526, beatmapInfo.BeatmapSet?.OnlineID);
            }
        }

        [Test]
        public void TestDecodeBeatmapDifficulty()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var difficulty = decoder.Decode(stream).Difficulty;

                Assert.AreEqual(6.5f, difficulty.DrainRate);
                Assert.AreEqual(4, difficulty.CircleSize);
                Assert.AreEqual(8, difficulty.OverallDifficulty);
                Assert.AreEqual(9, difficulty.ApproachRate);
                Assert.AreEqual(1.8, difficulty.SliderMultiplier);
                Assert.AreEqual(2, difficulty.SliderTickRate);
            }
        }

        [Test]
        public void TestDecodeBeatmapEvents()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var metadata = beatmap.Metadata;
                var breakPoint = beatmap.Breaks[0];

                Assert.AreEqual("machinetop_background.jpg", metadata.BackgroundFile);
                Assert.AreEqual(122474, breakPoint.StartTime);
                Assert.AreEqual(140135, breakPoint.EndTime);
                Assert.IsTrue(breakPoint.HasEffect);
            }
        }

        [Test]
        public void TestDecodeVideoWithLowercaseExtension()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("video-with-lowercase-extension.osb"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var metadata = beatmap.Metadata;

                Assert.AreEqual("BG.jpg", metadata.BackgroundFile);
            }
        }

        [Test]
        public void TestDecodeVideoWithUppercaseExtension()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("video-with-uppercase-extension.osb"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var metadata = beatmap.Metadata;

                Assert.AreEqual("BG.jpg", metadata.BackgroundFile);
            }
        }

        [Test]
        public void TestDecodeImageSpecifiedAsVideo()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("image-specified-as-video.osb"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var metadata = beatmap.Metadata;

                Assert.AreEqual("BG.jpg", metadata.BackgroundFile);
            }
        }

        [Test]
        public void TestDecodeBeatmapTimingPoints()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                var controlPoints = (LegacyControlPointInfo)beatmap.ControlPointInfo;

                Assert.AreEqual(4, controlPoints.TimingPoints.Count);
                Assert.AreEqual(5, controlPoints.DifficultyPoints.Count);
                Assert.AreEqual(34, controlPoints.SamplePoints.Count);
                Assert.AreEqual(8, controlPoints.EffectPoints.Count);

                var timingPoint = controlPoints.TimingPointAt(0);
                Assert.AreEqual(956, timingPoint.Time);
                Assert.AreEqual(329.67032967033, timingPoint.BeatLength);
                Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature);
                Assert.IsFalse(timingPoint.OmitFirstBarLine);

                timingPoint = controlPoints.TimingPointAt(48428);
                Assert.AreEqual(956, timingPoint.Time);
                Assert.AreEqual(329.67032967033d, timingPoint.BeatLength);
                Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature);
                Assert.IsFalse(timingPoint.OmitFirstBarLine);

                timingPoint = controlPoints.TimingPointAt(119637);
                Assert.AreEqual(119637, timingPoint.Time);
                Assert.AreEqual(659.340659340659, timingPoint.BeatLength);
                Assert.AreEqual(TimeSignature.SimpleQuadruple, timingPoint.TimeSignature);
                Assert.IsFalse(timingPoint.OmitFirstBarLine);

                var difficultyPoint = controlPoints.DifficultyPointAt(0);
                Assert.AreEqual(0, difficultyPoint.Time);
                Assert.AreEqual(1.0, difficultyPoint.SliderVelocity);

                difficultyPoint = controlPoints.DifficultyPointAt(48428);
                Assert.AreEqual(0, difficultyPoint.Time);
                Assert.AreEqual(1.0, difficultyPoint.SliderVelocity);

                difficultyPoint = controlPoints.DifficultyPointAt(116999);
                Assert.AreEqual(116999, difficultyPoint.Time);
                Assert.AreEqual(0.75, difficultyPoint.SliderVelocity, 0.1);

                var soundPoint = controlPoints.SamplePointAt(0);
                Assert.AreEqual(956, soundPoint.Time);
                Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank);
                Assert.AreEqual(60, soundPoint.SampleVolume);

                soundPoint = controlPoints.SamplePointAt(53373);
                Assert.AreEqual(53373, soundPoint.Time);
                Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank);
                Assert.AreEqual(60, soundPoint.SampleVolume);

                soundPoint = controlPoints.SamplePointAt(119637);
                Assert.AreEqual(119637, soundPoint.Time);
                Assert.AreEqual(HitSampleInfo.BANK_SOFT, soundPoint.SampleBank);
                Assert.AreEqual(80, soundPoint.SampleVolume);

                var effectPoint = controlPoints.EffectPointAt(0);
                Assert.AreEqual(0, effectPoint.Time);
                Assert.IsFalse(effectPoint.KiaiMode);

                effectPoint = controlPoints.EffectPointAt(53703);
                Assert.AreEqual(53703, effectPoint.Time);
                Assert.IsTrue(effectPoint.KiaiMode);

                effectPoint = controlPoints.EffectPointAt(116637);
                Assert.AreEqual(95901, effectPoint.Time);
                Assert.IsFalse(effectPoint.KiaiMode);
            }
        }

        [Test]
        public void TestDecodeOverlappingTimingPoints()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("overlapping-control-points.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo;

                Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(4));
                Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(3));
                Assert.That(controlPoints.EffectPoints.Count, Is.EqualTo(3));
                Assert.That(controlPoints.SamplePoints.Count, Is.EqualTo(3));

                Assert.That(controlPoints.DifficultyPointAt(500).SliderVelocity, Is.EqualTo(1.5).Within(0.1));
                Assert.That(controlPoints.DifficultyPointAt(1500).SliderVelocity, Is.EqualTo(1.5).Within(0.1));
                Assert.That(controlPoints.DifficultyPointAt(2500).SliderVelocity, Is.EqualTo(0.75).Within(0.1));
                Assert.That(controlPoints.DifficultyPointAt(3500).SliderVelocity, Is.EqualTo(1.5).Within(0.1));

                Assert.That(controlPoints.EffectPointAt(500).KiaiMode, Is.True);
                Assert.That(controlPoints.EffectPointAt(1500).KiaiMode, Is.True);
                Assert.That(controlPoints.EffectPointAt(2500).KiaiMode, Is.False);
                Assert.That(controlPoints.EffectPointAt(3500).KiaiMode, Is.True);

                Assert.That(controlPoints.SamplePointAt(500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM));
                Assert.That(controlPoints.SamplePointAt(1500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM));
                Assert.That(controlPoints.SamplePointAt(2500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_NORMAL));
                Assert.That(controlPoints.SamplePointAt(3500).SampleBank, Is.EqualTo(HitSampleInfo.BANK_DRUM));

                Assert.That(controlPoints.TimingPointAt(500).BeatLength, Is.EqualTo(500).Within(0.1));
                Assert.That(controlPoints.TimingPointAt(1500).BeatLength, Is.EqualTo(500).Within(0.1));
                Assert.That(controlPoints.TimingPointAt(2500).BeatLength, Is.EqualTo(250).Within(0.1));
                Assert.That(controlPoints.TimingPointAt(3500).BeatLength, Is.EqualTo(500).Within(0.1));
            }
        }

        [Test]
        public void TestDecodeOmitBarLineEffect()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("omit-barline-control-points.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo;

                Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(6));
                Assert.That(controlPoints.EffectPoints.Count, Is.EqualTo(0));

                Assert.That(controlPoints.TimingPointAt(500).OmitFirstBarLine, Is.False);
                Assert.That(controlPoints.TimingPointAt(1500).OmitFirstBarLine, Is.True);
                Assert.That(controlPoints.TimingPointAt(2500).OmitFirstBarLine, Is.False);
                Assert.That(controlPoints.TimingPointAt(3500).OmitFirstBarLine, Is.False);
                Assert.That(controlPoints.TimingPointAt(4500).OmitFirstBarLine, Is.False);
                Assert.That(controlPoints.TimingPointAt(5500).OmitFirstBarLine, Is.True);
            }
        }

        [Test]
        public void TestTimingPointResetsSpeedMultiplier()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("timingpoint-speedmultiplier-reset.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo;

                Assert.That(controlPoints.DifficultyPointAt(0).SliderVelocity, Is.EqualTo(0.5).Within(0.1));
                Assert.That(controlPoints.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1).Within(0.1));
            }
        }

        [Test]
        public void TestDecodeBeatmapColours()
        {
            var decoder = new LegacySkinDecoder();

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var comboColors = decoder.Decode(stream).ComboColours;

                Debug.Assert(comboColors != null);

                Color4[] expectedColors =
                {
                    new Color4(142, 199, 255, 255),
                    new Color4(255, 128, 128, 255),
                    new Color4(128, 255, 255, 255),
                    new Color4(128, 255, 128, 255),
                    new Color4(255, 187, 255, 255),
                    new Color4(255, 177, 140, 255),
                    new Color4(100, 100, 100, 255), // alpha is specified as 100, but should be ignored.
                };
                Assert.AreEqual(expectedColors.Length, comboColors.Count);
                for (int i = 0; i < expectedColors.Length; i++)
                    Assert.AreEqual(expectedColors[i], comboColors[i]);
            }
        }

        [Test]
        public void TestGetLastObjectTime()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("mania-last-object-not-latest.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);

                Assert.That(beatmap.HitObjects.Last().StartTime, Is.EqualTo(2494));
                Assert.That(beatmap.HitObjects.Last().GetEndTime(), Is.EqualTo(2494));

                Assert.That(beatmap.HitObjects.Max(h => h.GetEndTime()), Is.EqualTo(2582));
                Assert.That(beatmap.GetLastObjectTime(), Is.EqualTo(2582));
            }
        }

        [Test]
        public void TestDecodeBeatmapComboOffsetsOsu()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("hitobject-combo-offset.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);

                var converted = new OsuBeatmapConverter(beatmap, new OsuRuleset()).Convert();
                new OsuBeatmapProcessor(converted).PreProcess();
                new OsuBeatmapProcessor(converted).PostProcess();

                Assert.AreEqual(1, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets);
                Assert.AreEqual(2, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets);
                Assert.AreEqual(3, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets);
                Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets);
                Assert.AreEqual(8, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets);
                Assert.AreEqual(9, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets);
            }
        }

        [Test]
        public void TestDecodeBeatmapComboOffsetsCatch()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("hitobject-combo-offset.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);

                var converted = new CatchBeatmapConverter(beatmap, new CatchRuleset()).Convert();
                new CatchBeatmapProcessor(converted).PreProcess();
                new CatchBeatmapProcessor(converted).PostProcess();

                Assert.AreEqual(1, ((IHasComboInformation)converted.HitObjects.ElementAt(0)).ComboIndexWithOffsets);
                Assert.AreEqual(2, ((IHasComboInformation)converted.HitObjects.ElementAt(2)).ComboIndexWithOffsets);
                Assert.AreEqual(3, ((IHasComboInformation)converted.HitObjects.ElementAt(4)).ComboIndexWithOffsets);
                Assert.AreEqual(4, ((IHasComboInformation)converted.HitObjects.ElementAt(6)).ComboIndexWithOffsets);
                Assert.AreEqual(8, ((IHasComboInformation)converted.HitObjects.ElementAt(8)).ComboIndexWithOffsets);
                Assert.AreEqual(9, ((IHasComboInformation)converted.HitObjects.ElementAt(11)).ComboIndexWithOffsets);
            }
        }

        [Test]
        public void TestDecodeBeatmapHitObjectCoordinatesLegacy()
        {
            var decoder = new LegacyBeatmapDecoder();

            using (var resStream = TestResources.OpenResource("hitobject-coordinates-legacy.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                var positionData = hitObjects[0] as IHasPosition;

                Assert.IsNotNull(positionData);
                Assert.AreEqual(new Vector2(256, 256), positionData!.Position);
            }
        }

        [Test]
        public void TestDecodeBeatmapHitObjectCoordinatesLazer()
        {
            var decoder = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION);

            using (var resStream = TestResources.OpenResource("hitobject-coordinates-lazer.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                var positionData = hitObjects[0] as IHasPosition;

                Assert.IsNotNull(positionData);
                Assert.AreEqual(new Vector2(256.99853f, 256.001f), positionData!.Position);
            }
        }

        [Test]
        public void TestDecodeBeatmapHitObjects()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("Soleily - Renatus (Gamu) [Insane].osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                var curveData = hitObjects[0] as IHasPathWithRepeats;
                var positionData = hitObjects[0] as IHasPosition;

                Assert.IsNotNull(positionData);
                Assert.IsNotNull(curveData);
                Assert.AreEqual(new Vector2(192, 168), positionData!.Position);
                Assert.AreEqual(956, hitObjects[0].StartTime);
                Assert.IsTrue(hitObjects[0].Samples.Any(s => s.Name == HitSampleInfo.HIT_NORMAL));

                positionData = hitObjects[1] as IHasPosition;

                Assert.IsNotNull(positionData);
                Assert.AreEqual(new Vector2(304, 56), positionData!.Position);
                Assert.AreEqual(1285, hitObjects[1].StartTime);
                Assert.IsTrue(hitObjects[1].Samples.Any(s => s.Name == HitSampleInfo.HIT_CLAP));
            }
        }

        [Test]
        public void TestDecodeControlPointDifficultyChange()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("controlpoint-difficulty-multiplier.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var controlPointInfo = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo;

                Assert.That(controlPointInfo.DifficultyPointAt(5).SliderVelocity, Is.EqualTo(1));
                Assert.That(controlPointInfo.DifficultyPointAt(1000).SliderVelocity, Is.EqualTo(10));
                Assert.That(controlPointInfo.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1.8518518518518519d));
                Assert.That(controlPointInfo.DifficultyPointAt(3000).SliderVelocity, Is.EqualTo(0.5));
            }
        }

        [Test]
        public void TestDecodeControlPointCustomSampleBank()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("controlpoint-custom-samplebank.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First());
                Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[1]).LookupNames.First());
                Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First());
                Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[3]).LookupNames.First());

                // The fourth object is a slider.
                // `Samples` of a slider are presumed to control the volume of sounds that last the entire duration of the slider
                // (such as ticks, slider slide sounds, etc.)
                // Thus, the point of query of control points used for `Samples` is just beyond the start time of the slider.
                Assert.AreEqual("Gameplay/soft-hitnormal11", getTestableSampleInfo(hitObjects[4]).LookupNames.First());

                // That said, the `NodeSamples` of the slider are responsible for the sounds of the slider's head / tail / repeats / large ticks etc.
                // Therefore, they should be read at the time instant correspondent to the given node.
                // This means that the tail should use bank 8 rather than 11.
                Assert.AreEqual("Gameplay/soft-hitnormal11", ((ConvertSlider)hitObjects[4]).NodeSamples[0][0].LookupNames.First());
                Assert.AreEqual("Gameplay/soft-hitnormal8", ((ConvertSlider)hitObjects[4]).NodeSamples[1][0].LookupNames.First());
            }

            static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0];
        }

        [Test]
        public void TestDecodeHitObjectCustomSampleBank()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("hitobject-custom-samplebank.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                Assert.AreEqual("Gameplay/normal-hitnormal", getTestableSampleInfo(hitObjects[0]).LookupNames.First());
                Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[1]).LookupNames.First());
                Assert.AreEqual("Gameplay/normal-hitnormal3", getTestableSampleInfo(hitObjects[2]).LookupNames.First());
            }

            static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0];
        }

        [Test]
        public void TestDecodeHitObjectFileSamples()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("hitobject-file-samples.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                Assert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[0]).LookupNames.First());
                Assert.AreEqual("hit_2.wav", getTestableSampleInfo(hitObjects[1]).LookupNames.First());
                Assert.AreEqual("Gameplay/normal-hitnormal2", getTestableSampleInfo(hitObjects[2]).LookupNames.First());
                Assert.AreEqual("hit_1.wav", getTestableSampleInfo(hitObjects[3]).LookupNames.First());
                Assert.AreEqual(70, getTestableSampleInfo(hitObjects[3]).Volume);
            }

            static HitSampleInfo getTestableSampleInfo(HitObject hitObject) => hitObject.Samples[0];
        }

        [Test]
        public void TestDecodeSliderSamples()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("slider-samples.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                var slider1 = (ConvertSlider)hitObjects[0];

                Assert.AreEqual(1, slider1.NodeSamples[0].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[0][0].Name);
                Assert.AreEqual(1, slider1.NodeSamples[1].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[1][0].Name);
                Assert.AreEqual(1, slider1.NodeSamples[2].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider1.NodeSamples[2][0].Name);

                var slider2 = (ConvertSlider)hitObjects[1];

                Assert.AreEqual(2, slider2.NodeSamples[0].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[0][0].Name);
                Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[0][1].Name);
                Assert.AreEqual(2, slider2.NodeSamples[1].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[1][0].Name);
                Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[1][1].Name);
                Assert.AreEqual(2, slider2.NodeSamples[2].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider2.NodeSamples[2][0].Name);
                Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider2.NodeSamples[2][1].Name);

                var slider3 = (ConvertSlider)hitObjects[2];

                Assert.AreEqual(2, slider3.NodeSamples[0].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[0][0].Name);
                Assert.AreEqual(HitSampleInfo.HIT_WHISTLE, slider3.NodeSamples[0][1].Name);
                Assert.AreEqual(1, slider3.NodeSamples[1].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[1][0].Name);
                Assert.AreEqual(2, slider3.NodeSamples[2].Count);
                Assert.AreEqual(HitSampleInfo.HIT_NORMAL, slider3.NodeSamples[2][0].Name);
                Assert.AreEqual(HitSampleInfo.HIT_CLAP, slider3.NodeSamples[2][1].Name);
            }
        }

        [Test]
        public void TestDecodeHitObjectNullAdditionBank()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("hitobject-no-addition-bank.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                Assert.AreEqual(hitObjects[0].Samples[0].Bank, hitObjects[0].Samples[1].Bank);
            }
        }

        [Test]
        public void TestInvalidEventStillPasses()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var badResStream = TestResources.OpenResource("invalid-events.osu"))
            using (var badStream = new LineBufferedReader(badResStream))
            {
                Assert.DoesNotThrow(() => decoder.Decode(badStream));
            }
        }

        [Test]
        public void TestInvalidBankDefaultsToNormal()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("invalid-bank.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObjects = decoder.Decode(stream).HitObjects;

                assertObjectHasBanks(hitObjects[0], HitSampleInfo.BANK_DRUM);
                assertObjectHasBanks(hitObjects[1], HitSampleInfo.BANK_NORMAL);
                assertObjectHasBanks(hitObjects[2], HitSampleInfo.BANK_SOFT);
                assertObjectHasBanks(hitObjects[3], HitSampleInfo.BANK_DRUM);
                assertObjectHasBanks(hitObjects[4], HitSampleInfo.BANK_NORMAL);

                assertObjectHasBanks(hitObjects[5], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_DRUM);
                assertObjectHasBanks(hitObjects[6], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_NORMAL);
                assertObjectHasBanks(hitObjects[7], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_SOFT);
                assertObjectHasBanks(hitObjects[8], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_DRUM);
                assertObjectHasBanks(hitObjects[9], HitSampleInfo.BANK_DRUM, HitSampleInfo.BANK_NORMAL);
            }

            static void assertObjectHasBanks(HitObject hitObject, string normalBank, string? additionsBank = null)
            {
                Assert.AreEqual(normalBank, hitObject.Samples[0].Bank);

                if (additionsBank != null)
                    Assert.AreEqual(additionsBank, hitObject.Samples[1].Bank);
            }
        }

        [Test]
        public void TestFallbackDecoderForCorruptedHeader()
        {
            Decoder<Beatmap> decoder = null!;
            Beatmap beatmap = null!;

            using (var resStream = TestResources.OpenResource("corrupted-header.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyBeatmapDecoder>(decoder);
                Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream));
                Assert.IsNotNull(beatmap);
                Assert.AreEqual("Beatmap with corrupted header", beatmap.Metadata.Title);
                Assert.AreEqual("Evil Hacker", beatmap.Metadata.Author.Username);
            }
        }

        [Test]
        public void TestFallbackDecoderForMissingHeader()
        {
            Decoder<Beatmap> decoder = null!;
            Beatmap beatmap = null!;

            using (var resStream = TestResources.OpenResource("missing-header.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyBeatmapDecoder>(decoder);
                Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream));
                Assert.IsNotNull(beatmap);
                Assert.AreEqual("Beatmap with no header", beatmap.Metadata.Title);
                Assert.AreEqual("Incredibly Evil Hacker", beatmap.Metadata.Author.Username);
            }
        }

        [Test]
        public void TestDecodeFileWithEmptyLinesAtStart()
        {
            Decoder<Beatmap> decoder = null!;
            Beatmap beatmap = null!;

            using (var resStream = TestResources.OpenResource("empty-lines-at-start.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyBeatmapDecoder>(decoder);
                Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream));
                Assert.IsNotNull(beatmap);
                Assert.AreEqual("Empty lines at start", beatmap.Metadata.Title);
                Assert.AreEqual("Edge Case Hunter", beatmap.Metadata.Author.Username);
            }
        }

        [Test]
        public void TestDecodeFileWithEmptyLinesAndNoHeader()
        {
            Decoder<Beatmap> decoder = null!;
            Beatmap beatmap = null!;

            using (var resStream = TestResources.OpenResource("empty-line-instead-of-header.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyBeatmapDecoder>(decoder);
                Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream));
                Assert.IsNotNull(beatmap);
                Assert.AreEqual("The dog ate the file header", beatmap.Metadata.Title);
                Assert.AreEqual("Why does this keep happening", beatmap.Metadata.Author.Username);
            }
        }

        [Test]
        public void TestDecodeFileWithContentImmediatelyAfterHeader()
        {
            Decoder<Beatmap> decoder = null!;
            Beatmap beatmap = null!;

            using (var resStream = TestResources.OpenResource("no-empty-line-after-header.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyBeatmapDecoder>(decoder);
                Assert.DoesNotThrow(() => beatmap = decoder.Decode(stream));
                Assert.IsNotNull(beatmap);
                Assert.AreEqual("No empty line delimiting header from contents", beatmap.Metadata.Title);
                Assert.AreEqual("Edge Case Hunter", beatmap.Metadata.Author.Username);
            }
        }

        [Test]
        public void TestDecodeEmptyFile()
        {
            using (var resStream = new MemoryStream())
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.Throws<IOException>(() => Decoder.GetDecoder<Beatmap>(stream));
            }
        }

        [Test]
        public void TestAllowFallbackDecoderOverwrite()
        {
            Decoder<Beatmap> decoder = null!;

            using (var resStream = TestResources.OpenResource("corrupted-header.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyBeatmapDecoder>(decoder);
            }

            Assert.DoesNotThrow(LegacyDifficultyCalculatorBeatmapDecoder.Register);

            using (var resStream = TestResources.OpenResource("corrupted-header.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Assert.DoesNotThrow(() => decoder = Decoder.GetDecoder<Beatmap>(stream));
                Assert.IsInstanceOf<LegacyDifficultyCalculatorBeatmapDecoder>(decoder);
            }
        }

        [Test]
        public void TestMultiSegmentSliders()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("multi-segment-slider.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);

                // Multi-segment
                var first = ((IHasPath)decoded.HitObjects[0]).Path;

                Assert.That(first.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(first.ControlPoints[0].Type, Is.EqualTo(PathType.PERFECT_CURVE));
                Assert.That(first.ControlPoints[1].Position, Is.EqualTo(new Vector2(161, -244)));
                Assert.That(first.ControlPoints[1].Type, Is.EqualTo(null));

                // ReSharper disable once HeuristicUnreachableCode
                // weird one, see https://youtrack.jetbrains.com/issue/RIDER-70159.
                Assert.That(first.ControlPoints[2].Position, Is.EqualTo(new Vector2(376, -3)));
                Assert.That(first.ControlPoints[2].Type, Is.EqualTo(PathType.BEZIER));
                Assert.That(first.ControlPoints[3].Position, Is.EqualTo(new Vector2(68, 15)));
                Assert.That(first.ControlPoints[3].Type, Is.EqualTo(null));
                Assert.That(first.ControlPoints[4].Position, Is.EqualTo(new Vector2(259, -132)));
                Assert.That(first.ControlPoints[4].Type, Is.EqualTo(null));
                Assert.That(first.ControlPoints[5].Position, Is.EqualTo(new Vector2(92, -107)));
                Assert.That(first.ControlPoints[5].Type, Is.EqualTo(null));

                // Single-segment
                var second = ((IHasPath)decoded.HitObjects[1]).Path;

                Assert.That(second.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(second.ControlPoints[0].Type, Is.EqualTo(PathType.PERFECT_CURVE));
                Assert.That(second.ControlPoints[1].Position, Is.EqualTo(new Vector2(161, -244)));
                Assert.That(second.ControlPoints[1].Type, Is.EqualTo(null));
                Assert.That(second.ControlPoints[2].Position, Is.EqualTo(new Vector2(376, -3)));
                Assert.That(second.ControlPoints[2].Type, Is.EqualTo(null));

                // Implicit multi-segment
                var third = ((IHasPath)decoded.HitObjects[2]).Path;

                Assert.That(third.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(third.ControlPoints[0].Type, Is.EqualTo(PathType.BEZIER));
                Assert.That(third.ControlPoints[1].Position, Is.EqualTo(new Vector2(0, 192)));
                Assert.That(third.ControlPoints[1].Type, Is.EqualTo(null));
                Assert.That(third.ControlPoints[2].Position, Is.EqualTo(new Vector2(224, 192)));
                Assert.That(third.ControlPoints[2].Type, Is.EqualTo(null));

                Assert.That(third.ControlPoints[3].Position, Is.EqualTo(new Vector2(224, 0)));
                Assert.That(third.ControlPoints[3].Type, Is.EqualTo(PathType.BEZIER));
                Assert.That(third.ControlPoints[4].Position, Is.EqualTo(new Vector2(224, -192)));
                Assert.That(third.ControlPoints[4].Type, Is.EqualTo(null));
                Assert.That(third.ControlPoints[5].Position, Is.EqualTo(new Vector2(480, -192)));
                Assert.That(third.ControlPoints[5].Type, Is.EqualTo(null));
                Assert.That(third.ControlPoints[6].Position, Is.EqualTo(new Vector2(480, 0)));
                Assert.That(third.ControlPoints[6].Type, Is.EqualTo(null));

                // Last control point duplicated
                var fourth = ((IHasPath)decoded.HitObjects[3]).Path;

                Assert.That(fourth.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(fourth.ControlPoints[0].Type, Is.EqualTo(PathType.BEZIER));
                Assert.That(fourth.ControlPoints[1].Position, Is.EqualTo(new Vector2(1, 1)));
                Assert.That(fourth.ControlPoints[1].Type, Is.EqualTo(null));
                Assert.That(fourth.ControlPoints[2].Position, Is.EqualTo(new Vector2(2, 2)));
                Assert.That(fourth.ControlPoints[2].Type, Is.EqualTo(null));
                Assert.That(fourth.ControlPoints[3].Position, Is.EqualTo(new Vector2(3, 3)));
                Assert.That(fourth.ControlPoints[3].Type, Is.EqualTo(null));
                Assert.That(fourth.ControlPoints[4].Position, Is.EqualTo(new Vector2(3, 3)));
                Assert.That(fourth.ControlPoints[4].Type, Is.EqualTo(null));

                // Last control point in segment duplicated
                var fifth = ((IHasPath)decoded.HitObjects[4]).Path;

                Assert.That(fifth.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(fifth.ControlPoints[0].Type, Is.EqualTo(PathType.BEZIER));
                Assert.That(fifth.ControlPoints[1].Position, Is.EqualTo(new Vector2(1, 1)));
                Assert.That(fifth.ControlPoints[1].Type, Is.EqualTo(null));
                Assert.That(fifth.ControlPoints[2].Position, Is.EqualTo(new Vector2(2, 2)));
                Assert.That(fifth.ControlPoints[2].Type, Is.EqualTo(null));
                Assert.That(fifth.ControlPoints[3].Position, Is.EqualTo(new Vector2(3, 3)));
                Assert.That(fifth.ControlPoints[3].Type, Is.EqualTo(null));
                Assert.That(fifth.ControlPoints[4].Position, Is.EqualTo(new Vector2(3, 3)));
                Assert.That(fifth.ControlPoints[4].Type, Is.EqualTo(null));

                Assert.That(fifth.ControlPoints[5].Position, Is.EqualTo(new Vector2(4, 4)));
                Assert.That(fifth.ControlPoints[5].Type, Is.EqualTo(PathType.BEZIER));
                Assert.That(fifth.ControlPoints[6].Position, Is.EqualTo(new Vector2(5, 5)));
                Assert.That(fifth.ControlPoints[6].Type, Is.EqualTo(null));

                // Implicit perfect-curve multi-segment(Should convert to bezier to match stable)
                var sixth = ((IHasPath)decoded.HitObjects[5]).Path;

                Assert.That(sixth.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(sixth.ControlPoints[0].Type == PathType.BEZIER);
                Assert.That(sixth.ControlPoints[1].Position, Is.EqualTo(new Vector2(75, 145)));
                Assert.That(sixth.ControlPoints[1].Type == null);
                Assert.That(sixth.ControlPoints[2].Position, Is.EqualTo(new Vector2(170, 75)));

                Assert.That(sixth.ControlPoints[2].Type == PathType.BEZIER);
                Assert.That(sixth.ControlPoints[3].Position, Is.EqualTo(new Vector2(300, 145)));
                Assert.That(sixth.ControlPoints[3].Type == null);
                Assert.That(sixth.ControlPoints[4].Position, Is.EqualTo(new Vector2(410, 20)));
                Assert.That(sixth.ControlPoints[4].Type == null);

                // Explicit perfect-curve multi-segment(Should not convert to bezier)
                var seventh = ((IHasPath)decoded.HitObjects[6]).Path;

                Assert.That(seventh.ControlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(seventh.ControlPoints[0].Type == PathType.PERFECT_CURVE);
                Assert.That(seventh.ControlPoints[1].Position, Is.EqualTo(new Vector2(75, 145)));
                Assert.That(seventh.ControlPoints[1].Type == null);
                Assert.That(seventh.ControlPoints[2].Position, Is.EqualTo(new Vector2(170, 75)));

                Assert.That(seventh.ControlPoints[2].Type == PathType.PERFECT_CURVE);
                Assert.That(seventh.ControlPoints[3].Position, Is.EqualTo(new Vector2(300, 145)));
                Assert.That(seventh.ControlPoints[3].Type == null);
                Assert.That(seventh.ControlPoints[4].Position, Is.EqualTo(new Vector2(410, 20)));
                Assert.That(seventh.ControlPoints[4].Type == null);
            }
        }

        [Test]
        public void TestSliderLengthExtensionEdgeCase()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("duplicate-last-position-slider.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);

                var path = ((IHasPath)decoded.HitObjects[0]).Path;

                Assert.That(path.ExpectedDistance.Value, Is.EqualTo(2));
                Assert.That(path.Distance, Is.EqualTo(1));
            }
        }

        [TestCase(false)]
        [TestCase(true)]
        public void TestLegacyDefaultsPreserved(bool applyOffsets)
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = applyOffsets };

            using (var memoryStream = new MemoryStream())
            using (var stream = new LineBufferedReader(memoryStream))
            {
                var decoded = decoder.Decode(stream);

                Assert.Multiple(() =>
                {
                    Assert.That(decoded.AudioLeadIn, Is.EqualTo(0));
                    Assert.That(decoded.StackLeniency, Is.EqualTo(0.7f));
                    Assert.That(decoded.SpecialStyle, Is.False);
                    Assert.That(decoded.LetterboxInBreaks, Is.False);
                    Assert.That(decoded.WidescreenStoryboard, Is.False);
                    Assert.That(decoded.EpilepsyWarning, Is.False);
                    Assert.That(decoded.SamplesMatchPlaybackRate, Is.False);
                    Assert.That(decoded.Countdown, Is.EqualTo(CountdownType.None));
                    Assert.That(decoded.CountdownOffset, Is.EqualTo(0));
                    Assert.That(decoded.BeatmapInfo.Metadata.PreviewTime, Is.EqualTo(-1));
                    Assert.That(decoded.BeatmapInfo.Ruleset.OnlineID, Is.EqualTo(0));
                });
            }
        }

        [Test]
        public void TestUndefinedApproachRateInheritsOverallDifficulty()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("undefined-approach-rate.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);
                Assert.That(decoded.Difficulty.ApproachRate, Is.EqualTo(1));
                Assert.That(decoded.Difficulty.OverallDifficulty, Is.EqualTo(1));
            }
        }

        [Test]
        public void TestApproachRateDefinedBeforeOverallDifficulty()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("approach-rate-before-overall-difficulty.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);
                Assert.That(decoded.Difficulty.ApproachRate, Is.EqualTo(9));
                Assert.That(decoded.Difficulty.OverallDifficulty, Is.EqualTo(1));
            }
        }

        [Test]
        public void TestApproachRateDefinedAfterOverallDifficulty()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("approach-rate-after-overall-difficulty.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);
                Assert.That(decoded.Difficulty.ApproachRate, Is.EqualTo(9));
                Assert.That(decoded.Difficulty.OverallDifficulty, Is.EqualTo(1));
            }
        }

        [Test]
        public void TestLegacyAdjacentImplicitCatmullSegmentsAreMerged()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("adjacent-catmull-segments.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);
                var controlPoints = ((IHasPath)decoded.HitObjects[0]).Path.ControlPoints;

                Assert.That(controlPoints.Count, Is.EqualTo(6));
                Assert.That(controlPoints.Single(c => c.Type != null).Type, Is.EqualTo(PathType.CATMULL));
            }
        }

        [Test]
        public void TestNonLegacyAdjacentImplicitCatmullSegmentsAreNotMerged()
        {
            var decoder = new LegacyBeatmapDecoder(LegacyBeatmapEncoder.FIRST_LAZER_VERSION) { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("adjacent-catmull-segments.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);
                var controlPoints = ((IHasPath)decoded.HitObjects[0]).Path.ControlPoints;

                Assert.That(controlPoints.Count, Is.EqualTo(4));
                Assert.That(controlPoints[0].Type, Is.EqualTo(PathType.CATMULL));
                Assert.That(controlPoints[1].Type, Is.EqualTo(PathType.CATMULL));
                Assert.That(controlPoints[2].Type, Is.EqualTo(PathType.CATMULL));
                Assert.That(controlPoints[3].Type, Is.Null);
            }
        }

        [Test]
        public void TestLegacyDuplicateInitialCatmullPointIsMerged()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("catmull-duplicate-initial-controlpoint.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream);
                var controlPoints = ((IHasPath)decoded.HitObjects[0]).Path.ControlPoints;

                Assert.That(controlPoints.Count, Is.EqualTo(4));
                Assert.That(controlPoints[0].Type, Is.EqualTo(PathType.CATMULL));
                Assert.That(controlPoints[0].Position, Is.EqualTo(Vector2.Zero));
                Assert.That(controlPoints[1].Type, Is.Null);
                Assert.That(controlPoints[1].Position, Is.Not.EqualTo(Vector2.Zero));
            }
        }

        [Test]
        public void TestNaNControlPoints()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("nan-control-points.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var controlPoints = (LegacyControlPointInfo)decoder.Decode(stream).ControlPointInfo;

                Assert.That(controlPoints.TimingPoints.Count, Is.EqualTo(1));
                Assert.That(controlPoints.DifficultyPoints.Count, Is.EqualTo(2));

                Assert.That(controlPoints.TimingPointAt(1000).BeatLength, Is.EqualTo(500));

                Assert.That(controlPoints.DifficultyPointAt(2000).SliderVelocity, Is.EqualTo(1));
                Assert.That(controlPoints.DifficultyPointAt(3000).SliderVelocity, Is.EqualTo(1));

                Assert.That(controlPoints.DifficultyPointAt(2000).GenerateTicks, Is.False);
                Assert.That(controlPoints.DifficultyPointAt(3000).GenerateTicks, Is.True);
            }
        }

        [Test]
        public void TestSamplePointLeniency()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("sample-point-leniency.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var hitObject = decoder.Decode(stream).HitObjects.Single();
                Assert.That(hitObject.Samples.Select(s => s.Volume), Has.All.EqualTo(70));
            }
        }

        [Test]
        public void TestNewComboAfterBreak()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("break-between-objects.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var beatmap = decoder.Decode(stream);
                Assert.That(((IHasCombo)beatmap.HitObjects[0]).NewCombo, Is.True);
                Assert.That(((IHasCombo)beatmap.HitObjects[1]).NewCombo, Is.True);
                Assert.That(((IHasCombo)beatmap.HitObjects[2]).NewCombo, Is.False);
            }
        }

        /// <summary>
        /// Test cases that involve a spinner between two hitobjects.
        /// </summary>
        [Test]
        public void TestSpinnerNewComboBetweenObjects([Values("osu", "catch")] string rulesetName)
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("spinner-between-objects.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Ruleset ruleset;

                switch (rulesetName)
                {
                    case "osu":
                        ruleset = new OsuRuleset();
                        break;

                    case "catch":
                        ruleset = new CatchRuleset();
                        break;

                    default:
                        throw new ArgumentOutOfRangeException(nameof(rulesetName), rulesetName, null);
                }

                var working = new TestWorkingBeatmap(decoder.Decode(stream));
                var playable = working.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty<Mod>());

                // There's no good way to figure out these values other than to compare (in code) with osu!stable...

                Assert.That(((IHasComboInformation)playable.HitObjects[0]).ComboIndexWithOffsets, Is.EqualTo(1));
                Assert.That(((IHasComboInformation)playable.HitObjects[2]).ComboIndexWithOffsets, Is.EqualTo(2));
                Assert.That(((IHasComboInformation)playable.HitObjects[3]).ComboIndexWithOffsets, Is.EqualTo(2));
                Assert.That(((IHasComboInformation)playable.HitObjects[5]).ComboIndexWithOffsets, Is.EqualTo(3));
                Assert.That(((IHasComboInformation)playable.HitObjects[6]).ComboIndexWithOffsets, Is.EqualTo(3));
                Assert.That(((IHasComboInformation)playable.HitObjects[8]).ComboIndexWithOffsets, Is.EqualTo(4));
                Assert.That(((IHasComboInformation)playable.HitObjects[9]).ComboIndexWithOffsets, Is.EqualTo(4));
                Assert.That(((IHasComboInformation)playable.HitObjects[11]).ComboIndexWithOffsets, Is.EqualTo(5));
                Assert.That(((IHasComboInformation)playable.HitObjects[12]).ComboIndexWithOffsets, Is.EqualTo(6));
                Assert.That(((IHasComboInformation)playable.HitObjects[14]).ComboIndexWithOffsets, Is.EqualTo(7));
                Assert.That(((IHasComboInformation)playable.HitObjects[15]).ComboIndexWithOffsets, Is.EqualTo(8));
                Assert.That(((IHasComboInformation)playable.HitObjects[17]).ComboIndexWithOffsets, Is.EqualTo(9));
            }
        }

        [Test]
        public void TestSliderConversionWithCustomDistance([Values("taiko", "mania")] string rulesetName)
        {
            using (var resStream = TestResources.OpenResource("custom-slider-length.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                Ruleset ruleset;

                switch (rulesetName)
                {
                    case "taiko":
                        ruleset = new TaikoRuleset();
                        break;

                    case "mania":
                        ruleset = new ManiaRuleset();
                        break;

                    default:
                        throw new ArgumentOutOfRangeException(nameof(rulesetName), rulesetName, null);
                }

                var decoder = Decoder.GetDecoder<Beatmap>(stream);
                var working = new TestWorkingBeatmap(decoder.Decode(stream));
                IBeatmap beatmap = working.GetPlayableBeatmap(ruleset.RulesetInfo, Array.Empty<Mod>());

                Assert.That(beatmap.HitObjects[0].GetEndTime(), Is.EqualTo(3153));
            }
        }

        [Test]
        public void TestBeatmapDifficultyIsClamped()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("out-of-range-difficulties.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream).Difficulty;
                Assert.That(decoded.DrainRate, Is.EqualTo(10));
                Assert.That(decoded.CircleSize, Is.EqualTo(10));
                Assert.That(decoded.OverallDifficulty, Is.EqualTo(10));
                Assert.That(decoded.ApproachRate, Is.EqualTo(10));
                Assert.That(decoded.SliderMultiplier, Is.EqualTo(3.6));
                Assert.That(decoded.SliderTickRate, Is.EqualTo(8));
            }
        }

        [Test]
        public void TestManiaBeatmapDifficultyCircleSizeClamp()
        {
            var decoder = new LegacyBeatmapDecoder { ApplyOffsets = false };

            using (var resStream = TestResources.OpenResource("out-of-range-difficulties-mania.osu"))
            using (var stream = new LineBufferedReader(resStream))
            {
                var decoded = decoder.Decode(stream).Difficulty;
                Assert.That(decoded.CircleSize, Is.EqualTo(14));
            }
        }
    }
}
