/*
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
 * Copyright (c) 2017-2019 Swan & The Quaver Team <support@quavergame.com>.
*/

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
//using MonoGame.Extended.Collections;
using Quaver.API.Enums;
//using Quaver.API.Helpers;
//using Quaver.API.Maps.Parsers;
//using Quaver.API.Maps.Processors.Difficulty;
//using Quaver.API.Maps.Processors.Difficulty.Rulesets.Keys;
//using Quaver.API.Maps.Processors.Scoring;
using Quaver.API.Maps.Structures;
using YamlDotNet.Serialization;

namespace Quaver.API.Maps
{
    [Serializable]
    public class Qua
    {
        /// <summary>
        ///     The name of the audio file
        /// </summary>
        public string AudioFile { get; set; }

        /// <summary>
        ///     Time in milliseconds of the song where the preview starts
        /// </summary>
        public int SongPreviewTime { get; set; }

        /// <summary>
        ///     The name of the background file
        /// </summary>
        public string BackgroundFile { get; set; }

        /// <summary>
        ///     The name of the mapset banner
        /// </summary>
        public string BannerFile { get; set; }

        /// <summary>
        ///     The unique Map Identifier (-1 if not submitted)
        /// </summary>
        public int MapId { get; set; } = -1;

        /// <summary>
        ///     The unique Map Set identifier (-1 if not submitted)
        /// </summary>
        public int MapSetId { get; set; } = -1;

        /// <summary>
        ///     The game mode for this map
        /// </summary>
        public GameMode Mode { get; set; }

        /// <summary>
        ///     The title of the song
        /// </summary>
        public string Title { get; set; }

        /// <summary>
        ///     The artist of the song
        /// </summary>
        public string Artist { get; set; }

        /// <summary>
        ///     The source of the song (album, mixtape, etc.)
        /// </summary>
        public string Source { get; set; }

        /// <summary>
        ///     Any tags that could be used to help find the song.
        /// </summary>
        public string Tags { get; set; }

        /// <summary>
        ///     The creator of the map
        /// </summary>
        public string Creator { get; set; }

        /// <summary>
        ///     The difficulty name of the map.
        /// </summary>
        public string DifficultyName { get; set; }

        /// <summary>
        ///     A description about this map.
        /// </summary>
        public string Description { get; set; }

        /// <summary>
        ///     The genre of the song
        /// </summary>
        public string Genre { get; set; }

        /// <summary>
        ///     Indicates if the BPM changes in affect scroll velocity.
        ///
        ///     If this is set to false, SliderVelocities are in the denormalized format (BPM affects SV),
        ///     and if this is set to true, SliderVelocities are in the normalized format (BPM does not affect SV).
        ///
        ///     Use NormalizeSVs and DenormalizeSVs to change this value.
        ///
        ///     It's "does not affect" rather than "affects" so that the "affects" value (in this case, false) serializes to nothing to support old maps.
        /// </summary>
        public bool BPMDoesNotAffectScrollVelocity { get; set; }

        /// <summary>
        ///    The initial scroll velocity before the first SV change.
        ///
        ///    Only matters if BPMDoesNotAffectScrollVelocity is true.
        /// </summary>
        public float InitialScrollVelocity { get; set; }

        /// <summary>
        ///     If true, the map will have a +1 scratch key, allowing for 5/8 key play
        /// </summary>
        public bool HasScratchKey { get; set; }

        /// <summary>
        ///     EditorLayer .qua data
        /// </summary>
        public List<EditorLayerInfo> EditorLayers { get; private set; } = new List<EditorLayerInfo>();

        /// <summary>
        ///     CustomAudioSamples .qua data
        /// </summary>
        public List<CustomAudioSampleInfo> CustomAudioSamples { get; set; } = new List<CustomAudioSampleInfo>();

        /// <summary>
        ///     SoundEffects .qua data
        /// </summary>
        public List<SoundEffectInfo> SoundEffects { get; private set; } = new List<SoundEffectInfo>();

        /// <summary>
        ///     TimingPoint .qua data
        /// </summary>
        public List<TimingPointInfo> TimingPoints { get; private set; } = new List<TimingPointInfo>();

        /// <summary>
        ///     Slider Velocity .qua data
        ///
        ///     Note that SVs can be both in normalized and denormalized form, depending on BPMDoesNotAffectSV.
        ///     Check WithNormalizedSVs if you need normalized SVs.
        /// </summary>
        public List<SliderVelocityInfo> SliderVelocities { get; private set; } = new List<SliderVelocityInfo>();

        /// <summary>
        ///     HitObject .qua data
        /// </summary>
        public List<HitObjectInfo> HitObjects { get; private set; } = new List<HitObjectInfo>();

        /// <summary>
        ///     Finds the length of the map
        /// </summary>
        /// <returns></returns>
        [YamlIgnore]
        public int Length => HitObjects.Count == 0 ? 0 : HitObjects.Max(x => Math.Max(x.StartTime, x.EndTime));

        /// <summary>
        ///     Integer based seed used for shuffling the lanes when randomize mod is active.
        ///     Defaults to -1 if there is no seed.
        /// </summary>
        [YamlIgnore]
        public int RandomizeModifierSeed { get; set; } = -1;

        /// <summary>
        ///     The path of the .qua file if it is being parsed from one.
        /// </summary>
        [YamlIgnore]
        private string FilePath { get; set; }

        /// <summary>
        ///     Ctor
        /// </summary>
        public Qua() {}

