using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine;

/// <summary>
/// Core service for Template Engine operations.
/// Covers CRUD for templates and their members (attributes, alarms, scripts, compositions),
/// inheritance and composition rules, override/locking validation, collision detection, and acyclicity enforcement.
/// </summary>
public class TemplateService
{
    private readonly ITemplateEngineRepository _repository;
    private readonly IAuditService _auditService;

    /// <summary>
    /// Initializes a new instance of the TemplateService class.
    /// </summary>
    /// <param name="repository">Template engine repository for data access.</param>
    /// <param name="auditService">Service for audit logging.</param>
    public TemplateService(ITemplateEngineRepository repository, IAuditService auditService)
    {
        _repository = repository ?? throw new ArgumentNullException(nameof(repository));
        _auditService = auditService ?? throw new ArgumentNullException(nameof(auditService));
    }

    // ========================================================================
    // Template CRUD with Inheritance
    // ========================================================================

    /// <summary>
    /// Creates a new template with optional inheritance and folder assignment.
    /// </summary>
    /// <param name="name">Name of the template.</param>
    /// <param name="description">Optional description.</param>
    /// <param name="parentTemplateId">Optional ID of the parent template for inheritance.</param>
    /// <param name="user">Username of the user creating the template.</param>
    /// <param name="folderId">Optional ID of the folder to place the template in.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the created template or failure message.</returns>
    public async Task<Result<Template>> CreateTemplateAsync(
        string name,
        string? description,
        int? parentTemplateId,
        string user,
        int? folderId = null,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(name))
            return Result<Template>.Failure("Template name is required.");

        // Validate parent exists if specified
        if (parentTemplateId.HasValue)
        {
            var parent = await _repository.GetTemplateByIdAsync(parentTemplateId.Value, cancellationToken);
            if (parent == null)
                return Result<Template>.Failure($"Parent template with ID {parentTemplateId.Value} not found.");
        }

        var template = new Template(name)
        {
            Description = description,
            ParentTemplateId = parentTemplateId,
            FolderId = folderId
        };

        // No collision or acyclicity check is needed here: a freshly created
        // template has no members of its own, the parent (validated above to
        // exist) was already collision-checked when its members were added,
        // and a brand-new child cannot be an ancestor of its parent. Naming
        // collisions are enforced on every member-mutating call (AddAttribute,
        // AddAlarm, AddScript, AddComposition) and on rename in UpdateTemplate.

        // Save the entity first so EF Core populates the
        // auto-generated key, then write the audit row with the real
        // template.Id, then save the audit row. The pre-fix order logged
        // EntityId = "0" because the audit row was queued before
        // SaveChangesAsync ran — every Create audit entry lost the link back
        // to the row it described.
        await _repository.AddTemplateAsync(template, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);
        await _auditService.LogAsync(user, "Create", "Template", template.Id.ToString(), name, template, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        return Result<Template>.Success(template);
    }

    /// <summary>
    /// Updates a template's name and (optionally) description. Optional fields use
    /// leave-unchanged semantics so an omitted field is not silently wiped:
    /// a <c>null</c> <paramref name="description"/> leaves the stored description as-is —
    /// pass an empty string to explicitly clear it. Parent template is immutable after
    /// creation; a <c>null</c> <paramref name="parentTemplateId"/> leaves it unchanged,
    /// and a non-null value that differs from the current parent is rejected.
    /// </summary>
    /// <param name="templateId">ID of the template to update.</param>
    /// <param name="name">New name for the template (required, non-empty).</param>
    /// <param name="description">New description, or <c>null</c> to leave unchanged. Empty string clears it.</param>
    /// <param name="parentTemplateId"><c>null</c> to leave unchanged; a non-null value must match the existing parent (cannot be changed).</param>
    /// <param name="user">Username of the user updating the template.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the updated template or failure message.</returns>
    public async Task<Result<Template>> UpdateTemplateAsync(
        int templateId,
        string name,
        string? description,
        int? parentTemplateId,
        string user,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(name))
            return Result<Template>.Failure("Template name is required.");

        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<Template>.Failure($"Template with ID {templateId} not found.");

        // ParentTemplateId is immutable after creation — set once at create time.
        // A null parentTemplateId means "leave unchanged" (an omitted CLI/API field),
        // so only a non-null value that differs from the current parent is rejected.
        // Immutability still holds: there is no path that mutates ParentTemplateId here.
        if (parentTemplateId != null && parentTemplateId != template.ParentTemplateId)
        {
            return Result<Template>.Failure(
                "Parent template cannot be changed after creation.");
        }

        template.Name = name;
        // Leave-unchanged semantics: only overwrite the description when a value is
        // supplied. null = not provided (keep existing); "" = explicit clear.
        if (description != null)
            template.Description = description;

        // Check for naming collisions after the change
        var collisionResult = await ValidateCollisionsAsync(template, cancellationToken);
        if (collisionResult != null)
            return Result<Template>.Failure(collisionResult);

        await _repository.UpdateTemplateAsync(template, cancellationToken);
        await _auditService.LogAsync(user, "Update", "Template", templateId.ToString(), name, template, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        return Result<Template>.Success(template);
    }

