using System.Collections.ObjectModel; using System.Globalization; using System.Text; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; public enum LegacyThemeSort { InputOrder, GainRateDescending, GainRateAscending } public enum LegacyThemeValueKind { Current, Expected } public sealed record LegacyThemeSortDefinition( string Id, string Label, LegacyThemeSort Value); public sealed record LegacyThemeActionState( string ActionId, bool IsAvailable, string? UnavailableReason); public sealed class LegacyThemeActionDefinition { internal LegacyThemeActionDefinition( string id, string label, string builderKey, string code, int pageSize, LegacyThemeValueKind valueKind) { Id = id; Label = label; BuilderKey = builderKey; Code = code; Aliases = Array.AsReadOnly([code]); PageSize = pageSize; ValueKind = valueKind; } public string Id { get; } public string Label { get; } public string BuilderKey { get; } public string Code { get; } public IReadOnlyList Aliases { get; } public int PageSize { get; } public LegacyThemeValueKind ValueKind { get; } } public sealed class LegacyThemeSearchRow { internal LegacyThemeSearchRow( string resultId, ThemeSelectionIdentity identity, DataSourceKind source) { ResultId = resultId; Identity = identity; Market = identity.Market; Session = identity.Session; DisplayTitle = identity.Market == ThemeMarket.Nxt ? $"{identity.ThemeTitle}(NXT)" : identity.ThemeTitle; Source = source; } public string ResultId { get; } public ThemeMarket Market { get; } public ThemeSession Session { get; } public string DisplayTitle { get; } public DataSourceKind Source { get; } internal ThemeSelectionIdentity Identity { get; } } public sealed class LegacyThemePreviewRow { internal LegacyThemePreviewRow(ThemeItemPreview item) { InputIndex = item.InputIndex; ItemName = item.ItemName; ItemCode = item.ItemCode; } public int InputIndex { get; } public string ItemName { get; } internal string ItemCode { get; } } public sealed record LegacySelectedThemeSummary( ThemeMarket Market, ThemeSession Session, string DisplayTitle, int PreviewItemCount, bool PreviewIsTruncated); public sealed class LegacyThemeWorkflowSnapshot { internal LegacyThemeWorkflowSnapshot( object authority, long revision, string query, ThemeSession nxtSession, LegacyThemeSort sort, IEnumerable searchResults, bool searchIsTruncated, string? selectedResultId, ThemeSelectionIdentity? selectedIdentity, IEnumerable previewItems, bool previewIsTruncated) { Authority = authority; Revision = revision; Query = query; NxtSession = nxtSession; Sort = sort; SearchResults = ReadOnly(searchResults); SearchIsTruncated = searchIsTruncated; SelectedResultId = selectedResultId; SelectedIdentity = selectedIdentity; PreviewItems = ReadOnly(previewItems); PreviewIsTruncated = previewIsTruncated; SelectedTheme = selectedIdentity is null ? null : new LegacySelectedThemeSummary( selectedIdentity.Market, selectedIdentity.Session, selectedIdentity.Market == ThemeMarket.Nxt ? $"{selectedIdentity.ThemeTitle}(NXT)" : selectedIdentity.ThemeTitle, PreviewItems.Count, previewIsTruncated); ActionStates = LegacyThemeWorkflow.CreateActionStates( selectedIdentity, PreviewItems.Count); } public long Revision { get; } public string Query { get; } public ThemeSession NxtSession { get; } public LegacyThemeSort Sort { get; } public string SortId => LegacyThemeWorkflow.SortId(Sort); public IReadOnlyList SearchResults { get; } public bool SearchIsTruncated { get; } public string? SelectedResultId { get; } public LegacySelectedThemeSummary? SelectedTheme { get; } public IReadOnlyList PreviewItems { get; } public bool PreviewIsTruncated { get; } public IReadOnlyList ActionStates { get; } internal object Authority { get; } internal ThemeSelectionIdentity? SelectedIdentity { get; } private static IReadOnlyList ReadOnly(IEnumerable source) => Array.AsReadOnly(source.ToArray()); } public sealed record LegacyThemePlaylistSelection( string GroupCode, string Subject, string GraphicType, string Subtype, string DataCode); public sealed record LegacyThemePagePlan( int PreviewItemCount, int AccessibleItemCount, int PageSize, int PageCount, int MaximumPageCount, bool IsTruncated, string InitialPageText); public sealed record LegacyThemePlaylistDraft( string ActionId, string BuilderKey, string Code, IReadOnlyList Aliases, string Title, string PlaylistGraphicType, string PlaylistSubtype, string Detail, string Category, string Market, LegacyThemePlaylistSelection Selection, LegacyThemeValueKind ValueKind, LegacyThemeSort Sort, int PageSize, LegacyThemePagePlan PagePlan); public sealed class LegacyThemeWorkflowDataException : Exception { public LegacyThemeWorkflowDataException(string message) : base(message) { } } /// /// C#-authoritative migration of the read-only UC4 theme selector. Search and preview data /// come only from ; Web callers retain opaque result and /// action ids and never provide scene codes, resolver groups, theme identities, or PageN data. /// public sealed class LegacyThemeWorkflow { public const int MaximumSearchResults = 100; public const int MaximumPreviewItems = 240; public const int MaximumPageCount = 20; private static readonly IReadOnlyList RegisteredActions = CreateActions(); private static readonly IReadOnlyList RegisteredSorts = Array.AsReadOnly( [ new("INPUT_ORDER", "입력순", LegacyThemeSort.InputOrder), new("GAIN_DESC", "상승률순", LegacyThemeSort.GainRateDescending), new("GAIN_ASC", "하락률순", LegacyThemeSort.GainRateAscending) ]); private static readonly IReadOnlyDictionary ActionsById = new ReadOnlyDictionary( RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal)); private readonly IThemeSelectionService _selectionService; private readonly Func _localNow; private readonly object _authority = new(); static LegacyThemeWorkflow() { ValidateActions(RegisteredActions); } public LegacyThemeWorkflow( IThemeSelectionService selectionService, Func? localNow = null) { _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); _localNow = localNow ?? (() => DateTimeOffset.Now); } public static IReadOnlyList Actions => RegisteredActions; public static IReadOnlyList Sorts => RegisteredSorts; public ThemeSession CurrentNxtSession => _localNow().Hour <= 12 ? ThemeSession.PreMarket : ThemeSession.AfterMarket; public LegacyThemeWorkflowSnapshot CreateInitial( ThemeSession nxtSession = ThemeSession.PreMarket) { RequireNxtSession(nxtSession); return NewSnapshot( revision: 0, query: string.Empty, nxtSession, LegacyThemeSort.InputOrder, [], searchIsTruncated: false, selectedResultId: null, selectedIdentity: null, previewItems: [], previewIsTruncated: false); } public LegacyThemeWorkflowSnapshot ResetForTabActivation( LegacyThemeWorkflowSnapshot snapshot, ThemeSession nxtSession = ThemeSession.PreMarket) { RequireSnapshot(snapshot); RequireNxtSession(nxtSession); return NewSnapshot( checked(snapshot.Revision + 1), query: string.Empty, nxtSession, LegacyThemeSort.InputOrder, [], searchIsTruncated: false, selectedResultId: null, selectedIdentity: null, previewItems: [], previewIsTruncated: false); } public async Task SearchAsync( LegacyThemeWorkflowSnapshot snapshot, string query, ThemeSession nxtSession, CancellationToken cancellationToken = default) { RequireSnapshot(snapshot); var normalizedQuery = NormalizeQuery(query); RequireNxtSession(nxtSession); cancellationToken.ThrowIfCancellationRequested(); var result = await _selectionService.SearchAsync( normalizedQuery, nxtSession, MaximumSearchResults, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); var rows = ValidateSearchResult(result, normalizedQuery, nxtSession); var retainedSelection = snapshot.SelectedIdentity is null ? null : rows.SingleOrDefault(row => row.Identity == snapshot.SelectedIdentity); return NewSnapshot( checked(snapshot.Revision + 1), normalizedQuery, nxtSession, snapshot.Sort, rows, result.IsTruncated, retainedSelection?.ResultId, retainedSelection?.Identity, retainedSelection is null ? [] : snapshot.PreviewItems, retainedSelection is not null && snapshot.PreviewIsTruncated); } public async Task SelectAsync( LegacyThemeWorkflowSnapshot snapshot, string resultId, CancellationToken cancellationToken = default) { RequireSnapshot(snapshot); ArgumentException.ThrowIfNullOrWhiteSpace(resultId); var selected = snapshot.SearchResults.FirstOrDefault(row => string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? throw new KeyNotFoundException($"Unknown theme result id '{resultId}'."); cancellationToken.ThrowIfCancellationRequested(); var preview = await _selectionService.PreviewAsync( selected.Identity, MaximumPreviewItems, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); var previewRows = ValidatePreviewResult(preview, selected.Identity); return NewSnapshot( checked(snapshot.Revision + 1), snapshot.Query, snapshot.NxtSession, snapshot.Sort, snapshot.SearchResults, snapshot.SearchIsTruncated, selected.ResultId, selected.Identity, previewRows, preview.IsTruncated); } public LegacyThemeWorkflowSnapshot RemoveSelectedResult( LegacyThemeWorkflowSnapshot snapshot) { RequireSnapshot(snapshot); if (snapshot.SelectedResultId is null) { return snapshot; } return NewSnapshot( checked(snapshot.Revision + 1), snapshot.Query, snapshot.NxtSession, snapshot.Sort, snapshot.SearchResults.Where(row => !string.Equals( row.ResultId, snapshot.SelectedResultId, StringComparison.Ordinal)), snapshot.SearchIsTruncated, selectedResultId: null, selectedIdentity: null, previewItems: [], previewIsTruncated: false); } public LegacyThemeWorkflowSnapshot SelectSort( LegacyThemeWorkflowSnapshot snapshot, string sortId) { RequireSnapshot(snapshot); var sort = ParseSort(sortId); return NewSnapshot( checked(snapshot.Revision + 1), snapshot.Query, snapshot.NxtSession, sort, snapshot.SearchResults, snapshot.SearchIsTruncated, snapshot.SelectedResultId, snapshot.SelectedIdentity, snapshot.PreviewItems, snapshot.PreviewIsTruncated); } /// /// Materializes from a trusted snapshot and a closed action id only. All K3D-facing values, /// theme identity fields, sort values, and PageN calculations are rebuilt in this method. /// public LegacyThemePlaylistDraft MaterializePlaylistDraft( LegacyThemeWorkflowSnapshot snapshot, string actionId) { RequireSnapshot(snapshot); ArgumentException.ThrowIfNullOrWhiteSpace(actionId); var action = ActionsById.TryGetValue(actionId, out var found) ? found : throw new KeyNotFoundException($"Unknown theme action id '{actionId}'."); var identity = snapshot.SelectedIdentity ?? throw new InvalidOperationException("A theme must be selected and previewed first."); if (snapshot.PreviewItems.Count == 0) { throw new InvalidOperationException("An empty theme preview cannot be added to the playlist."); } if (action.ValueKind == LegacyThemeValueKind.Expected && identity.Market == ThemeMarket.Nxt) { throw new InvalidOperationException("NXT는 데이터가 없습니다."); } var sortId = SortId(snapshot.Sort); var valueKind = ValueKindId(action.ValueKind); var displayTitle = identity.Market == ThemeMarket.Nxt ? $"{identity.ThemeTitle}(NXT)" : identity.ThemeTitle; var playlistGraphicType = PlaylistGraphicType(action); var playlistSubtype = $"테마-{ValueKindLabel(action.ValueKind)}({SortLabel(snapshot.Sort)})"; var selection = identity.Market == ThemeMarket.Krx ? new LegacyThemePlaylistSelection( "PAGED_THEME", identity.ThemeTitle, sortId, valueKind, identity.ThemeCode) : new LegacyThemePlaylistSelection( "PAGED_NXT_THEME", displayTitle, SessionId(CurrentNxtSession), sortId, identity.ThemeCode); var maximumItems = checked(action.PageSize * MaximumPageCount); var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems); var pageCount = (accessibleItems + action.PageSize - 1) / action.PageSize; var pagePlan = new LegacyThemePagePlan( snapshot.PreviewItems.Count, accessibleItems, action.PageSize, pageCount, MaximumPageCount, snapshot.PreviewIsTruncated || snapshot.PreviewItems.Count > maximumItems, $"1/{pageCount.ToString(CultureInfo.InvariantCulture)}"); return new LegacyThemePlaylistDraft( action.Id, action.BuilderKey, action.Code, action.Aliases, displayTitle, playlistGraphicType, playlistSubtype, playlistSubtype, "plate", "theme", selection, action.ValueKind, snapshot.Sort, action.PageSize, pagePlan); } public static LegacyThemeActionDefinition? FindAction(string? actionId) => actionId is not null && ActionsById.TryGetValue(actionId, out var action) ? action : null; public LegacyThemeActionState GetActionState( LegacyThemeWorkflowSnapshot snapshot, string actionId) { RequireSnapshot(snapshot); ArgumentException.ThrowIfNullOrWhiteSpace(actionId); return snapshot.ActionStates.FirstOrDefault(state => string.Equals(state.ActionId, actionId, StringComparison.Ordinal)) ?? throw new KeyNotFoundException($"Unknown theme action id '{actionId}'."); } public static string SortId(LegacyThemeSort sort) => sort switch { LegacyThemeSort.InputOrder => "INPUT_ORDER", LegacyThemeSort.GainRateDescending => "GAIN_DESC", LegacyThemeSort.GainRateAscending => "GAIN_ASC", _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.") }; public static string SortLabel(LegacyThemeSort sort) => sort switch { LegacyThemeSort.InputOrder => "입력순", LegacyThemeSort.GainRateDescending => "상승률순", LegacyThemeSort.GainRateAscending => "하락률순", _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.") }; private LegacyThemeWorkflowSnapshot NewSnapshot( long revision, string query, ThemeSession nxtSession, LegacyThemeSort sort, IEnumerable searchResults, bool searchIsTruncated, string? selectedResultId, ThemeSelectionIdentity? selectedIdentity, IEnumerable previewItems, bool previewIsTruncated) => new( _authority, revision, query, nxtSession, sort, searchResults, searchIsTruncated, selectedResultId, selectedIdentity, previewItems, previewIsTruncated); private void RequireSnapshot(LegacyThemeWorkflowSnapshot snapshot) { ArgumentNullException.ThrowIfNull(snapshot); if (!ReferenceEquals(snapshot.Authority, _authority)) { throw new ArgumentException( "The theme snapshot belongs to another workflow instance.", nameof(snapshot)); } } private static IReadOnlyList ValidateSearchResult( ThemeSearchResult result, string query, ThemeSession nxtSession) { if (result is null || result.Items is null || !string.Equals(result.Query, query, StringComparison.Ordinal) || result.NxtSession != nxtSession) { throw InvalidData("search envelope"); } var serviceItems = result.Items.ToArray(); if (serviceItems.Length > MaximumSearchResults || result.IsTruncated && serviceItems.Length != MaximumSearchResults) { throw InvalidData("search envelope"); } var codes = new Dictionary> { [ThemeMarket.Krx] = new(StringComparer.Ordinal), [ThemeMarket.Nxt] = new(StringComparer.Ordinal) }; var sawNxt = false; foreach (var item in serviceItems) { if (item is null) { throw InvalidData("search item"); } ValidateIdentity(item.Identity, nxtSession); var expectedSource = item.Identity.Market == ThemeMarket.Krx ? DataSourceKind.Oracle : DataSourceKind.MariaDb; if (item.Source != expectedSource || !codes[item.Identity.Market].Add(item.Identity.ThemeCode)) { throw InvalidData("search identity or provider"); } // UC4 appended the Oracle list and then the MariaDB list. Preserve // each provider's returned order; do not sort rows by title or code. if (item.Identity.Market == ThemeMarket.Nxt) { sawNxt = true; } else if (sawNxt) { throw InvalidData("search provider order"); } } var rows = new LegacyThemeSearchRow[serviceItems.Length]; for (var index = 0; index < serviceItems.Length; index++) { rows[index] = new LegacyThemeSearchRow( $"theme-result-{index + 1:000}", serviceItems[index].Identity, serviceItems[index].Source); } return Array.AsReadOnly(rows); } private static IReadOnlyList ValidatePreviewResult( ThemePreviewResult result, ThemeSelectionIdentity identity) { if (result is null || result.Items is null || result.Identity != identity) { throw InvalidData("preview envelope"); } var serviceItems = result.Items.ToArray(); if (serviceItems.Length > MaximumPreviewItems || result.IsTruncated && serviceItems.Length != MaximumPreviewItems) { throw InvalidData("preview envelope"); } var indexes = new HashSet(); var previousIndex = -1; var rows = new LegacyThemePreviewRow[serviceItems.Length]; for (var index = 0; index < serviceItems.Length; index++) { var item = serviceItems[index] ?? throw InvalidData("preview item"); if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex || !indexes.Add(item.InputIndex) || !IsItemCodeForMarket(item.ItemCode, identity.Market) || !IsCanonicalText(item.ItemName, 200)) { throw InvalidData("preview item identity"); } previousIndex = item.InputIndex; rows[index] = new LegacyThemePreviewRow(item); } return Array.AsReadOnly(rows); } private static void ValidateIdentity( ThemeSelectionIdentity identity, ThemeSession requestedNxtSession) { if (identity is null || !Enum.IsDefined(identity.Market) || !Enum.IsDefined(identity.Session) || identity.Market == ThemeMarket.Krx && identity.Session != ThemeSession.NotApplicable || identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession || !IsThemeCode(identity.ThemeCode) || !IsCanonicalText(identity.ThemeTitle, 128) || identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal)) { throw InvalidData("theme identity"); } } private static string NormalizeQuery(string query) { ArgumentNullException.ThrowIfNull(query); if (!IsSafeText(query)) { throw new ArgumentException("The theme search query is invalid.", nameof(query)); } var normalized = query.Trim(); if (normalized.Length > LegacyThemeSelectionService.MaximumQueryLength || !IsSafeText(normalized)) { throw new ArgumentException( $"A theme search query must contain at most {LegacyThemeSelectionService.MaximumQueryLength} visible characters.", nameof(query)); } return normalized; } private static LegacyThemeSort ParseSort(string sortId) { ArgumentNullException.ThrowIfNull(sortId); return sortId switch { "INPUT_ORDER" => LegacyThemeSort.InputOrder, "GAIN_DESC" => LegacyThemeSort.GainRateDescending, "GAIN_ASC" => LegacyThemeSort.GainRateAscending, _ => throw new ArgumentOutOfRangeException( nameof(sortId), sortId, "Unknown theme sort id.") }; } private static string SessionId(ThemeSession session) => session switch { ThemeSession.PreMarket => "PRE_MARKET", ThemeSession.AfterMarket => "AFTER_MARKET", _ => throw new ArgumentOutOfRangeException( nameof(session), session, "NXT requires an explicit session.") }; private static string ValueKindId(LegacyThemeValueKind valueKind) => valueKind switch { LegacyThemeValueKind.Current => "CURRENT", LegacyThemeValueKind.Expected => "EXPECTED", _ => throw new ArgumentOutOfRangeException( nameof(valueKind), valueKind, "Unknown theme value kind.") }; private static string ValueKindLabel(LegacyThemeValueKind valueKind) => valueKind switch { LegacyThemeValueKind.Current => "현재가", LegacyThemeValueKind.Expected => "예상체결가", _ => throw new ArgumentOutOfRangeException( nameof(valueKind), valueKind, "Unknown theme value kind.") }; private static string PlaylistGraphicType(LegacyThemeActionDefinition action) => action.Id switch { "five-row-current" or "five-row-expected" => "5단 표그래프", "six-row-current" => "6종목 현재가", "twelve-row-current" => "12종목 현재가", _ => throw new ArgumentOutOfRangeException( nameof(action), action.Id, "Unknown theme action.") }; private static void RequireNxtSession(ThemeSession nxtSession) { if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) { throw new ArgumentOutOfRangeException( nameof(nxtSession), nxtSession, "An explicit NXT session is required."); } } private static bool IsThemeCode(string value) => value is not null && value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit); private static bool IsItemCodeForMarket(string value, ThemeMarket market) { if (value is null || value.Length is < 2 or > 13 || !value.All(char.IsAsciiLetterOrDigit)) { return false; } return market switch { ThemeMarket.Krx => value[0] is 'P' or 'D', ThemeMarket.Nxt => value[0] is 'X' or 'T', _ => false }; } private static bool IsCanonicalText(string value, int maximumLength) => value is not null && value.Length is >= 1 && value.Length <= maximumLength && string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value); private static bool IsSafeText(string value) { foreach (var rune in value.EnumerateRunes()) { var category = Rune.GetUnicodeCategory(rune); if (Rune.IsControl(rune) || rune.Value == '\uFFFD' || category is UnicodeCategory.Format or UnicodeCategory.Surrogate or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) { return false; } } return true; } private static IReadOnlyList CreateActions() => Array.AsReadOnly( [ new LegacyThemeActionDefinition( "five-row-current", "5단 표그래프 · 현재가", "s5074", "5074", 5, LegacyThemeValueKind.Current), new LegacyThemeActionDefinition( "five-row-expected", "5단 표그래프 · 예상체결가", "s5074", "5074", 5, LegacyThemeValueKind.Expected), new LegacyThemeActionDefinition( "six-row-current", "6종목 현재가", "s5077", "5077", 6, LegacyThemeValueKind.Current), new LegacyThemeActionDefinition( "twelve-row-current", "12종목 현재가", "s5088", "5088", 12, LegacyThemeValueKind.Current) ]); internal static IReadOnlyList CreateActionStates( ThemeSelectionIdentity? selectedIdentity, int previewItemCount) { var states = new LegacyThemeActionState[RegisteredActions.Count]; for (var index = 0; index < RegisteredActions.Count; index++) { var action = RegisteredActions[index]; var reason = selectedIdentity is null ? "theme-not-selected" : previewItemCount == 0 ? "theme-preview-is-empty" : null; states[index] = new LegacyThemeActionState( action.Id, reason is null, reason); } return Array.AsReadOnly(states); } private static void ValidateActions( IReadOnlyCollection actions) { if (actions.Count != 4 || actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != 4) { throw new InvalidOperationException("The UC4 action catalog must contain four unique actions."); } foreach (var action in actions) { var scene = LegacySceneCatalog.Definitions.Single(definition => string.Equals(definition.BuilderKey, action.BuilderKey, StringComparison.Ordinal)); if (!scene.CutAliases.Contains(action.Code, StringComparer.Ordinal) || action.Aliases.Count != 1 || !string.Equals(action.Aliases[0], action.Code, StringComparison.Ordinal) || (int)scene.PageSize != action.PageSize) { throw new InvalidOperationException( $"Theme action '{action.Id}' does not match the Core scene catalog."); } } } private static LegacyThemeWorkflowDataException InvalidData(string detail) => new($"The injected theme selection service returned invalid {detail} data."); }