using System.Globalization;
using System.Text.RegularExpressions;
namespace Deal.Modules.Kanban.Application.ColumnRules;
///
/// Парсер сумм из текста сообщения (rules.py extract_amounts L93–144, _norm_amount L62–73, _cur_from_tail L76–90).
///
///
/// Сохраняет смысл суммы: «от A до B»/«A–B» → from/to, «до B» → только верхняя граница, одна сумма/«от A»
/// → from=to. Валюту ищем сразу после суммы (символ «$ € ₽ ₮ £ ¥» или слово «usd eur rub … руб долл бакс»)
/// либо перед ней для «$1 200»; суффикс «к/К» в числе — тысячи («2к» → 2000, «1.5к$» → 1500 USD).
/// Суммы БЕЗ валюты игнорируются (прототип L98); повторное срабатывание конструкций на одном тексте
/// отсекается занятыми диапазонами (_free/_add L104–112) — порядок проходов 1:1 с прототипом.
/// Семантика 1:1, включая особенности: «usdt» словом после числа распознаётся как USD (первым срабатывает
/// startswith «usd», rules.py L82), «евро»/«рубли» словами не распознаются (в _CUR_WORDS их нет).
///
public static class AmountParser
{
// Цифры суммы: число с разделителями тысяч и опциональным суффиксом «к/К» (rules.py _AMT L58).
private const string Amount = @"\d[\d\s\u00a0]*(?:[.,]\d+)?[кkКK]?";
// Символы валют сразу после суммы (rules.py _CUR_SYMBOLS L15, порядок 1:1).
private static readonly (string Symbol, string Code)[] CurrencySymbols =
{
("$", "USD"), ("€", "EUR"), ("₽", "RUB"), ("₮", "USDT"), ("£", "GBP"), ("¥", "CNY"),
};
// Слова валют сразу после суммы (rules.py _CUR_WORDS L16, порядок 1:1 — важен для «usdt»).
private static readonly (string Word, string Code)[] CurrencyWords =
{
("usd", "USD"), ("eur", "EUR"), ("rub", "RUB"), ("usdt", "USDT"), ("gbp", "GBP"), ("cny", "CNY"),
("руб", "RUB"), ("долл", "USD"), ("бакс", "USD"),
};
// Проход 1: словесный диапазон «от A до B» (rules.py L115).
private static readonly Regex WordRangeRe = new(@"\bот\s+(" + Amount + @")\s+до\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 2: «до B» без пары «от…» — только верхняя граница (rules.py L121).
private static readonly Regex ToOnlyRe = new(@"\bдо\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 3: «от A» без верхней границы — считаем одной суммой (rules.py L127).
private static readonly Regex FromOnlyRe = new(@"\bот\s+(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 4: числовой диапазон «A–B» / «A - B» / «A до B» (rules.py L133).
private static readonly Regex NumericRangeRe = new("(" + Amount + @")\s*(?:[-–—]|\s+до\s+)\s*(" + Amount + @")", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
// Проход 5: одиночные суммы с валютой (rules.py L139).
private static readonly Regex AnyAmountRe = new(Amount, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
///
/// Разбирает суммы в тексте (rules.py extract_amounts L93–144).
///
/// Текст сообщения (сырой, как сохранил источник).
///
/// Список распознанных сумм/диапазонов с кодом валюты; пустой список — сумм с валютой нет.
/// Порядок — по проходам парсера (1:1 с прототипом), а не по позиции в тексте.
///
public static IReadOnlyList Parse(string? text)
{
string t = text ?? string.Empty;
if (string.IsNullOrWhiteSpace(t))
{
return Array.Empty();
}
var result = new List();
var occupied = new List<(int Start, int End)>();
// Свободен ли диапазон (не пересекается с уже занятым конструкцией выше) — прототип _free L106–107.
static bool IsFree(
List<(int Start, int End)> spans,
int start,
int end)
{
foreach ((int os, int oe) in spans)
{
if (start < oe && end > os)
{
return false;
}
}
return true;
}
void Add(
double? from,
double? to,
string? cur,
int start,
int end)
{
if (cur is not null && (from is not null || to is not null) && IsFree(occupied, start, end))
{
result.Add(new AmountRange(from, to, cur));
occupied.Add((start, end));
}
}
// 1) «от A до B» (+ валюта после B).
foreach (Match m in WordRangeRe.Matches(t))
{
double? a = NormalizeAmount(m.Groups[1].Value);
double? b = NormalizeAmount(m.Groups[2].Value);
string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length);
if (a is not null && b is not null && cur is not null)
{
Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length);
}
}
// 2) «до B» (без пары «от … до») — верхняя граница.
foreach (Match m in ToOnlyRe.Matches(t))
{
double? b = NormalizeAmount(m.Groups[1].Value);
string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
if (b is not null && cur is not null)
{
Add(null, b, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length);
}
}
// 3) «от A» без верхней границы — считаем одной суммой.
foreach (Match m in FromOnlyRe.Matches(t))
{
double? a = NormalizeAmount(m.Groups[1].Value);
string? cur = CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
if (a is not null && cur is not null)
{
Add(a, a, cur, m.Groups[1].Index, m.Groups[1].Index + m.Groups[1].Length);
}
}
// 4) Числовые диапазоны «A–B» / «A - B» / «A до B».
foreach (Match m in NumericRangeRe.Matches(t))
{
double? a = NormalizeAmount(m.Groups[1].Value);
double? b = NormalizeAmount(m.Groups[2].Value);
string? cur = CurFromTail(t, m.Groups[2].Index + m.Groups[2].Length)
?? CurFromTail(t, m.Groups[1].Index + m.Groups[1].Length);
if (a is not null && b is not null && cur is not null)
{
Add(a, b, cur, m.Groups[1].Index, m.Groups[2].Index + m.Groups[2].Length);
}
}
// 5) Одиночные суммы с валютой (не вошедшие в конструкции выше).
foreach (Match m in AnyAmountRe.Matches(t))
{
double? a = NormalizeAmount(m.Value);
string? cur = CurFromTail(t, m.Index + m.Length);
if (a is not null && cur is not null)
{
Add(a, a, cur, m.Index, m.Index + m.Length);
}
}
return result;
}
// Число из строки суммы: пробелы и неразрывные пробелы убираем, «,» — десятичный разделитель,
// суффикс «к/К» — множитель 1000 (прототип _norm_amount L62–73).
// raw: Сырая цифровая часть из regex (может содержать «к»/«К» на конце).
// Возвращает: Значение суммы или null при ошибке парсинга.
private static double? NormalizeAmount(string raw)
{
string s = raw.Replace("\u00a0", " ").Replace(" ", string.Empty).Replace(",", ".");
double multiplier = 1.0;
if (s.Length > 0)
{
char last = s[^1];
if (char.ToLowerInvariant(last) == 'k' || char.ToLowerInvariant(last) == 'к')
{
multiplier = 1000.0;
s = s[..^1];
}
}
return double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out double value)
? value * multiplier
: null;
}
// Код валюты рядом с суммой: символ/слово сразу после позиции либо символ за ≤3 символа до неё
// («$1 200», прототип _cur_from_tail L76–90).
// text: Весь текст сообщения.
// position: Позиция сразу после конца суммы (m.end).
// Возвращает: Код валюты или null, если валюты рядом нет.
private static string? CurFromTail(string text, int position)
{
// Окно до 12 символов после суммы (прототип: text[m_start:m_start+12].lower().strip()).
int tailLength = Math.Min(12, text.Length - position);
string tail = text.Substring(Math.Max(0, position), Math.Max(0, tailLength)).ToLowerInvariant().Trim();
foreach ((string symbol, string code) in CurrencySymbols)
{
if (tail.StartsWith(symbol, StringComparison.Ordinal))
{
return code;
}
}
foreach ((string word, string code) in CurrencyWords)
{
if (tail.StartsWith(word, StringComparison.Ordinal))
{
return code;
}
}
// Валюта перед числом («$1 200»): символ за ≤3 символа до конца суммы (прототип L86–89).
int headStart = Math.Max(0, position - 3);
string head = text.Substring(headStart, position - headStart).Trim();
foreach ((string symbol, string code) in CurrencySymbols)
{
if (head.EndsWith(symbol, StringComparison.Ordinal))
{
return code;
}
}
return null;
}
}