        /// <summary>
        ///     Returns true if the two maps are equal by value.
        /// </summary>
        /// <param name="other">the Qua to compare to</param>
        /// <returns></returns>
        public bool EqualByValue(Qua other)
        {
            return AudioFile == other.AudioFile
                   && SongPreviewTime == other.SongPreviewTime
                   && BackgroundFile == other.BackgroundFile
                   && BannerFile == other.BannerFile
                   && MapId == other.MapId
                   && MapSetId == other.MapSetId
                   && Mode == other.Mode
                   && Title == other.Title
                   && Artist == other.Artist
                   && Source == other.Source
                   && Tags == other.Tags
                   && Creator == other.Creator
                   && DifficultyName == other.DifficultyName
                   && Description == other.Description
                   && Genre == other.Genre
                   && TimingPoints.SequenceEqual(other.TimingPoints, TimingPointInfo.ByValueComparer)
                   && SliderVelocities.SequenceEqual(other.SliderVelocities, SliderVelocityInfo.ByValueComparer)
                   // ReSharper disable once CompareOfFloatsByEqualityOperator
                   && InitialScrollVelocity == other.InitialScrollVelocity
                   && BPMDoesNotAffectScrollVelocity == other.BPMDoesNotAffectScrollVelocity
                   && HasScratchKey == other.HasScratchKey
                   && HitObjects.SequenceEqual(other.HitObjects, HitObjectInfo.ByValueComparer)
                   && CustomAudioSamples.SequenceEqual(other.CustomAudioSamples, CustomAudioSampleInfo.ByValueComparer)
                   && SoundEffects.SequenceEqual(other.SoundEffects, SoundEffectInfo.ByValueComparer)
                   && EditorLayers.SequenceEqual(other.EditorLayers, EditorLayerInfo.ByValueComparer)
                   && RandomizeModifierSeed == other.RandomizeModifierSeed;
        }

        /// <summary>
        ///     Loads a .qua file from a stream
        /// </summary>
        /// <param name="buffer"></param>
        /// <param name="checkValidity"></param>
        /// <returns></returns>
        public static Qua Parse(byte[] buffer, bool checkValidity = true)
        {
            var input = new StringReader(Encoding.UTF8.GetString(buffer, 0, buffer.Length));

            var deserializer = new DeserializerBuilder();
            deserializer.IgnoreUnmatchedProperties();
            var qua = (Qua)deserializer.Build().Deserialize(input, typeof(Qua));

            RestoreDefaultValues(qua);
            AfterLoad(qua, checkValidity);

            return qua;
        }

        /// <summary>
        ///     Takes in a path to a .qua file and attempts to parse it.
        ///     Will throw an error if unable to be parsed.
        /// </summary>
        /// <param name="path"></param>
        /// <param name="checkValidity"></param>
        public static Qua Parse(string path, bool checkValidity = true)
        {
            Qua qua;

            using (var file = File.OpenText(path))
            {
                var deserializer = new DeserializerBuilder();
                deserializer.IgnoreUnmatchedProperties();
                qua = (Qua)deserializer.Build().Deserialize(file, typeof(Qua));
                qua.FilePath = path;

                RestoreDefaultValues(qua);
            }

            AfterLoad(qua, checkValidity);

            return qua;
        }

        /// <summary>
        ///     Serializes the Qua object and returns a string of it
        /// </summary>
        /// <returns></returns>
        public string Serialize()
        {
            // Sort the object before saving.
            Sort();

            // Set default values to zero so they don't waste space in the .qua file.
            var originalTimingPoints = TimingPoints;
            var originalHitObjects = HitObjects;
            var originalSoundEffects = SoundEffects;

            TimingPoints = new List<TimingPointInfo>();
            foreach (var tp in originalTimingPoints)
            {
                if (tp.Signature == TimeSignature.Quadruple)
                {
                    TimingPoints.Add(new TimingPointInfo()
                    {
                        Bpm = tp.Bpm,
                        Signature = 0,
                        StartTime = tp.StartTime,
                        Hidden = tp.Hidden
                    });
                }
                else
                {
                    TimingPoints.Add(tp);
                }
            }

            HitObjects = new List<HitObjectInfo>();
            foreach (var obj in originalHitObjects)
            {
                var keySoundsWithDefaults = new List<KeySoundInfo>();
                foreach (var keySound in obj.KeySounds)
                {
                    keySoundsWithDefaults.Add(new KeySoundInfo
                    {
                        Sample = keySound.Sample,
                        Volume = keySound.Volume == 100 ? 0 : keySound.Volume
                    });
                }

                HitObjects.Add(new HitObjectInfo()
                {
                    EndTime = obj.EndTime,
                    HitSound = obj.HitSound == HitSounds.Normal ? 0 : obj.HitSound,
                    KeySounds = keySoundsWithDefaults,
                    Lane = obj.Lane,
                    StartTime = obj.StartTime,
                    EditorLayer = obj.EditorLayer
                });
            }

            SoundEffects = new List<SoundEffectInfo>();
            foreach (var info in originalSoundEffects)
            {
                if (info.Volume == 100)
                {
                    SoundEffects.Add(new SoundEffectInfo()
                    {
                        StartTime = info.StartTime,
                        Sample = info.Sample,
                        Volume = 0
                    });
                }
                else
                {
                    SoundEffects.Add(info);
                }
            }

            var serializer = new Serializer();
            var stringWriter = new StringWriter {NewLine = "\r\n"};
            serializer.Serialize(stringWriter, this);
            var serialized = stringWriter.ToString();

            // Restore the original lists.
            TimingPoints = originalTimingPoints;
            HitObjects = originalHitObjects;
            SoundEffects = originalSoundEffects;

            return serialized;
        }

