using ScadaLink.Commons.Entities.Instances; using ScadaLink.Commons.Interfaces.Repositories; using ScadaLink.Commons.Interfaces.Services; using ScadaLink.Commons.Messages.Management; using ScadaLink.Commons.Types; using ScadaLink.Commons.Types.Enums; using ScadaLink.TemplateEngine; namespace ScadaLink.TemplateEngine.Services; /// /// Instance CRUD operations. /// - Create instance from template at site /// - Assign to area /// - Override non-locked attribute values /// - Cannot add or remove attributes (only override existing ones) /// - Per-attribute connection binding (bulk assignment support) /// - Enabled/disabled state. Concurrent edits are last-write-wins — there is no /// version token or conflict detection on instance state, matching the design /// decision (Component-TemplateEngine.md: "Concurrent editing uses /// last-write-wins — no pessimistic locking or conflict detection"). Optimistic /// concurrency in the system applies to deployment status records, not here. /// - Audit logging /// public class InstanceService { private readonly ITemplateEngineRepository _repository; private readonly IAuditService _auditService; public InstanceService(ITemplateEngineRepository repository, IAuditService auditService) { _repository = repository ?? throw new ArgumentNullException(nameof(repository)); _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService)); } /// /// Creates a new instance from a template at a site. /// public async Task> CreateInstanceAsync( string uniqueName, int templateId, int siteId, int? areaId, string user, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(uniqueName)) return Result.Failure("Instance unique name is required."); // Verify template exists var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken); if (template == null) return Result.Failure($"Template with ID {templateId} not found."); // Check for duplicate unique name var existing = await _repository.GetInstanceByUniqueNameAsync(uniqueName, cancellationToken); if (existing != null) return Result.Failure($"Instance with unique name '{uniqueName}' already exists."); // Verify area exists if specified if (areaId.HasValue) { var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken); if (area == null) return Result.Failure($"Area with ID {areaId.Value} not found."); if (area.SiteId != siteId) return Result.Failure($"Area with ID {areaId.Value} does not belong to site {siteId}."); } var instance = new Instance(uniqueName) { TemplateId = templateId, SiteId = siteId, AreaId = areaId, State = InstanceState.Disabled // New instances start disabled }; await _repository.AddInstanceAsync(instance, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Create", "Instance", instance.Id.ToString(), uniqueName, instance, cancellationToken); return Result.Success(instance); } /// /// Assigns an instance to an area. /// public async Task> AssignToAreaAsync( int instanceId, int? areaId, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); if (areaId.HasValue) { var area = await _repository.GetAreaByIdAsync(areaId.Value, cancellationToken); if (area == null) return Result.Failure($"Area with ID {areaId.Value} not found."); if (area.SiteId != instance.SiteId) return Result.Failure($"Area with ID {areaId.Value} does not belong to the instance's site."); } instance.AreaId = areaId; await _repository.UpdateInstanceAsync(instance, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "AssignArea", "Instance", instance.Id.ToString(), instance.UniqueName, instance, cancellationToken); return Result.Success(instance); } /// /// Sets an attribute override for an instance. Only non-locked attributes can be overridden. /// Cannot add or remove attributes — only override values of existing template attributes. /// public async Task> SetAttributeOverrideAsync( int instanceId, string attributeName, string? overrideValue, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); // Verify attribute exists in the template and is not locked var templateAttrs = await _repository.GetAttributesByTemplateIdAsync(instance.TemplateId, cancellationToken); var templateAttr = templateAttrs.FirstOrDefault(a => a.Name == attributeName); if (templateAttr == null) return Result.Failure( $"Attribute '{attributeName}' does not exist in template {instance.TemplateId}. Cannot add new attributes via overrides."); if (templateAttr.IsLocked) return Result.Failure( $"Attribute '{attributeName}' is locked and cannot be overridden."); // Find existing override or create new one var overrides = await _repository.GetOverridesByInstanceIdAsync(instanceId, cancellationToken); var existingOverride = overrides.FirstOrDefault(o => o.AttributeName == attributeName); if (existingOverride != null) { existingOverride.OverrideValue = overrideValue; await _repository.UpdateInstanceAttributeOverrideAsync(existingOverride, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "UpdateOverride", "InstanceAttributeOverride", existingOverride.Id.ToString(), attributeName, existingOverride, cancellationToken); return Result.Success(existingOverride); } else { var newOverride = new InstanceAttributeOverride(attributeName) { InstanceId = instanceId, OverrideValue = overrideValue }; await _repository.AddInstanceAttributeOverrideAsync(newOverride, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "CreateOverride", "InstanceAttributeOverride", newOverride.Id.ToString(), attributeName, newOverride, cancellationToken); return Result.Success(newOverride); } } /// /// Sets a per-instance alarm override. The alarm must exist in the /// instance's effective alarm set (direct, inherited, or composed) and /// must not be locked. For HiLo alarms, the override JSON merges into the /// inherited TriggerConfiguration setpoint-by-setpoint; for binary trigger /// types, it replaces the whole config. /// public async Task> SetAlarmOverrideAsync( int instanceId, string alarmCanonicalName, string? triggerConfigurationOverride, int? priorityLevelOverride, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); // Verify the alarm exists in the instance's effective alarm set and is // not locked. The effective set is resolved via TemplateResolver so that // composed (path-qualified) and inherited alarms are found — a lookup // against the template's direct alarms alone would miss them, silently // accepting an override for a non-existent name or bypassing the lock // rule for a composed alarm. Mirrors SetAttributeOverrideAsync. var allTemplates = await _repository.GetAllTemplatesAsync(cancellationToken); var resolvedAlarm = TemplateResolver .ResolveAllMembers(instance.TemplateId, allTemplates) .FirstOrDefault(m => m.MemberType == "Alarm" && m.CanonicalName == alarmCanonicalName); if (resolvedAlarm == null) return Result.Failure( $"Alarm '{alarmCanonicalName}' does not exist in template {instance.TemplateId}. " + "Cannot override an unknown alarm."); if (resolvedAlarm.IsLocked) return Result.Failure( $"Alarm '{alarmCanonicalName}' is locked and cannot be overridden."); var existingOverride = await _repository.GetAlarmOverrideAsync( instanceId, alarmCanonicalName, cancellationToken); if (existingOverride != null) { existingOverride.TriggerConfigurationOverride = triggerConfigurationOverride; existingOverride.PriorityLevelOverride = priorityLevelOverride; await _repository.UpdateInstanceAlarmOverrideAsync(existingOverride, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "UpdateAlarmOverride", "InstanceAlarmOverride", existingOverride.Id.ToString(), alarmCanonicalName, existingOverride, cancellationToken); return Result.Success(existingOverride); } else { var newOverride = new InstanceAlarmOverride(alarmCanonicalName) { InstanceId = instanceId, TriggerConfigurationOverride = triggerConfigurationOverride, PriorityLevelOverride = priorityLevelOverride }; await _repository.AddInstanceAlarmOverrideAsync(newOverride, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "CreateAlarmOverride", "InstanceAlarmOverride", newOverride.Id.ToString(), alarmCanonicalName, newOverride, cancellationToken); return Result.Success(newOverride); } } /// /// Removes a per-instance alarm override. After removal the instance /// inherits the template alarm config unchanged. /// public async Task> DeleteAlarmOverrideAsync( int instanceId, string alarmCanonicalName, string user, CancellationToken cancellationToken = default) { var existing = await _repository.GetAlarmOverrideAsync( instanceId, alarmCanonicalName, cancellationToken); if (existing == null) return Result.Failure($"No alarm override for '{alarmCanonicalName}' on instance {instanceId}."); await _repository.DeleteInstanceAlarmOverrideAsync(existing.Id, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "DeleteAlarmOverride", "InstanceAlarmOverride", existing.Id.ToString(), alarmCanonicalName, existing, cancellationToken); return Result.Success(true); } /// /// Sets connection bindings for an instance in bulk. /// public async Task>> SetConnectionBindingsAsync( int instanceId, IReadOnlyList bindings, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result>.Failure($"Instance with ID {instanceId} not found."); var existingBindings = await _repository.GetBindingsByInstanceIdAsync(instanceId, cancellationToken); var existingMap = existingBindings.ToDictionary(b => b.AttributeName, StringComparer.Ordinal); var results = new List(); foreach (var (attrName, connId) in bindings) { if (existingMap.TryGetValue(attrName, out var existing)) { existing.DataConnectionId = connId; await _repository.UpdateInstanceConnectionBindingAsync(existing, cancellationToken); results.Add(existing); } else { var binding = new InstanceConnectionBinding(attrName) { InstanceId = instanceId, DataConnectionId = connId }; await _repository.AddInstanceConnectionBindingAsync(binding, cancellationToken); results.Add(binding); } } await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "SetConnectionBindings", "Instance", instance.Id.ToString(), instance.UniqueName, bindings, cancellationToken); return Result>.Success(results); } /// /// Enables an instance. /// public async Task> EnableAsync(int instanceId, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); instance.State = InstanceState.Enabled; await _repository.UpdateInstanceAsync(instance, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Enable", "Instance", instance.Id.ToString(), instance.UniqueName, instance, cancellationToken); return Result.Success(instance); } /// /// Disables an instance. /// public async Task> DisableAsync(int instanceId, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); instance.State = InstanceState.Disabled; await _repository.UpdateInstanceAsync(instance, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Disable", "Instance", instance.Id.ToString(), instance.UniqueName, instance, cancellationToken); return Result.Success(instance); } /// /// Deletes an instance. /// public async Task> DeleteAsync(int instanceId, string user, CancellationToken cancellationToken = default) { var instance = await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); if (instance == null) return Result.Failure($"Instance with ID {instanceId} not found."); await _repository.DeleteInstanceAsync(instanceId, cancellationToken); await _repository.SaveChangesAsync(cancellationToken); await _auditService.LogAsync(user, "Delete", "Instance", instanceId.ToString(), instance.UniqueName, null, cancellationToken); return Result.Success(true); } /// /// Gets an instance by ID. /// public async Task GetByIdAsync(int instanceId, CancellationToken cancellationToken = default) => await _repository.GetInstanceByIdAsync(instanceId, cancellationToken); /// /// Gets all instances for a site. /// public async Task> GetBySiteIdAsync(int siteId, CancellationToken cancellationToken = default) => await _repository.GetInstancesBySiteIdAsync(siteId, cancellationToken); }