using System.Globalization; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; public enum LegacyTradingHaltRequestStatus { Idle, Loading, Ready, Error } public enum LegacyTradingHaltNameSort { None, Descending, Ascending } public sealed record LegacyTradingHaltResultView( string SelectionId, string Market, string StockCode, string DisplayName, bool IsSelected); public sealed record LegacyTradingHaltActionView( string ActionId, string Label, bool IsAvailable); public sealed record LegacyTradingHaltWorkflowSnapshot( long Revision, string Query, LegacyTradingHaltRequestStatus Status, IReadOnlyList Results, bool IsTruncated, LegacyTradingHaltResultView? Selected, LegacyTradingHaltNameSort NameSort, bool Ma5, bool Ma20, IReadOnlyList Actions, string ErrorMessage, string StatusMessage, LegacyOperatorStatusKind StatusKind); public sealed record LegacyTradingHaltPlaylistDraft( string DraftId, string BuilderKey, string CutCode, IReadOnlyList Aliases, string Title, string Detail, string Category, string Market, string StockCode, string DisplayName, LegacySceneSelection Selection, bool Ma5, bool Ma20, int? CandlePeriodDays, string PageText, bool IsEnabled, int FadeDuration, string Source, string ActionId) { public LegacyPlaylistEntry ToLegacyPlaylistEntry() => new(DraftId, CutCode, IsEnabled, FadeDuration, Selection); } /// /// Native authority for UC7's combined KOSPI/KOSDAQ trading-halt selector. /// The Web selects an opaque row and a closed action id; stock identity and scene fields /// always come from the validated Core service result. /// public sealed class LegacyTradingHaltWorkflowController { public const string CurrentActionId = "current"; public const string CandleActionId = "candle-120d"; public const int CandlePeriodDays = 120; private const int DefaultFadeDuration = 6; private const string CurrentLabel = "거래정지-현재가"; private const string CandleLabel = "캔들그래프-거래정지 · 120일"; private readonly object _gate = new(); private readonly ITradingHaltSelectionService _service; private List _results = []; private HaltState? _selected; private string _query = string.Empty; private string _errorMessage = string.Empty; private string _statusMessage = string.Empty; private LegacyTradingHaltRequestStatus _status = LegacyTradingHaltRequestStatus.Idle; private LegacyTradingHaltNameSort _nameSort; private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; private bool _isTruncated; private bool _ma5; private bool _ma20; private int _searchGeneration; private long _revision; private long _draftSequence; public LegacyTradingHaltWorkflowController(ITradingHaltSelectionService service) { _service = service ?? throw new ArgumentNullException(nameof(service)); } public LegacyTradingHaltWorkflowSnapshot Current { get { lock (_gate) { return CreateSnapshot(); } } } public async Task SearchAsync( string query, CancellationToken cancellationToken = default) { var normalizedQuery = NormalizeQuery(query); int generation; lock (_gate) { generation = ++_searchGeneration; _query = normalizedQuery; _results = []; _selected = null; _nameSort = LegacyTradingHaltNameSort.None; _status = LegacyTradingHaltRequestStatus.Loading; _isTruncated = false; _errorMessage = string.Empty; Touch(); } try { var result = await _service.SearchAsync( normalizedQuery, LegacyTradingHaltSelectionService.MaximumResults, cancellationToken).ConfigureAwait(false); lock (_gate) { if (generation != _searchGeneration) { return CreateSnapshot(); } _results = ValidateResult(result, normalizedQuery, generation); _isTruncated = result.IsTruncated; _status = LegacyTradingHaltRequestStatus.Ready; _errorMessage = string.Empty; _statusMessage = $"KOSPI·KOSDAQ 거래정지 종목 {_results.Count}개를 조회했습니다."; _statusKind = LegacyOperatorStatusKind.Information; Touch(); return CreateSnapshot(); } } catch (OperationCanceledException) { lock (_gate) { if (generation == _searchGeneration) { _status = LegacyTradingHaltRequestStatus.Idle; _errorMessage = string.Empty; Touch(); } } throw; } catch { lock (_gate) { if (generation == _searchGeneration) { _results = []; _selected = null; _status = LegacyTradingHaltRequestStatus.Error; _errorMessage = "거래정지 종목 조회 결과를 안전하게 확인하지 못했습니다."; _statusMessage = _errorMessage; _statusKind = LegacyOperatorStatusKind.Error; Touch(); } } throw; } } public LegacyTradingHaltWorkflowSnapshot SelectResult(string selectionId) { ArgumentNullException.ThrowIfNull(selectionId); lock (_gate) { _selected = _results.FirstOrDefault(item => string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ?? throw new ArgumentOutOfRangeException( nameof(selectionId), "The trading-halt selection id is not in the current result set."); Touch(); return CreateSnapshot(); } } public LegacyTradingHaltWorkflowSnapshot ToggleNameSort() { lock (_gate) { _nameSort = _nameSort switch { LegacyTradingHaltNameSort.Descending => LegacyTradingHaltNameSort.Ascending, _ => LegacyTradingHaltNameSort.Descending }; Touch(); return CreateSnapshot(); } } public LegacyTradingHaltWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20) { lock (_gate) { _ma5 = ma5; _ma20 = ma20; Touch(); return CreateSnapshot(); } } public LegacyTradingHaltPlaylistDraft MaterializeSelectedAction(string actionId) { ArgumentNullException.ThrowIfNull(actionId); lock (_gate) { var selected = _selected ?? throw new InvalidOperationException("Select a trading-halt stock first."); var isCurrent = string.Equals(actionId, CurrentActionId, StringComparison.Ordinal); var isCandle = string.Equals(actionId, CandleActionId, StringComparison.Ordinal); if (!isCurrent && !isCandle) { throw new ArgumentOutOfRangeException(nameof(actionId)); } var item = selected.Selection; var market = item.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq"; var builderKey = isCurrent ? "s5001" : "s8010"; var cutCode = isCurrent ? "5001" : "8051"; var graphicType = isCurrent ? "1열판기본" : string.Join( ',', new[] { _ma5 ? "MA5" : null, _ma20 ? "MA20" : null }.Where(static value => value is not null)); var selection = new LegacySceneSelection( isCurrent ? item.Market == TradingHaltMarket.Kospi ? "KOSPI" : "KOSDAQ" : item.Market == TradingHaltMarket.Kospi ? "KOSPI_STOCK" : "KOSDAQ_STOCK", item.DisplayName, graphicType, isCurrent ? "HALTED" : "TRADING_HALT", item.StockCode); return new LegacyTradingHaltPlaylistDraft( $"trading-halt-draft-{++_draftSequence:D8}", builderKey, cutCode, Array.AsReadOnly(new[] { cutCode }), item.DisplayName, isCurrent ? CurrentLabel : CandleLabel, isCurrent ? "stock" : "chart", market, item.StockCode, item.DisplayName, selection, isCandle && _ma5, isCandle && _ma20, isCandle ? CandlePeriodDays : null, "1/1", true, DefaultFadeDuration, "legacy-trading-halt-workflow", actionId); } } private LegacyTradingHaltWorkflowSnapshot CreateSnapshot() { var display = _results.ToArray(); if (_nameSort != LegacyTradingHaltNameSort.None) { var multiplier = _nameSort == LegacyTradingHaltNameSort.Ascending ? 1 : -1; display = display .Select((item, sourceIndex) => (item, sourceIndex)) .OrderBy(pair => pair.item.Selection.DisplayName, new DirectionComparer(multiplier)) .ThenBy(pair => pair.item.Selection.Market) .ThenBy(pair => pair.item.Selection.StockCode, StringComparer.Ordinal) .ThenBy(pair => pair.sourceIndex) .Select(pair => pair.item) .ToArray(); } var rows = display.Select(ToView).ToArray(); var selected = _selected is null ? null : ToView(_selected); var canAct = _selected is not null; return new LegacyTradingHaltWorkflowSnapshot( _revision, _query, _status, Array.AsReadOnly(rows), _isTruncated, selected, _nameSort, _ma5, _ma20, Array.AsReadOnly(new[] { new LegacyTradingHaltActionView(CurrentActionId, CurrentLabel, canAct), new LegacyTradingHaltActionView(CandleActionId, CandleLabel, canAct) }), _errorMessage, _statusMessage, _statusKind); } private LegacyTradingHaltResultView ToView(HaltState state) => new( state.SelectionId, state.Selection.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq", state.Selection.StockCode, state.Selection.DisplayName, _selected?.Selection == state.Selection); private static List ValidateResult( TradingHaltSelectionResult result, string expectedQuery, int generation) { ArgumentNullException.ThrowIfNull(result); if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || result.Items is null || result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults || result.IsTruncated && result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults) { throw new TradingHaltSelectionDataException( "The trading-halt search response correlation is invalid."); } var states = new List(result.Items.Count); var identities = new HashSet<(TradingHaltMarket Market, string Code)>(); var names = new HashSet<(TradingHaltMarket Market, string Name)>(); TradingHaltSelection? previous = null; for (var index = 0; index < result.Items.Count; index++) { var item = result.Items[index] ?? throw new TradingHaltSelectionDataException("A trading-halt row is missing."); if (!Enum.IsDefined(item.Market) || !IsAsciiAlphaNumeric(item.StockCode, 32) || !IsCanonicalText(item.DisplayName, 200) || !identities.Add((item.Market, item.StockCode)) || !names.Add((item.Market, item.DisplayName)) || previous is not null && CompareSourceOrder(previous, item) > 0) { throw new TradingHaltSelectionDataException( "Trading-halt identities must be canonical, unique, and KOSPI/KOSDAQ ordered."); } states.Add(new HaltState( $"trading-halt-result-{generation:D4}-{index + 1:D4}", item)); previous = item; } return states; } private static int CompareSourceOrder( TradingHaltSelection left, TradingHaltSelection right) { var market = left.Market.CompareTo(right.Market); if (market != 0) { return market; } var name = string.Compare(left.DisplayName, right.DisplayName, StringComparison.Ordinal); return name != 0 ? name : string.Compare(left.StockCode, right.StockCode, StringComparison.Ordinal); } private static string NormalizeQuery(string query) { ArgumentNullException.ThrowIfNull(query); var normalized = query.Trim(); if (query.Length > LegacyTradingHaltSelectionService.MaximumQueryLength || normalized.Length > LegacyTradingHaltSelectionService.MaximumQueryLength || !IsSafeText(query) || !IsSafeText(normalized)) { throw new ArgumentException( "A trading-halt query must contain at most 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 Touch() => _revision++; private sealed record HaltState( string SelectionId, TradingHaltSelection Selection); private sealed class DirectionComparer(int multiplier) : IComparer { public int Compare(string? left, string? right) => multiplier * string.Compare(left, right, StringComparison.Ordinal); } }