using System.Data; using System.Globalization; using System.Text; using MBN_STOCK_WEBVIEW.Infrastructure; using MBN_STOCK_WEBVIEW.LegacyApplication; using MMoneyCoderSharp.Data; internal static class ThemeSelectorCompatibilityAudit { private const string KrxQueryName = "AUDIT_THEME_SELECTOR_RAW_KRX"; private const string NxtQueryName = "AUDIT_THEME_SELECTOR_RAW_NXT"; private const string NxtActiveCodesQueryName = "AUDIT_THEME_SELECTOR_ACTIVE_NXT_CODES"; private const string KrxSql = """ SELECT s.SB_TITLE THEME_TITLE, s.SB_CODE THEME_CODE, s.SB_MARKET THEME_MARKET FROM SB_LIST s WHERE s.SB_MARKET = 'KRX' """; private const string NxtSql = """ SELECT s.SB_TITLE THEME_TITLE, s.SB_CODE THEME_CODE, s.SB_MARKET THEME_MARKET FROM SB_LIST s WHERE s.SB_MARKET = 'NXT' """; private const string NxtActiveCodesSql = """ SELECT DISTINCT i.SB_M_CODE THEME_CODE FROM SB_ITEM i JOIN ( SELECT CONCAT('X', s.F_STOCK_CODE) ITEM_CODE FROM N_STOCK s WHERE s.F_STOP_GUBUN = 'N' UNION ALL SELECT CONCAT('T', s.F_STOCK_CODE) ITEM_CODE FROM N_KOSDAQ_STOCK s WHERE s.F_STOP_GUBUN = 'N' ) current_nxt ON BINARY i.SB_I_CODE = BINARY current_nxt.ITEM_CODE """; public static async Task RunAsync( IDataQueryExecutor executor, LegacyThemeSelectionService service, ThemeSearchResult selector, LegacyThemeWorkflow workflow, LegacyThemeWorkflowSnapshot workflowSnapshot, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(executor); ArgumentNullException.ThrowIfNull(service); ArgumentNullException.ThrowIfNull(selector); ArgumentNullException.ThrowIfNull(workflow); ArgumentNullException.ThrowIfNull(workflowSnapshot); var krx = await LoadRowsAsync( executor, DataSourceKind.Oracle, KrxQueryName, KrxSql, ThemeMarket.Krx, "KRX", cancellationToken).ConfigureAwait(false); var nxt = await LoadRowsAsync( executor, DataSourceKind.MariaDb, NxtQueryName, NxtSql, ThemeMarket.Nxt, "NXT", cancellationToken).ConfigureAwait(false); var raw = krx.Concat(nxt).ToArray(); RequireUniqueCodes(raw); var rawKeys = raw.Select(static row => row.Key).ToHashSet(); var selectorKeys = selector.Items .Select(static item => new ThemeKey( item.Identity.Market, item.Identity.ThemeCode, item.Identity.ThemeTitle)) .ToHashSet(); if (selector.IsTruncated || rawKeys.Count != raw.Length || selectorKeys.Count != selector.Items.Count || !rawKeys.SetEquals(selectorKeys)) { throw new DatabaseConfigurationException( "The Theme selector did not preserve every safe raw SB_LIST identity."); } if (workflowSnapshot.SearchIsTruncated || workflowSnapshot.SearchResults.Count != selector.Items.Count) { throw new DatabaseConfigurationException( "The Theme operator workflow did not preserve every raw selector identity."); } var krxEdgeSpace = krx.Count(static row => HasEdgeWhitespace(row.Title)); var nxtEdgeSpace = nxt.Count(static row => HasEdgeWhitespace(row.Title)); var duplicateTitleRows = raw .GroupBy(static row => (row.Market, row.Title)) .Where(static group => group.Count() > 1) .Sum(static group => group.Count()); var duplicateTitleGroups = raw .GroupBy(static row => (row.Market, row.Title)) .Count(static group => group.Count() > 1); var krxSuffixRows = krx.Count(static row => row.Title.EndsWith("(NXT)", StringComparison.Ordinal)); if (krxEdgeSpace == 0 || nxtEdgeSpace == 0 || duplicateTitleRows == 0 || krxSuffixRows == 0) { throw new DatabaseConfigurationException( "The deployed Theme compatibility fixtures are missing from SB_LIST."); } var suffixRow = workflowSnapshot.SearchResults.FirstOrDefault(static row => row.Market == ThemeMarket.Krx && row.DisplayTitle.EndsWith("(NXT)", StringComparison.Ordinal)); if (suffixRow is null || suffixRow.Market != ThemeMarket.Krx || !suffixRow.DisplayTitle.EndsWith("(NXT)", StringComparison.Ordinal)) { throw new DatabaseConfigurationException( "A stored KRX '(NXT)' title lost its explicit KRX identity."); } var activeNxtCodes = await LoadActiveNxtCodesAsync( executor, cancellationToken).ConfigureAwait(false); var emptyNxt = selector.Items.FirstOrDefault(item => item.Identity.Market == ThemeMarket.Nxt && !activeNxtCodes.Contains(item.Identity.ThemeCode)); if (emptyNxt is null) { throw new DatabaseConfigurationException( "The deployed empty-active NXT Theme compatibility fixture is missing."); } var emptyPreview = await service.PreviewAsync( emptyNxt.Identity, LegacyThemeSelectionService.MaximumPreviewItems, cancellationToken).ConfigureAwait(false); if (emptyPreview.IsTruncated || emptyPreview.Items.Count != 0 || emptyPreview.Identity != emptyNxt.Identity) { throw new DatabaseConfigurationException( "The empty-active NXT Theme did not remain a correlated empty preview."); } var emptyIndex = Array.FindIndex( selector.Items.ToArray(), item => item == emptyNxt); if (emptyIndex < 0 || emptyIndex >= workflowSnapshot.SearchResults.Count) { throw new DatabaseConfigurationException( "The empty-active NXT Theme lost its opaque workflow correlation."); } var emptyWorkflowRow = workflowSnapshot.SearchResults[emptyIndex]; var selectedEmpty = await workflow.SelectAsync( workflowSnapshot, emptyWorkflowRow.ResultId, cancellationToken).ConfigureAwait(false); if (!string.Equals( selectedEmpty.SelectedResultId, emptyWorkflowRow.ResultId, StringComparison.Ordinal) || selectedEmpty.SelectedTheme?.Market != ThemeMarket.Nxt || selectedEmpty.ActionStates.Any(static state => state.IsAvailable || !string.Equals( state.UnavailableReason, "theme-preview-is-empty", StringComparison.Ordinal))) { throw new DatabaseConfigurationException( "An empty-active NXT Theme became eligible for playlist or playout action."); } Console.WriteLine( "THEME_SELECTOR_COMPATIBILITY: " + $"raw={raw.Length.ToString(CultureInfo.InvariantCulture)} " + $"visible={selector.Items.Count.ToString(CultureInfo.InvariantCulture)} " + $"krx={krx.Count.ToString(CultureInfo.InvariantCulture)} " + $"nxt={nxt.Count.ToString(CultureInfo.InvariantCulture)} " + $"krxEdgeSpace={krxEdgeSpace.ToString(CultureInfo.InvariantCulture)} " + $"nxtEdgeSpace={nxtEdgeSpace.ToString(CultureInfo.InvariantCulture)} " + $"duplicateTitleGroups={duplicateTitleGroups.ToString(CultureInfo.InvariantCulture)} " + $"duplicateTitleRows={duplicateTitleRows.ToString(CultureInfo.InvariantCulture)} " + $"krxStoredNxtSuffix={krxSuffixRows.ToString(CultureInfo.InvariantCulture)} " + "emptyNxtVisible=true emptyNxtActionsBlocked=true"); } private static async Task> LoadRowsAsync( IDataQueryExecutor executor, DataSourceKind source, string queryName, string sql, ThemeMarket market, string databaseMarket, CancellationToken cancellationToken) { var spec = new DataQuerySpec(sql); spec.ValidateFor(source); var table = await executor.ExecuteAsync( source, queryName, spec, cancellationToken).ConfigureAwait(false); if (table is null || table.Columns.Count != 3 || !string.Equals(table.Columns[0].ColumnName, "THEME_TITLE", StringComparison.Ordinal) || !string.Equals(table.Columns[1].ColumnName, "THEME_CODE", StringComparison.Ordinal) || !string.Equals(table.Columns[2].ColumnName, "THEME_MARKET", StringComparison.Ordinal) || table.Columns.Cast().Any(static column => column.DataType != typeof(string))) { throw new DatabaseConfigurationException( $"The {queryName} result has an invalid schema."); } var result = new RawThemeRow[table.Rows.Count]; for (var index = 0; index < table.Rows.Count; index++) { var row = table.Rows[index]; var title = RequireStoredTitle(row[0], queryName); var code = RequireCode(row[1], queryName); if (row[2] is not string actualMarket || !string.Equals(actualMarket, databaseMarket, StringComparison.Ordinal)) { throw new DatabaseConfigurationException( $"The {queryName} result contains an invalid market identity."); } result[index] = new RawThemeRow(market, code, title); } return result; } private static async Task> LoadActiveNxtCodesAsync( IDataQueryExecutor executor, CancellationToken cancellationToken) { var spec = new DataQuerySpec(NxtActiveCodesSql); spec.ValidateFor(DataSourceKind.MariaDb); var table = await executor.ExecuteAsync( DataSourceKind.MariaDb, NxtActiveCodesQueryName, spec, cancellationToken).ConfigureAwait(false); if (table is null || table.Columns.Count != 1 || !string.Equals(table.Columns[0].ColumnName, "THEME_CODE", StringComparison.Ordinal) || table.Columns[0].DataType != typeof(string)) { throw new DatabaseConfigurationException( $"The {NxtActiveCodesQueryName} result has an invalid schema."); } var result = new HashSet(StringComparer.Ordinal); foreach (DataRow row in table.Rows) { if (row[0] is not string code || !result.Add(code)) { throw new DatabaseConfigurationException( $"The {NxtActiveCodesQueryName} result contains an invalid code."); } } return result; } private static string RequireStoredTitle(object value, string queryName) { if (value is not string title || title.Length is < 1 or > 128 || string.IsNullOrWhiteSpace(title) || !IsSafeText(title)) { throw new DatabaseConfigurationException( $"The {queryName} result contains an unsafe stored title."); } return title; } private static string RequireCode(object value, string queryName) { if (value is not string code || code.Length is < 3 or > 8 || code.Any(static character => !char.IsAsciiDigit(character))) { throw new DatabaseConfigurationException( $"The {queryName} result contains an invalid theme code."); } return code; } private static void RequireUniqueCodes(IEnumerable rows) { if (rows.GroupBy(static row => (row.Market, row.Code)) .Any(static group => group.Count() != 1)) { throw new DatabaseConfigurationException( "The deployed Theme catalog contains an ambiguous market/code identity."); } } private static bool HasEdgeWhitespace(string value) => value.Length > 0 && (char.IsWhiteSpace(value[0]) || char.IsWhiteSpace(value[^1])); 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 readonly record struct ThemeKey( ThemeMarket Market, string Code, string Title); private sealed record RawThemeRow( ThemeMarket Market, string Code, string Title) { public ThemeKey Key => new(Market, Code, Title); } }