        /// <summary>
        ///     Serializes the Qua object and writes it to a file
        /// </summary>
        /// <param name="path"></param>
        public void Save(string path) => File.WriteAllText(path, Serialize());

        /// <summary>
        ///     If the .qua file is actually valid.
        /// </summary>
        /// <returns></returns>
        public bool IsValid()
        {
            // If there aren't any HitObjects
            if (HitObjects.Count == 0)
                return false;

            // If there aren't any TimingPoints
            if (TimingPoints.Count == 0)
                return false;

            // Check if the mode is actually valid
            if (!Enum.IsDefined(typeof(GameMode), Mode))
                return false;

            // Check that sound effects are valid.
            foreach (var info in SoundEffects)
            {
                // Sample should be a valid array index.
                if (info.Sample < 1 || info.Sample >= CustomAudioSamples.Count + 1)
                    return false;

                // The sample volume should be between 1 and 100.
                if (info.Volume < 1 || info.Volume > 100)
                    return false;
            }

            // Check that hit objects are valid.
            foreach (var info in HitObjects)
            {
                // LN end times should be > start times.
                if (info.IsLongNote && info.EndTime <= info.StartTime)
                    return false;

                // Check that key sounds are valid.
                foreach (var keySound in info.KeySounds)
                {
                    // Sample should be a valid array index.
                    if (keySound.Sample < 1 || keySound.Sample >= CustomAudioSamples.Count + 1)
                        return false;

                    // The sample volume should be above 0.
                    if (keySound.Volume < 1)
                        return false;
                }
            }

            return true;
        }

        /// <summary>
        ///     Does some sorting of the Qua
        /// </summary>
        public void Sort()
        {
            HitObjects = HitObjects.OrderBy(x => x.StartTime).ToList();
            TimingPoints = TimingPoints.OrderBy(x => x.StartTime).ToList();
            SliderVelocities = SliderVelocities.OrderBy(x => x.StartTime).ToList();
            SoundEffects = SoundEffects.OrderBy(x => x.StartTime).ToList();
        }

        /// <summary>
        ///     The average notes per second in the map.
        /// </summary>
        /// <returns></returns>
        public float AverageNotesPerSecond(float rate = 1.0f) => HitObjects.Count / (Length / (1000f * rate));

        /// <summary>
        ///     Calculates and returns the map's actions per second.
        ///
        ///     Actions per second is defined as:
        ///         - The amount of presses and long note releases the player performs a second
        ///         - Excludes break and intro times.
        ///
        ///     * Should be used instead of <see cref="AverageNotesPerSecond"/> for a more accurate
        ///     representation of density.
        /// </summary>
        /// <param name="rate"></param>
        /// <returns></returns>
        public float GetActionsPerSecond(float rate = 1.0f)
        {
            var actions = new List<int>();

            foreach (var ho in HitObjects)
            {
                actions.Add(ho.StartTime);

                if (ho.IsLongNote)
                    actions.Add(ho.EndTime);
            }

            if (actions.Count == 0)
                return 0;

            actions.Sort();

            var length = actions.Last();

            // Remove empty intro time
            length -= actions.First();

            // Exclude break times from the total length
            for (var i = 0; i < actions.Count; i++)
            {
                var action = actions[i];

                if (i == 0)
                    continue;

                var previousAction = actions[i - 1];

                var difference = action - previousAction;

                if (difference >= 1000)
                    length -= difference;
            }

            return actions.Count / (length / (1000f * rate));
        }

        /// <summary>
        ///    In Quaver, the key count is defined by the game mode.
        ///    This translates mode to key count.
        /// </summary>
        /// <returns></returns>
        public int GetKeyCount(bool includeScratch = true)
        {
            int count;

            switch (Mode)
            {
                case GameMode.Keys4:
                    count = 4;
                    break;
                case GameMode.Keys7:
                    count = 7;
                    break;
                default:
                    throw new InvalidEnumArgumentException();
            }

            if (HasScratchKey && includeScratch)
                count++;

            return count;
        }

        /// <summary>
        ///     Finds the most common BPM in a Qua object.
        /// </summary>
        /// <returns></returns>
        public float GetCommonBpm()
        {
            if (TimingPoints.Count == 0)
                return 0;

            // This fallback isn't really justified, but it's only used for tests.
            if (HitObjects.Count == 0)
                return TimingPoints[0].Bpm;

            var lastObject = HitObjects.OrderByDescending(x => x.IsLongNote ? x.EndTime : x.StartTime).First();
            double lastTime = lastObject.IsLongNote ? lastObject.EndTime : lastObject.StartTime;

            var durations = new Dictionary<float, int>();
            for (var i = TimingPoints.Count - 1; i >= 0; i--)
            {
                var point = TimingPoints[i];

                // Make sure that timing points past the last object don't break anything.
                if (point.StartTime > lastTime)
                    continue;

                var duration = (int) (lastTime - (i == 0 ? 0 : point.StartTime));
                lastTime = point.StartTime;

                if (durations.ContainsKey(point.Bpm))
                    durations[point.Bpm] += duration;
                else
                    durations[point.Bpm] = duration;
            }

            if (durations.Count == 0)
                return TimingPoints[0].Bpm; // osu! hangs on loading the map in this case; we return a sensible result.

            return durations.OrderByDescending(x => x.Value).First().Key;
        }

