using System; using System.Collections.Generic; using System.Globalization; using System.Text; using System.Text.RegularExpressions; using System.Threading; using Dinah.Core; namespace FileManager.NamingTemplate; public static partial class CommonFormatters { public const string DefaultDateFormat = "yyyy-MM-dd"; public const string DefaultTimeSpanFormat = "MMM"; public delegate TFormatted? PropertyFormatter(ITemplateTag templateTag, TProperty? value, string? formatString, CultureInfo? culture); public delegate string? PropertyFinalizer(ITemplateTag templateTag, T? value, CultureInfo? culture); public static PropertyFinalizer ToPropertyFormatter(PropertyFormatter preFormatter, PropertyFinalizer finalizer) { return (templateTag, value, culture) => finalizer(templateTag, preFormatter(templateTag, value, null, culture), culture); } public static PropertyFinalizer ToFinalizer(PropertyFormatter formatter) { return (templateTag, value, culture) => formatter(templateTag, value, null, culture); } public static string? StringFinalizer(ITemplateTag templateTag, string? value, CultureInfo? culture) => value; public static TPropertyValue? IdlePreFormatter(ITemplateTag templateTag, TPropertyValue? value, string? formatString, CultureInfo? culture) => value; public static string StringFormatter(ITemplateTag _, string? value, string? formatString, CultureInfo? culture) => _StringFormatter(value, formatString, culture); public static string _StringFormatter(string? value, string? formatString, IFormatProvider? provider) => _StringFormatter(value, formatString, GetCultureInfo(provider)); private static string _StringFormatter(string? value, string? formatString, CultureInfo? culture) { if (string.IsNullOrEmpty(value)) return string.Empty; if (string.IsNullOrWhiteSpace(formatString) || !StringFormatRegex().TryMatch(formatString, out var match)) return value; // first shorten the string if a number is specified in the format string if (match.TryParseInt("left", out var length) && length < value.Length) value = value[..length]; culture ??= CultureInfo.CurrentCulture; return match.Groups["case"].ValueSpan switch { "u" or "U" => value.ToUpper(culture), "l" or "L" => value.ToLower(culture), "T" => culture.TextInfo.ToTitleCase(value), "t" => culture.TextInfo.ToTitleCase(value.ToLower(culture)), _ => value, }; } [GeneratedRegex(@"^\s*(?\d+)?\s*(?[uUlLtT])?\s*$")] private static partial Regex StringFormatRegex(); public static string TemplateStringFormatter(T toFormat, string? templateString, IFormatProvider? provider, Dictionary> replacements) { if (string.IsNullOrWhiteSpace(templateString)) return ""; // is this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider var culture = GetCultureInfo(provider); var oldUiCulture = Thread.CurrentThread.CurrentUICulture; var result = CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().ReplaceWithGaps(templateString, GetValueForMatchingTag, Unescape), string.Empty); Thread.CurrentThread.CurrentUICulture = oldUiCulture; return result; string GetValueForMatchingTag(Match m) { var tag = m.Groups["tag"].Value; if (!replacements.TryGetValue(tag, out var getter)) return m.Value; var lang = m.ResolveValue("lang"); var cultureToUse = lang is null ? culture : CultureInfo.GetCultureInfo(lang); Thread.CurrentThread.CurrentUICulture = cultureToUse ?? oldUiCulture; var value = getter(toFormat); var format = m.ResolveValue("format"); var formatted = value switch { IFormattable formattable => formattable.ToString(format, cultureToUse), _ => _StringFormatter(value?.ToString(), format, cultureToUse), }; return formatted.IsNullOrEmpty() ? string.Empty : m.UnescapeValue("pre") + formatted + m.UnescapeValue("post"); } } private static CultureInfo? GetCultureInfo(IFormatProvider? provider) { return provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo; } // Matches runs of spaces followed by a space as well as runs of spaces at the beginning or the end of a string (does NOT touch tabs/newlines). [GeneratedRegex(@"^ +| +(?=$| )")] private static partial Regex CollapseSpacesAndTrimRegex(); // The templateString is scanned for contained braces with an enclosed tagname. // The tagname may be followed by an optional format specifier separated by a colon. // All other parts of the template string are left untouched as well as the braces where the tagname is unknown. // TemplateStringFormatter will use a dictionary to lookup the tagname and the corresponding value getter. [GeneratedRegex(""" (?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with # (?<=\G(?: # We lookbehind up to the start or the end of the last match for a tag format. \\. # - '\' escapes always the next character. Especially further '\' and the opening '{' | '[^']*' # - allow 'string' to be included in the format, with '' being an escaped ' character | "[^"]*" # - allow "string" to be included in the format, with "" being an escaped " character | [^\\'"] # - match any other character. This will not catch the tag format at first. Because ... ) *? ) # With *? the pattern above tries not to consume the tag format. (?: \{ (?
 (?:            # With an optional extra '{' surroundings of the tag format might be specified in a group called '
'.
	                    \\.                       # - '\' escapes always the next character. Especially further '\' and the opening '{'
	                    | '[^']*'                 # - allow 'string' to be included in the format
	                    | "[^"]*"                 # - allow "string" to be included in the format
	                    | [^\\'"{}]               # - match any other character. We also don't want further unescaped '{' or '}' in the format.
	                ) * ) )?                      # Capture all up to the next unescaped '{' to get the optional '
