using System.Text;
using System.Text.RegularExpressions;
namespace Deal.Modules.Pipeline.Application.Parse;
///
/// Чистка текстовых полей сообщения/карточки от markdown-разметки, ссылок и служебных символов
/// (pipeline.py clean_short L148–156 / clean_block L158–193, регэкспы/эмодзи L131–145).
///
///
/// 1:1 с прототипом: markdown-ссылки [текст](url) → текст, **__`~~-пары снимаются,
/// ||спойлер|| и голые URL удаляются, «C#»/«F#» защищаются от вырезания решётки (письмо + #),
/// эмодзи-диапазоны, маркеры списков в начале строк и лишние переносы/пробелы схлопываются.
/// дополнительно переводит переносы в пробелы (заголовок); сохраняет структуру строк
/// («О заявке» карточки). — обрезка краёв строки (python _clean_line L682–683).
///
public static class MessageTextCleaner
{
// Защитный символ, временно заменяющий решётку после буквы («C#», прототип L172/L176).
private const string HashGuard = "\u2063";
// Символ zero-width space (фото-превью Telegram), удаляется (прототип L173).
private const string ZeroWidthSpace = "\u200b";
// Неразрывный пробел, заменяется обычным (прототип L174).
private const string NonBreakingSpace = "\u00a0";
// Markdown-ссылка [текст](url) → текст ссылки (прототип _MD_LINK_RE L131).
private static readonly Regex MarkdownLinkRe = new(@"\[([^\]]*)\]\([^)\s]+\)", RegexOptions.CultureInvariant);
// Жирный **текст** (прототип _MD_BOLD L598).
private static readonly Regex MarkdownBoldRe = new(@"\*\*(.+?)\*\*", RegexOptions.CultureInvariant);
// Жирный __текст__ (прототип _MD_BOLD2_RE L132).
private static readonly Regex MarkdownBold2Re = new(@"__([^_\n]+?)__", RegexOptions.CultureInvariant);
// Код `текст` (прототип _MD_CODE_RE L133).
private static readonly Regex MarkdownCodeRe = new(@"`([^`\n]+?)`", RegexOptions.CultureInvariant);
// Зачёркнутый ~~текст~~ (прототип _MD_STRIKE_RE L134).
private static readonly Regex MarkdownStrikeRe = new(@"~~([^~\n]+?)~~", RegexOptions.CultureInvariant);
// Голая URL-ссылка (прототип _BARE_URL_RE L135).
private static readonly Regex BareUrlRe = new(@"https?://[^\s<>""']+", RegexOptions.CultureInvariant);
// Защита решётки в составе названия: «C#»/«F#» (прототип L172).
private static readonly Regex HashProtectRe = new(@"\b([A-Za-zА-Яа-яЁё])\#", RegexOptions.CultureInvariant);
// Служебные символы markdown в тексте (прототип L175).
private static readonly Regex MarkdownSymbolsRe = new("[*`#>~]+", RegexOptions.CultureInvariant);
// Маркеры списков/декоративные буллеты в начале строк (прототип L179).
private static readonly Regex LineStartMarkersRe = new(@"(?m)^[\s>#*\-–—•▪▫●○‣]+\s*", RegexOptions.CultureInvariant);
// Схлопывание пробелов и табуляций (прототип L180).
private static readonly Regex SpaceCollapseRe = new(@"[ \t]+", RegexOptions.CultureInvariant);
// Пробелы/табуляции после переноса строки (прототип L181).
private static readonly Regex LineIndentRe = new(@"\n[ \t]+", RegexOptions.CultureInvariant);
// Подряд идущие переносы строк → один (прототип L182).
private static readonly Regex MultiNewlineRe = new(@"\n{2,}", RegexOptions.CultureInvariant);
// Переносы строк в пробелы для clean_short (прототип L155).
private static readonly Regex NewlinesToSpaceRe = new(@"\n+", RegexOptions.CultureInvariant);
// Служебные символы на краях строки (python _MD_EDGES L597).
private static readonly Regex LineEdgesRe = new(@"^[\s*>#_~]+|[\s*>#_~]+$", RegexOptions.CultureInvariant);
// Символы, обрезаемые с краёв готового блока (прототип L183).
private static readonly char[] EdgeTrimChars = " \t\n\r-–—·•|:;,".ToCharArray();
// Одноразовые кодовые точки-разделители для ручного прохода символов.
private const int EmptyCodePoint = -1;
///
/// Чистит текстовое поле в одну строку (заголовок, суть без структуры): как ,
/// но переносы строк схлопываются в пробелы (python clean_short L148–156).
///
/// Сырой текст (markdown/ссылки/эмодзи); null → пустая строка (как str(text or "")).
/// Максимум кодовых точек результата; обрезка по границе переноса/пробела с многоточием
/// (L184–192); null/0 — без обрезки.
/// Очищенный однострочный текст.
public static string CleanShort(string? text, int? limit = null)
{
return NewlinesToSpaceRe.Replace(CleanBlock(text, limit), " ");
}
///
/// Чистит блок текста с сохранением переносов строк (python clean_block L158–193): применяет все
/// шаги прототипа в том же порядке (markdown → URL → защита «C#» → эмодзи → маркеры списков → пробелы →
/// обрезка краёв → лимит по границе).
///
/// Сырой текст; null → пустая строка.
/// Максимум кодовых точек результата; обрезка по последнему переносу/пробелу ближе
/// середины лимита, иначе жёсткая по лимиту; в конец добавляется «…». null/0 — без обрезки.
/// Очищенный текст с сохранённой структурой строк.
public static string CleanBlock(string? text, int? limit = null)
{
string s = text ?? string.Empty;
// Порядок 1:1 с прототипом (L163–183).
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 (python _clean_line L682–683):
/// убирает [\s>*#_~] с краёв и тримит.
///
/// Строка текста (может быть null — как None в python).
/// Строка без краевого мусора (пустая, если мусора было больше).
public static string CleanLine(string? line)
{
return LineEdgesRe.Replace(line ?? string.Empty, string.Empty).Trim();
}
// Количество кодовых точек в строке (python len() — позиции суррогатных пар считаются одной).
// value: Строка (не null).
// Возвращает: Число кодовых точек.
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;
}
// Первые max кодовых точек строки (python-срез s[:max] без разрыва
// суррогатных пар).
// value: Строка.
// max: Максимум кодовых точек; ≤0 или ≥ длины — строка как есть.
// Возвращает: Усечённая строка.
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();
}
// Убирает декоративные эмодзи/символы-маркеры (python _EMOJI_RE L137–145): доп. пиктограммы
// 1F000–1FAFF (включая региональные флаги 1F1E6–1F1FF), разные символы 2600–27BF, стрелки 2B00–2BFF
// и variation selector FE0F.
// value: Текст после снятия markdown-разметки.
// Возвращает: Текст без эмодзи-диапазонов (суррогатные пары удаляются целиком).
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 (IsEmojiCodePoint(codePoint))
{
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;
}
// Входит ли кодовая точка в эмодзи-диапазоны прототипа (L137–145).
// codePoint: Кодовая точка (BMP или доп. плоскость).
// Возвращает: True — декоративный символ, подлежащий удалению.
private static bool IsEmojiCodePoint(int codePoint)
{
return codePoint is >= 0x1F000 and <= 0x1FAFF
or >= 0x2600 and <= 0x27BF
or >= 0x2B00 and <= 0x2BFF
or 0xFE0F;
}
// Обрезка по границе последнего переноса/пробела в первых limit кодовых точках
// (прототип L184–192): выбирается перенос (или пробел), если он после середины лимита; иначе режем жёстко.
// value: Текст длиннее лимита.
// limit: Лимит кодовых точек.
// Возвращает: Усечённый текст с многоточием в конце.
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() + "…";
}
}