        /// <summary>
        ///     Gets the timing point at a particular time in the map.
        /// </summary>
        /// <param name="time"></param>
        /// <returns></returns>
        public TimingPointInfo GetTimingPointAt(double time)
        {
            var index = TimingPoints.FindLastIndex(x => x.StartTime <= time);

            // If the point can't be found, we want to return either null if there aren't
            // any points, or the first timing point, since it'll be considered as apart of it anyway.
            if (index == -1)
                return TimingPoints.Count == 0 ? null : TimingPoints.First();

            return TimingPoints[index];
        }

        /// <summary>
        ///     Gets a scroll velocity at a particular time in the map
        /// </summary>
        /// <param name="time"></param>
        /// <returns></returns>
        public SliderVelocityInfo GetScrollVelocityAt(double time)
        {
            var index = SliderVelocities.FindLastIndex(x => x.StartTime <= time);
            return index == -1 ? null : SliderVelocities[index];
        }

        /// <summary>
        ///    Finds the length of a timing point.
        /// </summary>
        /// <param name="point"></param>
        /// <returns></returns>
        public double GetTimingPointLength(TimingPointInfo point)
        {
            // Find the index of the current timing point.
            var index = TimingPoints.IndexOf(point);

            // ??
            if (index == -1)
                throw new ArgumentException();

            // There is another timing point ahead of this one
            // so we'll need to get the length of the two points.
            if (index + 1 < TimingPoints.Count)
                return TimingPoints[index + 1].StartTime - TimingPoints[index].StartTime;

            // Only one timing point, so we can assume that it goes to the end of the map.
            return Length - point.StartTime;
        }

        /*/// <summary>
        ///     Solves the difficulty of the map and returns the data for it.
        /// </summary>
        /// <param name="mods"></param>
        /// <param name="applyMods"></param>
        /// <returns></returns>
        public DifficultyProcessor SolveDifficulty(ModIdentifier mods = ModIdentifier.None, bool applyMods = false)
        {
            var qua = this;

            // Create a new version of the qua with modifiers applied, and use that for calculations.
            if (applyMods)
            {
                qua = Objects.DeepClone(qua);
                qua.ApplyMods(mods);
            }

            switch (Mode)
            {
                case GameMode.Keys4:
                    return new DifficultyProcessorKeys(qua, new StrainConstantsKeys(), mods);
                case GameMode.Keys7:
                    return new DifficultyProcessorKeys(qua, new StrainConstantsKeys(), mods);
                default:
                    throw new InvalidEnumArgumentException();
            }
        }*/

        /// <summary>
        ///     Computes the "SV-ness" of a map.
        ///
        ///     SliderVelocities, TimingPoints and HitObjects must be sorted by time before calling this function.
        /// </summary>
        /// <returns></returns>
        public double SVFactor()
        {
            // SVs below this are considered the same. "Basically stationary."
            const float MIN_MULTIPLIER = 1e-3f;
            // SVs above this are considered the same. "Basically teleport."
            const float MAX_MULTIPLIER = 1e2f;

            var qua = WithNormalizedSVs();

            // Create a list of important timestamps from the perspective of playing the map.
            var importantTimestamps = new List<float>();
            foreach (var hitObject in HitObjects)
            {
                importantTimestamps.Add(hitObject.StartTime);
                if (hitObject.IsLongNote)
                    importantTimestamps.Add(hitObject.EndTime);
            }
            importantTimestamps.Sort();
            var nextImportantTimestampIndex = 0;

            var sum = 0d;
            for (var i = 1; i < qua.SliderVelocities.Count; i++)
            {
                var prevSv = qua.SliderVelocities[i - 1];
                var sv = qua.SliderVelocities[i];

                // Find the first important timestamp after the SV.
                while (nextImportantTimestampIndex < importantTimestamps.Count &&
                       importantTimestamps[nextImportantTimestampIndex] < sv.StartTime)
                    nextImportantTimestampIndex++;

                // Don't count the SV if there's nothing important within 1 second after it.
                // This is to prevent line art from contributing to the SV-ness.
                if (nextImportantTimestampIndex >= importantTimestamps.Count ||
                    importantTimestamps[nextImportantTimestampIndex] > sv.StartTime + 1000)
                    continue;

                var prevMultiplier = Math.Min(Math.Max(Math.Abs(prevSv.Multiplier), MIN_MULTIPLIER), MAX_MULTIPLIER);
                var multiplier = Math.Min(Math.Max(Math.Abs(sv.Multiplier), MIN_MULTIPLIER), MAX_MULTIPLIER);

                // The difference between SV multipliers is computed under a log, because it matters that the SV multiplier
                // changed, for example, ten-fold (from 0.1× to 1× or from 1× to 10×), and not that it changed _by_ some value (e.g. by 0.9 or by 9).
                var prevLogMultiplier = Math.Log(prevMultiplier);
                var logMultiplier = Math.Log(multiplier);

                var difference = Math.Abs(logMultiplier - prevLogMultiplier);
                sum += difference;
            }

            return sum;
        }

        public override string ToString() => $"{Artist} - {Title} [{DifficultyName}]";