' group.
	                \{ (? [A-Z0-9]+ | \#)    # Capture the tags name as ''. It is always enclosed in '{' and '}'. It may only contain letters and numbers or be a single '#'.
	                    (?:@(?[a-z-]+))?    # Introduced by '@' the tag name may optionally be followed by a language specifier captured in a group called ''.
	                    (?::(?(?:
	                        \\.                   # - '\' escapes always the next character. Especially further '\' and the closing '}'
	                        | '[^']*'             # - allow 'string' to be included in the format
	                        | "[^"]*"             # - allow "string" to be included in the format
	                        | .
	                    ) *? ))?
	                \}
	                (?(pre)
	                    (?(?:
	                        \\.
	                        | '[^']*'
	                        | "[^"]*"
	                        | [^\\'"{}]
	                    ) * ) \}
	                    |
	                )
	                """,
		RegexOptions.IgnoreCase)]
	public static partial Regex TagFormatRegex();

	public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture)
		=> value?.ToString(formatString, culture) ?? "";

	public static string IntegerFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture)
		=> FloatFormatter(templateTag, value, formatString, culture);

	public static string FloatFormatter(ITemplateTag _, float value, string? formatString, CultureInfo? culture)
	{
		culture ??= CultureInfo.CurrentCulture;
		if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture);

		//Zero-pad the integer part
		formatString = new string('0', numDigits) + ".################";
		return value.ToString(formatString, culture);
	}

	public static string MinutesFormatter(ITemplateTag templateTag, TimeSpan value, string? formatString, CultureInfo? culture)
	{
		culture ??= CultureInfo.CurrentCulture;
		formatString ??= DefaultTimeSpanFormat;

		// the format string is build as a custom format for TimeSpans. Time portion like 'h' and 'm' are used to format the minutes and hours part of the TimeSpan.
		// They are limited by the next greater domain. So 'h' will be between 0 and 23, 'm' between 0 and 59. 'd' will be the total number of days.
		// To get the total timespan display in terms of total hours or total minutes, we allow the format string to include number formats with uppercase D, H or M.
		// As there might be up to three numbers shown in the format string, we distinguish between total days, hours and minutes with uppercase D, H and M instead of zeros.
		// A format "#,##0'minutes'" would for example become "#,##M'minutes'". If you combine them the lower units will be reduced by the higher units.
		// "D'days and'#,##0'minutes'" will show 1,439 minutes at maximum.
		// In the first step we search for number formats with uppercase D, H or M, format their values as number and replace them as quoted strings in the format string.
		var timeSpanForTotal = value;
		formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalD(), TimeSpan.TicksPerDay);
		formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalH(), TimeSpan.TicksPerHour);
		formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalM(), TimeSpan.TicksPerMinute);

		// The formatString should now be a valid TimeSpan format string.
		return value.ToString(formatString, culture);
	}

	private static string FormatAsNumberIntoTemplate(ITemplateTag templateTag, string formatString, CultureInfo culture, ref TimeSpan timeSpanForTotal, Regex regex, long ticks)
	{
		var total = timeSpanForTotal.Ticks / ticks;
		var matched = false;
		var result = regex.Replace(formatString, m =>
		{
			matched = true;
			var numPattern = RegexTimeStampToNumberPattern().Replace(m.Groups["format"].Value, "0");
			var formatted = FloatFormatter(templateTag, total, numPattern, culture);
			return $"'{formatted}'";
		});
		if (matched) timeSpanForTotal = TimeSpan.FromTicks(timeSpanForTotal.Ticks % ticks);
		return result;
	}

	public static string DateTimeFormatter(ITemplateTag _, DateTime value, string? formatString, CultureInfo? culture)
	{
		culture ??= CultureInfo.InvariantCulture;
		if (string.IsNullOrWhiteSpace(formatString))
			formatString = DefaultDateFormat;
		return value.ToString(formatString, culture);
	}

	public static string LanguageShortFormatter(ITemplateTag templateTag, string? language, string? formatString, CultureInfo? culture)
	{
		return StringFormatter(templateTag, language, "3u", culture);
	}

	public static string Unescape(string valueSpan)
	{
		return Unescape(valueSpan, ['\'', '"']);
	}

	public static string Unescape(ReadOnlySpan valueSpan, ReadOnlySpan quoteChars, bool unquoteBackslash = true, bool unescapeDoubleQuotesInsideQuotes = true)
	{
		if (valueSpan.IsEmpty) return "";
		
		Span search = stackalloc char[quoteChars.Length + 1];
		search[0] = '\\';
		quoteChars.CopyTo(search[1..]);

		var first = valueSpan.IndexOfAny(search);
		if (first < 0)
			return valueSpan.ToString();

		var sb = new StringBuilder(valueSpan.Length);
		sb.Append(valueSpan[..first]);
		for (var i = first; i < valueSpan.Length; i++)
		{
			var c = valueSpan[i];

			// scan quotation
			if (quoteChars.Contains(c))
			{
				i++; // skip quote

				while (i < valueSpan.Length)
				{
					var inner = valueSpan[i];

					// closing quote?
					if (inner == c)
					{
						i++; // skip
						if (!unescapeDoubleQuotesInsideQuotes ||
						    i >= valueSpan.Length ||
						    valueSpan[i] != c)
							// end block if no 2nd quote follows or doubled quotes don't have special meaning 
							break;
					}

					sb.Append(inner);
					i++;
				}

				i--; // skipped one too much as outer loop advances as well
				continue;
			}

			if (c == '\\' && unquoteBackslash && i + 1 < valueSpan.Length)
				i++; // skip backslash and take the next char

			sb.Append(valueSpan[i]);
		}

		return sb.ToString();
	}

	// These search for number formats with all notions of escaping and quoting, but all zeros replaced with D, H, or M to indicate that they should be replaced with the total number of
	// days hours or minutes in the timespan (not just the minutes part). Only one of them is written commented. The others are identical except for the letter D, H or M.
	// I most cases this regex will only find a straight bunch of D's, H's or M's, but it also allows for more complex formats.
	[GeneratedRegex("""
	                (?x)                   # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
	                (?<=\G(?:              # We lookbehind up to the start or the end of the last match for a number format.
	                    \\.                # - '\' escapes always the next character. Especially further '\'
	                    | '[^']*'          # - allow 'string' to be included in the format
	                    | "[^"]*"          # - allow "string" to be included in the format
	                    | [^\\'"]          # - match any other character. This will not catch the number format at first. Because ...
	                ) *? )                 # With *? the pattern above tries not to consume the number format.
	                (?             # We capture the whole number format in a group called ''.
	                    (?:\#[\#,.]*)?     # - For grouping a number format may start with `#` and grouping hints `,` or even a decimal point `.`.
	                    D                  # - At least one unescaped, unquoted uppercase D must be included in the format to indicate that this is a total days format.
	                    (?:(?:             # - Before further D's, there may be any combination of escaped characters and quoted strings.
	                            \\.        # - '\' escapes always the next character. Especially further '\' and the closing ']' 
	                            | '[^']*'  # - allow 'string' to be included in the format 
	                            | "[^"]*"  # - allow "string" to be included in the format 
	                        )* [D%‰\#,.]+  # After escaped characters and quoted strings, there needs to be at least one more real number format character (which may be D as well).
	                    )*                 # This may extend the format several times, for example in `D\:DD` or `D' days 'D\-D`.
	                    (?:[Ee][+-]?0+)?   # The original number format may end with an optional scientific notation part. This is also optional.
	                )                      # end of capture group ''
	                """)]
	private static partial Regex RegexMinutesTotalD();

	[GeneratedRegex("""(?<=\G(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)(?(?:#[#,.]*)?H(?:(?:\\.|'[^']*'|"[^"]*")*[H%‰#,.]+)*(?:[Ee][+-]?0+)?)""")]
	private static partial Regex RegexMinutesTotalH();

	[GeneratedRegex("""(?<=\G(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)(?(?:#[#,.]*)?M(?:(?:\\.|'[^']*'|"[^"]*")*[M%‰#,.]+)*(?:[Ee][+-]?0+)?)""")]
	private static partial Regex RegexMinutesTotalM();

	// Capture all D H or M characters in the number format, so that they can be replaced with zeros.
	[GeneratedRegex("""(?<=\G(?:\\.|'[^']*'|"[^"]*"|.)*?)[DHM]""")]
	private static partial Regex RegexTimeStampToNumberPattern();
}