// 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.Collections.Immutable;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Filter;
using osu.Game.Rulesets.Mods;
using osu.Game.Screens.Select.Filter;

namespace osu.Game.Screens.Select
{
    public class FilterCriteria
    {
        public GroupMode Group;
        public SortMode Sort;

        public BeatmapSetInfo? SelectedBeatmapSet;

        public OptionalRange<double> StarDifficulty;
        public OptionalRange<float> ApproachRate;
        public OptionalRange<float> DrainRate;
        public OptionalRange<float> CircleSize;
        public OptionalRange<float> OverallDifficulty;
        public OptionalRange<double> Length;
        public OptionalRange<double> BPM;
        public OptionalRange<int> BeatDivisor;
        public OptionalSet<BeatmapOnlineStatus> OnlineStatus = new OptionalSet<BeatmapOnlineStatus>();
        public OptionalRange<DateTimeOffset> LastPlayed;
        public OptionalRange<DateTimeOffset> DateRanked;
        public OptionalRange<DateTimeOffset> DateSubmitted;
        public OptionalTextFilter Creator;
        public OptionalTextFilter Artist;
        public OptionalTextFilter Title;
        public OptionalTextFilter DifficultyName;
        public OptionalTextFilter Source;
        public List<OptionalTextFilter> UserTags = [];

        public OptionalRange<double> UserStarDifficulty = new OptionalRange<double>
        {
            IsLowerInclusive = true,
            IsUpperInclusive = true
        };

        public OptionalTextFilter[] SearchTerms = Array.Empty<OptionalTextFilter>();

        public RulesetInfo? Ruleset;
        public IReadOnlyList<Mod>? Mods;
        public bool AllowConvertedBeatmaps;
        public int? BeatmapSetId;

        public bool? HasOnlineID;

        private string searchText = string.Empty;

        /// <summary>
        /// <see cref="SearchText"/> as a number (if it can be parsed as one).
        /// </summary>
        public int? SearchNumber { get; private set; }

        public string SearchText
        {
            get => searchText;
            set
            {
                searchText = value;

                List<OptionalTextFilter> terms = new List<OptionalTextFilter>();

                string remainingText = value;

                // Match either an open difficulty tag to the end of string,
                // or match a closed one with a whitespace after it.
                //
                // To keep things simple, the closing ']' may be included in the match group,
                // and is trimmed post-match.
                foreach (Match quotedSegment in Regex.Matches(value, "(^|\\s)\\[(.*)(\\]\\s|$)"))
                {
                    DifficultyName = new OptionalTextFilter
                    {
                        SearchTerm = quotedSegment.Groups[2].Value.Trim(']')
                    };

                    remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);
                }

                // First handle quoted segments to ensure we keep inline spaces in exact matches.
                foreach (Match quotedSegment in Regex.Matches(value, "(\"[^\"]+\"[!]?)"))
                {
                    terms.Add(new OptionalTextFilter { SearchTerm = quotedSegment.Value });
                    remainingText = remainingText.Replace(quotedSegment.Value, string.Empty);
                }

                // Then handle the rest splitting on any spaces.
                terms.AddRange(remainingText.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(s => new OptionalTextFilter
                {
                    SearchTerm = s
                }));

                SearchTerms = terms.ToArray();

                SearchNumber = null;

                if (SearchTerms.Length == 1 && int.TryParse(SearchTerms[0].SearchTerm, out int parsed))
                    SearchNumber = parsed;
            }
        }

        private ImmutableHashSet<string>? collectionBeatmapMD5Hashes;
        private Live<BeatmapCollection>? collection;

        /// <summary>
        /// Hashes from the <see cref="BeatmapCollection"/> to filter to.
        /// </summary>
        public IEnumerable<string>? CollectionBeatmapMD5Hashes =>
            collectionBeatmapMD5Hashes ??= Collection?.PerformRead(c => c.BeatmapMD5Hashes.ToImmutableHashSet());

        public Live<BeatmapCollection>? Collection
        {
            get => collection;
            set
            {
                collection = value;
                collectionBeatmapMD5Hashes = null;
            }
        }

        public IRulesetFilterCriteria? RulesetCriteria { get; set; }

        /// <summary>
        /// The user ID of the current local user, used to filter to own maps when <see cref="GroupMode.MyMaps"/> is selected.
        /// Or null if the user is not logged in.
        /// </summary>
        public int? LocalUserId { get; set; }

        /// <summary>
        /// The username of the current local user, used to filter to own maps when <see cref="GroupMode.MyMaps"/> is selected.
        /// Or null if the user is not logged in.
        /// </summary>
        public string? LocalUserUsername { get; set; }

        public readonly struct OptionalSet<T> : IEquatable<OptionalSet<T>>
            where T : struct, Enum
        {
            public bool HasFilter => true;

            public bool IsInRange(T value) => Values.Contains(value);

            public HashSet<T> Values { get; }

            public OptionalSet()
            {
                Values = Enum.GetValues<T>().ToHashSet();
            }

            public bool Equals(OptionalSet<T> other) => Values.SetEquals(other.Values);
        }

