using System.Collections.ObjectModel; using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using System.Text.Json.Serialization; using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; namespace MBN_STOCK_WEBVIEW.LegacyApplication; public enum LegacyFixedMarket { Overseas, Exchange, Index } public enum LegacyFixedCategory { Plate, Market, Chart } public sealed record LegacyFixedSelection( string GroupCode, string Subject, string GraphicType, string Subtype, string DataCode); public readonly record struct LegacyMovingAverageSelection(bool Ma5, bool Ma20); public sealed record LegacyFixedRuntimeAsset( LegacyFixedMarket Market, string RelativePath, string Sha256); public sealed class LegacyFixedAction { internal LegacyFixedAction( string id, LegacyFixedMarket market, string section, string label, string detail, LegacyFixedCategory category, string builderKey, string code, IReadOnlyList aliases, bool available, string prerequisite, LegacyFixedSelection selection, bool dynamicDate, bool dynamicNxtSession) { Id = id; Market = market; Section = section; Label = label; Detail = detail; Category = category; BuilderKey = builderKey; Code = code; Aliases = aliases; Available = available; Prerequisite = prerequisite; Selection = selection; DynamicDate = dynamicDate; DynamicNxtSession = dynamicNxtSession; } public string Id { get; } public LegacyFixedMarket Market { get; } public string Section { get; } public string Label { get; } public string Detail { get; } public LegacyFixedCategory Category { get; } public string BuilderKey { get; } public string Code { get; } public IReadOnlyList Aliases { get; } public bool Available { get; } public string Prerequisite { get; } public LegacyFixedSelection Selection { get; } public bool DynamicDate { get; } public bool DynamicNxtSession { get; } public bool SupportsMovingAverages => string.Equals(BuilderKey, "s8010", StringComparison.Ordinal); } public sealed record LegacyMaterializedFixedAction( LegacyFixedAction Action, LegacyFixedSelection Selection, LegacyFixedRuntimeAsset RuntimeAsset); public sealed class LegacyFixedSection { internal LegacyFixedSection( LegacyFixedMarket market, string name, IReadOnlyList actions) { Market = market; Name = name; Actions = actions; } public LegacyFixedMarket Market { get; } public string Name { get; } public IReadOnlyList Actions { get; } } /// /// C#-authoritative catalog for the three fixed legacy UC1 tabs. The embedded JSON is a /// generated build asset; callers can select only by the opaque action id and cannot provide /// builder, cut alias, or scene-selection values. /// public sealed class LegacyFixedActionCatalog { public const int ExpectedActionCount = 328; public const int ExpectedOverseasCount = 78; public const int ExpectedExchangeCount = 46; public const int ExpectedIndexCount = 204; public const int ExpectedAvailableCount = 322; public const string ExpectedDocumentSha256 = "E56FB9DE8247672022EE2F324AF4C37179EC58E399758425AD4C29DAF1BDDC56"; public const string ExpectedGeneratorSourceSha256 = "31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5"; private const string ResourceName = "MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json"; private const string ExpectedGeneratedFrom = "Web/legacy-fixed-workflow.js"; private static readonly JsonSerializerOptions StrictJsonOptions = new() { AllowTrailingCommas = false, PropertyNameCaseInsensitive = false, ReadCommentHandling = JsonCommentHandling.Disallow, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; private static readonly Lazy EmbeddedCatalog = new(LoadEmbedded, LazyThreadSafetyMode.ExecutionAndPublication); private readonly IReadOnlyDictionary _byId; private readonly IReadOnlyDictionary> _byMarket; private readonly IReadOnlyDictionary> _sectionsByMarket; private readonly IReadOnlyDictionary<(LegacyFixedMarket Market, string Section), IReadOnlyList> _bySection; private LegacyFixedActionCatalog( IReadOnlyList actions, IReadOnlyDictionary runtimeAssets, string documentSha256, string generatorSourceSha256) { Actions = actions; RuntimeAssets = runtimeAssets; DocumentSha256 = documentSha256; GeneratorSourceSha256 = generatorSourceSha256; _byId = new ReadOnlyDictionary( actions.ToDictionary(action => action.Id, StringComparer.Ordinal)); _byMarket = new ReadOnlyDictionary>( Enum.GetValues().ToDictionary( market => market, market => ReadOnly(actions.Where(action => action.Market == market)))); var sectionsByMarket = new Dictionary>(); var bySection = new Dictionary<(LegacyFixedMarket Market, string Section), IReadOnlyList>(); foreach (var market in Enum.GetValues()) { var sections = _byMarket[market] .GroupBy(action => action.Section, StringComparer.Ordinal) .Select(group => { var sectionActions = ReadOnly(group); bySection.Add((market, group.Key), sectionActions); return new LegacyFixedSection(market, group.Key, sectionActions); }); sectionsByMarket.Add(market, ReadOnly(sections)); } _sectionsByMarket = new ReadOnlyDictionary>( sectionsByMarket); _bySection = new ReadOnlyDictionary<(LegacyFixedMarket Market, string Section), IReadOnlyList>(bySection); } public static LegacyFixedActionCatalog Default => EmbeddedCatalog.Value; public IReadOnlyList Actions { get; } public IReadOnlyDictionary RuntimeAssets { get; } public string DocumentSha256 { get; } public string GeneratorSourceSha256 { get; } public IReadOnlyList GetActions(LegacyFixedMarket market) { RequireMarket(market); return _byMarket[market]; } public IReadOnlyList GetSections(LegacyFixedMarket market) { RequireMarket(market); return _sectionsByMarket[market]; } public IReadOnlyList GetActions( LegacyFixedMarket market, string section) { RequireMarket(market); ArgumentException.ThrowIfNullOrWhiteSpace(section); return _bySection.TryGetValue((market, section), out var actions) ? actions : Array.Empty(); } public LegacyFixedAction? FindAction(string? actionId) => actionId is not null && _byId.TryGetValue(actionId, out var action) ? action : null; public LegacyFixedAction GetAction(string actionId) { ArgumentException.ThrowIfNullOrWhiteSpace(actionId); return _byId.TryGetValue(actionId, out var action) ? action : throw new KeyNotFoundException($"Unknown fixed legacy action id '{actionId}'."); } public LegacyFixedRuntimeAsset GetRuntimeAsset(LegacyFixedMarket market) { RequireMarket(market); return RuntimeAssets[market]; } internal LegacyFixedActionCatalog WithRuntimeOrder( IReadOnlyDictionary> actionsByMarket) { ArgumentNullException.ThrowIfNull(actionsByMarket); var ordered = new List(Actions.Count); foreach (var market in Enum.GetValues()) { var source = actionsByMarket.TryGetValue(market, out var runtimeActions) ? runtimeActions : _byMarket[market]; if (source is null || source.Count != _byMarket[market].Count || source.Any(action => action is null || action.Market != market) || !source.Select(action => action.Id).ToHashSet(StringComparer.Ordinal) .SetEquals(_byMarket[market].Select(action => action.Id))) { throw new InvalidDataException( $"The runtime fixed-action order for '{MarketText(market)}' is invalid."); } ordered.AddRange(source); } return new LegacyFixedActionCatalog( ReadOnly(ordered), RuntimeAssets, DocumentSha256, GeneratorSourceSha256); } public LegacyMaterializedFixedAction Materialize( string actionId, DateTimeOffset localNow) { var action = GetAction(actionId); if (!action.Available) { throw new InvalidOperationException(action.Prerequisite); } var graphicType = action.DynamicNxtSession ? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET" : action.Selection.GraphicType; var dataCode = action.DynamicDate ? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : action.Selection.DataCode; var selection = action.Selection with { GraphicType = graphicType, DataCode = dataCode }; return new LegacyMaterializedFixedAction( action, selection, RuntimeAssets[action.Market]); } internal static LegacyFixedActionCatalog LoadForValidation( ReadOnlyMemory utf8Json, string? expectedDocumentSha256 = null) => Load(utf8Json.ToArray(), expectedDocumentSha256); private static LegacyFixedActionCatalog LoadEmbedded() { using var stream = typeof(LegacyFixedActionCatalog).Assembly .GetManifestResourceStream(ResourceName) ?? throw new InvalidDataException( $"The embedded fixed legacy catalog '{ResourceName}' is missing."); using var buffer = new MemoryStream(); stream.CopyTo(buffer); return Load(buffer.ToArray(), ExpectedDocumentSha256); } private static LegacyFixedActionCatalog Load(byte[] utf8Json, string? expectedDocumentSha256) { ArgumentNullException.ThrowIfNull(utf8Json); if (utf8Json.Length == 0) { throw new InvalidDataException("The fixed legacy catalog is empty."); } CatalogDocumentDto document; try { using var parsed = JsonDocument.Parse(utf8Json, new JsonDocumentOptions { AllowTrailingCommas = false, CommentHandling = JsonCommentHandling.Disallow }); RejectDuplicateProperties(parsed.RootElement, "$root"); document = JsonSerializer.Deserialize(utf8Json, StrictJsonOptions) ?? throw new InvalidDataException("The fixed legacy catalog root is null."); } catch (JsonException exception) { throw new InvalidDataException("The fixed legacy catalog JSON schema is invalid.", exception); } var documentHash = Convert.ToHexString(SHA256.HashData(utf8Json)); if (expectedDocumentSha256 is not null && !string.Equals(documentHash, expectedDocumentSha256, StringComparison.Ordinal)) { throw new InvalidDataException( $"The fixed legacy catalog hash is invalid. Expected {expectedDocumentSha256}; actual {documentHash}."); } ValidateDocumentMetadata(document); var runtimeAssets = CreateRuntimeAssets(document.RuntimeAssets); var actions = CreateActions(document.Actions); ValidateCatalogInvariants(actions); return new LegacyFixedActionCatalog( ReadOnly(actions), runtimeAssets, documentHash, document.SourceSha256); } private static void ValidateDocumentMetadata(CatalogDocumentDto document) { if (document.SchemaVersion != 1) { throw Invalid("schemaVersion must be 1"); } if (!string.Equals(document.GeneratedFrom, ExpectedGeneratedFrom, StringComparison.Ordinal)) { throw Invalid($"generatedFrom must be '{ExpectedGeneratedFrom}'"); } if (!string.Equals( document.SourceSha256, ExpectedGeneratorSourceSha256, StringComparison.Ordinal)) { throw Invalid("the generator source hash is not approved"); } if (document.RuntimeAssets is null) { throw Invalid("runtimeAssets is required"); } if (document.Actions is null) { throw Invalid("actions is required"); } } private static IReadOnlyDictionary CreateRuntimeAssets(RuntimeAssetsDto runtimeAssets) { var assets = new Dictionary { [LegacyFixedMarket.Overseas] = ValidateRuntimeAsset( LegacyFixedMarket.Overseas, runtimeAssets.Overseas, "bin/Debug/Res/해외.ini", "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"), [LegacyFixedMarket.Exchange] = ValidateRuntimeAsset( LegacyFixedMarket.Exchange, runtimeAssets.Exchange, "bin/Debug/Res/환율.ini", "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"), [LegacyFixedMarket.Index] = ValidateRuntimeAsset( LegacyFixedMarket.Index, runtimeAssets.Index, "bin/Debug/Res/지수.ini", "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6") }; return new ReadOnlyDictionary(assets); } private static LegacyFixedRuntimeAsset ValidateRuntimeAsset( LegacyFixedMarket market, RuntimeAssetDto asset, string expectedPath, string expectedSha256) { if (asset is null || !string.Equals(asset.RelativePath, expectedPath, StringComparison.Ordinal) || !string.Equals(asset.Sha256, expectedSha256, StringComparison.Ordinal)) { throw Invalid($"runtime asset metadata for '{MarketText(market)}' is invalid"); } RequireSha256(asset.Sha256, $"runtimeAssets.{MarketText(market)}.sha256"); return new LegacyFixedRuntimeAsset(market, asset.RelativePath, asset.Sha256); } private static LegacyFixedAction[] CreateActions(IReadOnlyList source) { if (source.Count != ExpectedActionCount) { throw Invalid($"exactly {ExpectedActionCount} actions are required"); } var actions = new LegacyFixedAction[source.Count]; for (var index = 0; index < source.Count; index++) { var dto = source[index] ?? throw Invalid($"action at index {index} is null"); var expectedId = $"fixed-{index + 1:000}"; if (!string.Equals(dto.Id, expectedId, StringComparison.Ordinal)) { throw Invalid($"action id at index {index} must be '{expectedId}'"); } var market = ParseMarket(dto.Market); var category = ParseCategory(dto.Category); var section = RequireText(dto.Section, $"{expectedId}.section"); var label = RequireText(dto.Label, $"{expectedId}.label"); var detail = RequireText(dto.Detail, $"{expectedId}.detail"); var builderKey = RequireText(dto.BuilderKey, $"{expectedId}.builderKey"); var code = RequireText(dto.Code, $"{expectedId}.code"); ValidateSceneIdentity(expectedId, builderKey, code, dto.Aliases); var selection = ValidateSelection(expectedId, dto); var prerequisite = RequireText( dto.Prerequisite, $"{expectedId}.prerequisite", allowEmpty: true, maximumLength: 512); if (dto.Available != string.IsNullOrEmpty(prerequisite)) { throw Invalid($"{expectedId} availability and prerequisite do not agree"); } actions[index] = new LegacyFixedAction( expectedId, market, section, label, detail, category, builderKey, code, Array.AsReadOnly(dto.Aliases.ToArray()), dto.Available, prerequisite, selection, dto.DynamicDate, dto.DynamicNxtSession); } return actions; } private static void ValidateSceneIdentity( string actionId, string builderKey, string code, IReadOnlyList aliases) { if (aliases is null) { throw Invalid($"{actionId}.aliases is required"); } var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate => string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal)) ?? throw Invalid($"{actionId} has unknown builder '{builderKey}'"); if (!definition.CutAliases.Contains(code, StringComparer.Ordinal)) { throw Invalid($"{actionId} code '{code}' is not an alias of '{builderKey}'"); } if (aliases.Count != 1 || !string.Equals(aliases[0], code, StringComparison.Ordinal)) { throw Invalid($"{actionId} must declare exactly its code as its sole alias"); } var aliasMatches = LegacySceneCatalog.FindByCutAlias(code); if (!aliasMatches.Any(candidate => string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal))) { throw Invalid($"{actionId} alias '{code}' does not resolve to '{builderKey}'"); } } private static LegacyFixedSelection ValidateSelection(string actionId, ActionDto action) { var source = action.Selection ?? throw Invalid($"{actionId}.selection is required"); var selection = new LegacyFixedSelection( RequireText(source.GroupCode, $"{actionId}.selection.groupCode"), RequireText(source.Subject, $"{actionId}.selection.subject", allowEmpty: true), RequireText(source.GraphicType, $"{actionId}.selection.graphicType", allowEmpty: true), RequireText(source.Subtype, $"{actionId}.selection.subtype", allowEmpty: true), RequireText(source.DataCode, $"{actionId}.selection.dataCode", allowEmpty: true)); if (action.DynamicDate && action.DynamicNxtSession) { throw Invalid($"{actionId} cannot have two dynamic selection fields"); } if (action.DynamicDate != string.Equals(selection.DataCode, "$TODAY", StringComparison.Ordinal)) { throw Invalid($"{actionId} has an invalid dynamic-date declaration"); } if (action.DynamicNxtSession != string.Equals(selection.GraphicType, "$NXT_SESSION", StringComparison.Ordinal)) { throw Invalid($"{actionId} has an invalid NXT-session declaration"); } if (string.Equals(action.BuilderKey, "s8010", StringComparison.Ordinal) && !string.IsNullOrEmpty(selection.GraphicType)) { throw Invalid($"{actionId} moving-average flags must be materialized by C#"); } var values = new[] { selection.GroupCode, selection.Subject, selection.GraphicType, selection.Subtype, selection.DataCode }; if (values.Any(value => value.Contains('$', StringComparison.Ordinal)) && !action.DynamicDate && !action.DynamicNxtSession) { throw Invalid($"{actionId} contains an unapproved selection placeholder"); } return selection; } private static void ValidateCatalogInvariants(IReadOnlyList actions) { RequireCount(actions, LegacyFixedMarket.Overseas, ExpectedOverseasCount); RequireCount(actions, LegacyFixedMarket.Exchange, ExpectedExchangeCount); RequireCount(actions, LegacyFixedMarket.Index, ExpectedIndexCount); if (actions.Count(action => action.Available) != ExpectedAvailableCount) { throw Invalid($"exactly {ExpectedAvailableCount} actions must be available"); } if (actions.Take(ExpectedOverseasCount).Any(action => action.Market != LegacyFixedMarket.Overseas) || actions.Skip(ExpectedOverseasCount).Take(ExpectedExchangeCount).Any(action => action.Market != LegacyFixedMarket.Exchange) || actions.Skip(ExpectedOverseasCount + ExpectedExchangeCount).Any(action => action.Market != LegacyFixedMarket.Index)) { throw Invalid("market action ranges are not in the approved legacy order"); } } private static void RequireCount( IEnumerable actions, LegacyFixedMarket market, int expected) { var actual = actions.Count(action => action.Market == market); if (actual != expected) { throw Invalid( $"market '{MarketText(market)}' must contain {expected} actions, not {actual}"); } } private static LegacyFixedMarket ParseMarket(string value) => value switch { "overseas" => LegacyFixedMarket.Overseas, "exchange" => LegacyFixedMarket.Exchange, "index" => LegacyFixedMarket.Index, _ => throw Invalid($"unknown market '{value}'") }; private static LegacyFixedCategory ParseCategory(string value) => value switch { "plate" => LegacyFixedCategory.Plate, "market" => LegacyFixedCategory.Market, "chart" => LegacyFixedCategory.Chart, _ => throw Invalid($"unknown category '{value}'") }; private static string MarketText(LegacyFixedMarket market) => market switch { LegacyFixedMarket.Overseas => "overseas", LegacyFixedMarket.Exchange => "exchange", LegacyFixedMarket.Index => "index", _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.") }; private static void RequireMarket(LegacyFixedMarket market) { if (!Enum.IsDefined(market)) { throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market."); } } private static string RequireText( string value, string field, bool allowEmpty = false, int maximumLength = 256) { if (value is null || (!allowEmpty && value.Length == 0) || value.Length > maximumLength || value.Any(char.IsControl) || !string.Equals(value, value.Trim(), StringComparison.Ordinal)) { throw Invalid($"{field} is invalid"); } return value; } private static void RequireSha256(string value, string field) { if (value is null || value.Length != 64 || value.Any(character => !(char.IsAsciiDigit(character) || character is >= 'A' and <= 'F'))) { throw Invalid($"{field} is not an uppercase SHA-256 value"); } } private static void RejectDuplicateProperties(JsonElement element, string path) { if (element.ValueKind == JsonValueKind.Object) { var names = new HashSet(StringComparer.Ordinal); foreach (var property in element.EnumerateObject()) { if (!names.Add(property.Name)) { throw new JsonException($"Duplicate property '{property.Name}' at {path}."); } RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); } } else if (element.ValueKind == JsonValueKind.Array) { var index = 0; foreach (var item in element.EnumerateArray()) { RejectDuplicateProperties(item, $"{path}[{index++}]"); } } } private static IReadOnlyList ReadOnly(IEnumerable source) => Array.AsReadOnly(source.ToArray()); private static InvalidDataException Invalid(string message) => new($"The fixed legacy catalog is invalid: {message}."); private sealed class CatalogDocumentDto { [JsonPropertyName("schemaVersion")] public required int SchemaVersion { get; init; } [JsonPropertyName("generatedFrom")] public required string GeneratedFrom { get; init; } [JsonPropertyName("sourceSha256")] public required string SourceSha256 { get; init; } [JsonPropertyName("runtimeAssets")] public required RuntimeAssetsDto RuntimeAssets { get; init; } [JsonPropertyName("actions")] public required List Actions { get; init; } } private sealed class RuntimeAssetsDto { [JsonPropertyName("overseas")] public required RuntimeAssetDto Overseas { get; init; } [JsonPropertyName("exchange")] public required RuntimeAssetDto Exchange { get; init; } [JsonPropertyName("index")] public required RuntimeAssetDto Index { get; init; } } private sealed class RuntimeAssetDto { [JsonPropertyName("relativePath")] public required string RelativePath { get; init; } [JsonPropertyName("sha256")] public required string Sha256 { get; init; } } private sealed class ActionDto { [JsonPropertyName("id")] public required string Id { get; init; } [JsonPropertyName("market")] public required string Market { get; init; } [JsonPropertyName("section")] public required string Section { get; init; } [JsonPropertyName("label")] public required string Label { get; init; } [JsonPropertyName("detail")] public required string Detail { get; init; } [JsonPropertyName("category")] public required string Category { get; init; } [JsonPropertyName("builderKey")] public required string BuilderKey { get; init; } [JsonPropertyName("code")] public required string Code { get; init; } [JsonPropertyName("aliases")] public required List Aliases { get; init; } [JsonPropertyName("available")] public required bool Available { get; init; } [JsonPropertyName("prerequisite")] public required string Prerequisite { get; init; } [JsonPropertyName("selection")] public required SelectionDto Selection { get; init; } [JsonPropertyName("dynamicDate")] public required bool DynamicDate { get; init; } [JsonPropertyName("dynamicNxtSession")] public required bool DynamicNxtSession { get; init; } } private sealed class SelectionDto { [JsonPropertyName("groupCode")] public required string GroupCode { get; init; } [JsonPropertyName("subject")] public required string Subject { get; init; } [JsonPropertyName("graphicType")] public required string GraphicType { get; init; } [JsonPropertyName("subtype")] public required string Subtype { get; init; } [JsonPropertyName("dataCode")] public required string DataCode { get; init; } } }