// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using NUnit.Framework.Legacy;
using osu.Framework.Bindables;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;

namespace osu.Game.Tests.NonVisual.Filtering
{
    [TestFixture]
    public class FilterQueryParserTest
    {
        [Test]
        public void TestApplyQueriesBareWords()
        {
            const string query = "looking for a beatmap";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("looking for a beatmap", filterCriteria.SearchText);
            ClassicAssert.AreEqual(4, filterCriteria.SearchTerms.Length);
        }

        [Test]
        public void TestApplyQueriesBareWordsWithExactMatch()
        {
            const string query = "looking for \"a beatmap\"! like \"this\"";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("looking for \"a beatmap\"! like \"this\"", filterCriteria.SearchText);
            ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length);

            Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("a beatmap"));
            Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));

            Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("this"));
            Assert.That(filterCriteria.SearchTerms[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));

            Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("looking"));
            Assert.That(filterCriteria.SearchTerms[2].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));

            Assert.That(filterCriteria.SearchTerms[3].SearchTerm, Is.EqualTo("for"));
            Assert.That(filterCriteria.SearchTerms[3].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));

            Assert.That(filterCriteria.SearchTerms[4].SearchTerm, Is.EqualTo("like"));
            Assert.That(filterCriteria.SearchTerms[4].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
        }

        [Test]
        public void TestApplyFullPhraseQueryWithExclamationPointInTerm()
        {
            const string query = "looking for \"circles!\"!";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("looking for \"circles!\"!", filterCriteria.SearchText);
            ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length);

            Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("circles!"));
            Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));

            Assert.That(filterCriteria.SearchTerms[1].SearchTerm, Is.EqualTo("looking"));
            Assert.That(filterCriteria.SearchTerms[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));

            Assert.That(filterCriteria.SearchTerms[2].SearchTerm, Is.EqualTo("for"));
            Assert.That(filterCriteria.SearchTerms[2].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
        }

        [Test]
        public void TestApplyBrokenFullPhraseQuery()
        {
            const string query = "\"!";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("\"!", filterCriteria.SearchText);
            ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length);

            Assert.That(filterCriteria.SearchTerms[0].SearchTerm, Is.EqualTo("!"));
            Assert.That(filterCriteria.SearchTerms[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
        }

        [TestCase("star")]
        [TestCase("stars")]
        public void TestApplyStarQueries(string variant)
        {
            string query = $"{variant}<4 easy";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("easy", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length);
            ClassicAssert.NotNull(filterCriteria.StarDifficulty.Max);
            ClassicAssert.AreEqual(filterCriteria.StarDifficulty.Max!, 4.00d);
            ClassicAssert.Null(filterCriteria.StarDifficulty.Min);
        }

        [Test]
        public void TestStarQueriesInclusive()
        {
            const string query = "stars>=6";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(filterCriteria.StarDifficulty.Min!, 6.00d);
            ClassicAssert.True(filterCriteria.StarDifficulty.IsLowerInclusive);
            ClassicAssert.Null(filterCriteria.StarDifficulty.Max);
        }

        /*
         * The following tests have been written a bit strangely (they don't check exact
         * bound equality with what the filter says).
         * This is to account for floating-point arithmetic issues.
         * For example, specifying a bpm<140 filter would previously match beatmaps with BPM
         * of 139.99999, which would be displayed in the UI as 140.
         * Due to this the tests check the last tick inside the range and the first tick
         * outside of the range.
         */

        [Test]
        public void TestApplyApproachRateQueries()
        {
            const string query = "ar>=9 difficult";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("difficult", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length);
            ClassicAssert.NotNull(filterCriteria.ApproachRate.Min);
            ClassicAssert.Greater(filterCriteria.ApproachRate.Min!, 8.9f);
            ClassicAssert.Less(filterCriteria.ApproachRate.Min!, 9.0f);
            ClassicAssert.Null(filterCriteria.ApproachRate.Max);
        }

        [Test]
        public void TestApplyDrainRateQueriesByDrKeyword()
        {
            const string query = "dr>2 quite specific dr<:6";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("quite specific", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(2, filterCriteria.SearchTerms.Length);
            ClassicAssert.Greater(filterCriteria.DrainRate.Min!, 2.0f);
            ClassicAssert.Less(filterCriteria.DrainRate.Min!, 2.1f);
            ClassicAssert.Greater(filterCriteria.DrainRate.Max!, 6.0f);
            ClassicAssert.Less(filterCriteria.DrainRate.Min!, 6.1f);
        }

        [Test]
        public void TestApplyDrainRateQueriesByHpKeyword()
        {
            const string query = "hp>2 quite specific hp<=6";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("quite specific", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(2, filterCriteria.SearchTerms.Length);
            ClassicAssert.Greater(filterCriteria.DrainRate.Min!, 2.0f);
            ClassicAssert.Less(filterCriteria.DrainRate.Min!, 2.1f);
            ClassicAssert.Greater(filterCriteria.DrainRate.Max!, 6.0f);
            ClassicAssert.Less(filterCriteria.DrainRate.Min!, 6.1f);
        }

        [Test]
        public void TestApplyOverallDifficultyQueries()
        {
            const string query = "od>4 easy od<8";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("easy", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length);
            ClassicAssert.Greater(filterCriteria.OverallDifficulty.Min!, 4.0);
            ClassicAssert.Less(filterCriteria.OverallDifficulty.Min!, 4.1);
            ClassicAssert.Greater(filterCriteria.OverallDifficulty.Max!, 7.9);
            ClassicAssert.Less(filterCriteria.OverallDifficulty.Max!, 8.0);
        }

        [Test]
        public void TestApplyBPMQueries()
        {
            const string query = "bpm=200";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(filterCriteria.BPM.Min!, 199.5d);
            ClassicAssert.AreEqual(filterCriteria.BPM.Max!, 200.5d);
        }

        [Test]
        public void TestApplyBPMRangeQueries()
        {
            const string query = "bpm>:200 gotta go fast";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("gotta go fast", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length);
            ClassicAssert.NotNull(filterCriteria.BPM.Min);
            ClassicAssert.AreEqual(filterCriteria.BPM.Min!, 199.5d);
            ClassicAssert.Null(filterCriteria.BPM.Max);
        }

        private static readonly object[] correct_length_query_examples =
        {
            new object[] { "23s", TimeSpan.FromSeconds(23), TimeSpan.FromSeconds(1) },
            new object[] { "9m", TimeSpan.FromMinutes(9), TimeSpan.FromMinutes(1) },
            new object[] { "0.25h", TimeSpan.FromHours(0.25), TimeSpan.FromHours(1) },
            new object[] { "70", TimeSpan.FromSeconds(70), TimeSpan.FromSeconds(1) },
            new object[] { "7m27s", TimeSpan.FromSeconds(447), TimeSpan.FromSeconds(1) },
            new object[] { "7:27", TimeSpan.FromSeconds(447), TimeSpan.FromSeconds(1) },
            new object[] { "1h2m3s", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) },
            new object[] { "1h2m3.5s", TimeSpan.FromSeconds(3723.5), TimeSpan.FromSeconds(1) },
            new object[] { "1:2:3", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) },
            new object[] { "1:02:03", TimeSpan.FromSeconds(3723), TimeSpan.FromSeconds(1) },
            new object[] { "6", TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(1) },
            new object[] { "6.5", TimeSpan.FromSeconds(6.5), TimeSpan.FromSeconds(1) },
            new object[] { "6.5s", TimeSpan.FromSeconds(6.5), TimeSpan.FromSeconds(1) },
            new object[] { "6.5m", TimeSpan.FromMinutes(6.5), TimeSpan.FromMinutes(1) },
            new object[] { "6h5m", TimeSpan.FromMinutes(365), TimeSpan.FromMinutes(1) },
            new object[] { "65m", TimeSpan.FromMinutes(65), TimeSpan.FromMinutes(1) },
            new object[] { "90s", TimeSpan.FromSeconds(90), TimeSpan.FromSeconds(1) },
            new object[] { "80m20s", TimeSpan.FromSeconds(4820), TimeSpan.FromSeconds(1) },
            new object[] { "1h20s", TimeSpan.FromSeconds(3620), TimeSpan.FromSeconds(1) },
        };

        [Test]
        [TestCaseSource(nameof(correct_length_query_examples))]
        public void TestApplyLengthQueries(string lengthQuery, TimeSpan expectedLength, TimeSpan scale)
        {
            string query = $"length={lengthQuery} time";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("time", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual(expectedLength.TotalMilliseconds - scale.TotalMilliseconds / 2.0, filterCriteria.Length.Min);
            ClassicAssert.AreEqual(expectedLength.TotalMilliseconds + scale.TotalMilliseconds / 2.0, filterCriteria.Length.Max);
        }

        private static readonly object[] incorrect_length_query_examples =
        {
            new object[] { "7.5m27s" },
            new object[] { "7m27" },
            new object[] { "7m7m7m" },
            new object[] { "7m70s" },
            new object[] { "5s6m" },
            new object[] { "0:" },
            new object[] { ":0" },
            new object[] { "0:3:" },
            new object[] { "3:15.5" },
        };

        [Test]
        [TestCaseSource(nameof(incorrect_length_query_examples))]
        public void TestInvalidLengthQueries(string lengthQuery)
        {
            string query = $"length={lengthQuery} time";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(false, filterCriteria.Length.HasFilter);
        }

        [Test]
        public void TestApplyDivisorQueries()
        {
            const string query = "that's a time signature alright! divisor:12";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("that's a time signature alright!", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual(12, filterCriteria.BeatDivisor.Min);
            ClassicAssert.True(filterCriteria.BeatDivisor.IsLowerInclusive);
            ClassicAssert.AreEqual(12, filterCriteria.BeatDivisor.Max);
            ClassicAssert.True(filterCriteria.BeatDivisor.IsUpperInclusive);
        }

        [Test]
        public void TestPartialStatusMatch()
        {
            const string query = "status=r";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.IsNotEmpty(filterCriteria.OnlineStatus.Values);
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Ranked));
        }

        [Test]
        public void TestApplyStatusQueries()
        {
            const string query = "I want the pp status=ranked";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("I want the pp", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(4, filterCriteria.SearchTerms.Length);
            ClassicAssert.IsNotEmpty(filterCriteria.OnlineStatus.Values);
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Ranked));
        }

        [Test]
        public void TestApplyMultipleEqualityStatusQueries()
        {
            const string query = "status=ranked status=loved";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.OnlineStatus.Values, Is.Empty);
        }

        [Test]
        public void TestPartialStatusNotMatch()
        {
            const string query = "status!=r";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.IsNotEmpty(filterCriteria.OnlineStatus.Values);
            Assert.That(filterCriteria.OnlineStatus.Values, Does.Not.Contain(BeatmapOnlineStatus.Ranked));
        }

        [Test]
        public void TestApplyEqualStatusQueryWithMultipleValues()
        {
            const string query = "status=ranked,loved";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.OnlineStatus.Values, Is.Not.Empty);
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Ranked));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Loved));
        }

        [Test]
        public void TestApplyRangeStatusMatches()
        {
            const string query = "status>=r";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.OnlineStatus.Values, Has.Count.EqualTo(4));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Ranked));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Approved));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Qualified));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Loved));
        }

        [Test]
        public void TestApplyRangeStatusWithMultipleMatchesQuery()
        {
            const string query = "status>=r,l";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.OnlineStatus.Values, Is.EquivalentTo(Enum.GetValues<BeatmapOnlineStatus>()));
        }

        [Test]
        public void TestApplyTwoRangeStatusQuery()
        {
            const string query = "status>r status<l";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.OnlineStatus.Values, Has.Count.EqualTo(2));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Approved));
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Qualified));
        }

        [Test]
        public void TestApplyRangeAndEqualStatusQuery()
        {
            const string query = "status>r status=loved";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.OnlineStatus.Values, Is.Not.Empty);
            Assert.That(filterCriteria.OnlineStatus.Values, Contains.Item(BeatmapOnlineStatus.Loved));
        }

        [TestCase("creator")]
        [TestCase("author")]
        [TestCase("mapper")]
        public void TestApplyCreatorQueries(string keyword)
        {
            string query = $"beatmap specifically by {keyword}=my_fav";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("beatmap specifically by", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("my_fav", filterCriteria.Creator.SearchTerm);
        }

        [Test]
        public void TestApplyTitleQueries()
        {
            const string query = "find me songs with title=\"a certain title\" please";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("find me songs with  please", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("a certain title", filterCriteria.Title.SearchTerm);
            Assert.That(filterCriteria.Title.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
        }

        [Test]
        public void TestApplyArtistQueries()
        {
            const string query = "find me songs by artist=singer please";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("find me songs by  please", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("singer", filterCriteria.Artist.SearchTerm);
            Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.Substring));
        }

        [Test]
        public void TestApplyArtistQueriesWithSpaces()
        {
            const string query = "really like artist=\"name with space\" yes";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("really like  yes", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(3, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("name with space", filterCriteria.Artist.SearchTerm);
            Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
        }

        [Test]
        public void TestApplyArtistQueriesWithSpacesFullPhrase()
        {
            const string query = "artist=\"The Only One\"!";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.SearchText.Trim(), Is.Empty);
            ClassicAssert.AreEqual(0, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("The Only One", filterCriteria.Artist.SearchTerm);
            Assert.That(filterCriteria.Artist.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
        }

        [Test]
        public void TestApplyArtistQueriesOneDoubleQuote()
        {
            const string query = "weird artist=double\"quote";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("weird", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(1, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("double\"quote", filterCriteria.Artist.SearchTerm);
        }

        [Test]
        public void TestOperatorParsing()
        {
            const string query = "artist=><something";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("><something", filterCriteria.Artist.SearchTerm);
        }

        [Test]
        public void TestUnrecognisedKeywordIsIgnored()
        {
            const string query = "unrecognised=keyword";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("unrecognised=keyword", filterCriteria.SearchText);
        }

        [TestCase("cs=nope")]
        [TestCase("bpm>=bad")]
        [TestCase("divisor<nah")]
        [TestCase("status=noidea")]
        public void TestInvalidKeywordValueIsIgnored(string query)
        {
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(query, filterCriteria.SearchText);
        }

        [Test]
        public void TestCustomKeywordIsParsed()
        {
            var customCriteria = new CustomFilterCriteria();
            const string query = "custom=readme unrecognised=keyword";
            var filterCriteria = new FilterCriteria { RulesetCriteria = customCriteria };
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("readme", customCriteria.CustomValue);
            ClassicAssert.AreEqual("unrecognised=keyword", filterCriteria.SearchText.Trim());
        }

        [TestCase("[1]", new[] { 0 })]
        [TestCase("[1", new[] { 0 })]
        [TestCase("My[Favourite", new[] { 2 })]
        [TestCase("My[Favourite]", new[] { 2 })]
        [TestCase("My[Favourite]Song", new[] { 2 })]
        [TestCase("Favourite]", new[] { 2 })]
        [TestCase("[Diff", new[] { 0, 1, 3, 4, 6 })]
        [TestCase("[Diff]", new[] { 0, 1, 3, 4, 6 })]
        [TestCase("[Favourite]", new[] { 3 })]
        [TestCase("Title1 [Diff]", new[] { 0, 1 })]
        [TestCase("Title1[Diff]", new int[] { })]
        [TestCase("[diff ]with]", new[] { 4 })]
        [TestCase("[diff ]with [[ brackets]]]]", new[] { 4 })]
        [TestCase("[Diff in title]", new int[] { })]
        [TestCase("[Diff in diff]", new[] { 6 })]
        [TestCase("diff=Diff", new[] { 0, 1, 3, 4, 6 })]
        [TestCase("diff=Diff1", new[] { 0 })]
        [TestCase("diff=\"Diff\"", new[] { 3, 4, 6 })]
        [TestCase("diff=!\"Diff\"", new int[] { })]
        public void TestDifficultySearch(string query, int[] expectedBeatmapIndexes)
        {
            var carouselBeatmaps = (((string title, string difficultyName)[])new[]
            {
                ("Title1", "Diff1"),
                ("Title1", "Diff2"),
                ("My[Favourite]Song", "Expert"),
                ("Title", "My Favourite Diff"),
                ("Another One", "diff ]with [[ brackets]]]"),
                ("Diff in title", "a"),
                ("a", "Diff in diff"),
            }).Select(info => new FilterMatchingTest.CarouselBeatmap(new BeatmapInfo
            {
                Metadata = new BeatmapMetadata
                {
                    Title = info.title
                },
                DifficultyName = info.difficultyName
            })).ToList();

            var criteria = new FilterCriteria();

            FilterQueryParser.ApplyQueries(criteria, query);
            carouselBeatmaps.ForEach(b => b.Filter(criteria));

            int[] visibleBeatmaps = carouselBeatmaps
                                    .Where(b => !b.Filtered.Value)
                                    .Select(b => carouselBeatmaps.IndexOf(b)).ToArray();

            Assert.That(visibleBeatmaps, Is.EqualTo(expectedBeatmapIndexes));
        }

        [Test]
        public void TestApplySourceQueries()
        {
            const string query = "find me songs with source=\"unit tests\" please";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual("find me songs with  please", filterCriteria.SearchText.Trim());
            ClassicAssert.AreEqual(5, filterCriteria.SearchTerms.Length);
            ClassicAssert.AreEqual("unit tests", filterCriteria.Source.SearchTerm);
            Assert.That(filterCriteria.Source.MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
        }

        private class CustomFilterCriteria : IRulesetFilterCriteria
        {
            public string? CustomValue { get; set; }

            public bool Matches(BeatmapInfo beatmapInfo, FilterCriteria criteria) => true;

            public bool TryParseCustomKeywordCriteria(string key, Operator op, string value)
            {
                if (key == "custom" && op == Operator.Equal)
                {
                    CustomValue = value;
                    return true;
                }

                return false;
            }

            public bool FilterMayChangeFromMods(FilterCriteria criteria, ValueChangedEvent<IReadOnlyList<Mod>> mods) => false;
        }

        private static readonly object[] correct_date_query_examples =
        {
            new object[] { "600" },
            new object[] { "0.5s" },
            new object[] { "120m" },
            new object[] { "48h120s" },
            new object[] { "10y24M" },
            new object[] { "10y60d120s" },
            new object[] { "0y0M2d" },
            new object[] { "1y1M2d" }
        };

        [Test]
        [TestCaseSource(nameof(correct_date_query_examples))]
        public void TestValidDateQueries(string dateQuery)
        {
            string query = $"lastplayed<{dateQuery} time";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
        }

        private static readonly object[] incorrect_date_query_examples =
        {
            new object[] { ".5s" },
            new object[] { "7m27" },
            new object[] { "7m7m7m" },
            new object[] { "5s6m" },
            new object[] { "7d7y" },
            new object[] { "0:3:6" },
            new object[] { "0:3:" },
            new object[] { "\"three days\"" },
            new object[] { "0.1y0.1M2d" },
            new object[] { "0.99y0.99M2d" },
            new object[] { string.Empty }
        };

        [Test]
        [TestCaseSource(nameof(incorrect_date_query_examples))]
        public void TestInvalidDateQueries(string dateQuery)
        {
            string query = $"played<{dateQuery} time";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(false, filterCriteria.LastPlayed.HasFilter);
        }

        [Test]
        public void TestGreaterDateQuery()
        {
            const string query = "lastplayed>50";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.LastPlayed.Max!, Is.Not.Null);
            Assert.That(filterCriteria.LastPlayed.Min!, Is.Null);
            // the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
            // (irrelevant in proportion to the actual filter proscribed).
            Assert.That(filterCriteria.LastPlayed.Max!, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5)));
        }

        [Test]
        public void TestLowerDateQuery()
        {
            const string query = "lastplayed<50";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.LastPlayed.Max!, Is.Null);
            Assert.That(filterCriteria.LastPlayed.Min!, Is.Not.Null);
            // the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
            // (irrelevant in proportion to the actual filter proscribed).
            Assert.That(filterCriteria.LastPlayed.Min!, Is.EqualTo(DateTimeOffset.Now.AddDays(-50)).Within(TimeSpan.FromSeconds(5)));
        }

        [Test]
        public void TestBothSidesDateQuery()
        {
            const string query = "lastplayed>3M lastplayed<1y6M";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            Assert.That(filterCriteria.LastPlayed.Min!, Is.Not.Null);
            Assert.That(filterCriteria.LastPlayed.Max!, Is.Not.Null);
            // the parser internally references `DateTimeOffset.Now`, so to not make things too annoying for tests, just assume some tolerance
            // (irrelevant in proportion to the actual filter proscribed).
            Assert.That(filterCriteria.LastPlayed.Min!, Is.EqualTo(DateTimeOffset.Now.AddMonths(-6).AddYears(-1)).Within(TimeSpan.FromSeconds(5)));
            Assert.That(filterCriteria.LastPlayed.Max!, Is.EqualTo(DateTimeOffset.Now.AddMonths(-3)).Within(TimeSpan.FromSeconds(5)));
        }

        [Test]
        public void TestEqualDateQuery()
        {
            const string query = "lastplayed=50";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(false, filterCriteria.LastPlayed.HasFilter);
        }

        [Test]
        public void TestOutOfRangeDateQuery()
        {
            const string query = "lastplayed<10000y";
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
            ClassicAssert.AreEqual(DateTimeOffset.MinValue.AddMilliseconds(1), filterCriteria.LastPlayed.Min);
        }

        private static DateTimeOffset dateTimeOffsetFromDateOnly(int year, int month, int day) =>
            new DateTimeOffset(year, month, day, 0, 0, 0, TimeSpan.Zero);

        private static readonly object[] ranked_date_valid_test_cases =
        {
            new object[] { "ranked<2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked<2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked<2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked<2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Max },

            new object[] { "ranked<=2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked<=2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked<=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked<=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },

            new object[] { "ranked>2012", dateTimeOffsetFromDateOnly(2013, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked>2012.03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked>2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked>2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Min },

            new object[] { "ranked>=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked>=2012.03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked>=2012/03/05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked>=2012-3-5", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },

            new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked=2012", dateTimeOffsetFromDateOnly(2012, 1, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 3, 1), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked=2012-03", dateTimeOffsetFromDateOnly(2012, 4, 1), (FilterCriteria x) => x.DateRanked.Max },
            new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 5), (FilterCriteria x) => x.DateRanked.Min },
            new object[] { "ranked=2012-03-05", dateTimeOffsetFromDateOnly(2012, 3, 6), (FilterCriteria x) => x.DateRanked.Max },
        };

        [Test]
        [TestCaseSource(nameof(ranked_date_valid_test_cases))]
        public void TestValidRankedDateQueries(string query, DateTimeOffset expected, Func<FilterCriteria, DateTimeOffset?> f)
        {
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(true, filterCriteria.DateRanked.HasFilter);
            ClassicAssert.AreEqual(expected, f(filterCriteria));
        }

        private static readonly object[] ranked_date_invalid_test_cases =
        {
            new object[] { "ranked<0" },
            new object[] { "ranked=99999" },
            new object[] { "ranked>=2012-03-05-04" },
        };

        [Test]
        [TestCaseSource(nameof(ranked_date_invalid_test_cases))]
        public void TestInvalidRankedDateQueries(string query)
        {
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(false, filterCriteria.DateRanked.HasFilter);
        }

        private static readonly object[] submitted_date_test_cases =
        {
            new object[] { "submitted<2012", true },
            new object[] { "submitted<2012.03", true },
            new object[] { "submitted<2012/03/05", true },
            new object[] { "submitted<2012-3-5", true },

            new object[] { "submitted<0", false },
            new object[] { "submitted=99999", false },
            new object[] { "submitted>=2012-03-05-04", false },
            new object[] { "submitted>=2012/03.05-04", false },

            new object[] { "created<2012", true },
            new object[] { "created<2012.03", true },
            new object[] { "created<2012/03/05", true },
            new object[] { "created<2012-3-5", true },

            new object[] { "created<0", false },
            new object[] { "created=99999", false },
            new object[] { "created>=2012-03-05-04", false },
            new object[] { "created>=2012/03.05-04", false },
        };

        [Test]
        [TestCaseSource(nameof(submitted_date_test_cases))]
        public void TestInvalidRankedDateQueries(string query, bool expected)
        {
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, query);
            ClassicAssert.AreEqual(expected, filterCriteria.DateSubmitted.HasFilter);
        }

        private static readonly object[] played_query_tests =
        {
            new object[] { "0", DateTimeOffset.MinValue, true },
            new object[] { "0", DateTimeOffset.Now, false },
            new object[] { "false", DateTimeOffset.MinValue, true },
            new object[] { "false", DateTimeOffset.Now, false },
            new object[] { "no", DateTimeOffset.MinValue, true },
            new object[] { "no", DateTimeOffset.Now, false },

            new object[] { "1", DateTimeOffset.MinValue, false },
            new object[] { "1", DateTimeOffset.Now, true },
            new object[] { "true", DateTimeOffset.MinValue, false },
            new object[] { "true", DateTimeOffset.Now, true },
            new object[] { "yes", DateTimeOffset.MinValue, false },
            new object[] { "yes", DateTimeOffset.Now, true },
        };

        [Test]
        [TestCaseSource(nameof(played_query_tests))]
        public void TestPlayedQuery(string query, DateTimeOffset reference, bool matched)
        {
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, $"played={query}");
            ClassicAssert.AreEqual(true, filterCriteria.LastPlayed.HasFilter);
            ClassicAssert.AreEqual(matched, filterCriteria.LastPlayed.IsInRange(reference));
        }

        [Test]
        public void TestMultipleTextFilters()
        {
            var filterCriteria = new FilterCriteria();
            FilterQueryParser.ApplyQueries(filterCriteria, "tag=\"simple\" tag=\"clean\"!");
            Assert.That(filterCriteria.UserTags, Has.Count.EqualTo(2));
            Assert.That(filterCriteria.UserTags[0].SearchTerm, Is.EqualTo("simple"));
            Assert.That(filterCriteria.UserTags[0].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.IsolatedPhrase));
            Assert.That(filterCriteria.UserTags[1].SearchTerm, Is.EqualTo("clean"));
            Assert.That(filterCriteria.UserTags[1].MatchMode, Is.EqualTo(FilterCriteria.MatchMode.FullPhrase));
        }
    }
}
