// 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.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Edit;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
using osuTK.Input;

namespace osu.Game.Rulesets.Osu.Tests.Editor
{
    public partial class TestSceneObjectMerging : TestSceneOsuEditor
    {
        private OsuSelectionHandler selectionHandler => Editor.ChildrenOfType<OsuSelectionHandler>().First();

        [Test]
        public void TestSimpleMerge()
        {
            HitCircle? circle1 = null;
            HitCircle? circle2 = null;

            AddStep("select first two circles", () =>
            {
                circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
                circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1);
                EditorClock.Seek(circle1.StartTime);
                EditorBeatmap.SelectedHitObjects.Add(circle1);
                EditorBeatmap.SelectedHitObjects.Add(circle2);
            });

            moveMouseToHitObject(1);
            AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection"));

            mergeSelection();

            AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
                (pos: circle1.Position, pathType: PathType.LINEAR),
                (pos: circle2.Position, pathType: null)));

            AddStep("undo", () => Editor.Undo());
            AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && objectsRestored(circle1, circle2));
        }

        [Test]
        public void TestMergeCircleSlider()
        {
            HitCircle? circle1 = null;
            Slider? slider = null;
            HitCircle? circle2 = null;

            AddStep("select a circle, slider, circle", () =>
            {
                circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
                slider = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > circle1.StartTime);
                circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h.StartTime > slider.StartTime);
                EditorClock.Seek(circle1.StartTime);
                EditorBeatmap.SelectedHitObjects.Add(circle1);
                EditorBeatmap.SelectedHitObjects.Add(slider);
                EditorBeatmap.SelectedHitObjects.Add(circle2);
            });

            mergeSelection();

            AddAssert("slider created", () =>
            {
                if (circle1 == null || circle2 == null || slider == null)
                    return false;

                var controlPoints = slider.Path.ControlPoints;
                (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints.Count + 2];
                args[0] = (circle1.Position, PathType.LINEAR);

                for (int i = 0; i < controlPoints.Count; i++)
                {
                    args[i + 1] = (controlPoints[i].Position + slider.Position, i == controlPoints.Count - 1 ? PathType.LINEAR : controlPoints[i].Type);
                }

                args[^1] = (circle2.Position, null);
                return sliderCreatedFor(args);
            });

            AddAssert("samples exist", sliderSampleExist);

            AddStep("undo", () => Editor.Undo());
            AddAssert("merged objects restored", () => circle1 is not null && circle2 is not null && slider is not null && objectsRestored(circle1, slider, circle2));
        }

        [Test]
        public void TestMergeSliderSlider()
        {
            Slider? slider1 = null;
            SliderPath? slider1Path = null;
            Slider? slider2 = null;

            AddStep("select two sliders", () =>
            {
                slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider);
                slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value);
                slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime);
                EditorClock.Seek(slider1.StartTime);
                EditorBeatmap.SelectedHitObjects.Add(slider1);
                EditorBeatmap.SelectedHitObjects.Add(slider2);
            });

            mergeSelection();

            AddAssert("slider created", () =>
            {
                if (slider1 == null || slider2 == null || slider1Path == null)
                    return false;

                var controlPoints1 = slider1Path.ControlPoints;
                var controlPoints2 = slider2.Path.ControlPoints;
                (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1];

                for (int i = 0; i < controlPoints1.Count - 1; i++)
                {
                    args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type);
                }

                for (int i = 0; i < controlPoints2.Count; i++)
                {
                    args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type);
                }

                return sliderCreatedFor(args);
            });

            AddAssert("samples exist", sliderSampleExist);

            AddAssert("merged slider matches first slider", () =>
            {
                var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
                return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples)
                                           && mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples)
                                           && mergedSlider.Samples.SequenceEqual(slider1.Samples);
            });

            AddAssert("slider end is at same completion for last slider", () =>
            {
                if (slider1Path == null || slider2 == null)
                    return false;

                var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
                return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance);
            });
        }

        [Test]
        public void TestNonMerge()
        {
            HitCircle? circle1 = null;
            HitCircle? circle2 = null;
            Spinner? spinner = null;

            AddStep("select first two circles and spinner", () =>
            {
                circle1 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle);
                circle2 = (HitCircle)EditorBeatmap.HitObjects.First(h => h is HitCircle && h != circle1);
                spinner = (Spinner)EditorBeatmap.HitObjects.First(h => h is Spinner);
                EditorClock.Seek(spinner.StartTime);
                EditorBeatmap.SelectedHitObjects.Add(circle1);
                EditorBeatmap.SelectedHitObjects.Add(circle2);
                EditorBeatmap.SelectedHitObjects.Add(spinner);
            });

            mergeSelection();

            AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
                (pos: circle1.Position, pathType: PathType.LINEAR),
                (pos: circle2.Position, pathType: null)));

            AddAssert("samples exist", sliderSampleExist);

            AddAssert("spinner not merged", () => EditorBeatmap.HitObjects.Contains(spinner));
        }

        [Test]
        public void TestIllegalMerge()
        {
            HitCircle? circle1 = null;
            HitCircle? circle2 = null;

            AddStep("add two circles on the same position", () =>
            {
                circle1 = new HitCircle();
                circle2 = new HitCircle { Position = circle1.Position + Vector2.UnitX };
                EditorClock.Seek(0);
                EditorBeatmap.Add(circle1);
                EditorBeatmap.Add(circle2);
                EditorBeatmap.SelectedHitObjects.Add(circle1);
                EditorBeatmap.SelectedHitObjects.Add(circle2);
            });

            moveMouseToHitObject(1);
            AddAssert("merge option not available", () => selectionHandler.ContextMenuItems.Length > 0 && selectionHandler.ContextMenuItems.All(o => o.Text.Value != "Merge selection"));
            mergeSelection();
            AddAssert("circles not merged", () => circle1 is not null && circle2 is not null
                                                                      && EditorBeatmap.HitObjects.Contains(circle1) && EditorBeatmap.HitObjects.Contains(circle2));
        }

        [Test]
        public void TestSameStartTimeMerge()
        {
            HitCircle? circle1 = null;
            HitCircle? circle2 = null;

            AddStep("add two circles at the same time", () =>
            {
                circle1 = new HitCircle();
                circle2 = new HitCircle { Position = circle1.Position + 100 * Vector2.UnitX };
                EditorClock.Seek(0);
                EditorBeatmap.Add(circle1);
                EditorBeatmap.Add(circle2);
                EditorBeatmap.SelectedHitObjects.Add(circle1);
                EditorBeatmap.SelectedHitObjects.Add(circle2);
            });

            moveMouseToHitObject(1);
            AddAssert("merge option available", () => selectionHandler.ContextMenuItems.Any(o => o.Text.Value == "Merge selection"));

            mergeSelection();

            AddAssert("slider created", () => circle1 is not null && circle2 is not null && sliderCreatedFor(
                (pos: circle1.Position, pathType: PathType.LINEAR),
                (pos: circle2.Position, pathType: null)));
        }

        [Test]
        public void TestMergeSliderSliderSameStartTime()
        {
            Slider? slider1 = null;
            SliderPath? slider1Path = null;
            Slider? slider2 = null;

            AddStep("select two sliders", () =>
            {
                slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider);
                slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value);
                slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime);
                EditorClock.Seek(slider1.StartTime);
                EditorBeatmap.SelectedHitObjects.AddRange([slider1, slider2]);
            });

            AddStep("move sliders to the same start time", () =>
            {
                slider2!.StartTime = slider1!.StartTime;
            });

            mergeSelection();

            AddAssert("slider created", () =>
            {
                if (slider1 == null || slider2 == null || slider1Path == null)
                    return false;

                var controlPoints1 = slider1Path.ControlPoints;
                var controlPoints2 = slider2.Path.ControlPoints;
                (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1];

                for (int i = 0; i < controlPoints1.Count - 1; i++)
                {
                    args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type);
                }

                for (int i = 0; i < controlPoints2.Count; i++)
                {
                    args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type);
                }

                return sliderCreatedFor(args);
            });

            AddAssert("samples exist", sliderSampleExist);

            AddAssert("merged slider matches first slider", () =>
            {
                var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
                return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples)
                                           && mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples)
                                           && mergedSlider.Samples.SequenceEqual(slider1.Samples);
            });

            AddAssert("slider end is at same completion for last slider", () =>
            {
                if (slider1Path == null || slider2 == null)
                    return false;

                var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
                return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance);
            });
        }

        [Test]
        public void TestMergeSliderSliderSameStartAndEndTime()
        {
            Slider? slider1 = null;
            SliderPath? slider1Path = null;
            Slider? slider2 = null;

            AddStep("select two sliders", () =>
            {
                slider1 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider);
                slider1Path = new SliderPath(slider1.Path.ControlPoints.Select(p => new PathControlPoint(p.Position, p.Type)).ToArray(), slider1.Path.ExpectedDistance.Value);
                slider2 = (Slider)EditorBeatmap.HitObjects.First(h => h is Slider && h.StartTime > slider1.StartTime);
                EditorClock.Seek(slider1.StartTime);
                EditorBeatmap.SelectedHitObjects.AddRange([slider1, slider2]);
            });

            AddStep("move sliders to the same start & end time", () =>
            {
                slider2!.StartTime = slider1!.StartTime;
                slider2.Path = slider1.Path;
            });

            mergeSelection();

            AddAssert("slider created", () =>
            {
                if (slider1 == null || slider2 == null || slider1Path == null)
                    return false;

                var controlPoints1 = slider1Path.ControlPoints;
                var controlPoints2 = slider2.Path.ControlPoints;
                (Vector2, PathType?)[] args = new (Vector2, PathType?)[controlPoints1.Count + controlPoints2.Count - 1];

                for (int i = 0; i < controlPoints1.Count - 1; i++)
                {
                    args[i] = (controlPoints1[i].Position + slider1.Position, controlPoints1[i].Type);
                }

                for (int i = 0; i < controlPoints2.Count; i++)
                {
                    args[i + controlPoints1.Count - 1] = (controlPoints2[i].Position + controlPoints1[^1].Position + slider1.Position, controlPoints2[i].Type);
                }

                return sliderCreatedFor(args);
            });

            AddAssert("samples exist", sliderSampleExist);

            AddAssert("merged slider matches first slider", () =>
            {
                var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
                return slider1 is not null && mergedSlider.HeadCircle.Samples.SequenceEqual(slider1.HeadCircle.Samples)
                                           && mergedSlider.TailCircle.Samples.SequenceEqual(slider1.TailCircle.Samples)
                                           && mergedSlider.Samples.SequenceEqual(slider1.Samples);
            });

            AddAssert("slider end is at same completion for last slider", () =>
            {
                if (slider1Path == null || slider2 == null)
                    return false;

                var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
                return Precision.AlmostEquals(mergedSlider.Path.Distance, slider1Path.CalculatedDistance + slider2.Path.Distance);
            });
        }

        private void mergeSelection()
        {
            AddStep("merge selection", () =>
            {
                InputManager.PressKey(Key.LControl);
                InputManager.PressKey(Key.LShift);
                InputManager.Key(Key.M);
                InputManager.ReleaseKey(Key.LShift);
                InputManager.ReleaseKey(Key.LControl);
            });
        }

        private bool sliderCreatedFor(params (Vector2 pos, PathType? pathType)[] expectedControlPoints)
        {
            if (EditorBeatmap.SelectedHitObjects.Count != 1)
                return false;

            var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();
            int i = 0;

            foreach ((Vector2 pos, PathType? pathType) in expectedControlPoints)
            {
                var controlPoint = mergedSlider.Path.ControlPoints[i++];

                if (!Precision.AlmostEquals(controlPoint.Position + mergedSlider.Position, pos) || controlPoint.Type != pathType)
                    return false;
            }

            return true;
        }

        private bool objectsRestored(params HitObject[] objects)
        {
            foreach (var hitObject in objects)
            {
                if (EditorBeatmap.HitObjects.Contains(hitObject))
                    return false;
            }

            return true;
        }

        private bool sliderSampleExist()
        {
            if (EditorBeatmap.SelectedHitObjects.Count != 1)
                return false;

            var mergedSlider = (Slider)EditorBeatmap.SelectedHitObjects.First();

            return mergedSlider.Samples[0] is not null;
        }

        private void moveMouseToHitObject(int index)
        {
            AddStep($"hover mouse over hit object {index}", () =>
            {
                if (EditorBeatmap.HitObjects.Count <= index)
                    return;

                Vector2 position = ((OsuHitObject)EditorBeatmap.HitObjects[index]).Position;
                InputManager.MoveMouseTo(selectionHandler.ToScreenSpace(position));
            });
        }
    }
}