using Cleanuparr.Domain.Enums; using Cleanuparr.Infrastructure.Models; using Cleanuparr.Infrastructure.Services.Interfaces; using Cleanuparr.Persistence.Models.Configuration.QueueCleaner; using Microsoft.Extensions.Logging; namespace Cleanuparr.Infrastructure.Services; public class RuleIntervalValidator : IRuleIntervalValidator { private readonly ILogger _logger; public RuleIntervalValidator(ILogger logger) { _logger = logger; } public ValidationResult ValidateStallRuleIntervals(StallRule newRule, List existingRules) { _logger.LogDebug("Validating stall rule intervals for rule {rule}", newRule.Name); var allRules = existingRules.Cast().ToList(); allRules.Add(newRule); return ValidateRuleIntervals(allRules, newRule.Name); } public ValidationResult ValidateSlowRuleIntervals(SlowRule newRule, List existingRules) { _logger.LogDebug("Validating slow rule intervals for rule {rule}", newRule.Name); var allRules = existingRules.Cast().ToList(); allRules.Add(newRule); return ValidateRuleIntervals(allRules, newRule.Name); } public List FindGapsInCoverage(List rules) where T : QueueRule { _logger.LogDebug("Finding gaps in coverage for {rule} rules", rules.Count); var gaps = new List(); var enabledRules = rules.Where(r => r.Enabled).ToList(); // Find gaps for each privacy type gaps.AddRange(FindGapsForPrivacyType(enabledRules, TorrentPrivacyType.Public)); gaps.AddRange(FindGapsForPrivacyType(enabledRules, TorrentPrivacyType.Private)); _logger.LogDebug("Found {GapCount} gaps in coverage", gaps.Count); return gaps; } /// /// Validates that the provided rules do not create overlapping intervals. /// /// The collection of all rules, including the newly created one. /// The name of the new rule. /// private ValidationResult ValidateRuleIntervals(List allRules, string newRuleName) { // Remove duplicate rules with the same ID (keep the last one, which is typically the updated version) var deduplicatedRules = allRules .GroupBy(r => r.Id) .Select(g => g.Last()) .ToList(); // Only consider enabled rules for validation List enabledRules = deduplicatedRules .Where(r => r.Enabled) .ToList(); // Expand privacy types (Both -> Public + Private) List intervals = ExpandPrivacyTypes(enabledRules); // Group by privacy type and check for overlaps List publicIntervals = intervals .Where(i => i.PrivacyType == TorrentPrivacyType.Public) .ToList(); List privateIntervals = intervals .Where(i => i.PrivacyType == TorrentPrivacyType.Private) .ToList(); List publicOverlaps = FindAllOverlappingIntervals(publicIntervals, newRuleName); List privateOverlaps = FindAllOverlappingIntervals(privateIntervals, newRuleName); HashSet overlappingRules = []; foreach (var overlap in publicOverlaps) { overlappingRules.Add(overlap.ConflictingRuleName); _logger.LogWarning("Rule {newRuleName} overlaps for Public torrents with rule {ruleName} (both cover {start}%-{end}%)", newRuleName, overlap.ConflictingRuleName, overlap.OverlapStart, overlap.OverlapEnd ); } foreach (var overlap in privateOverlaps) { overlappingRules.Add(overlap.ConflictingRuleName); _logger.LogWarning("Rule {newRuleName} overlaps for Private torrents with rule {ruleName} (both cover {start}%-{end}%)", newRuleName, overlap.ConflictingRuleName, overlap.OverlapStart, overlap.OverlapEnd ); } if (overlappingRules.Count > 0) { var details = overlappingRules.ToList(); return ValidationResult.Failure("Rule creates overlapping intervals with existing rules: " + string.Join(", ", overlappingRules), details); } return ValidationResult.Success(); } private static List ExpandPrivacyTypes(List rules) { return rules .SelectMany(rule => GetPrivacyTypes(rule.PrivacyType) .Select(privacyType => new RuleInterval { PrivacyType = privacyType, Start = rule.MinCompletionPercentage, End = rule.MaxCompletionPercentage, RuleName = rule.Name, RuleId = rule.Id })) .ToList(); } private static IEnumerable GetPrivacyTypes(TorrentPrivacyType type) { if (type == TorrentPrivacyType.Both) { yield return TorrentPrivacyType.Public; yield return TorrentPrivacyType.Private; } else { yield return type; } } /// /// Finds all overlapping intervals for the new rule being validated. /// /// List of intervals to check for overlaps /// Name of the rule being validated /// List of all overlaps found private static List FindAllOverlappingIntervals(List intervals, string newRuleName) { var overlaps = new List(); if (intervals.Count < 2) { return overlaps; } var sortedIntervals = intervals .OrderBy(i => i.Start) .ThenBy(i => i.End) .ToList(); // Find the new rule interval(s) var newRuleIntervals = sortedIntervals.Where(i => i.RuleName == newRuleName).ToList(); var existingIntervals = sortedIntervals.Where(i => i.RuleName != newRuleName).ToList(); // Check each new rule interval against all existing intervals foreach (var newInterval in newRuleIntervals) { foreach (var existingInterval in existingIntervals) { // Skip if same rule ID (handles the case where a rule is being updated) if (newInterval.RuleId == existingInterval.RuleId) { continue; } // Check for overlap if (newInterval.Start < existingInterval.End && existingInterval.Start < newInterval.End) { var overlapStart = Math.Max(newInterval.Start, existingInterval.Start); var overlapEnd = Math.Min(newInterval.End, existingInterval.End); if (overlapEnd > overlapStart) { overlaps.Add(new OverlapResult { ConflictingRuleName = existingInterval.RuleName, OverlapStart = overlapStart, OverlapEnd = overlapEnd }); } } } } return overlaps; } private static List FindGapsForPrivacyType(List rules, TorrentPrivacyType privacyType) where T : QueueRule { var gaps = new List(); // Get relevant intervals for this privacy type var relevantRules = rules.Where(r => r.PrivacyType == privacyType || r.PrivacyType == TorrentPrivacyType.Both).ToList(); if (!relevantRules.Any()) { gaps.Add(new IntervalGap { PrivacyType = privacyType, Start = 0, End = 100 }); return gaps; } var intervals = relevantRules .Select(r => new { Start = Math.Max(0, Math.Min(100, (int)r.MinCompletionPercentage)), End = Math.Max(0, Math.Min(100, (int)r.MaxCompletionPercentage)) }) .Where(i => i.End >= i.Start) .OrderBy(i => i.Start) .ThenBy(i => i.End) .ToList(); double currentCoverageEnd = 0; foreach (var interval in intervals) { if (interval.Start > currentCoverageEnd) { gaps.Add(new IntervalGap { PrivacyType = privacyType, Start = currentCoverageEnd, End = interval.Start }); } if (interval.End > currentCoverageEnd) { currentCoverageEnd = interval.End; } if (currentCoverageEnd >= 100) { break; } } if (currentCoverageEnd < 100) { gaps.Add(new IntervalGap { PrivacyType = privacyType, Start = currentCoverageEnd, End = 100 }); } return gaps; } private class OverlapResult { public string ConflictingRuleName { get; set; } = string.Empty; public double OverlapStart { get; set; } public double OverlapEnd { get; set; } } }