    /// <summary>
    /// Deletes a template if no instances depend on it.
    /// </summary>
    /// <param name="templateId">ID of the template to delete.</param>
    /// <param name="user">Username of the user deleting the template.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating success or failure.</returns>
    public async Task<Result<bool>> DeleteTemplateAsync(
        int templateId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<bool>.Failure($"Template with ID {templateId} not found.");

        // Deletion-constraint logic (instances / child / derived / composing
        // templates) lives in exactly one place — TemplateDeletionService — so a
        // future rule change cannot drift between two implementations.
        // TemplateService owns only the audit-logging side
        // effect, which the deletion service is unaware of.
        var deletionService = new Services.TemplateDeletionService(_repository);
        var deleteResult = await deletionService.DeleteTemplateAsync(templateId, cancellationToken);
        if (deleteResult.IsFailure)
            return deleteResult;

        // TemplateDeletionService already persisted the delete; the audit entry
        // is added to the change tracker here and needs its own SaveChangesAsync.
        await _auditService.LogAsync(user, "Delete", "Template", templateId.ToString(), template.Name, null, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        return Result<bool>.Success(true);
    }

    /// <summary>
    /// Moves a template to a different folder.
    /// </summary>
    /// <param name="templateId">ID of the template to move.</param>
    /// <param name="newFolderId">ID of the target folder, or null to remove from folder.</param>
    /// <param name="user">Username of the user moving the template.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the updated template or failure message.</returns>
    public async Task<Result<Template>> MoveTemplateAsync(
        int templateId,
        int? newFolderId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<Template>.Failure($"Template with ID {templateId} not found.");

        if (newFolderId.HasValue)
        {
            var folder = await _repository.GetFolderByIdAsync(newFolderId.Value, cancellationToken);
            if (folder == null)
                return Result<Template>.Failure($"Target folder with ID {newFolderId.Value} not found.");
        }

        // No-op move — skip the collision check (a template moving to its own
        // folder cannot collide with itself).
        if (template.FolderId != newFolderId)
        {
            // Sibling-name uniqueness at the destination,
            // mirroring TemplateFolderService.MoveFolderAsync. A template move
            // changes only FolderId, so there is no inheritance- or
            // composition-graph cycle to detect (templates have no folder-
            // children navigation; ParentTemplateId is untouched here). The
            // only invariant the move can break is two templates sharing a
            // (FolderId, Name) at the destination, which the design's
            // naming-collisions-are-design-time-errors rule forbids.
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var collision = allTemplates.FirstOrDefault(t =>
                t.Id != templateId &&
                t.FolderId == newFolderId &&
                string.Equals(t.Name, template.Name, StringComparison.OrdinalIgnoreCase));
            if (collision != null)
            {
                var location = newFolderId.HasValue ? "the target folder" : "the root";
                return Result<Template>.Failure(
                    $"A template named '{template.Name}' already exists in {location}.");
            }
        }

        template.FolderId = newFolderId;
        await _repository.UpdateTemplateAsync(template, cancellationToken);
        await _auditService.LogAsync(user, "Move", "Template", template.Id.ToString(), template.Name, template, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        return Result<Template>.Success(template);
    }

    /// <summary>
    /// Gets a template by ID.
    /// </summary>
    /// <param name="templateId">ID of the template.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>The template, or null if not found.</returns>
    public async Task<Template?> GetTemplateByIdAsync(int templateId, CancellationToken cancellationToken = default)
    {
        return await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
    }

    /// <summary>
    /// Gets all templates.
    /// </summary>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>List of all templates.</returns>
    public async Task<IReadOnlyList<Template>> GetAllTemplatesAsync(CancellationToken cancellationToken = default)
    {
        return await _repository.GetAllTemplatesAsync(cancellationToken);
    }

    // ========================================================================
    // Attribute Definitions with Lock Flags
    // ========================================================================

    /// <summary>
    /// Adds an attribute definition to a template.
    /// </summary>
    /// <param name="templateId">ID of the template.</param>
    /// <param name="attribute">Attribute definition to add.</param>
    /// <param name="user">Username of the user adding the attribute.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the added attribute or failure message.</returns>
    public async Task<Result<TemplateAttribute>> AddAttributeAsync(
        int templateId,
        TemplateAttribute attribute,
        string user,
        CancellationToken cancellationToken = default)
    {
        // Defense-in-depth: hoist the (DataType, ElementDataType, Value)
        // guard — previously only enforced on the CLI/API path in
        // ManagementActor.ValidateAttributeTypes — into the service so the
        // Central UI's direct TemplateService write path shares one server-side
        // check. Mirrors ValidateAttributeTypes exactly, reusing AttributeValueCodec.
        var typeError = ValidateAttributeTypes(
            attribute.Name, attribute.DataType, attribute.ElementDataType, attribute.Value);
        if (typeError != null)
            return Result<TemplateAttribute>.Failure(typeError);

        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<TemplateAttribute>.Failure($"Template with ID {templateId} not found.");

        // Check for duplicate name at this template level
        if (template.Attributes.Any(a => a.Name == attribute.Name))
            return Result<TemplateAttribute>.Failure(
                $"Attribute '{attribute.Name}' already exists on template '{template.Name}'.");

        attribute.TemplateId = templateId;

        // If inheriting, validate not trying to add a member that would collide
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        var testTemplate = CloneTemplateWithNewAttribute(template, attribute);
        var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
        if (collisions.Count > 0)
            return Result<TemplateAttribute>.Failure(string.Join(" ", collisions));

        // Save-then-audit so the audit row carries the
        // real attribute Id rather than a literal "0".
        await _repository.AddTemplateAttributeAsync(attribute, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);
        await _auditService.LogAsync(user, "Create", "TemplateAttribute", attribute.Id.ToString(), attribute.Name, attribute, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Propagate the new member to derived descendants.
        await ReconcileDescendantsAsync(templateId, user, cancellationToken);

        return Result<TemplateAttribute>.Success(attribute);
    }

    /// <summary>
    /// Updates an attribute definition with lock and inheritance validation.
    /// </summary>
    /// <param name="attributeId">ID of the attribute to update.</param>
    /// <param name="proposed">New attribute definition values.</param>
    /// <param name="user">Username of the user updating the attribute.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the updated attribute or failure message.</returns>
    public async Task<Result<TemplateAttribute>> UpdateAttributeAsync(
        int attributeId,
        TemplateAttribute proposed,
        string user,
        CancellationToken cancellationToken = default)
    {
        var existing = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
        if (existing == null)
            return Result<TemplateAttribute>.Failure($"Attribute with ID {attributeId} not found.");

        // Defense-in-depth: hoist the (DataType, ElementDataType, Value)
        // guard out of ManagementActor.ValidateAttributeTypes so the Central UI's
        // direct TemplateService write path shares one server-side check. DataType
        // and ElementDataType are fixed by the defining level (not copied from the
        // proposed attribute below), so the effective persisted triple is the
        // existing type pairing with the proposed value. Reuses AttributeValueCodec.
        var typeError = ValidateAttributeTypes(
            existing.Name, existing.DataType, existing.ElementDataType, proposed.Value);
        if (typeError != null)
            return Result<TemplateAttribute>.Failure(typeError);

        // Validate override rules if this is an override (parent has same-named attribute)
        var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
        if (template?.ParentTemplateId != null)
        {
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var parentMember = TemplateResolver.FindMemberByCanonicalName(
                existing.Name, template.ParentTemplateId.Value, allTemplates);
            if (parentMember != null && parentMember.IsLocked)
                return Result<TemplateAttribute>.Failure(
                    $"Attribute '{existing.Name}' is locked in parent and cannot be overridden.");

            // Derived templates may not override fields the base marked LockedInDerived.
            if (template.IsDerived)
            {
                var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
                var baseAttr = baseTemplate?.Attributes.FirstOrDefault(a => a.Name == existing.Name);
                if (baseAttr != null && baseAttr.LockedInDerived)
                    return Result<TemplateAttribute>.Failure(
                        $"Attribute '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
            }
        }

        // Validate lock change rules
        var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
        if (lockError != null)
            return Result<TemplateAttribute>.Failure(lockError);

        // LockedInDerived is a one-way ratchet on base templates: once a base
        // marks an attribute LockedInDerived it cannot be cleared, otherwise
        // derived overrides that were previously blocked would become
        // retroactively legal. Only meaningful on base
        // templates — derived rows never carry an authoritative LockedInDerived.
        if (template?.IsDerived != true)
        {
            var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
                existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
            if (lockedInDerivedError != null)
                return Result<TemplateAttribute>.Failure(lockedInDerivedError);
        }

        // Validate fixed-field granularity. DataType and DataSourceReference are
        // fixed by the defining level for every attribute — locked or not — so
        // the error is always honoured (a locked attribute is already rejected
        // earlier inside the helper).
        var granularityError = LockEnforcer.ValidateAttributeOverride(existing, proposed);
        if (granularityError != null)
            return Result<TemplateAttribute>.Failure(granularityError);

        // Apply overridable fields. DataType / DataSourceReference are fixed and
        // are deliberately not copied from the proposed attribute.
        existing.Value = proposed.Value;
        existing.Description = proposed.Description;
        existing.IsLocked = proposed.IsLocked;
        if (template?.IsDerived == true)
            existing.IsInherited = proposed.IsInherited;
        else
            existing.LockedInDerived = proposed.LockedInDerived;

        await _repository.UpdateTemplateAttributeAsync(existing, cancellationToken);
        await _auditService.LogAsync(user, "Update", "TemplateAttribute", attributeId.ToString(), existing.Name, existing, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Propagate the changed effective value to derived descendants.
        await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);

        return Result<TemplateAttribute>.Success(existing);
    }

    /// <summary>
    /// Deletes an attribute definition from a template.
    /// </summary>
    /// <param name="attributeId">ID of the attribute to delete.</param>
    /// <param name="user">Username of the user deleting the attribute.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating success or failure.</returns>
    public async Task<Result<bool>> DeleteAttributeAsync(
        int attributeId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var attribute = await _repository.GetTemplateAttributeByIdAsync(attributeId, cancellationToken);
        if (attribute == null)
            return Result<bool>.Failure($"Attribute with ID {attributeId} not found.");

        // Cannot remove inherited parent members — only direct members
        var template = await _repository.GetTemplateByIdAsync(attribute.TemplateId, cancellationToken);
        if (template?.ParentTemplateId != null)
        {
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var parentMember = TemplateResolver.FindMemberByCanonicalName(
                attribute.Name, template.ParentTemplateId.Value, allTemplates);
            if (parentMember != null)
                return Result<bool>.Failure(
                    $"Cannot remove attribute '{attribute.Name}': it is inherited from parent template.");
        }

        await _repository.DeleteTemplateAttributeAsync(attributeId, cancellationToken);
        await _auditService.LogAsync(user, "Delete", "TemplateAttribute", attributeId.ToString(), attribute.Name, null, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Remove the now-orphaned inherited placeholder from derived descendants.
        await ReconcileDescendantsAsync(attribute.TemplateId, user, cancellationToken);

        return Result<bool>.Success(true);
    }

    /// <summary>
    /// Validates the (DataType, ElementDataType, Value) triple for an attribute.
    /// Returns a human-readable failure message (for <c>Result.Failure</c>), or
    /// <c>null</c> when the triple is valid. Mirrors the CLI/API-path guard in
    /// <c>ManagementActor.ValidateAttributeTypes</c> so both server write paths
    /// share one server-side check, reusing <see cref="AttributeValueCodec"/>
    /// for the element-type and value-decode logic:
    /// <list type="bullet">
    /// <item>DataType must be a defined enum value.</item>
    /// <item>List attributes require a valid scalar element type, and a present
    /// default value must decode against it.</item>
    /// <item>Scalar attributes may not carry an element type.</item>
    /// </list>
    /// </summary>
    private static string? ValidateAttributeTypes(
        string name, DataType dataType, DataType? elementType, string? value)
    {
        if (!Enum.IsDefined(dataType))
            return $"Attribute '{name}' has an unrecognised data type.";

        if (dataType == DataType.List)
        {
            if (elementType is null || !AttributeValueCodec.IsValidElementType(elementType.Value))
                return $"List attribute '{name}' requires a valid element type " +
                       "(String, Int32, Float, Double, Boolean, DateTime).";

            if (!string.IsNullOrWhiteSpace(value))
            {
                try
                {
                    AttributeValueCodec.Decode(value, DataType.List, elementType);
                }
                catch (FormatException ex)
                {
                    return $"List attribute '{name}' has an invalid list value: {ex.Message}";
                }
            }
        }
        else if (elementType is not null)
        {
            return $"Attribute '{name}': ElementDataType is only valid on List attributes.";
        }

        return null;
    }

    // ========================================================================
    // Alarm Definitions
    // ========================================================================

    /// <summary>
    /// Adds an alarm definition to a template.
    /// </summary>
    /// <param name="templateId">ID of the template.</param>
    /// <param name="alarm">Alarm definition to add.</param>
    /// <param name="user">Username of the user adding the alarm.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the added alarm or failure message.</returns>
    public async Task<Result<TemplateAlarm>> AddAlarmAsync(
        int templateId,
        TemplateAlarm alarm,
        string user,
        CancellationToken cancellationToken = default)
    {
        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<TemplateAlarm>.Failure($"Template with ID {templateId} not found.");

        if (template.Alarms.Any(a => a.Name == alarm.Name))
            return Result<TemplateAlarm>.Failure(
                $"Alarm '{alarm.Name}' already exists on template '{template.Name}'.");

        // Validate priority range
        if (alarm.PriorityLevel < 0 || alarm.PriorityLevel > 1000)
            return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");

        alarm.TemplateId = templateId;

        // Check collisions
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        var testTemplate = CloneTemplateWithNewAlarm(template, alarm);
        var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
        if (collisions.Count > 0)
            return Result<TemplateAlarm>.Failure(string.Join(" ", collisions));

        // Save-then-audit so the audit row carries the
        // real alarm Id rather than a literal "0".
        await _repository.AddTemplateAlarmAsync(alarm, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);
        await _auditService.LogAsync(user, "Create", "TemplateAlarm", alarm.Id.ToString(), alarm.Name, alarm, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Propagate the new member to derived descendants.
        await ReconcileDescendantsAsync(templateId, user, cancellationToken);

        return Result<TemplateAlarm>.Success(alarm);
    }

    /// <summary>
    /// Updates an alarm definition with lock and inheritance validation.
    /// </summary>
    /// <param name="alarmId">ID of the alarm to update.</param>
    /// <param name="proposed">New alarm definition values.</param>
    /// <param name="user">Username of the user updating the alarm.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the updated alarm or failure message.</returns>
    public async Task<Result<TemplateAlarm>> UpdateAlarmAsync(
        int alarmId,
        TemplateAlarm proposed,
        string user,
        CancellationToken cancellationToken = default)
    {
        var existing = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
        if (existing == null)
            return Result<TemplateAlarm>.Failure($"Alarm with ID {alarmId} not found.");

        // Validate priority range
        if (proposed.PriorityLevel < 0 || proposed.PriorityLevel > 1000)
            return Result<TemplateAlarm>.Failure("Alarm priority must be between 0 and 1000.");

        // Validate lock change
        var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
        if (lockError != null)
            return Result<TemplateAlarm>.Failure(lockError);

        // Check parent lock
        var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
        if (template?.ParentTemplateId != null)
        {
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var parentMember = TemplateResolver.FindMemberByCanonicalName(
                existing.Name, template.ParentTemplateId.Value, allTemplates);
            if (parentMember != null && parentMember.IsLocked)
                return Result<TemplateAlarm>.Failure(
                    $"Alarm '{existing.Name}' is locked in parent and cannot be overridden.");

            // Derived templates may not override alarms the base marked LockedInDerived.
            if (template.IsDerived)
            {
                var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
                var baseAlarm = baseTemplate?.Alarms.FirstOrDefault(a => a.Name == existing.Name);
                if (baseAlarm != null && baseAlarm.LockedInDerived)
                    return Result<TemplateAlarm>.Failure(
                        $"Alarm '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
            }
        }

        // One-way LockedInDerived ratchet on base templates.
        if (template?.IsDerived != true)
        {
            var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
                existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
            if (lockedInDerivedError != null)
                return Result<TemplateAlarm>.Failure(lockedInDerivedError);
        }

        // Validate fixed fields
        var overrideError = LockEnforcer.ValidateAlarmOverride(existing, proposed);
        if (overrideError != null)
            return Result<TemplateAlarm>.Failure(overrideError);

        // Apply overridable fields
        existing.PriorityLevel = proposed.PriorityLevel;
        existing.TriggerConfiguration = proposed.TriggerConfiguration;
        existing.Description = proposed.Description;
        existing.OnTriggerScriptId = proposed.OnTriggerScriptId;
        existing.IsLocked = proposed.IsLocked;
        if (template?.IsDerived == true)
            existing.IsInherited = proposed.IsInherited;
        else
            existing.LockedInDerived = proposed.LockedInDerived;
        // Name and TriggerType are NOT updated (fixed)

        await _repository.UpdateTemplateAlarmAsync(existing, cancellationToken);
        await _auditService.LogAsync(user, "Update", "TemplateAlarm", alarmId.ToString(), existing.Name, existing, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Propagate the changed effective value to derived descendants.
        await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);

        return Result<TemplateAlarm>.Success(existing);
    }

    /// <summary>
    /// Deletes an alarm definition from a template.
    /// </summary>
    /// <param name="alarmId">ID of the alarm to delete.</param>
    /// <param name="user">Username of the user deleting the alarm.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating success or failure.</returns>
    public async Task<Result<bool>> DeleteAlarmAsync(
        int alarmId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var alarm = await _repository.GetTemplateAlarmByIdAsync(alarmId, cancellationToken);
        if (alarm == null)
            return Result<bool>.Failure($"Alarm with ID {alarmId} not found.");

        var template = await _repository.GetTemplateByIdAsync(alarm.TemplateId, cancellationToken);
        if (template?.ParentTemplateId != null)
        {
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var parentMember = TemplateResolver.FindMemberByCanonicalName(
                alarm.Name, template.ParentTemplateId.Value, allTemplates);
            if (parentMember != null)
                return Result<bool>.Failure(
                    $"Cannot remove alarm '{alarm.Name}': it is inherited from parent template.");
        }

        await _repository.DeleteTemplateAlarmAsync(alarmId, cancellationToken);
        await _auditService.LogAsync(user, "Delete", "TemplateAlarm", alarmId.ToString(), alarm.Name, null, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Remove the now-orphaned inherited placeholder from derived descendants.
        await ReconcileDescendantsAsync(alarm.TemplateId, user, cancellationToken);

        return Result<bool>.Success(true);
    }

    // ========================================================================
    // Script Definitions
    // ========================================================================

    /// <summary>
    /// Adds a script definition to a template.
    /// </summary>
    /// <param name="templateId">ID of the template.</param>
    /// <param name="script">Script definition to add.</param>
    /// <param name="user">Username of the user adding the script.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the added script or failure message.</returns>
    public async Task<Result<TemplateScript>> AddScriptAsync(
        int templateId,
        TemplateScript script,
        string user,
        CancellationToken cancellationToken = default)
    {
        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<TemplateScript>.Failure($"Template with ID {templateId} not found.");

        if (template.Scripts.Any(s => s.Name == script.Name))
            return Result<TemplateScript>.Failure(
                $"Script '{script.Name}' already exists on template '{template.Name}'.");

        script.TemplateId = templateId;

        // Check collisions
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        var testTemplate = CloneTemplateWithNewScript(template, script);
        var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
        if (collisions.Count > 0)
            return Result<TemplateScript>.Failure(string.Join(" ", collisions));

        // Save-then-audit so the audit row carries the
        // real script Id rather than a literal "0".
        await _repository.AddTemplateScriptAsync(script, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);
        await _auditService.LogAsync(user, "Create", "TemplateScript", script.Id.ToString(), script.Name, script, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Propagate the new member to derived descendants.
        await ReconcileDescendantsAsync(templateId, user, cancellationToken);

        return Result<TemplateScript>.Success(script);
    }

    /// <summary>
    /// Updates a script definition with lock and inheritance validation.
    /// </summary>
    /// <param name="scriptId">ID of the script to update.</param>
    /// <param name="proposed">New script definition values.</param>
    /// <param name="user">Username of the user updating the script.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the updated script or failure message.</returns>
    public async Task<Result<TemplateScript>> UpdateScriptAsync(
        int scriptId,
        TemplateScript proposed,
        string user,
        CancellationToken cancellationToken = default)
    {
        var existing = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
        if (existing == null)
            return Result<TemplateScript>.Failure($"Script with ID {scriptId} not found.");

        // Validate lock change
        var lockError = LockEnforcer.ValidateLockChange(existing.IsLocked, proposed.IsLocked, existing.Name);
        if (lockError != null)
            return Result<TemplateScript>.Failure(lockError);

        // Check parent lock; the LockedInDerived ratchet is enforced after we
        // know whether the owning template is derived.
        var template = await _repository.GetTemplateByIdAsync(existing.TemplateId, cancellationToken);
        if (template?.ParentTemplateId != null)
        {
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var parentMember = TemplateResolver.FindMemberByCanonicalName(
                existing.Name, template.ParentTemplateId.Value, allTemplates);
            if (parentMember != null && parentMember.IsLocked)
                return Result<TemplateScript>.Failure(
                    $"Script '{existing.Name}' is locked in parent and cannot be overridden.");

            // Derived templates may not override scripts the base marked LockedInDerived.
            if (template.IsDerived)
            {
                var baseTemplate = await _repository.GetTemplateByIdAsync(template.ParentTemplateId.Value, cancellationToken);
                var baseScript = baseTemplate?.Scripts.FirstOrDefault(s => s.Name == existing.Name);
                if (baseScript != null && baseScript.LockedInDerived)
                    return Result<TemplateScript>.Failure(
                        $"Script '{existing.Name}' is locked by base template '{baseTemplate!.Name}' and cannot be overridden.");
            }
        }

        // One-way LockedInDerived ratchet on base templates.
        if (template?.IsDerived != true)
        {
            var lockedInDerivedError = LockEnforcer.ValidateLockedInDerivedChange(
                existing.LockedInDerived, proposed.LockedInDerived, existing.Name);
            if (lockedInDerivedError != null)
                return Result<TemplateScript>.Failure(lockedInDerivedError);
        }

        // Validate fixed fields
        var overrideError = LockEnforcer.ValidateScriptOverride(existing, proposed);
        if (overrideError != null)
            return Result<TemplateScript>.Failure(overrideError);

        // Apply overridable fields
        existing.Code = proposed.Code;
        existing.TriggerType = proposed.TriggerType;
        existing.TriggerConfiguration = proposed.TriggerConfiguration;
        existing.MinTimeBetweenRuns = proposed.MinTimeBetweenRuns;
        // Per-script execution timeout is an overridable field.
        existing.ExecutionTimeoutSeconds = proposed.ExecutionTimeoutSeconds;
        existing.ParameterDefinitions = proposed.ParameterDefinitions;
        existing.ReturnDefinition = proposed.ReturnDefinition;
        existing.IsLocked = proposed.IsLocked;
        if (template?.IsDerived == true)
            existing.IsInherited = proposed.IsInherited;
        else
            existing.LockedInDerived = proposed.LockedInDerived;
        // Name is NOT updated (fixed)

        await _repository.UpdateTemplateScriptAsync(existing, cancellationToken);
        await _auditService.LogAsync(user, "Update", "TemplateScript", scriptId.ToString(), existing.Name, existing, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Propagate the changed effective value to derived descendants.
        await ReconcileDescendantsAsync(existing.TemplateId, user, cancellationToken);

        return Result<TemplateScript>.Success(existing);
    }

    /// <summary>
    /// Deletes a script definition from a template.
    /// </summary>
    /// <param name="scriptId">ID of the script to delete.</param>
    /// <param name="user">Username of the user deleting the script.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating success or failure.</returns>
    public async Task<Result<bool>> DeleteScriptAsync(
        int scriptId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var script = await _repository.GetTemplateScriptByIdAsync(scriptId, cancellationToken);
        if (script == null)
            return Result<bool>.Failure($"Script with ID {scriptId} not found.");

        var template = await _repository.GetTemplateByIdAsync(script.TemplateId, cancellationToken);
        if (template?.ParentTemplateId != null)
        {
            var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
            var parentMember = TemplateResolver.FindMemberByCanonicalName(
                script.Name, template.ParentTemplateId.Value, allTemplates);
            if (parentMember != null)
                return Result<bool>.Failure(
                    $"Cannot remove script '{script.Name}': it is inherited from parent template.");
        }

        await _repository.DeleteTemplateScriptAsync(scriptId, cancellationToken);
        await _auditService.LogAsync(user, "Delete", "TemplateScript", scriptId.ToString(), script.Name, null, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Remove the now-orphaned inherited placeholder from derived descendants.
        await ReconcileDescendantsAsync(script.TemplateId, user, cancellationToken);

        return Result<bool>.Success(true);
    }

    // ========================================================================
    // Composition with Recursive Nesting
    // ========================================================================

    /// <summary>
    /// Adds a composition (template slot) to a template with acyclicity and collision validation.
    /// </summary>
    /// <param name="templateId">ID of the parent template.</param>
    /// <param name="composedTemplateId">ID of the template to compose.</param>
    /// <param name="instanceName">Name for the composition slot.</param>
    /// <param name="user">Username of the user adding the composition.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the added composition or failure message.</returns>
    public async Task<Result<TemplateComposition>> AddCompositionAsync(
        int templateId,
        int composedTemplateId,
        string instanceName,
        string user,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(instanceName))
            return Result<TemplateComposition>.Failure("Instance name is required for composition.");

        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<TemplateComposition>.Failure($"Template with ID {templateId} not found.");

        var baseTemplate = await _repository.GetTemplateByIdAsync(composedTemplateId, cancellationToken);
        if (baseTemplate == null)
            return Result<TemplateComposition>.Failure($"Composed template with ID {composedTemplateId} not found.");

        // Only base templates can be composed; derived templates are slot-owned.
        if (baseTemplate.IsDerived)
            return Result<TemplateComposition>.Failure(
                $"Cannot compose template '{baseTemplate.Name}': it is a derived template. Compose its base instead.");

        // Check for duplicate instance name
        if (template.Compositions.Any(c => c.InstanceName == instanceName))
            return Result<TemplateComposition>.Failure(
                $"Composition instance name '{instanceName}' already exists on template '{template.Name}'.");

        // Acyclicity is checked against the base, not the to-be-created derived template —
        // the derived inherits from the base, so a base→base cycle is the meaningful check.
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        var cycleError = CycleDetector.DetectCompositionCycle(templateId, composedTemplateId, allTemplates);
        if (cycleError != null)
            return Result<TemplateComposition>.Failure(cycleError);

        var crossCycleError = CycleDetector.DetectCrossGraphCycle(templateId, null, composedTemplateId, allTemplates);
        if (crossCycleError != null)
            return Result<TemplateComposition>.Failure(crossCycleError);

        var probeComposition = new TemplateComposition(instanceName)
        {
            TemplateId = templateId,
            ComposedTemplateId = composedTemplateId
        };

        var testTemplate = CloneTemplateWithNewComposition(template, probeComposition);
        var collisions = CollisionDetector.DetectCollisions(testTemplate, allTemplates);
        if (collisions.Count > 0)
            return Result<TemplateComposition>.Failure(string.Join(" ", collisions));

        // No global name pre-check: derived templates store their contained
        // (slot) name, which need only be unique within the owner — and that is
        // already enforced above and by the (TemplateId, InstanceName) index.
        // CreateCascadedCompositionAsync already saves the
        // composition row, so composition.Id is populated by the time control
        // returns here — the audit row therefore carries the real Id instead
        // of the pre-fix literal "0".
        var composition = await CreateCascadedCompositionAsync(template, baseTemplate, instanceName, user, cancellationToken);
        await _auditService.LogAsync(user, "Create", "TemplateComposition", composition.Id.ToString(), instanceName, composition, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        return Result<TemplateComposition>.Success(composition);
    }

    /// <summary>
    /// Creates a derived template under <paramref name="outerTemplate"/> that
    /// wraps <paramref name="source"/>, then recursively cascades the source's
    /// own compositions so the slot graph is replicated under the new derived.
    /// Used both for the user-initiated top-level compose and the recursive
    /// children — neither path re-validates (caller pre-flights).
    /// </summary>
    private async Task<TemplateComposition> CreateCascadedCompositionAsync(
        Template outerTemplate,
        Template source,
        string instanceName,
        string user,
        CancellationToken cancellationToken)
    {
        // A derived template stores its contained name — the slot's instance
        // name, unique within its owner. The qualified path is computed on read
        // (see TemplateNaming.QualifiedName).
        var derived = BuildDerivedTemplate(source, instanceName);

        await _repository.AddTemplateAsync(derived, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        var composition = new TemplateComposition(instanceName)
        {
            TemplateId = outerTemplate.Id,
            ComposedTemplateId = derived.Id
        };
        await _repository.AddTemplateCompositionAsync(composition, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        derived.OwnerCompositionId = composition.Id;
        await _repository.UpdateTemplateAsync(derived, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        // Cascade — replicate each of the source's compositions onto the new
        // derived. The child's ParentTemplateId points at the source-side
        // child (so override chains stay intact across nesting).
        foreach (var childComp in source.Compositions.ToList())
        {
            var childSource = await _repository.GetTemplateByIdAsync(childComp.ComposedTemplateId, cancellationToken);
            if (childSource == null) continue;
            await CreateCascadedCompositionAsync(derived, childSource, childComp.InstanceName, user, cancellationToken);
        }

        return composition;
    }

    /// <summary>
    /// Renames a composition slot while preserving the composed template and nested slots.
    /// </summary>
    /// <param name="compositionId">ID of the composition to rename.</param>
    /// <param name="newInstanceName">New slot name.</param>
    /// <param name="user">Username of the user renaming the composition.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result containing the updated composition or failure message.</returns>
    public async Task<Result<TemplateComposition>> RenameCompositionAsync(
        int compositionId,
        string newInstanceName,
        string user,
        CancellationToken cancellationToken = default)
    {
        if (string.IsNullOrWhiteSpace(newInstanceName))
            return Result<TemplateComposition>.Failure("Slot name is required.");

        var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
        if (composition == null)
            return Result<TemplateComposition>.Failure($"Composition with ID {compositionId} not found.");

        if (composition.InstanceName == newInstanceName) return Result<TemplateComposition>.Success(composition);

        var owner = await _repository.GetTemplateByIdAsync(composition.TemplateId, cancellationToken);
        if (owner == null)
            return Result<TemplateComposition>.Failure($"Owning template with ID {composition.TemplateId} not found.");

        if (owner.Compositions.Any(c => c.Id != compositionId && c.InstanceName == newInstanceName))
            return Result<TemplateComposition>.Failure(
                $"Slot name '{newInstanceName}' already exists on '{owner.Name}'.");

        // The derived template stores the slot's contained name, so renaming
        // the slot renames just that one template. Nested derived templates
        // keep their own contained names — an ancestor slot rename never
        // touches them, and the qualified path is recomputed on read.
        var derived = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);
        if (derived != null && derived.IsDerived && derived.OwnerCompositionId == compositionId)
        {
            derived.Name = newInstanceName;
            await _repository.UpdateTemplateAsync(derived, cancellationToken);
        }

        composition.InstanceName = newInstanceName;
        await _repository.UpdateTemplateCompositionAsync(composition, cancellationToken);
        await _auditService.LogAsync(user, "Update", "TemplateComposition", compositionId.ToString(), newInstanceName, composition, cancellationToken);
        await _repository.SaveChangesAsync(cancellationToken);

        return Result<TemplateComposition>.Success(composition);
    }

    /// <summary>
    /// Deletes a composition (template slot) and cascades deletion of slot-owned derived templates.
    /// </summary>
    /// <param name="compositionId">ID of the composition to delete.</param>
    /// <param name="user">Username of the user deleting the composition.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating success or failure.</returns>
    public async Task<Result<bool>> DeleteCompositionAsync(
        int compositionId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var composition = await _repository.GetTemplateCompositionByIdAsync(compositionId, cancellationToken);
        if (composition == null)
            return Result<bool>.Failure($"Composition with ID {compositionId} not found.");

        // Identify the slot-owned derived template (this is now the
        // typical case; pre-migration data may still have the composition point at a base).
        var composedTemplate = await _repository.GetTemplateByIdAsync(composition.ComposedTemplateId, cancellationToken);

        await _repository.DeleteTemplateCompositionAsync(compositionId, cancellationToken);
        await _auditService.LogAsync(user, "Delete", "TemplateComposition", compositionId.ToString(), composition.InstanceName, null, cancellationToken);

        if (composedTemplate != null && composedTemplate.IsDerived && composedTemplate.OwnerCompositionId == compositionId)
        {
            await CascadeDeleteDerivedAsync(composedTemplate, user, cancellationToken);
        }

        await _repository.SaveChangesAsync(cancellationToken);

        return Result<bool>.Success(true);
    }

    /// <summary>
    /// Recursively deletes a derived template along with the cascade of inner
    /// derived templates the compose flow created. Each composition row on the
    /// derived has its slot-owned child template removed first, then the row,
    /// then the derived itself.
    /// </summary>
    private async Task CascadeDeleteDerivedAsync(Template derived, string user, CancellationToken cancellationToken)
    {
        foreach (var child in derived.Compositions.ToList())
        {
            var childDerived = await _repository.GetTemplateByIdAsync(child.ComposedTemplateId, cancellationToken);
            await _repository.DeleteTemplateCompositionAsync(child.Id, cancellationToken);
            await _auditService.LogAsync(user, "Delete", "TemplateComposition", child.Id.ToString(), child.InstanceName, null, cancellationToken);
            if (childDerived != null && childDerived.IsDerived && childDerived.OwnerCompositionId == child.Id)
                await CascadeDeleteDerivedAsync(childDerived, user, cancellationToken);
        }

        await _repository.DeleteTemplateAsync(derived.Id, cancellationToken);
        await _auditService.LogAsync(user, "Delete", "Template", derived.Id.ToString(), derived.Name, null, cancellationToken);
    }

    private static Template BuildDerivedTemplate(Template baseTemplate, string derivedName)
    {
        var derived = new Template(derivedName)
        {
            Description = baseTemplate.Description,
            ParentTemplateId = baseTemplate.Id,
            IsDerived = true,
        };

        foreach (var attr in baseTemplate.Attributes)
        {
            derived.Attributes.Add(new TemplateAttribute(attr.Name)
            {
                Value = attr.Value,
                DataType = attr.DataType,
                ElementDataType = attr.ElementDataType,
                IsLocked = attr.IsLocked,
                Description = attr.Description,
                DataSourceReference = attr.DataSourceReference,
                IsInherited = true,
                LockedInDerived = false,
            });
        }

        foreach (var alarm in baseTemplate.Alarms)
        {
            derived.Alarms.Add(new TemplateAlarm(alarm.Name)
            {
                Description = alarm.Description,
                PriorityLevel = alarm.PriorityLevel,
                IsLocked = alarm.IsLocked,
                TriggerType = alarm.TriggerType,
                TriggerConfiguration = alarm.TriggerConfiguration,
                OnTriggerScriptId = alarm.OnTriggerScriptId,
                IsInherited = true,
                LockedInDerived = false,
            });
        }

        foreach (var script in baseTemplate.Scripts)
        {
            derived.Scripts.Add(new TemplateScript(script.Name, script.Code)
            {
                IsLocked = script.IsLocked,
                TriggerType = script.TriggerType,
                TriggerConfiguration = script.TriggerConfiguration,
                ParameterDefinitions = script.ParameterDefinitions,
                ReturnDefinition = script.ReturnDefinition,
                MinTimeBetweenRuns = script.MinTimeBetweenRuns,
                ExecutionTimeoutSeconds = script.ExecutionTimeoutSeconds,
                IsInherited = true,
                LockedInDerived = false,
            });
        }

        // Native alarm sources follow the same inherit/override/lock model as the
        // other member types (TemplateNativeAlarmSource), and the inheritance
        // resolver + staleness summary count them — so materialize their inherited
        // placeholders here too, or a freshly-composed derived template reports
        // stale for every inherited native source.
        foreach (var src in baseTemplate.NativeAlarmSources)
        {
            derived.NativeAlarmSources.Add(new TemplateNativeAlarmSource(src.Name)
            {
                Description = src.Description,
                ConnectionName = src.ConnectionName,
                SourceReference = src.SourceReference,
                ConditionFilter = src.ConditionFilter,
                IsLocked = src.IsLocked,
                IsInherited = true,
                LockedInDerived = false,
            });
        }

        return derived;
    }

    // ========================================================================
    // Inherited-member propagation & resync
    // ========================================================================
    //
    // A derived template stores IsInherited placeholder rows mirroring the
    // members it inherits. BuildDerivedTemplate materializes them at compose time,
    // but a member ADDED / UPDATED / REMOVED on a base AFTER the derivation
    // existed never reached the children — so derived row-sets drifted incomplete
    // (the editor's editable tabs missed inherited members) and stored rows
    // diverged from the resolved set. Two mechanisms keep them in sync:
    //   • ReconcileDescendantsAsync — called automatically after every base member
    //     mutation, propagates the change to the whole derived subtree.
    //   • ResyncInheritedMembersAsync — an explicit operator action (CLI + UI)
    //     that repairs an already-stale template and its subtree.
    // Both delegate to one order-independent reconcile that only ever touches
    // IsInherited placeholder rows (never an authored override), which the
    // inheritance resolver ignores when picking winners — so reconciling one
    // template never changes what another resolves.

    /// <summary>
    /// Reconciles the stored inherited member rows of a template and its entire
    /// derived subtree against the resolved effective inherited set: materializes
    /// missing placeholders, re-syncs drifted ones, and removes orphaned ones.
    /// This is the operator-facing repair action behind the CLI
    /// <c>template resync-members</c> command and the editor's "Resync" button.
    /// </summary>
    /// <param name="templateId">The template to resync (its subtree is included).</param>
    /// <param name="user">Username for the audit row.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A result carrying the per-subtree change counts.</returns>
    public async Task<Result<ResyncInheritedMembersResult>> ResyncInheritedMembersAsync(
        int templateId,
        string user,
        CancellationToken cancellationToken = default)
    {
        var all = await _repository.GetAllTemplatesAsync(cancellationToken) ?? Array.Empty<Template>();
        var byId = BuildTemplateLookup(all);
        if (!byId.TryGetValue(templateId, out var root))
            return Result<ResyncInheritedMembersResult>.Failure($"Template with ID {templateId} not found.");

        // Reconcile the target itself AND its subtree: resyncing a base repairs
        // every derivation; resyncing a leaf repairs just it.
        var targets = new List<int> { templateId };
        targets.AddRange(GetDescendantIdsBreadthFirst(templateId, all));

        var total = new ReconcileCounts();
        var changed = 0;
        foreach (var id in targets)
        {
            var counts = await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken);
            if (counts.Any) changed++;
            total.Add(counts);
        }

        if (total.Any)
        {
            await _auditService.LogAsync(
                user, "Resync", "Template", templateId.ToString(), root.Name,
                new { templatesChanged = changed, total.Added, total.Updated, total.Removed },
                cancellationToken);
            await _repository.SaveChangesAsync(cancellationToken);
        }

        return Result<ResyncInheritedMembersResult>.Success(new ResyncInheritedMembersResult
        {
            TemplatesChanged = changed,
            MembersAdded = total.Added,
            MembersUpdated = total.Updated,
            MembersRemoved = total.Removed
        });
    }

    /// <summary>
    /// Propagates a base member change to every derived descendant by reconciling
    /// their inherited rows. Called automatically after a base member is added,
    /// updated, or deleted. The base template itself is NOT reconciled — it is the
    /// author of the change. A no-op when the template has no descendants, which is
    /// the common case.
    /// </summary>
    /// <param name="templateId">The template whose member set changed.</param>
    /// <param name="user">Username for the audit row.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <param name="loadedTemplates">
    /// The full, TRACKED template graph if the caller already has one. Reconciling
    /// writes through these entities and compares script bodies, so this must be
    /// the tracked <c>GetAllTemplatesAsync</c> shape — never the no-tracking,
    /// body-free analysis projection. When <see langword="null"/> the graph is
    /// loaded here.
    ///
    /// <para>
    /// Passing it matters because every member Add/Update/Delete on
    /// <see cref="TemplateService"/> used to load the whole graph once for its own
    /// checks and then again inside this method. Handing the graph over collapses
    /// that to one load per operation.
    /// </para>
    /// </param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async Task ReconcileDescendantsAsync(
        int templateId,
        string user,
        CancellationToken cancellationToken = default,
        IReadOnlyList<Template>? loadedTemplates = null)
    {
        var all = loadedTemplates ?? await _repository.GetAllTemplatesAsync(cancellationToken);
        var descendants = GetDescendantIdsBreadthFirst(templateId, all);
        if (descendants.Count == 0)
            return;

        var byId = BuildTemplateLookup(all);
        var total = new ReconcileCounts();
        foreach (var id in descendants)
            total.Add(await ReconcileInheritedRowsAsync(id, all, byId, cancellationToken));

        if (total.Any)
        {
            var name = byId.TryGetValue(templateId, out var t) ? t.Name : templateId.ToString();
            await _auditService.LogAsync(
                user, "Resync", "Template", templateId.ToString(), name,
                new { propagatedFrom = templateId, total.Added, total.Updated, total.Removed },
                cancellationToken);
            await _repository.SaveChangesAsync(cancellationToken);
        }
    }

    /// <summary>
    /// Reconciles ONE template's stored IsInherited rows against its resolved
    /// effective inherited set (the same resolver the editor preview + staleness
    /// banner use). Adds missing placeholders, re-syncs drifted ones, removes
    /// orphans. Authored override rows (<c>IsInherited == false</c>) are never
    /// touched. Repository writes are queued; the caller owns SaveChanges.
    /// </summary>
    private async Task<ReconcileCounts> ReconcileInheritedRowsAsync(
        int templateId,
        IReadOnlyList<Template> all,
        Dictionary<int, Template> byId,
        CancellationToken cancellationToken)
    {
        var counts = new ReconcileCounts();
        if (!byId.TryGetValue(templateId, out var child) || child.ParentTemplateId == null)
            return counts; // root templates inherit nothing

        var resolved = TemplateInheritanceResolver.Resolve(templateId, all);

        await ReconcileAttributesAsync(child, resolved.Attributes, byId, counts, cancellationToken);
        await ReconcileAlarmsAsync(child, resolved.Alarms, byId, counts, cancellationToken);
        await ReconcileScriptsAsync(child, resolved.Scripts, byId, counts, cancellationToken);
        await ReconcileNativeSourcesAsync(child, resolved.NativeAlarmSources, byId, counts, cancellationToken);
        return counts;
    }

    private async Task ReconcileAttributesAsync(
        Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
        Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
    {
        var inherited = new HashSet<string>(StringComparer.Ordinal);
        foreach (var m in resolved)
        {
            if (!m.IsInherited) continue; // own / override rows are authoritative
            inherited.Add(m.Name);
            if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
            var win = origin.Attributes.FirstOrDefault(a => a.Name == m.Name);
            if (win == null) continue;

            var existing = child.Attributes.FirstOrDefault(a => a.Name == m.Name);
            if (existing == null)
            {
                await _repository.AddTemplateAttributeAsync(new TemplateAttribute(m.Name)
                {
                    TemplateId = child.Id,
                    Value = m.EffectiveValue,
                    DataType = win.DataType,
                    ElementDataType = win.ElementDataType,
                    Description = win.Description,
                    DataSourceReference = win.DataSourceReference,
                    IsLocked = win.IsLocked,
                    IsInherited = true,
                    LockedInDerived = false,
                }, ct);
                counts.Added++;
            }
            else if (existing.IsInherited && (
                existing.Value != m.EffectiveValue ||
                existing.DataType != win.DataType ||
                existing.ElementDataType != win.ElementDataType ||
                existing.Description != win.Description ||
                existing.DataSourceReference != win.DataSourceReference ||
                existing.IsLocked != win.IsLocked))
            {
                existing.Value = m.EffectiveValue;
                existing.DataType = win.DataType;
                existing.ElementDataType = win.ElementDataType;
                existing.Description = win.Description;
                existing.DataSourceReference = win.DataSourceReference;
                existing.IsLocked = win.IsLocked;
                await _repository.UpdateTemplateAttributeAsync(existing, ct);
                counts.Updated++;
            }
        }

        foreach (var orphan in child.Attributes.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
        {
            await _repository.DeleteTemplateAttributeAsync(orphan.Id, ct);
            counts.Removed++;
        }
    }

    private async Task ReconcileAlarmsAsync(
        Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
        Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
    {
        var inherited = new HashSet<string>(StringComparer.Ordinal);
        foreach (var m in resolved)
        {
            if (!m.IsInherited) continue;
            inherited.Add(m.Name);
            if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
            var win = origin.Alarms.FirstOrDefault(a => a.Name == m.Name);
            if (win == null) continue;

            // EffectiveTriggerConfiguration is the per-setpoint MERGED HiLo config
            // (mirrors the flattener), so the placeholder matches what a deploy and
            // the staleness comparison produce.
            var existing = child.Alarms.FirstOrDefault(a => a.Name == m.Name);
            if (existing == null)
            {
                await _repository.AddTemplateAlarmAsync(new TemplateAlarm(m.Name)
                {
                    TemplateId = child.Id,
                    Description = win.Description,
                    PriorityLevel = win.PriorityLevel,
                    IsLocked = win.IsLocked,
                    TriggerType = win.TriggerType,
                    TriggerConfiguration = m.EffectiveTriggerConfiguration,
                    OnTriggerScriptId = win.OnTriggerScriptId,
                    IsInherited = true,
                    LockedInDerived = false,
                }, ct);
                counts.Added++;
            }
            else if (existing.IsInherited && (
                existing.PriorityLevel != win.PriorityLevel ||
                existing.TriggerConfiguration != m.EffectiveTriggerConfiguration ||
                existing.Description != win.Description ||
                existing.OnTriggerScriptId != win.OnTriggerScriptId ||
                existing.TriggerType != win.TriggerType ||
                existing.IsLocked != win.IsLocked))
            {
                existing.PriorityLevel = win.PriorityLevel;
                existing.TriggerConfiguration = m.EffectiveTriggerConfiguration;
                existing.Description = win.Description;
                existing.OnTriggerScriptId = win.OnTriggerScriptId;
                existing.TriggerType = win.TriggerType;
                existing.IsLocked = win.IsLocked;
                await _repository.UpdateTemplateAlarmAsync(existing, ct);
                counts.Updated++;
            }
        }

        foreach (var orphan in child.Alarms.Where(a => a.IsInherited && !inherited.Contains(a.Name)).ToList())
        {
            await _repository.DeleteTemplateAlarmAsync(orphan.Id, ct);
            counts.Removed++;
        }
    }

    private async Task ReconcileScriptsAsync(
        Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
        Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
    {
        var inherited = new HashSet<string>(StringComparer.Ordinal);
        foreach (var m in resolved)
        {
            if (!m.IsInherited) continue;
            inherited.Add(m.Name);
            if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
            var win = origin.Scripts.FirstOrDefault(s => s.Name == m.Name);
            if (win == null) continue;

            var existing = child.Scripts.FirstOrDefault(s => s.Name == m.Name);
            if (existing == null)
            {
                await _repository.AddTemplateScriptAsync(new TemplateScript(m.Name, win.Code)
                {
                    TemplateId = child.Id,
                    IsLocked = win.IsLocked,
                    TriggerType = win.TriggerType,
                    TriggerConfiguration = win.TriggerConfiguration,
                    ParameterDefinitions = win.ParameterDefinitions,
                    ReturnDefinition = win.ReturnDefinition,
                    MinTimeBetweenRuns = win.MinTimeBetweenRuns,
                    ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds,
                    IsInherited = true,
                    LockedInDerived = false,
                }, ct);
                counts.Added++;
            }
            else if (existing.IsInherited && (
                existing.Code != win.Code ||
                existing.TriggerType != win.TriggerType ||
                existing.TriggerConfiguration != win.TriggerConfiguration ||
                existing.ParameterDefinitions != win.ParameterDefinitions ||
                existing.ReturnDefinition != win.ReturnDefinition ||
                existing.MinTimeBetweenRuns != win.MinTimeBetweenRuns ||
                existing.ExecutionTimeoutSeconds != win.ExecutionTimeoutSeconds ||
                existing.IsLocked != win.IsLocked))
            {
                existing.Code = win.Code;
                existing.TriggerType = win.TriggerType;
                existing.TriggerConfiguration = win.TriggerConfiguration;
                existing.ParameterDefinitions = win.ParameterDefinitions;
                existing.ReturnDefinition = win.ReturnDefinition;
                existing.MinTimeBetweenRuns = win.MinTimeBetweenRuns;
                existing.ExecutionTimeoutSeconds = win.ExecutionTimeoutSeconds;
                existing.IsLocked = win.IsLocked;
                await _repository.UpdateTemplateScriptAsync(existing, ct);
                counts.Updated++;
            }
        }

        foreach (var orphan in child.Scripts.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
        {
            await _repository.DeleteTemplateScriptAsync(orphan.Id, ct);
            counts.Removed++;
        }
    }

    private async Task ReconcileNativeSourcesAsync(
        Template child, IReadOnlyList<ResolvedTemplateMemberInfo> resolved,
        Dictionary<int, Template> byId, ReconcileCounts counts, CancellationToken ct)
    {
        var inherited = new HashSet<string>(StringComparer.Ordinal);
        foreach (var m in resolved)
        {
            if (!m.IsInherited) continue;
            inherited.Add(m.Name);
            if (!byId.TryGetValue(m.OriginTemplateId, out var origin)) continue;
            var win = origin.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
            if (win == null) continue;

            var existing = child.NativeAlarmSources.FirstOrDefault(s => s.Name == m.Name);
            if (existing == null)
            {
                await _repository.AddTemplateNativeAlarmSourceAsync(new TemplateNativeAlarmSource(m.Name)
                {
                    TemplateId = child.Id,
                    Description = win.Description,
                    ConnectionName = win.ConnectionName,
                    SourceReference = win.SourceReference,
                    ConditionFilter = win.ConditionFilter,
                    IsLocked = win.IsLocked,
                    IsInherited = true,
                    LockedInDerived = false,
                }, ct);
                counts.Added++;
            }
            else if (existing.IsInherited && (
                existing.ConnectionName != win.ConnectionName ||
                existing.SourceReference != win.SourceReference ||
                existing.ConditionFilter != win.ConditionFilter ||
                existing.Description != win.Description ||
                existing.IsLocked != win.IsLocked))
            {
                existing.ConnectionName = win.ConnectionName;
                existing.SourceReference = win.SourceReference;
                existing.ConditionFilter = win.ConditionFilter;
                existing.Description = win.Description;
                existing.IsLocked = win.IsLocked;
                await _repository.UpdateTemplateNativeAlarmSourceAsync(existing, ct);
                counts.Updated++;
            }
        }

        foreach (var orphan in child.NativeAlarmSources.Where(s => s.IsInherited && !inherited.Contains(s.Name)).ToList())
        {
            await _repository.DeleteTemplateNativeAlarmSourceAsync(orphan.Id, ct);
            counts.Removed++;
        }
    }

    /// <summary>
    /// Breadth-first list of every template that (transitively) derives from
    /// <paramref name="rootId"/>, excluding the root itself. Cycle-guarded.
    /// </summary>
    private static List<int> GetDescendantIdsBreadthFirst(int rootId, IReadOnlyList<Template> all)
    {
        if (all is null) return new List<int>();
        var childrenByParent = new Dictionary<int, List<int>>();
        foreach (var t in all)
        {
            if (t.ParentTemplateId is { } pid)
            {
                if (!childrenByParent.TryGetValue(pid, out var list))
                    childrenByParent[pid] = list = new List<int>();
                list.Add(t.Id);
            }
        }

        var result = new List<int>();
        var seen = new HashSet<int> { rootId };
        var queue = new Queue<int>();
        if (childrenByParent.TryGetValue(rootId, out var direct))
            foreach (var c in direct) queue.Enqueue(c);

        while (queue.Count > 0)
        {
            var id = queue.Dequeue();
            if (!seen.Add(id)) continue;
            result.Add(id);
            if (childrenByParent.TryGetValue(id, out var kids))
                foreach (var c in kids) queue.Enqueue(c);
        }

        return result;
    }

    private static Dictionary<int, Template> BuildTemplateLookup(IReadOnlyList<Template> all)
    {
        var byId = new Dictionary<int, Template>(all.Count);
        foreach (var t in all) byId[t.Id] = t; // DB ids are unique; last-wins is harmless
        return byId;
    }

    /// <summary>Running tally of inherited-row changes during a reconcile.</summary>
    private sealed class ReconcileCounts
    {
        public int Added;
        public int Updated;
        public int Removed;

        /// <summary>True when any row was added, updated, or removed.</summary>
        public bool Any => Added > 0 || Updated > 0 || Removed > 0;

        /// <summary>Accumulates another tally's counts into this one.</summary>
        /// <param name="other">The counts to add.</param>
        public void Add(ReconcileCounts other)
        {
            Added += other.Added;
            Updated += other.Updated;
            Removed += other.Removed;
        }
    }

    // ========================================================================
    // Path-Qualified Canonical Naming (via TemplateResolver)
    // ========================================================================

    /// <summary>
    /// Resolves all effective members for a template using canonical (path-qualified) names.
    /// </summary>
    /// <param name="templateId">ID of the template to resolve.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>List of resolved template members with canonical names.</returns>
    public async Task<IReadOnlyList<TemplateResolver.ResolvedTemplateMember>> ResolveTemplateMembersAsync(
        int templateId,
        CancellationToken cancellationToken = default)
    {
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        return TemplateResolver.ResolveAllMembers(templateId, allTemplates);
    }

    // ========================================================================
    // Override validation (integrated into Update* methods above)
    // ========================================================================

    /// <summary>
    /// Validates whether overriding a member by canonical name is allowed.
    /// Used for composition overrides and inheritance overrides.
    /// </summary>
    /// <param name="templateId">ID of the template.</param>
    /// <param name="canonicalName">Canonical name of the member to validate.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating whether override is allowed.</returns>
    public async Task<Result<bool>> ValidateOverrideAsync(
        int templateId,
        string canonicalName,
        CancellationToken cancellationToken = default)
    {
        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return Result<bool>.Failure($"Template with ID {templateId} not found.");

        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        var members = TemplateResolver.ResolveAllMembers(templateId, allTemplates);
        var member = members.FirstOrDefault(m => m.CanonicalName == canonicalName);

        if (member == null)
            return Result<bool>.Failure($"No member found with canonical name '{canonicalName}'.");

        if (member.IsLocked)
            return Result<bool>.Failure($"Member '{canonicalName}' is locked and cannot be overridden.");

        return Result<bool>.Success(true);
    }

    // ========================================================================
    // Naming Collision Detection
    // ========================================================================

    /// <summary>
    /// Checks a template for naming collisions across all its members.
    /// </summary>
    /// <param name="templateId">ID of the template to check.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>List of collision error messages, or empty if none found.</returns>
    public async Task<IReadOnlyList<string>> DetectCollisionsAsync(
        int templateId,
        CancellationToken cancellationToken = default)
    {
        var template = await _repository.GetTemplateByIdAsync(templateId, cancellationToken);
        if (template == null)
            return new[] { $"Template with ID {templateId} not found." };

        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        return CollisionDetector.DetectCollisions(template, allTemplates);
    }

    // ========================================================================
    // Graph Acyclicity
    // ========================================================================

    /// <summary>
    /// Validates that a proposed inheritance or composition change does not create a cycle.
    /// </summary>
    /// <param name="templateId">ID of the template being modified.</param>
    /// <param name="proposedParentId">Proposed parent template ID for inheritance.</param>
    /// <param name="proposedComposedTemplateId">Proposed composed template ID for composition.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>Result indicating whether the change would create a cycle.</returns>
    public async Task<Result<bool>> ValidateAcyclicityAsync(
        int templateId,
        int? proposedParentId,
        int? proposedComposedTemplateId,
        CancellationToken cancellationToken = default)
    {
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);

        if (proposedParentId.HasValue)
        {
            var inheritanceCycle = CycleDetector.DetectInheritanceCycle(templateId, proposedParentId.Value, allTemplates);
            if (inheritanceCycle != null)
                return Result<bool>.Failure(inheritanceCycle);
        }

        if (proposedComposedTemplateId.HasValue)
        {
            var compositionCycle = CycleDetector.DetectCompositionCycle(templateId, proposedComposedTemplateId.Value, allTemplates);
            if (compositionCycle != null)
                return Result<bool>.Failure(compositionCycle);
        }

        var crossCycle = CycleDetector.DetectCrossGraphCycle(templateId, proposedParentId, proposedComposedTemplateId, allTemplates);
        if (crossCycle != null)
            return Result<bool>.Failure(crossCycle);

        return Result<bool>.Success(true);
    }

    // ========================================================================
    // Helper methods
    // ========================================================================

    private async Task<string?> ValidateCollisionsAsync(Template template, CancellationToken cancellationToken)
    {
        var allTemplates = await _repository.GetAllTemplatesForAnalysisAsync(cancellationToken);
        var collisions = CollisionDetector.DetectCollisions(template, allTemplates);
        if (collisions.Count > 0)
            return string.Join(" ", collisions);
        return null;
    }

    private static Template CloneTemplateWithNewAttribute(Template original, TemplateAttribute newAttr)
    {
        var clone = new Template(original.Name)
        {
            Id = original.Id,
            ParentTemplateId = original.ParentTemplateId,
            Description = original.Description,
        };
        foreach (var a in original.Attributes) clone.Attributes.Add(a);
        clone.Attributes.Add(newAttr);
        foreach (var a in original.Alarms) clone.Alarms.Add(a);
        foreach (var s in original.Scripts) clone.Scripts.Add(s);
        foreach (var c in original.Compositions) clone.Compositions.Add(c);
        return clone;
    }

    private static Template CloneTemplateWithNewAlarm(Template original, TemplateAlarm newAlarm)
    {
        var clone = new Template(original.Name)
        {
            Id = original.Id,
            ParentTemplateId = original.ParentTemplateId,
            Description = original.Description,
        };
        foreach (var a in original.Attributes) clone.Attributes.Add(a);
        foreach (var a in original.Alarms) clone.Alarms.Add(a);
        clone.Alarms.Add(newAlarm);
        foreach (var s in original.Scripts) clone.Scripts.Add(s);
        foreach (var c in original.Compositions) clone.Compositions.Add(c);
        return clone;
    }

    private static Template CloneTemplateWithNewScript(Template original, TemplateScript newScript)
    {
        var clone = new Template(original.Name)
        {
            Id = original.Id,
            ParentTemplateId = original.ParentTemplateId,
            Description = original.Description,
        };
        foreach (var a in original.Attributes) clone.Attributes.Add(a);
        foreach (var a in original.Alarms) clone.Alarms.Add(a);
        foreach (var s in original.Scripts) clone.Scripts.Add(s);
        clone.Scripts.Add(newScript);
        foreach (var c in original.Compositions) clone.Compositions.Add(c);
        return clone;
    }

    private static Template CloneTemplateWithNewComposition(Template original, TemplateComposition newComp)
    {
        var clone = new Template(original.Name)
        {
            Id = original.Id,
            ParentTemplateId = original.ParentTemplateId,
            Description = original.Description,
        };
        foreach (var a in original.Attributes) clone.Attributes.Add(a);
        foreach (var a in original.Alarms) clone.Alarms.Add(a);
        foreach (var s in original.Scripts) clone.Scripts.Add(s);
        foreach (var c in original.Compositions) clone.Compositions.Add(c);
        clone.Compositions.Add(newComp);
        return clone;
    }
}
