using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems; using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites; using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates; using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories; using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport; namespace ZB.MOM.WW.ScadaBridge.Transport.Export; /// /// Expands an into the full set of entities that /// must be in the bundle for the selection to be self-consistent. Walks the /// dependency edges documented in §6.1 of the Transport design doc: /// /// Template composes Template (via TemplateComposition.ComposedTemplateId). /// Template references SharedScript (by name, scanned in script bodies). /// Template references ExternalSystem (by name, scanned in script bodies + attribute DataSourceReference). /// ApiMethod references SharedScript (by name, scanned in ApiMethod.Script). /// Selected Template's folder chain (ancestor folders included). /// ExternalSystemDefinition pulls its ExternalSystemMethods. /// /// Templates are returned topologically sorted (base-before-derived) via Kahn's /// algorithm so importers can apply them in order. /// /// M8 adds site/instance-scoped expansion: selecting a site pulls its /// DataConnections and Instances; selecting an instance (with /// ) pulls its owning site, the /// data connections it binds, and feeds its template into the existing template /// closure so the template/shared-script/external-system graph expands too. /// /// public sealed class DependencyResolver { private readonly ITemplateEngineRepository _templates; private readonly IExternalSystemRepository _externalSystems; private readonly INotificationRepository _notifications; private readonly IInboundApiRepository _inboundApi; private readonly ISiteRepository _siteRepository; /// Initializes a new instance of . /// Repository for template, instance and shared script access. /// Repository for external system definitions and methods. /// Repository for notification lists and SMTP configurations. /// Repository for inbound API keys and methods. /// Repository for sites and site-scoped data connections. public DependencyResolver( ITemplateEngineRepository templates, IExternalSystemRepository externalSystems, INotificationRepository notifications, IInboundApiRepository inboundApi, ISiteRepository siteRepository) { _templates = templates ?? throw new ArgumentNullException(nameof(templates)); _externalSystems = externalSystems ?? throw new ArgumentNullException(nameof(externalSystems)); _notifications = notifications ?? throw new ArgumentNullException(nameof(notifications)); _inboundApi = inboundApi ?? throw new ArgumentNullException(nameof(inboundApi)); _siteRepository = siteRepository ?? throw new ArgumentNullException(nameof(siteRepository)); } /// Expands the selection into a fully self-consistent set of entities for bundling. /// The user's export selection specifying which entities to include. /// Cancellation token. /// A with all transitively required entities. public async Task ResolveAsync(ExportSelection selection, CancellationToken ct) { ArgumentNullException.ThrowIfNull(selection); // ---- Seed: fetch the directly-selected entities ---- var templates = new Dictionary(); foreach (var id in selection.TemplateIds.Distinct()) { var t = await _templates.GetTemplateWithChildrenAsync(id, ct).ConfigureAwait(false); if (t is not null) templates[t.Id] = t; } var sharedScripts = new Dictionary(); foreach (var id in selection.SharedScriptIds.Distinct()) { var s = await _templates.GetSharedScriptByIdAsync(id, ct).ConfigureAwait(false); if (s is not null) sharedScripts[s.Id] = s; } var externalSystems = new Dictionary(); foreach (var id in selection.ExternalSystemIds.Distinct()) { var es = await _externalSystems.GetExternalSystemByIdAsync(id, ct).ConfigureAwait(false); if (es is not null) externalSystems[es.Id] = es; } var dbConnections = new Dictionary(); foreach (var id in selection.DatabaseConnectionIds.Distinct()) { var db = await _externalSystems.GetDatabaseConnectionByIdAsync(id, ct).ConfigureAwait(false); if (db is not null) dbConnections[db.Id] = db; } var notificationLists = new Dictionary(); foreach (var id in selection.NotificationListIds.Distinct()) { var nl = await _notifications.GetNotificationListByIdAsync(id, ct).ConfigureAwait(false); if (nl is not null) notificationLists[nl.Id] = nl; } var smtpConfigs = new Dictionary(); foreach (var id in selection.SmtpConfigurationIds.Distinct()) { var sm = await _notifications.GetSmtpConfigurationByIdAsync(id, ct).ConfigureAwait(false); if (sm is not null) smtpConfigs[sm.Id] = sm; } // SMS provider configs (S10b): mirror the SMTP seed exactly — resolve each // selected id via the by-id repository accessor; missing ids are skipped. var smsConfigs = new Dictionary(); foreach (var id in selection.SmsConfigurationIds.Distinct()) { var sms = await _notifications.GetSmsConfigurationByIdAsync(id, ct).ConfigureAwait(false); if (sms is not null) smsConfigs[sms.Id] = sms; } // Inbound API keys are intentionally NOT resolved into the bundle: per the // inbound-API-key re-architecture (C4) keys are not transported between // environments. Only API methods travel. var apiMethods = new Dictionary(); foreach (var id in selection.ApiMethodIds.Distinct()) { var m = await _inboundApi.GetApiMethodByIdAsync(id, ct).ConfigureAwait(false); if (m is not null) apiMethods[m.Id] = m; } // ---- Seed: site/instance-scoped selection (M8) ---- // sites/dataConnections/instances are keyed by surrogate id for dedup. var sites = new Dictionary(); var dataConnections = new Dictionary(); var instances = new Dictionary(); // Selecting a SITE pulls its data connections and all of its instances. The // instance's overrides/bindings are loaded eagerly so the serializer can read // them off the entity navigation collections. foreach (var siteId in selection.SiteIds.Distinct()) { var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false); if (site is null) continue; sites[site.Id] = site; foreach (var conn in await _siteRepository.GetDataConnectionsBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) { dataConnections[conn.Id] = conn; } foreach (var siteInstance in await _siteRepository.GetInstancesBySiteIdAsync(site.Id, ct).ConfigureAwait(false)) { if (instances.ContainsKey(siteInstance.Id)) continue; var loaded = await LoadInstanceWithChildrenAsync(siteInstance.Id, ct).ConfigureAwait(false); if (loaded is not null) instances[loaded.Id] = loaded; } } // Selecting an INSTANCE directly. Dedup against instances already pulled in // via their owning site above (a site + one of its instances selected together // must not double-add the instance). foreach (var instanceId in selection.InstanceIds.Distinct()) { if (instances.ContainsKey(instanceId)) continue; var loaded = await LoadInstanceWithChildrenAsync(instanceId, ct).ConfigureAwait(false); if (loaded is not null) instances[loaded.Id] = loaded; } // ---- Dependency expansion ---- if (selection.IncludeDependencies) { // Each gathered instance pulls in its owning site, the data connections it // binds, and (by feeding instance.TemplateId into the template dictionary) // its template — so the template/shared-script/external-system closure below // expands transitively over instance templates too. await ExpandSiteInstanceClosureAsync(instances, sites, dataConnections, templates, ct).ConfigureAwait(false); await ExpandTemplateClosureAsync(templates, ct).ConfigureAwait(false); await ExpandSharedScriptsFromTemplatesAsync(templates, sharedScripts, ct).ConfigureAwait(false); await ExpandSharedScriptsFromApiMethodsAsync(apiMethods, sharedScripts, ct).ConfigureAwait(false); await ExpandExternalSystemsFromTemplatesAsync(templates, externalSystems, ct).ConfigureAwait(false); } // ExternalSystemMethods always travel with their parent ExternalSystem. var externalSystemMethods = new List(); foreach (var es in externalSystems.Values.OrderBy(x => x.Name, StringComparer.Ordinal)) { var methods = await _externalSystems .GetMethodsByExternalSystemIdAsync(es.Id, ct) .ConfigureAwait(false); externalSystemMethods.AddRange(methods); } // Folder ancestor chain — always pull, regardless of IncludeDependencies, // so the imported tree never references missing parents. var folders = await ResolveFolderChainAsync(templates.Values, ct).ConfigureAwait(false); // ---- Topological sort of templates (base-before-derived) ---- var orderedTemplates = TopologicallySortTemplates(templates.Values); // ---- Deterministic site/instance ordering (M8) ---- // siteId → SiteIdentifier map: every connection's/instance's owning site lands // in `sites` after closure expansion, so ordering + dependsOn edges resolve names. var siteIdentifierById = sites.Values.ToDictionary(s => s.Id, s => s.SiteIdentifier); // I1: when an instance/data-connection is selected directly with // IncludeDependencies=false, its owning site is not packed (so it never // lands in `sites`) and the manifest dep-edge would degrade to the raw id // (Site:) via SiteIdentifierOf's fallback. Resolve the identifier // for any referenced-but-unpacked owning site so the manifest reads // Site: regardless of the deps flag — readability only, the // site row itself stays out of the bundle. var referencedSiteIds = instances.Values.Select(i => i.SiteId) .Concat(dataConnections.Values.Select(c => c.SiteId)) .Distinct() .Where(id => !siteIdentifierById.ContainsKey(id)); foreach (var siteId in referencedSiteIds) { var site = await _siteRepository.GetSiteByIdAsync(siteId, ct).ConfigureAwait(false); if (site is not null) siteIdentifierById[site.Id] = site.SiteIdentifier; } var orderedSites = sites.Values .OrderBy(s => s.SiteIdentifier, StringComparer.Ordinal) .ToList(); var orderedDataConnections = dataConnections.Values .OrderBy(c => SiteIdentifierOf(c.SiteId, siteIdentifierById), StringComparer.Ordinal) .ThenBy(c => c.Name, StringComparer.Ordinal) .ToList(); var orderedInstances = instances.Values .OrderBy(i => i.UniqueName, StringComparer.Ordinal) .ToList(); // ---- Build deterministic content manifest ---- var manifest = BuildContentManifest( folders, orderedTemplates, sharedScripts.Values, externalSystems.Values, externalSystemMethods, dbConnections.Values, notificationLists.Values, smtpConfigs.Values, smsConfigs.Values, apiMethods.Values, orderedSites, orderedDataConnections, orderedInstances, siteIdentifierById, templates); return new ResolvedExport( TemplateFolders: folders, Templates: orderedTemplates, SharedScripts: sharedScripts.Values.OrderBy(s => s.Name, StringComparer.Ordinal).ToList(), ExternalSystems: externalSystems.Values.OrderBy(e => e.Name, StringComparer.Ordinal).ToList(), ExternalSystemMethods: externalSystemMethods, DatabaseConnections: dbConnections.Values.OrderBy(d => d.Name, StringComparer.Ordinal).ToList(), NotificationLists: notificationLists.Values.OrderBy(n => n.Name, StringComparer.Ordinal).ToList(), SmtpConfigs: smtpConfigs.Values.OrderBy(s => s.Host, StringComparer.Ordinal).ToList(), ApiMethods: apiMethods.Values.OrderBy(a => a.Name, StringComparer.Ordinal).ToList(), ContentManifest: manifest) { Sites = orderedSites, DataConnections = orderedDataConnections, Instances = orderedInstances, // SMS (S10b): ordered by AccountSid — the natural key the importer matches // on, mirroring how SMTP is ordered by Host. SmsConfigs = smsConfigs.Values.OrderBy(s => s.AccountSid, StringComparer.Ordinal).ToList(), }; } // ---- Instance child loading (M8) ---- // Loads an instance and attaches its overrides/bindings onto the entity's own // navigation collections, so the serializer reads children off the entity (the // same shape central-config entities use). Returns null if the instance is gone. private async Task LoadInstanceWithChildrenAsync(int instanceId, CancellationToken ct) { var instance = await _templates.GetInstanceByIdAsync(instanceId, ct).ConfigureAwait(false); if (instance is null) return null; var attributeOverrides = await _templates.GetOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); var alarmOverrides = await _templates.GetAlarmOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); var nativeAlarmOverrides = await _templates.GetNativeAlarmSourceOverridesByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); var bindings = await _templates.GetBindingsByInstanceIdAsync(instance.Id, ct).ConfigureAwait(false); instance.AttributeOverrides = attributeOverrides.ToList(); instance.AlarmOverrides = alarmOverrides.ToList(); instance.NativeAlarmSourceOverrides = nativeAlarmOverrides.ToList(); instance.ConnectionBindings = bindings.ToList(); return instance; } // ---- Site/instance closure (M8) ---- // For every gathered instance: ensure its owning Site is in the bundle, include // the DataConnections it binds (via ConnectionBinding.DataConnectionId resolved // within the instance's site, plus NativeAlarmSourceOverride.ConnectionNameOverride // resolved by name within that site), and feed its TemplateId into the template // dictionary so the template closure expands over it. private async Task ExpandSiteInstanceClosureAsync( Dictionary instances, Dictionary sites, Dictionary dataConnections, Dictionary templates, CancellationToken ct) { // Snapshot the instances: the loop only grows sites/dataConnections/templates, // never instances, so iterating a snapshot keeps the dictionary stable. foreach (var instance in instances.Values.ToList()) { // 1. Owning site. if (!sites.ContainsKey(instance.SiteId)) { var site = await _siteRepository.GetSiteByIdAsync(instance.SiteId, ct).ConfigureAwait(false); if (site is not null) sites[site.Id] = site; } // 2. Template → the existing template closure picks it up below. if (!templates.ContainsKey(instance.TemplateId)) { var template = await _templates.GetTemplateWithChildrenAsync(instance.TemplateId, ct).ConfigureAwait(false); if (template is not null) templates[template.Id] = template; } // 3. Data connections the instance references, resolved within its site. // Connection bindings reference connections by id; native-alarm-source // overrides reference them by name — resolve both against the site set. var bindingConnectionIds = instance.ConnectionBindings .Select(b => b.DataConnectionId) .Where(id => !dataConnections.ContainsKey(id)) .Distinct() .ToList(); var referencedConnectionNames = instance.NativeAlarmSourceOverrides .Select(o => o.ConnectionNameOverride) .Where(n => !string.IsNullOrEmpty(n)) .Select(n => n!) .ToHashSet(StringComparer.Ordinal); if (bindingConnectionIds.Count == 0 && referencedConnectionNames.Count == 0) continue; var siteConnections = await _siteRepository .GetDataConnectionsBySiteIdAsync(instance.SiteId, ct) .ConfigureAwait(false); foreach (var conn in siteConnections) { if (dataConnections.ContainsKey(conn.Id)) continue; if (bindingConnectionIds.Contains(conn.Id) || referencedConnectionNames.Contains(conn.Name)) { dataConnections[conn.Id] = conn; } } } } private static string SiteIdentifierOf(int siteId, IReadOnlyDictionary siteIdentifierById) => siteIdentifierById.TryGetValue(siteId, out var id) ? id : siteId.ToString(); // ---- Template composition closure ---- private async Task ExpandTemplateClosureAsync(Dictionary templates, CancellationToken ct) { var queue = new Queue