using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyApplication; /// /// C# authority for the original UC3 comparison workflow. Search results and fixed targets /// are selected by opaque ids, saved bytes are parsed by the strict Core CP949 parser, and /// an action id is the only caller-supplied value used to materialize a playlist draft. /// public sealed class LegacyComparisonWorkflowController { private readonly ILegacyComparisonDataService _dataService; private readonly ILegacyComparisonPairStore _pairStore; private List _domesticResults = []; private List _worldResults = []; private List _savedPairs = []; private LegacyComparisonTarget? _firstTarget; private LegacyComparisonTarget? _secondTarget; private string? _selectedDomesticResultId; private string? _selectedWorldResultId; private string? _selectedFixedTargetId; private string? _selectedPairId; private string _domesticQuery = string.Empty; private string _worldQuery = string.Empty; private string _domesticError = string.Empty; private string _worldError = string.Empty; private string _statusMessage = string.Empty; private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle; private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle; private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; private long _revision; private long _pairSequence; private long _draftSequence; private int _domesticSearchVersion; private int _worldSearchVersion; private int _activationGeneration; private bool _isBusy; public LegacyComparisonWorkflowController( ILegacyComparisonDataService dataService, ILegacyComparisonPairStore pairStore) { _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); _pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore)); } public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot(); /// /// Recreates UC3's per-control state before its saved CP949 pairs are loaded for /// a new tab activation. Search and pair-load generations invalidate completions /// that belong to the discarded control instance. /// public LegacyComparisonWorkflowSnapshot ResetForTabActivation() { Interlocked.Increment(ref _activationGeneration); Interlocked.Increment(ref _domesticSearchVersion); Interlocked.Increment(ref _worldSearchVersion); _domesticResults = []; _worldResults = []; _savedPairs = []; _firstTarget = null; _secondTarget = null; _selectedDomesticResultId = null; _selectedWorldResultId = null; _selectedFixedTargetId = null; _selectedPairId = null; _domesticQuery = string.Empty; _worldQuery = string.Empty; _domesticError = string.Empty; _worldError = string.Empty; _statusMessage = string.Empty; _domesticStatus = LegacyComparisonSearchStatus.Idle; _worldStatus = LegacyComparisonSearchStatus.Idle; _statusKind = LegacyOperatorStatusKind.Neutral; _isBusy = false; Touch(); return CreateSnapshot(); } public async Task SearchDomesticAsync( string query, CancellationToken cancellationToken = default) { var normalizedQuery = NormalizeSearchQuery(query); var version = Interlocked.Increment(ref _domesticSearchVersion); _domesticQuery = normalizedQuery; _domesticStatus = LegacyComparisonSearchStatus.Loading; _domesticError = string.Empty; _domesticResults = []; _selectedDomesticResultId = null; Touch(); try { var results = await _dataService.SearchDomesticAsync( normalizedQuery, LegacyComparisonWorkflowCatalog.DomesticMaximumResults, cancellationToken).ConfigureAwait(false); if (version != Volatile.Read(ref _domesticSearchVersion)) { return CreateSnapshot(); } _domesticResults = ValidateSearchResults( results, LegacyComparisonWorkflowCatalog.DomesticMaximumResults, domestic: true, version); _domesticStatus = LegacyComparisonSearchStatus.Ready; _domesticError = string.Empty; Touch(); return CreateSnapshot(); } catch (OperationCanceledException) { if (version == Volatile.Read(ref _domesticSearchVersion)) { _domesticStatus = LegacyComparisonSearchStatus.Idle; _domesticError = string.Empty; Touch(); } throw; } catch { if (version == Volatile.Read(ref _domesticSearchVersion)) { _domesticResults = []; _domesticStatus = LegacyComparisonSearchStatus.Error; _domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다."; Touch(); } throw; } } public async Task SearchWorldAsync( string query, CancellationToken cancellationToken = default) { // UC3's blank world query loaded the complete US/TW universe, so blank remains valid. var normalizedQuery = NormalizeSearchQuery(query); var version = Interlocked.Increment(ref _worldSearchVersion); _worldQuery = normalizedQuery; _worldStatus = LegacyComparisonSearchStatus.Loading; _worldError = string.Empty; _worldResults = []; _selectedWorldResultId = null; Touch(); try { var results = await _dataService.SearchWorldAsync( normalizedQuery, LegacyComparisonWorkflowCatalog.WorldMaximumResults, cancellationToken).ConfigureAwait(false); if (version != Volatile.Read(ref _worldSearchVersion)) { return CreateSnapshot(); } _worldResults = ValidateSearchResults( results, LegacyComparisonWorkflowCatalog.WorldMaximumResults, domestic: false, version); _worldStatus = LegacyComparisonSearchStatus.Ready; _worldError = string.Empty; Touch(); return CreateSnapshot(); } catch (OperationCanceledException) { if (version == Volatile.Read(ref _worldSearchVersion)) { _worldStatus = LegacyComparisonSearchStatus.Idle; _worldError = string.Empty; Touch(); } throw; } catch { if (version == Volatile.Read(ref _worldSearchVersion)) { _worldResults = []; _worldStatus = LegacyComparisonSearchStatus.Error; _worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다."; Touch(); } throw; } } public LegacyComparisonWorkflowSnapshot SelectDomesticResult(string selectionId) => SelectSearchResult(_domesticResults, selectionId, domestic: true); public LegacyComparisonWorkflowSnapshot SelectWorldResult(string selectionId) => SelectSearchResult(_worldResults, selectionId, domestic: false); public LegacyComparisonWorkflowSnapshot SelectFixedTarget(string targetId) { ArgumentNullException.ThrowIfNull(targetId); if (LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId) is not null) { _selectedFixedTargetId = targetId; Touch(); } return CreateSnapshot(); } public LegacyComparisonWorkflowSnapshot ChooseDomesticResult(string selectionId) => ChooseSearchResult(_domesticResults, selectionId, domestic: true); public LegacyComparisonWorkflowSnapshot ChooseWorldResult(string selectionId) => ChooseSearchResult(_worldResults, selectionId, domestic: false); public LegacyComparisonWorkflowSnapshot ChooseFixedTarget(string targetId) { ArgumentNullException.ThrowIfNull(targetId); var target = LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId); if (target is not null) { _selectedFixedTargetId = targetId; RotateDraft(target); Touch(); } return CreateSnapshot(); } public LegacyComparisonWorkflowSnapshot SwapDraftTargets() { (_firstTarget, _secondTarget) = (_secondTarget, _firstTarget); Touch(); return CreateSnapshot(); } public LegacyComparisonWorkflowSnapshot ClearDraftTargets() { _firstTarget = null; _secondTarget = null; Touch(); return CreateSnapshot(); } public async Task LoadSavedPairsAsync( CancellationToken cancellationToken = default) { var activationGeneration = Volatile.Read(ref _activationGeneration); BeginBusy(); try { var source = await _pairStore.ReadCp949Async(cancellationToken).ConfigureAwait(false); var candidate = new List(); if (source is { Length: > 0 }) { var bytes = source.ToArray(); var rows = LegacyComparisonFileParser.Parse(bytes); foreach (var row in rows) { cancellationToken.ThrowIfCancellationRequested(); var firstReference = new LegacyComparisonStoredTargetReference( row.First.Name, row.First.Market); var secondReference = new LegacyComparisonStoredTargetReference( row.Second.Name, row.Second.Market); var first = await ResolveStoredTargetAsync( firstReference, cancellationToken).ConfigureAwait(false); var second = await ResolveStoredTargetAsync( secondReference, cancellationToken).ConfigureAwait(false); candidate.Add(new SavedPairState( $"comparison-pair-{row.RowNumber:D8}", first, second)); } } if (activationGeneration != Volatile.Read(ref _activationGeneration)) { return CreateSnapshot(); } _savedPairs = candidate; _pairSequence = Math.Max(_pairSequence, candidate.Count); _selectedPairId = candidate.FirstOrDefault()?.RowId; _statusMessage = candidate.Count == 0 ? "저장된 종목 비교쌍이 없습니다." : $"저장된 종목 비교쌍 {candidate.Count}개를 불러왔습니다."; _statusKind = LegacyOperatorStatusKind.Information; } catch { if (activationGeneration != Volatile.Read(ref _activationGeneration)) { return CreateSnapshot(); } _statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다."; _statusKind = LegacyOperatorStatusKind.Error; throw; } finally { if (activationGeneration == Volatile.Read(ref _activationGeneration)) { EndBusy(); } } return CreateSnapshot(); } public async Task AddDraftPairAsync( CancellationToken cancellationToken = default) { if (_firstTarget is null || _secondTarget is null) { throw new InvalidOperationException("Select two comparison targets first."); } if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows) { throw new InvalidOperationException("The saved comparison pair limit was reached."); } var signature = LegacyComparisonWorkflowCatalog.PairSignature( _firstTarget, _secondTarget); if (_savedPairs.Any(pair => string.Equals( LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second), signature, StringComparison.Ordinal))) { throw new InvalidOperationException("The exact comparison pair is already saved."); } var rowId = $"comparison-pair-{++_pairSequence:D8}"; var candidate = new List(_savedPairs) { new(rowId, _firstTarget, _secondTarget) }; return await CommitPairsAsync( candidate, rowId, "종목 비교쌍을 저장했습니다.", cancellationToken).ConfigureAwait(false); } public LegacyComparisonWorkflowSnapshot SelectSavedPair(string rowId) { ArgumentNullException.ThrowIfNull(rowId); if (_savedPairs.Any(pair => string.Equals(pair.RowId, rowId, StringComparison.Ordinal))) { _selectedPairId = rowId; Touch(); } return CreateSnapshot(); } public async Task MoveSelectedPairAsync( LegacyComparisonPairMoveDirection direction, CancellationToken cancellationToken = default) { if (!Enum.IsDefined(direction)) { throw new ArgumentOutOfRangeException(nameof(direction)); } var index = SelectedPairIndex(); var destination = index + (direction == LegacyComparisonPairMoveDirection.Up ? -1 : 1); if (index < 0 || destination < 0 || destination >= _savedPairs.Count) { return CreateSnapshot(); } var candidate = new List(_savedPairs); (candidate[index], candidate[destination]) = (candidate[destination], candidate[index]); return await CommitPairsAsync( candidate, _selectedPairId, "종목 비교쌍 순서를 변경했습니다.", cancellationToken).ConfigureAwait(false); } public async Task DeleteSelectedPairAsync( CancellationToken cancellationToken = default) { var index = SelectedPairIndex(); if (index < 0) { return CreateSnapshot(); } var candidate = new List(_savedPairs); candidate.RemoveAt(index); var nextSelection = candidate.Count == 0 ? null : candidate[Math.Min(index, candidate.Count - 1)].RowId; return await CommitPairsAsync( candidate, nextSelection, "선택한 종목 비교쌍을 삭제했습니다.", cancellationToken).ConfigureAwait(false); } public Task DeleteAllPairsAsync( CancellationToken cancellationToken = default) => _savedPairs.Count == 0 ? Task.FromResult(CreateSnapshot()) : CommitPairsAsync( [], null, "종목 비교쌍을 모두 삭제했습니다.", cancellationToken); public LegacyGenericPlaylistDraft MaterializeSelectedAction(string actionId) { ArgumentNullException.ThrowIfNull(actionId); if (_isBusy) { throw new InvalidOperationException("The comparison workflow is busy."); } var index = SelectedPairIndex(); if (index < 0) { throw new InvalidOperationException("Select a saved comparison pair first."); } var pair = _savedPairs[index]; var draftId = $"comparison-draft-{++_draftSequence:D8}"; return LegacyComparisonWorkflowCatalog.Materialize( actionId, pair.First, pair.Second, draftId); } private LegacyComparisonWorkflowSnapshot ChooseSearchResult( IReadOnlyList results, string selectionId, bool domestic) { ArgumentNullException.ThrowIfNull(selectionId); var result = results.FirstOrDefault(item => string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)); if (result is not null) { if (domestic) { _selectedDomesticResultId = selectionId; } else { _selectedWorldResultId = selectionId; } RotateDraft(result.Target); Touch(); } return CreateSnapshot(); } private LegacyComparisonWorkflowSnapshot SelectSearchResult( IReadOnlyList results, string selectionId, bool domestic) { ArgumentNullException.ThrowIfNull(selectionId); if (results.Any(item => string.Equals( item.SelectionId, selectionId, StringComparison.Ordinal))) { if (domestic) { _selectedDomesticResultId = selectionId; } else { _selectedWorldResultId = selectionId; } Touch(); } return CreateSnapshot(); } private void RotateDraft(LegacyComparisonTarget selected) { _secondTarget = _firstTarget; _firstTarget = selected; } private async Task ResolveStoredTargetAsync( LegacyComparisonStoredTargetReference stored, CancellationToken cancellationToken) { var fixedTarget = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(stored); var resolved = fixedTarget ?? await _dataService.ResolveStoredTargetAsync( stored, cancellationToken).ConfigureAwait(false); if (resolved is null) { throw new InvalidOperationException("A stored comparison target no longer resolves."); } resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved); if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored)) { throw new InvalidOperationException("A stored comparison target resolved to a different identity."); } return resolved; } private async Task CommitPairsAsync( IReadOnlyList candidate, string? nextSelection, string successMessage, CancellationToken cancellationToken) { BeginBusy(); try { var writeRows = candidate.Select((pair, index) => { var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First); var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second); return new LegacyComparisonPairWriteRow( index + 1, first.Name, first.Market, second.Name, second.Market); }).ToArray(); await _pairStore.WriteCp949Async( Array.AsReadOnly(writeRows), cancellationToken).ConfigureAwait(false); _savedPairs = candidate.ToList(); _selectedPairId = nextSelection; _statusMessage = successMessage; _statusKind = LegacyOperatorStatusKind.Information; } catch { _statusMessage = "종목 비교쌍 파일 쓰기를 완료하지 못해 변경을 적용하지 않았습니다."; _statusKind = LegacyOperatorStatusKind.Error; throw; } finally { EndBusy(); } return CreateSnapshot(); } private static List ValidateSearchResults( IReadOnlyList? results, int maximumResults, bool domestic, int version) { if (results is null || results.Count > maximumResults) { throw new InvalidOperationException("The comparison search result count is invalid."); } var validated = new List(results.Count); var targetIds = new HashSet(StringComparer.Ordinal); for (var index = 0; index < results.Count; index++) { var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]); var allowedKind = domestic ? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock : target.Kind == LegacyComparisonTargetKind.WorldStock; if (!allowedKind || !targetIds.Add(target.TargetId)) { throw new InvalidOperationException("The comparison search result identity is invalid."); } validated.Add(new SearchResultState( $"comparison-{(domestic ? "domestic" : "world")}-{version:D4}-{index + 1:D4}", target)); } return validated; } private LegacyComparisonWorkflowSnapshot CreateSnapshot() { var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0 ? _savedPairs[selectedIndex] : null; var domesticResults = _domesticResults .Select(result => LegacyComparisonWorkflowCatalog.ToView( result.Target, result.SelectionId)) .ToArray(); var worldResults = _worldResults .Select(result => LegacyComparisonWorkflowCatalog.ToView( result.Target, result.SelectionId)) .ToArray(); var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets .Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId)) .ToArray(); var savedPairs = _savedPairs.Select((pair, index) => new LegacyComparisonSavedPairView( pair.RowId, index + 1, $"{pair.First.DisplayName} ↔ {pair.Second.DisplayName}", LegacyComparisonWorkflowCatalog.ToView(pair.First, $"{pair.RowId}-first"), LegacyComparisonWorkflowCatalog.ToView(pair.Second, $"{pair.RowId}-second"), string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal))) .ToArray(); return new LegacyComparisonWorkflowSnapshot( _revision, _isBusy, new LegacyComparisonSearchSnapshot( _domesticQuery, _domesticStatus, Array.AsReadOnly(domesticResults), _selectedDomesticResultId, domesticResults.Length, _domesticError), new LegacyComparisonSearchSnapshot( _worldQuery, _worldStatus, Array.AsReadOnly(worldResults), _selectedWorldResultId, worldResults.Length, _worldError), Array.AsReadOnly(fixedTargets), _selectedFixedTargetId, _firstTarget is null ? null : LegacyComparisonWorkflowCatalog.ToView(_firstTarget, "comparison-draft-first"), _secondTarget is null ? null : LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"), Array.AsReadOnly(savedPairs), _selectedPairId, LegacyComparisonWorkflowCatalog.CreateActionViews( selectedPair?.First, selectedPair?.Second), _statusMessage, _statusKind); } private int SelectedPairIndex() => _selectedPairId is null ? -1 : _savedPairs.FindIndex(pair => string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal)); private static string NormalizeSearchQuery(string query) { ArgumentNullException.ThrowIfNull(query); var normalized = query.Trim(); if (normalized.Length > 64 || normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character))) { throw new ArgumentException("A comparison query must be at most 64 safe characters.", nameof(query)); } return normalized; } private void BeginBusy() { if (_isBusy) { throw new InvalidOperationException("The comparison workflow is already busy."); } _isBusy = true; Touch(); } private void EndBusy() { _isBusy = false; Touch(); } private void Touch() => _revision++; private sealed record SearchResultState( string SelectionId, LegacyComparisonTarget Target); private sealed record SavedPairState( string RowId, LegacyComparisonTarget First, LegacyComparisonTarget Second); }