using System.Text.Json;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services;
///
/// Операции пространства «Выбранные» — partial-часть
///
public sealed partial class CardsService
{
///
/// 400 перенос по стадии
///
public const string UnknownStageDetail = "Неизвестная стадия";
private const string PlannedStage = CardsDefaultContainers.Planned;
private const string RejectedStage = CardsDefaultContainers.Rejected;
private const string TakenCommentText = "Взял в работу.";
///
/// 400 ссылка: пустой url после Trim.
///
public const string EmptyLinkDetail = "Пустая ссылка";
private const string HttpsUrlScheme = "https://";
private const string HttpUrlScheme = "http://";
// Ключ тела PATCH: заголовок (JSON-строка).
private const string PatchKeyTitle = "title";
// Ключ тела PATCH: краткое содержание (JSON-строка).
private const string PatchKeySummary = "summary";
// Ключ тела PATCH: контактная строка (JSON-строка).
private const string PatchKeyContact = "contact";
// Ключ тела PATCH: текст технического задания (JSON-строка).
private const string PatchKeyTzText = "tzText";
// Ключ тела PATCH: стек (JSON-массив строк либо null — очистка).
private const string PatchKeyStack = "stack";
// Ключ тела PATCH: бюджет (JSON-объект {from,to,cur} либо null/не-объект — очистка).
private const string PatchKeyBudget = "budget";
private static readonly CardBudgetDto ClearedBudget = new(From: null, To: null, Cur: string.Empty);
///
/// Карточки пространства «Выбранные»
///
/// Фильтр по контейнеру-стадии; null — все стадии «Выбранных».
/// Полные карточки в порядке UpdatedAt DESC; пусто — карточек нет.
public Task> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
{
return _store.ListSelectedCardsAsync(containerId, ct);
}
///
/// Ручное создание «локальной» карточки без внешнего источника.
///
/// Начальные поля карточки (тело POST /api/cards, см. ).
/// Созданная карточка (полное чтение после записи).
public async Task CreateLocalCardAsync(CardLocalCreateDto draft, CancellationToken ct)
{
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
DateTimeOffset receivedAt = DateTimeOffset.FromUnixTimeMilliseconds(nowMs);
string containerId = draft.ContainerId is not null && CardsDefaultContainers.Contains(draft.ContainerId)
? draft.ContainerId
: PlannedStage;
var snapshot = new CardSnapshot
{
Id = PrefixId.New(KanbanIdPrefixes.Card),
Col = containerId,
IsNew = false,
Local = true,
Title = draft.Title.Trim(),
Summary = draft.Summary,
Source = new SourceRef { Kind = SourceKinds.Local, ReceivedAt = receivedAt },
Content = new SourceContent { Text = draft.Summary },
ReceivedAt = receivedAt,
Stack = draft.Stack ?? Array.Empty(),
BudgetFrom = draft.Budget?.From,
BudgetTo = draft.Budget?.To,
BudgetCur = draft.Budget?.Cur ?? string.Empty,
Contact = draft.Contact,
TzText = draft.TzText,
History = new[] { new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, "createdLocal", null) },
};
await _store.AddCardAsync(snapshot, ct);
return await _store.GetCardAsync(snapshot.Id, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после создания: " + snapshot.Id);
}
///
/// «Взять в работу»
///
/// Id карточки (c_...).
/// Карточка в стадии planned.
/// Карточка не найдена.
public async Task TakeCardAsync(string cardId, CancellationToken ct)
{
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
if (CardsDefaultContainers.Contains(card.Col))
{
return card;
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, PlannedStage);
if (!await _store.MoveCardStageAsync(cardId, PlannedStage, entry, nowMs, ct))
{
// Карточка исчезла между чтением и переносом (гонка с удалением).
throw new NotFoundException(CardEntityName, cardId);
}
await _store.AddCommentAsync(
PrefixId.New(KanbanIdPrefixes.Comment), cardId, CommentAuthor, TakenCommentText, ct);
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
///
/// Точечная правка полей карточки по телу PATCH — presence-aware.
///
/// Id карточки (c_...).
/// Тело PATCH: ключ → JSON-значение (наличие ключа = поле меняется).
/// Обновлённая карточка.
/// Карточка не найдена.
public async Task PatchCardAsync(
string cardId,
IReadOnlyDictionary body,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(body);
bool updated = await _store.PatchCardAsync(cardId, ResolvePatch(body), ct);
if (!updated)
{
throw new NotFoundException(CardEntityName, cardId);
}
return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
}
///
/// Добавляет ссылку карточке
///
/// Id карточки (c_...).
/// Название ссылки; пустое после Trim → name = url.
/// URL ссылки (без схемы — добавится https://).
/// Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.
public async Task AddLinkAsync(
string cardId,
string name,
string url,
CancellationToken ct)
{
CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null)
{
return new CardResultDto(null, null);
}
string normalizedUrl = (url ?? string.Empty).Trim();
if (normalizedUrl.Length == 0)
{
return new CardResultDto(EmptyLinkDetail, null);
}
if (!normalizedUrl.StartsWith(HttpsUrlScheme, StringComparison.Ordinal)
&& !normalizedUrl.StartsWith(HttpUrlScheme, StringComparison.Ordinal))
{
normalizedUrl = HttpsUrlScheme + normalizedUrl;
}
string trimmedName = (name ?? string.Empty).Trim();
var link = new CardLinkDto(
PrefixId.New(KanbanIdPrefixes.Link),
trimmedName.Length == 0 ? normalizedUrl : trimmedName,
normalizedUrl);
if (!await _store.AddLinkAsync(cardId, link, ct))
{
return new CardResultDto(null, null);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId);
return new CardResultDto(null, saved);
}
///
/// Удаляет ссылку карточки по id
///
/// Id карточки (c_...).
/// Id удаляемой ссылки (pl_...).
/// Результат: Card=null без Error (404) | Card — карточка без ссылки.
public async Task RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
{
if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{
return new CardResultDto(null, null);
}
CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId);
return new CardResultDto(null, saved);
}
///
/// Перенос карточки по контейнерам-стадиям «Выбранных»
///
/// Id карточки (c_...).
/// Новый контейнер-стадия — id каталога .
/// Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.
public async Task MoveStageCardAsync(
string cardId,
string containerId,
CancellationToken ct)
{
if (!CardsDefaultContainers.Contains(containerId))
{
return new CardResultDto(UnknownStageDetail, null);
}
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var entry = new CardHistoryDto(PrefixId.New(KanbanIdPrefixes.History), nowMs, null, containerId);
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
if (!moved)
{
return new CardResultDto(null, null);
}
CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId);
return new CardResultDto(null, card);
}
///
/// Полная ручная очистка терминальной стадии «Отклонено» — hard-delete строк
///
/// Сколько карточек удалено (0 — стадия пуста).
public Task ClearRejectedAsync(CancellationToken ct)
{
return _store.ClearStageAsync(RejectedStage, ct);
}
private static CardPatch ResolvePatch(IReadOnlyDictionary body)
{
string? title = null;
string? summary = null;
string? contact = null;
string? tzText = null;
IReadOnlyList? stack = null;
CardBudgetDto? budget = null;
foreach ((string key, JsonElement value) in body)
{
switch (key)
{
case PatchKeyTitle:
if (ReadTextValue(value, out string parsedTitle))
{
title = parsedTitle;
}
break;
case PatchKeySummary:
if (ReadTextValue(value, out string parsedSummary))
{
summary = parsedSummary;
}
break;
case PatchKeyContact:
if (ReadTextValue(value, out string parsedContact))
{
contact = parsedContact;
}
break;
case PatchKeyTzText:
if (ReadTextValue(value, out string parsedTzText))
{
tzText = parsedTzText;
}
break;
case PatchKeyStack:
stack = ReadStack(value);
break;
case PatchKeyBudget:
budget = ReadBudget(value);
break;
}
}
return new CardPatch(title, summary, contact, tzText, stack, budget, null, null, null);
}
// Читает JSON-строку текстового поля; null/не-строка → ключ не применяется.
// element: Значение ключа тела.
// text: Прочитанная строка (при успехе).
// Возвращает: True — значение строковое (в т.ч. пустое — очистка текста).
private static bool ReadTextValue(JsonElement element, out string text)
{
if (element.ValueKind == JsonValueKind.String)
{
text = element.GetString() ?? string.Empty;
return true;
}
text = string.Empty;
return false;
}
// Читает стек: массив строк (не-строки отбрасываются); null/не-массив → пустой стек.
// element: Значение ключа stack.
// Возвращает: Новый стек (полная замена массива).
private static IReadOnlyList ReadStack(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Array)
{
return Array.Empty();
}
var items = new List();
foreach (JsonElement item in element.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
items.Add(item.GetString() ?? string.Empty);
}
}
return items;
}
// Читает бюджет: объект {from,to,cur} → пара границ + валюта; null/не-объект → очистка.
// element: Значение ключа budget.
// Возвращает: Бюджет патча: валюта пуста — «бюджета нет» (очистка).
private static CardBudgetDto ReadBudget(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object)
{
return ClearedBudget;
}
double? from = ReadNumber(element, "from");
double? to = ReadNumber(element, "to");
string cur = ReadText(element, "cur");
return new CardBudgetDto(from, to, cur);
}
// Читает число поля объекта бюджета: число → значение; иное — пусто.
// obj: Объект бюджета.
// fieldName: Имя поля (from/to).
// Возвращает: Граница либо null.
private static double? ReadNumber(JsonElement obj, string fieldName)
{
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.Number
? element.GetDouble()
: null;
}
// Читает строку поля объекта бюджета: строка → значение; отсутствие/иное → пусто.
// obj: Объект бюджета.
// fieldName: Имя поля (cur).
// Возвращает: Код валюты либо пустая строка.
private static string ReadText(JsonElement obj, string fieldName)
{
return obj.TryGetProperty(fieldName, out JsonElement element) && element.ValueKind == JsonValueKind.String
? element.GetString() ?? string.Empty
: string.Empty;
}
}