        /*/// <summary>
        ///     Replaces long notes with regular notes starting at the same time.
        /// </summary>
        public void ReplaceLongNotesWithRegularNotes()
        {
            for (var i = 0; i < HitObjects.Count; i++)
            {
                var temp = HitObjects[i];
                temp.EndTime = 0;
                HitObjects[i] = temp;
            }
        }

        /// <summary>
        ///     Replaces regular notes with long notes and vice versa.
        ///
        ///     HitObjects and TimingPoints MUST be sorted by StartTime prior to calling this method,
        ///     see <see cref="Sort()"/>.
        /// </summary>
        public void ApplyInverse()
        {
            // Minimal LN and gap lengths in milliseconds.
            //
            // Ideally this should be computed in a smart way using the judgements so that it is always possible to get
            // perfects, but making map mods depend on the judgements (affected by strict/chill/accuracy adjustments) is
            // a really bad idea. I'm setting these to values that will probably work fine for the majority of the
            // cases.
            const int MINIMAL_LN_LENGTH = 36;
            const int MINIMAL_GAP_LENGTH = 36;

            var newHitObjects = new List<HitObjectInfo>();

            // An array indicating whether the currently processed HitObject is the first in its lane.
            var firstInLane = new bool[GetKeyCount()];
            for (var i = 0; i < firstInLane.Length; i++)
                firstInLane[i] = true;

            for (var i = 0; i < HitObjects.Count; i++)
            {
                var currentObject = HitObjects[i];

                // Find the next and second next hit object in the lane.
                HitObjectInfo nextObjectInLane = null, secondNextObjectInLane = null;
                for (var j = i + 1; j < HitObjects.Count; j++)
                {
                    if (HitObjects[j].Lane == currentObject.Lane)
                    {
                        if (nextObjectInLane == null)
                        {
                            nextObjectInLane = HitObjects[j];
                        }
                        else
                        {
                            secondNextObjectInLane = HitObjects[j];
                            break;
                        }
                    }
                }

                var isFirstInLane = firstInLane[currentObject.Lane - 1];
                firstInLane[currentObject.Lane - 1] = false;

                // If this is the only object in its lane, keep it as is.
                if (nextObjectInLane == null && isFirstInLane)
                {
                    newHitObjects.Add(currentObject);
                    continue;
                }

                // Figure out the time gap between the end of the LN which we'll create and the next object.
                int? timeGap = null;
                if (nextObjectInLane != null)
                {
                    var timingPoint = GetTimingPointAt(nextObjectInLane.StartTime);
                    float bpm;

                    // If the timing point starts at the next object, we want to use the previous timing point's BPM.
                    // For example, consider a fast section of the map transitioning into a very low BPM ending starting
                    // with the next hit object. Since the LN release and the gap are still in the fast section, they
                    // should use the fast section's BPM.
                    if ((int) Math.Round(timingPoint.StartTime) == nextObjectInLane.StartTime)
                    {
                        var prevTimingPointIndex = TimingPoints.FindLastIndex(x => x.StartTime < timingPoint.StartTime);

                        // No timing points before the object? Just use the first timing point then, it has the correct
                        // BPM.
                        if (prevTimingPointIndex == -1)
                            prevTimingPointIndex = 0;

                        bpm = TimingPoints[prevTimingPointIndex].Bpm;
                    }
                    else
                    {
                        bpm = timingPoint.Bpm;
                    }

                    // The time gap is quarter of the milliseconds per beat.
                    timeGap = (int?) Math.Max(Math.Round(15000 / bpm), MINIMAL_GAP_LENGTH);
                }

                // Summary of the changes:
                // Regular 1 -> Regular 2 => LN until 2 - time gap
                // Regular 1 -> LN 2      => LN until 2
                //      LN 1 -> Regular 2 => LN from 1 end until 2 - time gap
                //      LN 1 -> LN 2      => LN from 1 end until 2
                //
                // Exceptions:
                // - last LNs are kept (treated as regular 2)
                // - last regular objects are removed and treated as LN 2

                if (currentObject.IsLongNote)
                {
                    // LNs before regular objects are changed so they start where they ended and end a time gap before
                    // the object.
                    // LNs before LNs do the same but without a time gap.

                    if (nextObjectInLane == null)
                    {
                        // If this is the last object in its lane, though, then it's probably a better idea
                        // to leave it be. For example, finishing long LNs in charts.
                    }
                    else
                    {
                        currentObject.StartTime = currentObject.EndTime; // (this part can mess up the ordering)
                        currentObject.EndTime = nextObjectInLane.StartTime - timeGap.Value;

                        // Clear the keysounds as we're moving the start, so they won't make sense.
                        currentObject.KeySounds = new List<KeySoundInfo>();

                        // If the next object is not an LN and it's the last object in the lane, or if it's an LN and
                        // not the last object in the lane, create a regular object at the next object's start position.
                        if ((secondNextObjectInLane == null) != nextObjectInLane.IsLongNote)
                            currentObject.EndTime = nextObjectInLane.StartTime;

                        // Filter out really short LNs or even negative length resulting from jacks or weird BPM values.
                        if (currentObject.EndTime - currentObject.StartTime < MINIMAL_LN_LENGTH)
                        {
                            // These get skipped entirely.
                            //
                            // Actually, there can be a degenerate pattern of multiple LNs with really short gaps
                            // in between them (less than MINIMAL_LN_LENGTH), which this logic will convert
                            // into nothing. That should be pretty rare though.
                            continue;
                        }
                    }
                }
                else
                {
                    // Regular objects are replaced with LNs starting from their start and ending quarter of a beat
                    // before the next object's start.
                    if (nextObjectInLane == null)
                    {
                        // If this is the last object in lane, though, then it's not included, and instead the previous
                        // LN spans up to this object's StartTime.
                        continue;
                    }

                    currentObject.EndTime = nextObjectInLane.StartTime - timeGap.Value;

                    // If the next object is not an LN and it's the last object in the lane, or if it's an LN and
                    // not the last object in the lane, this LN should span until its start.
                    if ((secondNextObjectInLane == null) == (nextObjectInLane.EndTime == 0))
                    {
                        currentObject.EndTime = nextObjectInLane.StartTime;
                    }

                    // Filter out really short LNs or even negative length resulting from jacks or weird BPM values.
                    if (currentObject.EndTime - currentObject.StartTime < MINIMAL_LN_LENGTH)
                    {
                        // These get converted back into regular objects.
                        currentObject.EndTime = 0;
                    }
                }

                newHitObjects.Add(currentObject);
            }

            // LN conversion can mess up the ordering, so sort it again. See the (this part can mess up the ordering)
            // comment above.
            HitObjects = newHitObjects.OrderBy(x => x.StartTime).ToList();
        }

        /// <summary>
        ///     Applies mods to the map.
        /// </summary>
        /// <param name="mods">a list of mods to apply</param>
        public void ApplyMods(ModIdentifier mods)
        {
            if (mods.HasFlag(ModIdentifier.NoLongNotes))
                ReplaceLongNotesWithRegularNotes();

            if (mods.HasFlag(ModIdentifier.Inverse))
                ApplyInverse();

            // FullLN is NLN followed by Inverse.
            if (mods.HasFlag(ModIdentifier.FullLN))
            {
                ReplaceLongNotesWithRegularNotes();
                ApplyInverse();
            }

            if (mods.HasFlag(ModIdentifier.Mirror))
                MirrorHitObjects();
        }

        /// <summary>
        ///     Used by the Randomize modifier to shuffle around the lanes.
        ///     Replaces long notes with regular notes starting at the same time.
        /// </summary>
        public void RandomizeLanes(int seed)
        {
            // if seed is default, then abort.
            if (seed == -1)
                return;

            RandomizeModifierSeed = seed;

            var values = new List<int>();
            values.AddRange(Enumerable.Range(0, GetKeyCount()).Select(x => x + 1));

            values.Shuffle(new Random(seed));

            for (var i = 0; i < HitObjects.Count; i++)
            {
                var temp = HitObjects[i];
                temp.Lane = values[temp.Lane - 1];
                HitObjects[i] = temp;
            }
        }

        /// <summary>
        ///     Flips the lanes of the HitObjects
        /// </summary>
        public void MirrorHitObjects()
        {
            for (var i = 0; i < HitObjects.Count; i++)
            {
                var temp = HitObjects[i];
                temp.Lane = GetKeyCount() - temp.Lane + 1;
                HitObjects[i] = temp;
            }
        }*/

