using System.Text;
using System.Text.RegularExpressions;
namespace Deal.Modules.Pipeline.Application.Parse;
///
/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов.
///
public static class MessageTextCleaner
{
private const string HashGuard = "\u2063";
private const string ZeroWidthSpace = "\u200b";
private const string NonBreakingSpace = "\u00a0";
private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant);
private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant);
private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant);
private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant);
private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant);
private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant);
private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant);
private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant);
private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant);
private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant);
private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray();
// Одноразовые кодовые точки-разделители для ручного прохода символов.
private const int EmptyCodePoint = -1;
///
/// Чистит текстовое поле в одну строку
///
/// Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как str(text or "")).
/// Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием; null/0 — без обрезки.
/// Очищенный однострочный текст.
public static string CleanShort(string? text, int? limit = null)
{
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
}
///
/// Чистит блок текста с сохранением переносов строк
///
/// Сырой текст; null → пустая строка.
/// Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки.
/// Очищенный текст с сохранённой структурой строк.
public static string CleanBlock(string? text, int? limit = null)
{
string s = text ?? string.Empty;
s = MarkdownLinkRe.Replace(s, m => m.Groups[1].Value.Trim());
s = MarkdownBoldRe.Replace(s, "$1");
s = MarkdownBold2Re.Replace(s, "$1");
s = MarkdownCodeRe.Replace(s, "$1");
s = MarkdownStrikeRe.Replace(s, "$1");
s = s.Replace("||", string.Empty, StringComparison.Ordinal);
s = BareUrlRe.Replace(s, " ");
s = HashProtectRe.Replace(s, m => m.Groups[1].Value + HashGuard);
s = s.Replace(ZeroWidthSpace, string.Empty, StringComparison.Ordinal);
s = s.Replace(NonBreakingSpace, " ", StringComparison.Ordinal);
s = MarkdownSymbolsRe.Replace(s, " ");
s = s.Replace(HashGuard, "#", StringComparison.Ordinal);
s = RemoveEmojiCodePoints(s);
s = LineStartMarkersRe.Replace(s, string.Empty);
s = SpaceCollapseRe.Replace(s, " ");
s = LineIndentRe.Replace(s, "\n");
s = MultiNewlineRe.Replace(s, "\n");
s = s.Trim(EdgeTrimChars);
if (limit is > 0 && CountCodePoints(s) > limit.Value)
{
s = CutByBoundary(s, limit.Value);
}
return s;
}
///
/// Обрезает строку по краевым служебным символам markdown
///
/// Строка текста.
/// Строка без краевого мусора (пустая, если мусора было больше).
public static string CleanLine(string? line)
{
return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim();
}
internal static int CountCodePoints(string value)
{
int count = 0;
for (int index = 0; index < value.Length; index++)
{
count++;
if (char.IsHighSurrogate(value[index]) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1]))
{
index++;
}
}
return count;
}
internal static string SliceCodePoints(string value, int max)
{
if (max <= 0)
{
return string.Empty;
}
if (CountCodePoints(value) <= max)
{
return value;
}
var builder = new StringBuilder(value.Length);
int taken = 0;
for (int index = 0; index < value.Length && taken < max; index++)
{
bool pair = char.IsHighSurrogate(value[index])
&& index + 1 < value.Length
&& char.IsLowSurrogate(value[index + 1]);
builder.Append(value[index]);
if (pair)
{
index++;
builder.Append(value[index]);
}
taken++;
}
return builder.ToString();
}
private static string RemoveEmojiCodePoints(string value)
{
var builder = new StringBuilder(value.Length);
for (int index = 0; index < value.Length; index++)
{
int codePoint = DecodeCodePoint(value, index, out int length);
if (codePoint == EmptyCodePoint)
{
builder.Append(value[index]); // непарный суррогат: не эмодзи — сохраняем как есть (1:1 python)
continue;
}
if (codePoint.IsEmojiCodePoint())
{
index += length - 1;
continue;
}
builder.Append(value, index, length);
index += length - 1;
}
return builder.ToString();
}
// Кодовая точка с позиции строки (суррогатная пара — целиком).
// value: Строка.
// index: Позиция символа.
// length: Длина последовательности в UTF-16 единицах (1 или 2).
// Возвращает: Кодовая точка или EmptyCodePoint для непарного суррогата.
private static int DecodeCodePoint(
string value,
int index,
out int length)
{
char current = value[index];
if (char.IsHighSurrogate(current) && index + 1 < value.Length && char.IsLowSurrogate(value[index + 1]))
{
length = 2;
return char.ConvertToUtf32(current, value[index + 1]);
}
if (char.IsLowSurrogate(current) || char.IsHighSurrogate(current))
{
length = 1;
return EmptyCodePoint;
}
length = 1;
return current;
}
private static string CutByBoundary(string value, int limit)
{
string cut = SliceCodePoints(value, limit);
int lineBreak = cut.LastIndexOf('\n');
int space = cut.LastIndexOf(' ');
int at = lineBreak > limit / 2
? lineBreak
: (space > limit / 2 ? space : -1);
if (at >= 0)
{
cut = cut[..at];
}
return cut.TrimEnd() + "…";
}
}