using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Sites;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Types;
using ScadaLink.Commons.Types.Enums;
using ScadaLink.Commons.Types.Flattening;
namespace ScadaLink.TemplateEngine.Flattening;
///
/// Implements the template flattening algorithm.
/// Takes a template inheritance/composition graph plus instance overrides and connection bindings,
/// and produces a fully resolved FlattenedConfiguration.
///
/// Resolution order (most specific wins):
/// 1. Instance overrides (highest priority)
/// 2. Child template (most derived first in inheritance chain)
/// 3. Parent templates (walking up inheritance chain)
/// 4. Composed modules (recursively flattened with path-qualified canonical names)
///
/// Locked fields cannot be overridden by instance overrides.
///
public class FlatteningService
{
///
/// Produces a fully flattened configuration for an instance.
///
/// The instance to flatten.
///
/// The inheritance chain from most-derived to root (index 0 = the instance's template,
/// last = the ultimate base template). Each template includes its own attributes, alarms, scripts.
///
///
/// Map of template ID → list of compositions (composed module definitions).
/// For each composition, the key is the parent template ID and the value includes the
/// composed template's resolved chain.
///
///
/// Map of composed template ID → its inheritance chain (same format as templateChain).
///
///
/// Available data connections for resolving connection bindings.
///
/// A Result containing the FlattenedConfiguration or an error message.
public Result Flatten(
Instance instance,
IReadOnlyList templateChain,
IReadOnlyDictionary> compositionMap,
IReadOnlyDictionary> composedTemplateChains,
IReadOnlyDictionary dataConnections)
{
if (templateChain.Count == 0)
return Result.Failure("Template chain is empty.");
try
{
// Step 1: Resolve attributes from inheritance chain (most-derived-first wins for same name)
var attributes = ResolveInheritedAttributes(templateChain);
// Step 2: Resolve composed module attributes with path-qualified names
ResolveComposedAttributes(templateChain, compositionMap, composedTemplateChains, attributes);
// Step 3: Apply instance overrides (respecting locks)
ApplyInstanceOverrides(instance.AttributeOverrides, attributes);
// Step 4: Apply connection bindings
ApplyConnectionBindings(instance.ConnectionBindings, attributes, dataConnections);
// Step 5: Resolve alarms from inheritance chain
var alarms = ResolveInheritedAlarms(templateChain);
ResolveComposedAlarms(templateChain, compositionMap, composedTemplateChains, alarms);
// Step 6: Resolve scripts from inheritance chain
var scripts = ResolveInheritedScripts(templateChain);
ResolveComposedScripts(templateChain, compositionMap, composedTemplateChains, scripts);
// Step 7: Resolve alarm on-trigger script references to canonical names
ResolveAlarmScriptReferences(alarms, scripts);
// Step 8: Collect connection configurations for deployment packaging
var connections = new Dictionary();
foreach (var attr in attributes.Values)
{
if (attr.BoundDataConnectionId.HasValue &&
!string.IsNullOrEmpty(attr.BoundDataConnectionName) &&
!connections.ContainsKey(attr.BoundDataConnectionName))
{
if (dataConnections.TryGetValue(attr.BoundDataConnectionId.Value, out var conn))
{
connections[attr.BoundDataConnectionName] = new ConnectionConfig
{
Protocol = conn.Protocol,
ConfigurationJson = conn.PrimaryConfiguration,
BackupConfigurationJson = conn.BackupConfiguration,
FailoverRetryCount = conn.FailoverRetryCount
};
}
}
}
var config = new FlattenedConfiguration
{
InstanceUniqueName = instance.UniqueName,
TemplateId = instance.TemplateId,
SiteId = instance.SiteId,
AreaId = instance.AreaId,
Attributes = attributes.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
Alarms = alarms.Values.OrderBy(a => a.CanonicalName, StringComparer.Ordinal).ToList(),
Scripts = scripts.Values.OrderBy(s => s.CanonicalName, StringComparer.Ordinal).ToList(),
Connections = connections.Count > 0 ? connections : null,
GeneratedAtUtc = DateTimeOffset.UtcNow
};
return Result.Success(config);
}
catch (Exception ex)
{
return Result.Failure($"Flattening failed: {ex.Message}");
}
}
private static Dictionary ResolveInheritedAttributes(
IReadOnlyList templateChain)
{
var result = new Dictionary(StringComparer.Ordinal);
// Walk from base (last) to most-derived (first) so derived values win
for (int i = templateChain.Count - 1; i >= 0; i--)
{
var template = templateChain[i];
var source = i == 0 ? "Template" : "Inherited";
foreach (var attr in template.Attributes)
{
// If a parent defined this attribute as locked, derived cannot change the value
if (result.TryGetValue(attr.Name, out var existing) && existing.IsLocked)
continue;
result[attr.Name] = new ResolvedAttribute
{
CanonicalName = attr.Name,
Value = attr.Value,
DataType = attr.DataType.ToString(),
IsLocked = attr.IsLocked,
Description = attr.Description,
DataSourceReference = attr.DataSourceReference,
Source = source
};
}
}
return result;
}
private static void ResolveComposedAttributes(
IReadOnlyList templateChain,
IReadOnlyDictionary> compositionMap,
IReadOnlyDictionary> composedTemplateChains,
Dictionary attributes)
{
// Process compositions from each template in the chain
foreach (var template in templateChain)
{
if (!compositionMap.TryGetValue(template.Id, out var compositions))
continue;
foreach (var composition in compositions)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
continue;
var prefix = composition.InstanceName;
var composedAttrs = ResolveInheritedAttributes(composedChain);
foreach (var (name, attr) in composedAttrs)
{
var canonicalName = $"{prefix}.{name}";
// Don't overwrite if already defined (most-derived wins)
if (!attributes.ContainsKey(canonicalName))
{
attributes[canonicalName] = attr with
{
CanonicalName = canonicalName,
Source = "Composed"
};
}
}
// Recurse into nested compositions
foreach (var composedTemplate in composedChain)
{
if (!compositionMap.TryGetValue(composedTemplate.Id, out var nestedCompositions))
continue;
foreach (var nested in nestedCompositions)
{
if (!composedTemplateChains.TryGetValue(nested.ComposedTemplateId, out var nestedChain))
continue;
var nestedPrefix = $"{prefix}.{nested.InstanceName}";
var nestedAttrs = ResolveInheritedAttributes(nestedChain);
foreach (var (name, attr) in nestedAttrs)
{
var canonicalName = $"{nestedPrefix}.{name}";
if (!attributes.ContainsKey(canonicalName))
{
attributes[canonicalName] = attr with
{
CanonicalName = canonicalName,
Source = "Composed"
};
}
}
}
}
}
}
}
private static void ApplyInstanceOverrides(
ICollection overrides,
Dictionary attributes)
{
foreach (var ovr in overrides)
{
if (!attributes.TryGetValue(ovr.AttributeName, out var existing))
continue; // Cannot add new attributes via overrides
if (existing.IsLocked)
continue; // Locked attributes cannot be overridden
attributes[ovr.AttributeName] = existing with
{
Value = ovr.OverrideValue,
Source = "Override"
};
}
}
private static void ApplyConnectionBindings(
ICollection bindings,
Dictionary attributes,
IReadOnlyDictionary dataConnections)
{
foreach (var binding in bindings)
{
if (!attributes.TryGetValue(binding.AttributeName, out var existing))
continue;
if (existing.DataSourceReference == null)
continue; // Only data-sourced attributes can have connection bindings
if (!dataConnections.TryGetValue(binding.DataConnectionId, out var connection))
continue;
attributes[binding.AttributeName] = existing with
{
BoundDataConnectionId = connection.Id,
BoundDataConnectionName = connection.Name,
BoundDataConnectionProtocol = connection.Protocol
};
}
}
private static Dictionary ResolveInheritedAlarms(
IReadOnlyList templateChain)
{
var result = new Dictionary(StringComparer.Ordinal);
for (int i = templateChain.Count - 1; i >= 0; i--)
{
var template = templateChain[i];
var source = i == 0 ? "Template" : "Inherited";
foreach (var alarm in template.Alarms)
{
if (result.TryGetValue(alarm.Name, out var existing) && existing.IsLocked)
continue;
result[alarm.Name] = new ResolvedAlarm
{
CanonicalName = alarm.Name,
Description = alarm.Description,
PriorityLevel = alarm.PriorityLevel,
IsLocked = alarm.IsLocked,
TriggerType = alarm.TriggerType.ToString(),
TriggerConfiguration = alarm.TriggerConfiguration,
OnTriggerScriptCanonicalName = null, // Resolved later
Source = source
};
}
}
return result;
}
private static void ResolveComposedAlarms(
IReadOnlyList templateChain,
IReadOnlyDictionary> compositionMap,
IReadOnlyDictionary> composedTemplateChains,
Dictionary alarms)
{
foreach (var template in templateChain)
{
if (!compositionMap.TryGetValue(template.Id, out var compositions))
continue;
foreach (var composition in compositions)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
continue;
var prefix = composition.InstanceName;
var composedAlarms = ResolveInheritedAlarms(composedChain);
foreach (var (name, alarm) in composedAlarms)
{
var canonicalName = $"{prefix}.{name}";
if (!alarms.ContainsKey(canonicalName))
{
alarms[canonicalName] = alarm with
{
CanonicalName = canonicalName,
TriggerConfiguration = PrefixTriggerAttribute(alarm.TriggerConfiguration, prefix),
Source = "Composed"
};
}
}
}
}
}
private static Dictionary ResolveInheritedScripts(
IReadOnlyList templateChain)
{
var result = new Dictionary(StringComparer.Ordinal);
for (int i = templateChain.Count - 1; i >= 0; i--)
{
var template = templateChain[i];
var source = i == 0 ? "Template" : "Inherited";
foreach (var script in template.Scripts)
{
if (result.TryGetValue(script.Name, out var existing) && existing.IsLocked)
continue;
result[script.Name] = new ResolvedScript
{
CanonicalName = script.Name,
Code = script.Code,
IsLocked = script.IsLocked,
TriggerType = script.TriggerType,
TriggerConfiguration = script.TriggerConfiguration,
ParameterDefinitions = script.ParameterDefinitions,
ReturnDefinition = script.ReturnDefinition,
MinTimeBetweenRuns = script.MinTimeBetweenRuns,
Source = source
};
}
}
return result;
}
private static void ResolveComposedScripts(
IReadOnlyList templateChain,
IReadOnlyDictionary> compositionMap,
IReadOnlyDictionary> composedTemplateChains,
Dictionary scripts)
{
foreach (var template in templateChain)
{
if (!compositionMap.TryGetValue(template.Id, out var compositions))
continue;
foreach (var composition in compositions)
{
if (!composedTemplateChains.TryGetValue(composition.ComposedTemplateId, out var composedChain))
continue;
var prefix = composition.InstanceName;
var composedScripts = ResolveInheritedScripts(composedChain);
foreach (var (name, script) in composedScripts)
{
var canonicalName = $"{prefix}.{name}";
if (!scripts.ContainsKey(canonicalName))
{
scripts[canonicalName] = script with
{
CanonicalName = canonicalName,
Source = "Composed"
};
}
}
}
}
}
///
/// Prefixes the "attribute" (or "attributeName") field in alarm trigger configuration JSON
/// with the composition instance name, so composed alarms monitor the path-qualified attribute.
///
private static string? PrefixTriggerAttribute(string? triggerConfigJson, string prefix)
{
if (string.IsNullOrEmpty(triggerConfigJson)) return triggerConfigJson;
try
{
using var doc = System.Text.Json.JsonDocument.Parse(triggerConfigJson);
var root = doc.RootElement;
// Find the attribute key name used
string? attrKey = null;
if (root.TryGetProperty("attribute", out _)) attrKey = "attribute";
else if (root.TryGetProperty("attributeName", out _)) attrKey = "attributeName";
if (attrKey == null) return triggerConfigJson;
var attrValue = root.GetProperty(attrKey).GetString();
if (string.IsNullOrEmpty(attrValue)) return triggerConfigJson;
// Rebuild JSON with prefixed attribute name
using var ms = new System.IO.MemoryStream();
using (var writer = new System.Text.Json.Utf8JsonWriter(ms))
{
writer.WriteStartObject();
foreach (var prop in root.EnumerateObject())
{
if (prop.Name == attrKey)
writer.WriteString(attrKey, $"{prefix}.{attrValue}");
else
prop.WriteTo(writer);
}
writer.WriteEndObject();
}
return System.Text.Encoding.UTF8.GetString(ms.ToArray());
}
catch
{
return triggerConfigJson;
}
}
///
/// Resolves alarm on-trigger script references from script IDs to canonical names.
/// This is done by finding the script in the template chain whose ID matches the alarm's OnTriggerScriptId,
/// then mapping to the corresponding canonical name in the resolved scripts.
///
private static void ResolveAlarmScriptReferences(
Dictionary alarms,
Dictionary scripts)
{
// Build a lookup of script names (we only have canonical names at this point)
// The alarm's OnTriggerScriptCanonicalName will be set by the caller or validation step
// For now, this is a placeholder — the actual resolution depends on how alarm trigger configs
// reference scripts (by name within the same scope).
// The trigger configuration JSON may contain a "scriptName" field.
}
}