        /// <summary>
        /// </summary>
        public void SortSliderVelocities() => SliderVelocities = SliderVelocities.OrderBy(x => x.StartTime).ToList();

        /// <summary>
        /// </summary>
        public void SortTimingPoints() => TimingPoints = TimingPoints.OrderBy(x => x.StartTime).ToList();

        /// <summary>
        ///     Gets the judgement of a particular hitobject in the map
        /// </summary>
        /// <param name="ho"></param>
        /// <returns></returns>
        public int GetHitObjectJudgementIndex(HitObjectInfo ho)
        {
            var index = -1;

            var total = 0;

            for (var i = 0; i < HitObjects.Count; i++)
            {
                if (HitObjects[i] == ho)
                    return total;

                if (HitObjects[i].IsLongNote)
                    total += 2;
                else
                    total += 1;
            }

            return index;
        }

        /// <summary>
        ///     Gets a hitobject at a particular judgement index
        /// </summary>
        /// <param name="index"></param>
        /// <returns></returns>
        public HitObjectInfo GetHitObjectAtJudgementIndex(int index)
        {
            HitObjectInfo h = null;

            var total = 0;

            for (var i = 0; i < HitObjects.Count; i++)
            {
                total += 1;

                if (total - 1 == index)
                {
                    h = HitObjects[i];
                    break;
                }

                if (HitObjects[i].IsLongNote)
                    total += 1;

                if (total - 1 == index)
                {
                    h = HitObjects[i];
                    break;
                }
            }

            return h;
        }

        /// <summary>
        /// </summary>
        /// <param name="qua"></param>
        public static void RestoreDefaultValues(Qua qua)
        {
            // Restore default values.
            for (var i = 0; i < qua.TimingPoints.Count; i++)
            {
                var tp = qua.TimingPoints[i];
                if (tp.Signature == 0)
                    tp.Signature = TimeSignature.Quadruple;
                qua.TimingPoints[i] = tp;
            }

            for (var i = 0; i < qua.HitObjects.Count; i++)
            {
                var obj = qua.HitObjects[i];

                if (obj.HitSound == 0)
                    obj.HitSound = HitSounds.Normal;

                foreach (var keySound in obj.KeySounds)
                    if (keySound.Volume == 0)
                        keySound.Volume = 100;

                qua.HitObjects[i] = obj;
            }

            for (var i = 0; i < qua.SoundEffects.Count; i++)
            {
                var info = qua.SoundEffects[i];
                if (info.Volume == 0)
                    info.Volume = 100;
                qua.SoundEffects[i] = info;
            }
        }

