using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using GmRelay.Shared.Domain; using Microsoft.Extensions.Logging; namespace GmRelay.Shared.Features.Sessions.CreateSession.Wizard; /// /// Central state machine for the game/pool creation wizard. Lives in /// GmRelay.Shared so it can be driven from any platform /// messenger. Platform-specific code (Telegram.Bot, /// NetCord, …) lives in the corresponding adapter and converts /// its native update type into a before /// calling . /// public sealed class GameCreationWizard { private readonly IWizardDraftRepository _drafts; private readonly IWizardMessenger _messenger; private readonly ILogger _log; public GameCreationWizard( IWizardDraftRepository drafts, IWizardMessenger messenger, ILogger log) { _drafts = drafts; _messenger = messenger; _log = log; } /// /// Handle a single user interaction with the wizard. Adapters should /// map their native event (Telegram Update, Discord /// interaction, …) into a first. /// public async Task HandleInteractionAsync( WizardInteraction interaction, WizardDraft draft, CancellationToken ct) { try { if (interaction.CallbackPayload is not null) { await HandleCallbackAsync(draft, interaction, ct); } else { await HandleTextAsync(draft, interaction, ct); } } catch (WizardStorageException) { if (interaction.CallbackPayload is not null) { await _messenger.AnswerInteractionAsync( interaction.InteractionId, "💥 Ошибка хранилища, попробуйте /newsession", ct); } } catch (Exception ex) { _log.LogError(ex, "Wizard interaction failed for draft {DraftId}", draft.Id); if (interaction.CallbackPayload is not null) { try { await _messenger.AnswerInteractionAsync( interaction.InteractionId, "⚠️ Ошибка", ct); } catch { /* swallow — we're already in error path */ } } } } private async Task HandleCallbackAsync( WizardDraft draft, WizardInteraction interaction, CancellationToken ct) { if (!WizardCallbackData.TryParse(interaction.CallbackPayload, out var action, out var step, out var choice)) { await _messenger.AnswerInteractionAsync(interaction.InteractionId, "Неизвестная команда", ct); return; } switch (action) { case "cancel": await _drafts.DeleteAsync(draft.Id, ct); await _messenger.EditDraftMessageAsync( draft, "❌ Мастер отменён.", Array.Empty(), ct); await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct); return; case "back": ApplyBack(draft, step); await PersistAndRenderAsync(draft, interaction.InteractionId, ct); return; case "create": // Routed by the platform's CreateSessionHandler, not here. await _messenger.AnswerInteractionAsync(interaction.InteractionId, null, ct); return; default: // For "Choice" callbacks, action == step. await ApplyChoiceAsync(draft, step, choice, interaction.InteractionId, ct); return; } } private async Task HandleTextAsync( WizardDraft draft, WizardInteraction interaction, CancellationToken ct) { if (interaction.Text is not { } text) { // Photo or other non-text — handle cover step only. if (interaction.PhotoFileId is { } fileId && draft.Step == WizardStepNames.Cover) { ApplyCoverPhoto(draft, fileId); await PersistAndRenderAsync(draft, null, ct); } return; } var (nextStep, error, payload) = ApplyText(draft, text); if (payload is { } p) SavePayload(draft, p); if (error is { } errMsg) { // Re-render the same step with ⚠️ prefix. var (rendered, actions) = WizardStepViewBuilder.Build(draft, LoadPayload(draft)); await _messenger.EditDraftMessageAsync( draft, "⚠️ " + errMsg + "\n\n" + rendered, actions, ct); return; } if (nextStep is { } step) { draft.Step = step; } await PersistAndRenderAsync(draft, null, ct); } private async Task ApplyChoiceAsync( WizardDraft draft, string step, string choice, string interactionId, CancellationToken ct) { var (nextStep, error, payload) = ApplyChoice(draft, step, choice); if (error is { } err) { await _messenger.AnswerInteractionAsync(interactionId, err, ct); return; } if (payload is { } p) SavePayload(draft, p); if (nextStep is { } s) { draft.Step = s; } await PersistAndRenderAsync(draft, interactionId, ct); } private async Task PersistAndRenderAsync(WizardDraft draft, string? interactionId, CancellationToken ct) { draft.UpdatedAt = DateTime.UtcNow; await _drafts.UpsertAsync(draft, ct); var payload = LoadPayload(draft); IReadOnlyList? clubs = null; if (draft.Step == WizardStepNames.PickClub) { clubs = await _messenger.GetOwnerClubsAsync(draft.OwnerId, ct); } var (text, actions) = WizardStepViewBuilder.Build(draft, payload, clubs); await _messenger.EditDraftMessageAsync(draft, text, actions, ct); if (interactionId is { } id) { await _messenger.AnswerInteractionAsync(id, null, ct); } } // ── Text input dispatcher ───────────────────────────────────────── private static (string? nextStep, string? error, WizardPayload payload) ApplyText(WizardDraft draft, string input) { var payload = LoadPayload(draft); switch (draft.Step) { case WizardStepNames.Title: return ValidateText(input, WizardStepLimits.MaxTitleLength, "Название не может быть пустым", "Слишком длинное название", out var title) ? (WizardStepNames.Description, SetTitle(payload, title), payload) : (null, title, payload); case WizardStepNames.Description: if (input == "-") return (WizardStepNames.Cover, SetDescription(payload, null), payload); return ValidateText(input, WizardStepLimits.MaxDescriptionLength, "Описание не может быть пустым", "Слишком длинное описание", out var desc) ? (WizardStepNames.Cover, SetDescription(payload, desc), payload) : (null, desc, payload); case WizardStepNames.Cover: if (input == "-") return (NextAfterCover(payload), SetImageUrl(payload, null), payload); if (Uri.TryCreate(input, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) return (NextAfterCover(payload), SetImageUrl(payload, input), payload); return (null, "Некорректный URL", payload); case WizardStepNames.System when payload.System is null: // "Other" branch — only active if free-text was offered. return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var sys) ? (WizardStepNames.Duration, SetSystem(payload, sys), payload) : (null, sys, payload); case WizardStepNames.Duration when payload.DurationMinutes is null: return TryParseHours(input, out var durMin) ? (WizardStepNames.DateTime, SetDurationMinutes(payload, durMin), payload) : (null, "Неверная длительность (1..12 ч)", payload); case WizardStepNames.DateTime: return MoscowTime.TryParseMoscow(input, out var dt) && dt > DateTimeOffset.UtcNow ? (WizardStepNames.Capacity, SetScheduledAt(payload, dt), payload) : (null, dt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.Capacity when payload.Single?.MaxPlayers is null: return int.TryParse(input, out var cap) && cap >= WizardStepLimits.MinCapacity && cap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.Visibility, SetMaxPlayers(payload, cap), payload) : (null, "Лимит должен быть 1..50", payload); case WizardStepNames.PoolSystemDuration when payload.System is null: return ValidateText(input, WizardStepLimits.MaxSystemLength, "Слишком длинное название системы", "Слишком длинное название системы", out var psys) ? (WizardStepNames.PoolSystemDuration, SetSystem(payload, psys), payload) : (null, psys, payload); case WizardStepNames.PoolSystemDuration when payload.DurationMinutes is null: return TryParseHours(input, out var pdur) ? (WizardStepNames.Visibility, SetDurationMinutes(payload, pdur), payload) : (null, "Неверная длительность (1..12 ч)", payload); case WizardStepNames.PoolSlotDateTime: return MoscowTime.TryParseMoscow(input, out var slotDt) && slotDt > DateTimeOffset.UtcNow ? (WizardStepNames.PoolSlotCapacity, SetCurrentSlotDateTime(payload, slotDt), payload) : (null, slotDt == default ? "Не удалось разобрать дату" : "Дата в прошлом", payload); case WizardStepNames.PoolSlotCapacity: return int.TryParse(input, out var slotCap) && slotCap >= WizardStepLimits.MinCapacity && slotCap <= WizardStepLimits.MaxCapacity ? (WizardStepNames.PoolAddSlots, SetCurrentSlotMaxPlayers(payload, slotCap), payload) : (null, "Лимит должен быть 1..50", payload); default: return (null, "Ожидается выбор кнопкой", payload); } } // ── Callback (button) dispatcher ────────────────────────────────── private static (string? nextStep, string? error, WizardPayload payload) ApplyChoice(WizardDraft draft, string step, string choice) { var payload = LoadPayload(draft); var (next, err) = step switch { WizardStepNames.Type => ApplyTypeChoice(payload, choice), WizardStepNames.System => ApplySystemChoice(payload, choice), WizardStepNames.Duration => ApplyDurationChoice(payload, choice), WizardStepNames.Capacity => ApplyCapacityChoice(payload, choice), WizardStepNames.Visibility => ApplyVisibilityChoice(payload, choice), WizardStepNames.PickClub => ApplyPickClubChoice(payload, choice), WizardStepNames.Publish => ApplyPublishChoice(payload, choice), WizardStepNames.PoolSystemDuration => ApplyPoolSystemDurationChoice(payload, choice), WizardStepNames.PoolAddSlots => ApplyPoolAddSlotsChoice(payload, choice), WizardStepNames.PoolSlotCapacity => ApplyPoolSlotCapacityChoice(payload, choice), _ => (null, "Неизвестный шаг"), }; return (next, err, payload); } private static (string?, string?) ApplyTypeChoice(WizardPayload p, string choice) => choice switch { "single" => (WizardStepNames.Title, SetType(p, WizardCreationType.Single)), "pool" => (WizardStepNames.Title, SetType(p, WizardCreationType.Pool)), _ => (null, "Неизвестный выбор"), }; private static (string?, string?) ApplySystemChoice(WizardPayload p, string choice) => choice switch { "_other" => (WizardStepNames.System, null), // stay, await text "_skip" => (NextAfterSystem(p), SetSystem(p, null)), { } code => (WizardStepNames.Duration, SetSystem(p, code)), }; private static (string?, string?) ApplyDurationChoice(WizardPayload p, string choice) => choice switch { "_other" => (WizardStepNames.Duration, null), "_skip" => (NextAfterDuration(p), SetDurationMinutes(p, null)), { } d => int.TryParse(d, out var min) ? (NextAfterDuration(p), SetDurationMinutes(p, min)) : (null, "Неверная длительность"), }; private static (string?, string?) ApplyCapacityChoice(WizardPayload p, string choice) => choice switch { "waitlist:on" => (WizardStepNames.Visibility, SetWaitlist(p, true)), "waitlist:off" => (WizardStepNames.Visibility, SetWaitlist(p, false)), "no_limit" => (WizardStepNames.Visibility, SetMaxPlayers(p, null)), _ => (null, "Неизвестный выбор"), }; private static (string?, string?) ApplyVisibilityChoice(WizardPayload p, string choice) => choice switch { "public" => (NextAfterVisibility(p), SetVisibility(p, WizardVisibility.Public)), "club" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Club)), "members" => (WizardStepNames.PickClub, SetVisibility(p, WizardVisibility.Members)), "pickclub" => (WizardStepNames.PickClub, null), _ => (null, "Неизвестный выбор"), }; private static (string?, string?) ApplyPickClubChoice(WizardPayload p, string choice) => Guid.TryParse(choice, out var id) ? (NextAfterVisibility(p), SetClubId(p, id)) : (null, "Неверный идентификатор клуба"); private static (string?, string?) ApplyPublishChoice(WizardPayload p, string choice) => choice switch { "yes" => (WizardStepNames.Confirm, SetPublishInShowcase(p, true)), "no" => (WizardStepNames.Confirm, SetPublishInShowcase(p, false)), _ => (null, "Неизвестный выбор"), }; private static (string?, string?) ApplyPoolSystemDurationChoice(WizardPayload p, string choice) => choice switch { "_custom" => (WizardStepNames.PoolSystemDuration, null), { } c when c.Contains(':') => SplitSystemDuration(c) is (var sys, var dur) ? (WizardStepNames.Visibility, SetSystem(p, sys) ?? SetDurationMinutes(p, dur)) : (null, "Неверный выбор"), _ => (null, "Неизвестный выбор"), }; private static (string?, string?) ApplyPoolAddSlotsChoice(WizardPayload p, string choice) => choice switch { "add" => BeginNewPoolSlot(p), "done" => p.Pool?.Slots.Count > 0 ? (WizardStepNames.PoolConfirm, null) : (null, "Добавьте хотя бы один слот"), _ => (null, "Неизвестный выбор"), }; private static (string?, string?) ApplyPoolSlotCapacityChoice(WizardPayload p, string choice) => choice switch { "waitlist:on" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, true)), "waitlist:off" => (WizardStepNames.PoolAddSlots, CommitCurrentPoolSlot(p, false)), _ => (null, "Неизвестный выбор"), }; // ── Back navigation ─────────────────────────────────────────────── private static void ApplyBack(WizardDraft draft, string fromStep) { // The callback's "step" portion is the step the user is currently on (e.g. the // Confirm button emits `wizard:back` with no step, in which case we fall back to // the draft's current step). Both should produce the same result. var current = string.IsNullOrEmpty(fromStep) ? draft.Step : fromStep; var payload = LoadPayload(draft); var previous = PreviousStep(current, payload); if (previous is { } step) draft.Step = step; } private static string? PreviousStep(string step, WizardPayload p) => step switch { WizardStepNames.Title => null, // first step WizardStepNames.Description => WizardStepNames.Title, WizardStepNames.Cover => WizardStepNames.Description, WizardStepNames.System => WizardStepNames.Cover, WizardStepNames.Duration => WizardStepNames.System, WizardStepNames.DateTime => WizardStepNames.Duration, WizardStepNames.Capacity => WizardStepNames.DateTime, WizardStepNames.Visibility => WizardStepNames.Capacity, WizardStepNames.PickClub => WizardStepNames.Visibility, WizardStepNames.Publish => WizardStepNames.PickClub, WizardStepNames.Confirm => WizardStepNames.Publish, WizardStepNames.PoolSystemDuration => null, // first pool step WizardStepNames.PoolAddSlots => WizardStepNames.PoolSystemDuration, WizardStepNames.PoolSlotDateTime => WizardStepNames.PoolAddSlots, WizardStepNames.PoolSlotCapacity => WizardStepNames.PoolSlotDateTime, WizardStepNames.PoolConfirm => WizardStepNames.PoolAddSlots, _ => null, }; // ── Payload I/O ─────────────────────────────────────────────────── public static WizardPayload LoadPayload(WizardDraft draft) { if (string.IsNullOrEmpty(draft.PayloadJson)) return new WizardPayload(); return System.Text.Json.JsonSerializer.Deserialize( draft.PayloadJson, WizardPayloadJsonContext.Default.WizardPayload) ?? new WizardPayload(); } private static void SavePayload(WizardDraft draft, WizardPayload payload) { draft.PayloadJson = System.Text.Json.JsonSerializer.Serialize( payload, WizardPayloadJsonContext.Default.WizardPayload); } // Mutators — return the error message if any (kept here to centralise flow). private static string? SetTitle(WizardPayload p, string v) { p.Title = v; return null; } private static string? SetDescription(WizardPayload p, string? v) { p.Description = v; return null; } private static string? SetImageUrl(WizardPayload p, string? v) { p.ImageUrl = v; p.ImageFileId = null; return null; } private static void ApplyCoverPhoto(WizardDraft d, string fileId) { var p = LoadPayload(d); p.ImageFileId = fileId; p.ImageUrl = null; SavePayload(d, p); var next = NextAfterCover(p); if (next is { } s) d.Step = s; } private static string? SetSystem(WizardPayload p, string? v) { p.System = v; return null; } private static string? SetDurationMinutes(WizardPayload p, int? v) { p.DurationMinutes = v; return null; } private static string? SetScheduledAt(WizardPayload p, DateTimeOffset v) { p.Single ??= new WizardSingleInput(); p.Single.ScheduledAt = v; return null; } private static string? SetMaxPlayers(WizardPayload p, int? v) { p.Single ??= new WizardSingleInput(); p.Single.MaxPlayers = v; return null; } private static string? SetWaitlist(WizardPayload p, bool v) { p.Waitlist = v; return null; } private static string? SetVisibility(WizardPayload p, WizardVisibility? v) { p.Visibility = v; return null; } private static string? SetClubId(WizardPayload p, Guid v) { p.ClubId = v; return null; } private static string? SetType(WizardPayload p, WizardCreationType v) { p.Type = v; return null; } private static string? SetPublishInShowcase(WizardPayload p, bool v) { p.PublishInShowcase = v; return null; } private static string? SetCurrentSlotDateTime(WizardPayload p, DateTimeOffset v) { p.Pool ??= new WizardPoolInput(); var current = EnsureCurrentPoolSlot(p); current.ScheduledAt = v; return null; } private static string? SetCurrentSlotMaxPlayers(WizardPayload p, int v) { p.Pool ??= new WizardPoolInput(); var current = EnsureCurrentPoolSlot(p); current.MaxPlayers = v; return null; } private static string? CommitCurrentPoolSlot(WizardPayload p, bool waitlist) { p.Pool ??= new WizardPoolInput(); var current = EnsureCurrentPoolSlot(p); current.Waitlist = waitlist; return null; } private static (string? nextStep, string? error) BeginNewPoolSlot(WizardPayload p) { p.Pool ??= new WizardPoolInput(); p.Pool.Slots.Add(new WizardSlotInput()); return (WizardStepNames.PoolSlotDateTime, null); } private static WizardSlotInput EnsureCurrentPoolSlot(WizardPayload p) { // Slots added via BeginNewPoolSlot are always committed before they leave the // PoolSlotCapacity step (CommitCurrentPoolSlot). If we somehow get here without // a slot, start a new one to keep the flow recoverable. p.Pool ??= new WizardPoolInput(); var last = p.Pool.Slots.LastOrDefault(); if (last is not null && last.MaxPlayers == 0) return last; p.Pool.Slots.Add(new WizardSlotInput()); return p.Pool.Slots[^1]; } // ── Flow helpers ────────────────────────────────────────────────── private static string? NextAfterCover(WizardPayload p) => p.Type == WizardCreationType.Pool ? WizardStepNames.PoolSystemDuration : WizardStepNames.System; private static string? NextAfterSystem(WizardPayload p) => WizardStepNames.Duration; private static string? NextAfterDuration(WizardPayload p) { if (p.Type == WizardCreationType.Pool) return WizardStepNames.Visibility; return p.Single?.MaxPlayers is not null ? WizardStepNames.Visibility : WizardStepNames.DateTime; } private static string? NextAfterVisibility(WizardPayload p) { if (p.Visibility is WizardVisibility.Club or WizardVisibility.Members) { if (p.ClubId is null) return WizardStepNames.PickClub; } return p.Type == WizardCreationType.Pool ? WizardStepNames.PoolAddSlots : WizardStepNames.Publish; } private static (string? sys, int? dur) SplitSystemDuration(string s) { var idx = s.IndexOf(':'); if (idx <= 0 || idx >= s.Length - 1) return (null, null); var sys = s.Substring(0, idx); if (!int.TryParse(s.Substring(idx + 1), out var durMin)) return (null, null); return (sys, durMin); } private static bool ValidateText( string input, int maxLength, string emptyMsg, string tooLongMsg, out string trimmed) { trimmed = input.Trim(); if (string.IsNullOrEmpty(trimmed)) { trimmed = emptyMsg; return false; } if (trimmed.Length > maxLength) { trimmed = tooLongMsg; return false; } return true; } private static bool TryParseHours(string input, out int minutes) { minutes = 0; var s = input.Trim(); if (s.EndsWith("h", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); if (s.EndsWith("ч", StringComparison.OrdinalIgnoreCase)) s = s.Substring(0, s.Length - 1); if (!double.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var hours)) return false; if (hours < WizardStepLimits.MinDurationHours || hours > WizardStepLimits.MaxDurationHours) return false; minutes = (int)Math.Round(hours * 60); return true; } }