using System.Collections.ObjectModel; using System.Globalization; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; public enum LegacyExpertRequestStatus { Idle, Loading, Ready, Error } public sealed record LegacyExpertResultView( string SelectionId, string ExpertCode, string ExpertName); public sealed record LegacyExpertRecommendationView( int PlayIndex, string StockCode, string StockName, decimal BuyAmount); public sealed record LegacyExpertSearchSnapshot( string Query, LegacyExpertRequestStatus Status, IReadOnlyList Results, bool IsTruncated, string ErrorMessage); public sealed record LegacyExpertPreviewSnapshot( LegacyExpertRequestStatus Status, IReadOnlyList Items, bool IsTruncated, int PageSize, int PageCount, int MaximumPageCount, string ErrorMessage); public sealed record LegacyExpertActionView( string ActionId, string Label, bool IsAvailable); public sealed record LegacyExpertWorkflowSnapshot( long Revision, LegacyExpertSearchSnapshot Search, LegacyExpertResultView? SelectedExpert, LegacyExpertPreviewSnapshot Preview, IReadOnlyList Actions, string StatusMessage, LegacyOperatorStatusKind StatusKind); public sealed record LegacyExpertPlaylistDraft( string DraftId, string BuilderKey, string CutCode, IReadOnlyList Aliases, string Title, string Detail, string Category, string Source, string ExpertCode, string ExpertName, int ItemCount, bool PreviewTruncated, int PageSize, int PageCount, int MaximumPageCount, string PageText, LegacySceneSelection Selection, bool IsEnabled, int FadeDuration, string ActionId) { public LegacyPlaylistEntry ToLegacyPlaylistEntry() => new(DraftId, CutCode, IsEnabled, FadeDuration, Selection); } /// /// Native authority for UC6's read-only expert selector and recommendation preview. /// EList CRUD is intentionally outside this workflow. /// public sealed class LegacyExpertWorkflowController { public const string ActionId = "five-row-current"; public const int PageSize = 5; public const int MaximumPageCount = 20; public const int MaximumAccessibleItems = PageSize * MaximumPageCount; private const int DefaultFadeDuration = 6; private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가"; private static readonly ReadOnlyCollection Aliases = Array.AsReadOnly(new[] { "5074" }); private readonly object _gate = new(); private readonly IExpertSelectionService _service; private List _searchResults = []; private ExpertState? _selected; private List _previewItems = []; private string _query = string.Empty; private string _searchError = string.Empty; private string _previewError = string.Empty; private string _statusMessage = string.Empty; private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle; private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle; private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; private bool _searchTruncated; private bool _previewTruncated; private int _searchGeneration; private int _previewGeneration; private long _revision; private long _draftSequence; public LegacyExpertWorkflowController(IExpertSelectionService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); } public LegacyExpertWorkflowSnapshot Current { get { lock (_gate) { return CreateSnapshot(); } } } public LegacyExpertWorkflowSnapshot RemoveSelectedResultPreservingPreview() { lock (_gate) { if (_selected is null) { return CreateSnapshot(); } var removed = _searchResults.RemoveAll(row => row.Identity == _selected.Identity); if (removed > 0) { // UC6 removed the visible expert row but intentionally retained m_code, // m_name and the recommendation grid until the operator selected again. Touch(); } return CreateSnapshot(); } } public async Task SearchAsync( string query, CancellationToken cancellationToken = default) { var normalizedQuery = NormalizeQuery(query); int generation; lock (_gate) { generation = ++_searchGeneration; _previewGeneration++; _query = normalizedQuery; _searchResults = []; _selected = null; ResetPreview(); _searchStatus = LegacyExpertRequestStatus.Loading; _searchError = string.Empty; Touch(); } try { var result = await _service.SearchAsync( normalizedQuery, LegacyExpertSelectionService.MaximumResults, cancellationToken).ConfigureAwait(false); lock (_gate) { if (generation != _searchGeneration) { return CreateSnapshot(); } _searchResults = ValidateSearchResult(result, normalizedQuery, generation); _searchTruncated = result.IsTruncated; _searchStatus = LegacyExpertRequestStatus.Ready; _searchError = string.Empty; _statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다."; _statusKind = LegacyOperatorStatusKind.Information; Touch(); return CreateSnapshot(); } } catch (OperationCanceledException) { lock (_gate) { if (generation == _searchGeneration) { _searchStatus = LegacyExpertRequestStatus.Idle; _searchError = string.Empty; Touch(); } } throw; } catch { lock (_gate) { if (generation == _searchGeneration) { _searchResults = []; _searchStatus = LegacyExpertRequestStatus.Error; _searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다."; _statusMessage = _searchError; _statusKind = LegacyOperatorStatusKind.Error; Touch(); } } throw; } } public async Task SelectExpertAsync( string selectionId, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(selectionId); ExpertState selected; int generation; lock (_gate) { selected = _searchResults.FirstOrDefault(item => string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ?? throw new ArgumentOutOfRangeException( nameof(selectionId), "The expert selection id is not in the current result set."); _selected = selected; generation = ++_previewGeneration; _previewItems = []; _previewTruncated = false; _previewStatus = LegacyExpertRequestStatus.Loading; _previewError = string.Empty; Touch(); } try { var result = await _service.PreviewAsync( selected.Identity, LegacyExpertSelectionService.MaximumPreviewItems, cancellationToken).ConfigureAwait(false); lock (_gate) { if (generation != _previewGeneration || _selected?.Identity != selected.Identity) { return CreateSnapshot(); } _previewItems = ValidatePreviewResult(result, selected.Identity); _previewTruncated = result.IsTruncated; _previewStatus = LegacyExpertRequestStatus.Ready; _previewError = string.Empty; _statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다."; _statusKind = LegacyOperatorStatusKind.Information; Touch(); return CreateSnapshot(); } } catch (OperationCanceledException) { lock (_gate) { if (generation == _previewGeneration) { _previewStatus = LegacyExpertRequestStatus.Idle; _previewError = string.Empty; Touch(); } } throw; } catch { lock (_gate) { if (generation == _previewGeneration) { _previewItems = []; _previewTruncated = false; _previewStatus = LegacyExpertRequestStatus.Error; _previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다."; _statusMessage = _previewError; _statusKind = LegacyOperatorStatusKind.Error; Touch(); } } throw; } } public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId) { ArgumentNullException.ThrowIfNull(actionId); lock (_gate) { if (!string.Equals(actionId, ActionId, StringComparison.Ordinal)) { throw new ArgumentOutOfRangeException(nameof(actionId)); } if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready) { throw new InvalidOperationException("Select and preview an expert first."); } var itemCount = _previewItems.Count; if (itemCount > MaximumAccessibleItems) { throw new InvalidOperationException( "An expert cut supports zero through 100 accessible recommendation rows."); } var pageCount = checked((itemCount + PageSize - 1) / PageSize); if (pageCount > MaximumPageCount) { throw new InvalidOperationException("The expert PageN plan exceeds 20 pages."); } var identity = _selected.Identity; var selection = new LegacySceneSelection( "PAGED_EXPERT", identity.ExpertName, "INPUT_ORDER", "CURRENT", identity.ExpertCode); return new LegacyExpertPlaylistDraft( $"expert-draft-{++_draftSequence:D8}", "s5074", "5074", Aliases, identity.ExpertName, ActionLabel, "expert", "legacy-expert-workflow", identity.ExpertCode, identity.ExpertName, itemCount, _previewTruncated, PageSize, pageCount, MaximumPageCount, $"1/{pageCount}", selection, true, DefaultFadeDuration, ActionId); } } private LegacyExpertWorkflowSnapshot CreateSnapshot() { var searchRows = _searchResults.Select(ToView).ToArray(); var previewRows = _previewItems.Select(item => new LegacyExpertRecommendationView( item.PlayIndex, item.StockCode, item.StockName, item.BuyAmount)).ToArray(); var pageCount = previewRows.Length == 0 ? 0 : (previewRows.Length + PageSize - 1) / PageSize; var actionAvailable = _selected is not null && _previewStatus == LegacyExpertRequestStatus.Ready && previewRows.Length <= MaximumAccessibleItems && pageCount <= MaximumPageCount; return new LegacyExpertWorkflowSnapshot( _revision, new LegacyExpertSearchSnapshot( _query, _searchStatus, Array.AsReadOnly(searchRows), _searchTruncated, _searchError), _selected is null ? null : ToView(_selected), new LegacyExpertPreviewSnapshot( _previewStatus, Array.AsReadOnly(previewRows), _previewTruncated, PageSize, pageCount, MaximumPageCount, _previewError), Array.AsReadOnly(new[] { new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable) }), _statusMessage, _statusKind); } private static List ValidateSearchResult( ExpertSearchResult result, string expectedQuery, int generation) { ArgumentNullException.ThrowIfNull(result); if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || result.Items is null || result.Items.Count > LegacyExpertSelectionService.MaximumResults || result.IsTruncated && result.Items.Count != LegacyExpertSelectionService.MaximumResults) { throw new ExpertSelectionDataException("The expert search response correlation is invalid."); } var states = new List(result.Items.Count); var codes = new HashSet(StringComparer.Ordinal); ExpertSelectionIdentity? previous = null; for (var index = 0; index < result.Items.Count; index++) { var item = result.Items[index] ?? throw new ExpertSelectionDataException("The expert search row is missing."); if (item.Source != DataSourceKind.Oracle) { throw new ExpertSelectionDataException("Only Oracle expert identities are selectable."); } ValidateExpertIdentity(item.Identity); if (!codes.Add(item.Identity.ExpertCode) || previous is not null && CompareIdentities(previous, item.Identity) > 0) { throw new ExpertSelectionDataException( "Expert codes must be unique and identities sorted by name then code."); } states.Add(new ExpertState( $"expert-result-{generation:D4}-{index + 1:D4}", item.Identity)); previous = item.Identity; } return states; } private static List ValidatePreviewResult( ExpertPreviewResult result, ExpertSelectionIdentity expectedIdentity) { ArgumentNullException.ThrowIfNull(result); ValidateExpertIdentity(result.Identity); if (result.Identity != expectedIdentity || result.Items is null || result.Items.Count > MaximumAccessibleItems || result.IsTruncated && result.Items.Count != MaximumAccessibleItems) { throw new ExpertSelectionDataException("The expert preview response correlation is invalid."); } var items = new List(result.Items.Count); var indexes = new HashSet(); var previousIndex = -1; foreach (var item in result.Items) { if (item is null || item.PlayIndex is < 0 or > 9_999 || item.PlayIndex <= previousIndex || !IsAsciiAlphaNumeric(item.StockCode, 32) || !IsCanonicalText(item.StockName, 200) || item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m || decimal.Truncate(item.BuyAmount) != item.BuyAmount || !indexes.Add(item.PlayIndex)) { throw new ExpertSelectionDataException( "The expert preview contains an invalid or duplicate recommendation."); } items.Add(item); previousIndex = item.PlayIndex; } return items; } private static void ValidateExpertIdentity(ExpertSelectionIdentity identity) { ArgumentNullException.ThrowIfNull(identity); if (identity.ExpertCode.Length != 4 || identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) || !IsCanonicalText(identity.ExpertName, 128)) { throw new ExpertSelectionDataException("The expert identity is invalid."); } } private static int CompareIdentities( ExpertSelectionIdentity left, ExpertSelectionIdentity right) { var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal); return name != 0 ? name : string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal); } private static LegacyExpertResultView ToView(ExpertState state) => new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName); private static string NormalizeQuery(string query) { ArgumentNullException.ThrowIfNull(query); if (!IsSafeText(query)) { throw new ArgumentException("The expert query contains an unsafe character.", nameof(query)); } var normalized = query.Trim(); if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength || !IsSafeText(normalized)) { throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query)); } return normalized; } private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) => value is not null && value.Length is > 0 && value.Length <= maximumLength && value.All(static character => char.IsAsciiLetterOrDigit(character)); private static bool IsCanonicalText(string? value, int maximumLength) => value is not null && value.Length is > 0 && value.Length <= maximumLength && string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value); private static bool IsSafeText(string value) { foreach (var character in value) { var category = CharUnicodeInfo.GetUnicodeCategory(character); if (char.IsControl(character) || char.IsSurrogate(character) || character == '\uFFFD' || category is UnicodeCategory.Format or UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) { return false; } } return true; } private void ResetPreview() { _previewItems = []; _previewTruncated = false; _previewStatus = LegacyExpertRequestStatus.Idle; _previewError = string.Empty; } private void Touch() => _revision++; private sealed record ExpertState( string SelectionId, ExpertSelectionIdentity Identity); }