using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.SharedKernel.Utilities;
namespace Deal.Modules.Pipeline.Application.Services;
///
/// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки.
///
public static class AiRawCardMapper
{
private const int MaxTitleCodePoints = 140;
private const int MaxFallbackTextCodePoints = 140;
private const string BudgetAmountPattern = @"[0-9.,]+\s*[кkКK]?";
// Строковые значения bool-полей, которые считаются «ложью».
private static readonly IReadOnlySet FalsyAnswerValues =
new HashSet(StringComparer.Ordinal) { "0", BoolText.False, "no", "нет", "null", "none" };
// Регулярное выражение числа бюджета: число + необязательные «к»/валюта-суффиксы.
private static readonly System.Text.RegularExpressions.Regex BudgetNumRe =
new(@"\A" + BudgetAmountPattern + @"(?:руб|р|₽)?\z", System.Text.RegularExpressions.RegexOptions.CultureInvariant);
///
/// Маппит JSON-ответ модели в контрактный разбор карточки.
///
/// Сырой JSON-ответ модели (ClassifyReply.Json; типовую схему задаёт промпт).
/// Исходный текст сообщения.
/// Разбор: структурированный блок «О заявке»/суть, стек, бюджет, контакты, тип/спам/доска.
/// Ответ не объект/не разбирается — «ИИ не дал разбора» (ветка aiFail воркера).
public static AiParsedCardDto Map(string json, string text)
{
JsonObject? root = JsonNode.Parse(json) as JsonObject
?? throw new JsonException("Ответ ИИ-классификатора — не JSON-объект (ok=true, но схема нарушена).");
string title = MessageTextCleaner.CleanShort(ReadRawString(root, AiRawCardKeys.Title), MaxTitleCodePoints);
if (title.Length == 0)
{
title = MessageTextCleaner.CleanShort(text, MaxFallbackTextCodePoints); // python L455 fallback
}
IReadOnlyList stack = ReadStack(root);
AiBudgetDto? budget = ReadBudget(root);
IReadOnlyList contacts = ReadContacts(root, text);
string? board = ReadRawString(root, AiRawCardKeys.Board).Trim();
if (board.Length == 0)
{
board = null;
}
return new AiParsedCardDto(
Title: title,
Company: ReadNullableString(root, AiRawCardKeys.Company),
Format: ReadNullableString(root, AiRawCardKeys.Format),
Task: ReadNullableString(root, AiRawCardKeys.Task),
Requirements: ReadItems(root, AiRawCardKeys.Requirements),
Plus: ReadItems(root, AiRawCardKeys.Plus),
Conditions: ReadNullableString(root, AiRawCardKeys.Conditions),
Summary: ReadNullableString(root, AiRawCardKeys.Summary),
Stack: stack,
Budget: budget,
Contacts: contacts,
IsVacancy: ReadBool(root, AiRawCardKeys.IsVacancy),
IsVacancyKnown: ReadBool(root, AiRawCardKeys.IsVacancyKnown), // false — воркер стемпит true после успешного ИИ
IsSpam: ReadBool(root, AiRawCardKeys.IsSpam),
Board: board);
}
private static string ReadRawString(JsonObject root, string field)
{
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
{
return string.Empty;
}
return node is JsonValue value ? ValueToString(value) : string.Empty;
}
private static string? ReadNullableString(JsonObject root, string field)
{
string value = ReadRawString(root, field);
return value.Length == 0 ? null : value;
}
private static bool ReadBool(JsonObject root, string field)
{
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
{
return false;
}
if (node is not JsonValue value)
{
return false;
}
if (value.TryGetValue(out bool flag))
{
return flag;
}
if (value.TryGetValue(out int number))
{
return number != 0;
}
if (value.TryGetValue(out string? text))
{
string lower = (text ?? string.Empty).Trim().ToLowerInvariant();
return lower.Length > 0 && !FalsyAnswerValues.Contains(lower);
}
return false;
}
private static IReadOnlyList? ReadItems(JsonObject root, string field)
{
if (!root.TryGetPropertyValue(field, out JsonNode? node) || node is null)
{
return null;
}
IReadOnlyList normalized = node is JsonValue scalarValue
? MessageListNormalizer.NormalizeList(NodeToString(scalarValue)) // python: строка разбивается по [;|\n]
: node is JsonArray array
? MessageListNormalizer.NormalizeList(array.Select(NodeToString))
: Array.Empty();
var result = new List(normalized.Count);
foreach (string item in normalized)
{
string cleaned = MessageTextCleaner.CleanShort(item);
if (cleaned.Length > 0)
{
result.Add(cleaned);
}
}
return result;
}
private static IReadOnlyList ReadStack(JsonObject root)
{
if (!root.TryGetPropertyValue(AiRawCardKeys.Stack, out JsonNode? node) || node is null)
{
return Array.Empty();
}
return node is JsonValue value
? MessageListNormalizer.NormalizeStack(ValueToString(value))
: node is JsonArray array
? MessageListNormalizer.NormalizeStack(array.Select(NodeToString))
: Array.Empty();
}
private static AiBudgetDto? ReadBudget(JsonObject root)
{
if (!root.TryGetPropertyValue(AiRawCardKeys.Budget, out JsonNode? node) || node is not JsonObject budget)
{
return null;
}
string currency = ReadRawString(budget, AiRawCardKeys.Currency);
if (currency.Length == 0)
{
currency = ReadRawString(budget, AiRawCardKeys.CurrencyShort); // ai.py L327: currency или cur
}
double? from = ParseBudgetBound(budget, AiRawCardKeys.From);
double? to = ParseBudgetBound(budget, AiRawCardKeys.To);
CardBudgetDto? normalized = BudgetNormalizer.Normalize(new BudgetRangeDto(from, to, currency));
return normalized is null
? null
: new AiBudgetDto(normalized.From, normalized.To, normalized.Cur);
}
private static double? ParseBudgetBound(JsonObject budget, string field)
{
if (!budget.TryGetPropertyValue(field, out JsonNode? node) || node is null)
{
return null;
}
if (node is JsonValue value && value.TryGetValue(out double direct))
{
return direct == 0 ? null : direct; // python L313: x == 0 → None
}
string text = ReadRawString(budget, field).Replace("\u00a0", string.Empty).Replace(" ", string.Empty).ToLowerInvariant();
if (text.Length == 0)
{
return null;
}
double multiplier = 1.0;
if (text.EndsWith('к') || text.EndsWith('k'))
{
multiplier = 1000.0;
text = text[..^1];
}
if (text.EndsWith("руб", StringComparison.Ordinal))
{
text = text[..^3];
}
else if (text.EndsWith('р') || text.EndsWith('₽'))
{
text = text[..^1];
}
if (!BudgetNumRe.IsMatch(text) || !double.TryParse(text.Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double amount))
{
return null;
}
double result = amount * multiplier;
return result == 0 ? null : result;
}
private static IReadOnlyList ReadContacts(JsonObject root, string text)
{
List candidates = new();
if (root.TryGetPropertyValue(AiRawCardKeys.Contacts, out JsonNode? node) && node is not null)
{
if (node is JsonValue)
{
candidates.Add(ValueToString((JsonValue)node)); // строка: разобьётся разделителями ниже
}
else if (node is JsonArray array)
{
foreach (JsonNode? item in array)
{
switch (item)
{
case null:
continue;
case JsonValue value:
candidates.Add(ValueToString(value));
break;
case JsonObject contactObject:
{
string value = ReadRawString(contactObject, AiRawCardKeys.Value);
if (value.Length > 0)
{
candidates.Add(value);
}
break;
}
}
}
}
else if (candidates.Count == 0)
{
return Array.Empty();
}
}
IReadOnlyList qualified = ContactsQualifier.Build(
candidates.Count > 0 ? candidates : null, text);
var result = new List(qualified.Count);
foreach (CardContactDto contact in qualified)
{
result.Add(new AiContactDto(contact.Type, contact.Value));
}
return result;
}
private static string NodeToString(JsonNode? node)
=> node is JsonValue value ? ValueToString(value) : string.Empty;
private static string ValueToString(JsonValue value)
{
if (value.TryGetValue(out string? text))
{
return text ?? string.Empty;
}
if (value.TryGetValue(out bool flag))
{
return flag ? "true" : "false";
}
return value.ToJsonString().Trim('"');
}
}