        /// <summary>
        /// </summary>
        /// <param name="qua"></param>
        /// <param name="checkValidity"></param>
        /// <exception cref="ArgumentException"></exception>
        private static void AfterLoad(Qua qua, bool checkValidity)
        {
            if (checkValidity && !qua.IsValid())
                throw new ArgumentException("The .qua file is invalid. It does not have HitObjects, TimingPoints, its Mode is invalid or some hit objects are invalid.");

            // Try to sort the Qua before returning.
            qua.Sort();
        }

        /// <summary>
        ///     Converts SVs to the normalized format (BPM does not affect SV).
        ///
        ///     Must be done after sorting TimingPoints and SliderVelocities.
        /// </summary>
        public void NormalizeSVs()
        {
            if (BPMDoesNotAffectScrollVelocity)
                // Already normalized.
                return;

            var baseBpm = GetCommonBpm();

            var normalizedScrollVelocities = new List<SliderVelocityInfo>();

            var currentBpm = TimingPoints[0].Bpm;
            var currentSvIndex = 0;
            float? currentSvStartTime = null;
            var currentSvMultiplier = 1f;
            float? currentAdjustedSvMultiplier = null;
            float? initialSvMultiplier = null;

            for (var i = 0; i < TimingPoints.Count; i++)
            {
                var timingPoint = TimingPoints[i];

                var nextTimingPointHasSameTimestamp = false;
                if (i + 1 < TimingPoints.Count && TimingPoints[i + 1].StartTime == timingPoint.StartTime)
                    nextTimingPointHasSameTimestamp = true;

                while (true)
                {
                    if (currentSvIndex >= SliderVelocities.Count)
                        break;

                    var sv = SliderVelocities[currentSvIndex];
                    if (sv.StartTime > timingPoint.StartTime)
                        break;

                    // If there are more timing points on this timestamp, the SV only applies on the
                    // very last one, so skip it for now.
                    if (nextTimingPointHasSameTimestamp && sv.StartTime == timingPoint.StartTime)
                        break;

                    if (sv.StartTime < timingPoint.StartTime)
                    {
                        var multiplier = sv.Multiplier * (currentBpm / baseBpm);

                        if (currentAdjustedSvMultiplier == null)
                        {
                            currentAdjustedSvMultiplier = multiplier;
                            initialSvMultiplier = multiplier;
                        }

                        // ReSharper disable once CompareOfFloatsByEqualityOperator
                        if (multiplier != currentAdjustedSvMultiplier.Value)
                        {
                            normalizedScrollVelocities.Add(new SliderVelocityInfo
                            {
                                StartTime = sv.StartTime,
                                Multiplier = multiplier,
                            });
                            currentAdjustedSvMultiplier = multiplier;
                        }
                    }

                    currentSvStartTime = sv.StartTime;
                    currentSvMultiplier = sv.Multiplier;
                    currentSvIndex += 1;
                }

                // Timing points reset the previous SV multiplier.
                if (currentSvStartTime == null || currentSvStartTime.Value < timingPoint.StartTime)
                    currentSvMultiplier = 1;

                currentBpm = timingPoint.Bpm;

                // C# is stupid.
                var multiplierToo = currentSvMultiplier * (currentBpm / baseBpm);

                if (currentAdjustedSvMultiplier == null)
                {
                    currentAdjustedSvMultiplier = multiplierToo;
                    initialSvMultiplier = multiplierToo;
                }

                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (multiplierToo != currentAdjustedSvMultiplier.Value)
                {
                    normalizedScrollVelocities.Add(new SliderVelocityInfo
                    {
                        StartTime = timingPoint.StartTime,
                        Multiplier = multiplierToo,
                    });
                    currentAdjustedSvMultiplier = multiplierToo;
                }
            }

            for (; currentSvIndex < SliderVelocities.Count; currentSvIndex++)
            {
                var sv = SliderVelocities[currentSvIndex];
                var multiplier = sv.Multiplier * (currentBpm / baseBpm);

                Debug.Assert(currentAdjustedSvMultiplier != null, nameof(currentAdjustedSvMultiplier) + " != null");
                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (multiplier != currentAdjustedSvMultiplier.Value)
                {
                    normalizedScrollVelocities.Add(new SliderVelocityInfo
                    {
                        StartTime = sv.StartTime,
                        Multiplier = multiplier,
                    });
                    currentAdjustedSvMultiplier = multiplier;
                }
            }

            BPMDoesNotAffectScrollVelocity = true;
            InitialScrollVelocity = initialSvMultiplier ?? 1;
            SliderVelocities = normalizedScrollVelocities;
        }