        public struct OptionalRange<T> : IEquatable<OptionalRange<T>>
            where T : struct
        {
            public bool HasFilter => Max != null || Min != null;

            public bool IsInRange(T value)
            {
                bool lowerRangeSatisfied = true;
                bool upperRangeSatisfied = true;

                if (Min != null)
                {
                    int comparison = Comparer<T>.Default.Compare(value, Min.Value);
                    lowerRangeSatisfied = comparison > 0 || (comparison == 0 && IsLowerInclusive);
                }

                if (Max != null)
                {
                    int comparison = Comparer<T>.Default.Compare(value, Max.Value);
                    upperRangeSatisfied = comparison < 0 || (comparison == 0 && IsUpperInclusive);
                }

                bool result = lowerRangeSatisfied && upperRangeSatisfied;
                if (InvertRange)
                    result = !result;
                return result;
            }

            public T? Min;
            public T? Max;
            public bool IsLowerInclusive;
            public bool IsUpperInclusive;

            /// <summary>
            /// When <see langword="true"/>, the meaning of this filter is inverted, i.e. it will <i>exclude</i> items that satisfy this range.
            /// </summary>
            public bool InvertRange;

            public bool Equals(OptionalRange<T> other)
                => EqualityComparer<T?>.Default.Equals(Min, other.Min)
                   && EqualityComparer<T?>.Default.Equals(Max, other.Max)
                   && IsLowerInclusive.Equals(other.IsLowerInclusive)
                   && IsUpperInclusive.Equals(other.IsUpperInclusive);
        }

        public struct OptionalTextFilter : IEquatable<OptionalTextFilter>
        {
            public bool HasFilter => !string.IsNullOrEmpty(SearchTerm);

            public MatchMode MatchMode { get; private set; }

            public bool Matches(string value)
            {
                if (!HasFilter)
                    return true;

                // search term is guaranteed to be non-empty, so if the string we're comparing is empty, it's not matching
                if (string.IsNullOrEmpty(value))
                    return ExcludeTerm;

                bool result;

                switch (MatchMode)
                {
                    default:
                    case MatchMode.Substring:
                        // Note that we are using ordinal here to avoid performance issues caused by globalisation concerns.
                        // See https://github.com/ppy/osu/issues/11571 / https://github.com/dotnet/docs/issues/18423.
                        result = value.Contains(SearchTerm, StringComparison.OrdinalIgnoreCase);
                        break;

                    case MatchMode.IsolatedPhrase:
                        result = Regex.IsMatch(value, $@"(^|\b){Regex.Escape(searchTerm)}($|\b)", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
                        break;

                    case MatchMode.FullPhrase:
                        result = CultureInfo.InvariantCulture.CompareInfo.Compare(value, searchTerm, CompareOptions.OrdinalIgnoreCase) == 0;
                        break;
                }

                if (ExcludeTerm)
                    result = !result;

                return result;
            }

            /// <summary>
            /// When <see langword="true"/>, the meaning of this filter is inverted, i.e. it will <i>exclude</i> items which match <see cref="SearchTerm"/>.
            /// </summary>
            public bool ExcludeTerm;

            private string searchTerm;

            public string SearchTerm
            {
                get => searchTerm;
                set
                {
                    searchTerm = value;

                    if (searchTerm.StartsWith('\"'))
                    {
                        // length check ensures that the quote character in the `StartsWith()` check above and the `EndsWith()` check below is not the same character.
                        if (searchTerm.EndsWith("\"!", StringComparison.Ordinal) && searchTerm.Length >= 3)
                        {
                            searchTerm = searchTerm.TrimEnd('!').Trim('\"');
                            MatchMode = MatchMode.FullPhrase;
                        }
                        else
                        {
                            searchTerm = searchTerm.Trim('\"');
                            MatchMode = MatchMode.IsolatedPhrase;
                        }
                    }
                    else
                        MatchMode = MatchMode.Substring;
                }
            }

            public bool Equals(OptionalTextFilter other) => SearchTerm == other.SearchTerm;
        }

        /// <summary>
        /// Given a new filter criteria, decide whether a full sort needs to be performed.
        /// </summary>
        /// <param name="newCriteria"></param>
        /// <returns></returns>
        public bool RequiresSorting(FilterCriteria newCriteria)
        {
            if (Sort != newCriteria.Sort)
                return true;

            switch (Sort)
            {
                // Some sorts are stable across all other changes.
                // Running these sorts will sort all items, including currently hidden items.
                case SortMode.Artist:
                case SortMode.Author:
                case SortMode.DateSubmitted:
                case SortMode.DateAdded:
                case SortMode.DateRanked:
                case SortMode.Source:
                case SortMode.Title:
                    return false;

                // Some sorts use aggregate max comparisons, which will change based on filtered items.
                // These sorts generally ignore items hidden by filtered state, so we must force a sort under all circumstances here.
                //
                // This makes things very slow when typing a text search, and we probably want to consider a way to optimise things going forward.
                case SortMode.LastPlayed:
                case SortMode.BPM:
                case SortMode.Length:
                case SortMode.Difficulty:
                    return true;

                default:
                    throw new ArgumentOutOfRangeException(nameof(Sort), Sort, "Unknown sort mode");
            }
        }

        public enum MatchMode
        {
            /// <summary>
            /// Match using a simple "contains" substring match.
            /// </summary>
            Substring,

            /// <summary>
            /// Match for the search phrase being isolated by spaces, or at the start or end of the text.
            /// </summary>
            IsolatedPhrase,

            /// <summary>
            /// Match for the search phrase matching the full text in completion.
            /// </summary>
            FullPhrase,
        }
    }
}