        /// <summary>
        ///     Converts SVs to the denormalized format (BPM affects SV).
        ///
        ///     Must be done after sorting TimingPoints and SliderVelocities.
        /// </summary>
        public void DenormalizeSVs()
        {
            if (!BPMDoesNotAffectScrollVelocity)
                // Already denormalized.
                return;

            var baseBpm = GetCommonBpm();

            var denormalizedScrollVelocities = new List<SliderVelocityInfo>();
            var currentBpm = TimingPoints[0].Bpm;

            // For the purposes of this conversion, 0 and +inf should be handled like max value.
            // ReSharper disable once CompareOfFloatsByEqualityOperator
            if (currentBpm == 0 || float.IsPositiveInfinity(currentBpm))
                currentBpm = float.MaxValue;

            var currentSvIndex = 0;
            var currentSvMultiplier = InitialScrollVelocity;
            float? currentAdjustedSvMultiplier = null;

            for (var i = 0; i < TimingPoints.Count; i++)
            {
                var timingPoint = TimingPoints[i];
                while (true)
                {
                    if (currentSvIndex >= SliderVelocities.Count)
                        break;

                    var sv = SliderVelocities[currentSvIndex];
                    if (sv.StartTime > timingPoint.StartTime)
                        break;

                    if (sv.StartTime < timingPoint.StartTime)
                    {
                        var multiplier = sv.Multiplier / (currentBpm / baseBpm);

                        // ReSharper disable once CompareOfFloatsByEqualityOperator
                        if (currentAdjustedSvMultiplier == null || multiplier != currentAdjustedSvMultiplier)
                        {
                            // ReSharper disable once CompareOfFloatsByEqualityOperator
                            if (currentAdjustedSvMultiplier == null && sv.Multiplier != InitialScrollVelocity)
                            {
                                // Insert an SV 1 ms earlier to simulate the initial scroll speed multiplier.
                                denormalizedScrollVelocities.Add(new SliderVelocityInfo
                                {
                                    StartTime = sv.StartTime - 1,
                                    Multiplier = InitialScrollVelocity / (currentBpm / baseBpm),
                                });
                            }

                            denormalizedScrollVelocities.Add(new SliderVelocityInfo
                            {
                                StartTime = sv.StartTime,
                                Multiplier = multiplier,
                            });
                            currentAdjustedSvMultiplier = multiplier;
                        }
                    }

                    currentSvMultiplier = sv.Multiplier;
                    currentSvIndex += 1;
                }

                currentBpm = timingPoint.Bpm;

                // For the purposes of this conversion, 0 and +inf should be handled like max value.
                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (currentBpm == 0 || float.IsPositiveInfinity(currentBpm))
                    currentBpm = float.MaxValue;

                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (currentAdjustedSvMultiplier == null && currentSvMultiplier != InitialScrollVelocity)
                {
                    // Insert an SV 1 ms earlier to simulate the initial scroll speed multiplier.
                    denormalizedScrollVelocities.Add(new SliderVelocityInfo
                    {
                        StartTime = timingPoint.StartTime - 1,
                        Multiplier = InitialScrollVelocity / (currentBpm / baseBpm),
                    });
                }

                // Timing points reset the SV multiplier.
                currentAdjustedSvMultiplier = 1;

                // Skip over multiple timing points at the same timestamp.
                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (i + 1 < TimingPoints.Count && TimingPoints[i + 1].StartTime == timingPoint.StartTime)
                    continue;

                // C# is stupid.
                var multiplierToo = currentSvMultiplier / (currentBpm / baseBpm);

                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (multiplierToo != currentAdjustedSvMultiplier.Value)
                {
                    denormalizedScrollVelocities.Add(new SliderVelocityInfo
                    {
                        StartTime = timingPoint.StartTime,
                        Multiplier = multiplierToo,
                    });
                    currentAdjustedSvMultiplier = multiplierToo;
                }
            }

            for (; currentSvIndex < SliderVelocities.Count; currentSvIndex++)
            {
                var sv = SliderVelocities[currentSvIndex];
                var multiplier = sv.Multiplier / (currentBpm / baseBpm);

                Debug.Assert(currentAdjustedSvMultiplier != null, nameof(currentAdjustedSvMultiplier) + " != null");
                // ReSharper disable once CompareOfFloatsByEqualityOperator
                if (multiplier != currentAdjustedSvMultiplier.Value)
                {
                    denormalizedScrollVelocities.Add(new SliderVelocityInfo
                    {
                        StartTime = sv.StartTime,
                        Multiplier = multiplier,
                    });
                    currentAdjustedSvMultiplier = multiplier;
                }
            }

            BPMDoesNotAffectScrollVelocity = false;
            InitialScrollVelocity = 0;
            SliderVelocities = denormalizedScrollVelocities;
        }

        /// <summary>
        ///     Returns a Qua with normalized SVs.
        /// </summary>
        /// <returns></returns>
        public Qua WithNormalizedSVs()
        {
            var qua = (Qua) MemberwiseClone();
            // Relies on NormalizeSVs not changing anything within the by-reference members (but rather creating a new List).
            qua.NormalizeSVs();
            return qua;
        }

        /// <summary>
        ///     Returns a Qua with denormalized SVs.
        /// </summary>
        /// <returns></returns>
        public Qua WithDenormalizedSVs()
        {
            var qua = (Qua) MemberwiseClone();
            // Relies on DenormalizeSVs not changing anything within the by-reference members (but rather creating a new List).
            qua.DenormalizeSVs();
            return qua;
        }

        /// <summary>
        ///     Returns the path of the file background. If no background exists, it will return null.
        /// </summary>
        /// <returns></returns>
        public string GetBackgroundPath() => GetFullPath(BackgroundFile);

        /// <summary>
        ///     Returns the path of the banner background. If no background exists, it will return null.
        /// </summary>
        /// <returns></returns>
        public string GetBannerPath() => GetFullPath(BannerFile);

        private string GetFullPath(string file)
        {
            if (string.IsNullOrEmpty(file) || string.IsNullOrEmpty(FilePath))
                return null;

            return $"{Path.GetDirectoryName(FilePath)}/{file}";
        }

        /// <summary>
        ///     Returns the path of the audio track file. If no track exists, it will return null.
        /// </summary>
        /// <returns></returns>
        public string GetAudioPath()
        {
            if (string.IsNullOrEmpty(AudioFile) || string.IsNullOrEmpty(FilePath))
                return null;

            return $"{Path.GetDirectoryName(FilePath)}/{AudioFile}";
        }
    }
}