using Moq;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
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.Types.Enums;

namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests;

public class TemplateServiceTests
{
    private readonly Mock<ITemplateEngineRepository> _repoMock;
    private readonly Mock<IAuditService> _auditMock;
    private readonly TemplateService _service;

    public TemplateServiceTests()
    {
        _repoMock = new Mock<ITemplateEngineRepository>();
        _auditMock = new Mock<IAuditService>();
        _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>())).ReturnsAsync(1);
        _service = new TemplateService(_repoMock.Object, _auditMock.Object);
    }

    // ========================================================================
    // WP-1: Template CRUD with Inheritance
    // ========================================================================

    [Fact]
    public async Task CreateTemplate_Success()
    {
        var result = await _service.CreateTemplateAsync("Pump", "A pump template", null, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("Pump", result.Value.Name);
        _repoMock.Verify(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
        // TemplateEngine-020: SaveChangesAsync runs twice — once after the
        // AddTemplateAsync to populate the auto-generated key (so the audit
        // row can carry the real Id), and once after LogAsync to persist the
        // audit row itself.
        _repoMock.Verify(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()), Times.Exactly(2));
        _auditMock.Verify(a => a.LogAsync("admin", "Create", "Template", It.IsAny<string>(), "Pump", It.IsAny<object?>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task CreateTemplate_EmptyName_Fails()
    {
        var result = await _service.CreateTemplateAsync("", null, null, "admin");
        Assert.True(result.IsFailure);
        Assert.Contains("required", result.Error);
    }

    [Fact]
    public async Task CreateTemplate_WithParent_Success()
    {
        var parent = new Template("Base") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parent });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parent });

        var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(1, result.Value.ParentTemplateId);
    }

    [Fact]
    public async Task CreateTemplate_WithParent_DoesNotRunDeadCollisionQuery()
    {
        // A freshly created child has no members of its own, and the parent's
        // members were already collision-validated when they were added — so
        // create-time collision detection on a child is a guaranteed no-op.
        // The previous code allocated an unused full-table read; the fix
        // removes it. This guards against the dead query being reintroduced.
        var parent = new Template("Base") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);

        var result = await _service.CreateTemplateAsync("Child", null, 1, "admin");

        Assert.True(result.IsSuccess);
        // No whole-graph walk of ANY shape — neither the tracked
        // GetAllTemplatesAsync nor the no-tracking analysis projection that the
        // collision/acyclicity checks now use.
        _repoMock.Verify(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()), Times.Never);
        _repoMock.Verify(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task CreateTemplate_NonexistentParent_Fails()
    {
        _repoMock.Setup(r => r.GetTemplateByIdAsync(999, It.IsAny<CancellationToken>())).ReturnsAsync((Template?)null);

        var result = await _service.CreateTemplateAsync("Child", null, 999, "admin");
        Assert.True(result.IsFailure);
        Assert.Contains("not found", result.Error);
    }

    [Fact]
    public async Task CreateTemplate_WithFolderId_SetsFolderId()
    {
        var folder = new TemplateFolder("Dev") { Id = 7 };
        _repoMock.Setup(r => r.GetFolderByIdAsync(It.IsAny<int>(), It.IsAny<CancellationToken>())).ReturnsAsync(folder);

        var result = await _service.CreateTemplateAsync("X", null, null, "admin", folderId: 7);

        Assert.True(result.IsSuccess);
        Assert.Equal(7, result.Value.FolderId);
    }

    [Fact]
    public async Task DeleteTemplate_Success()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Instance>());
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var result = await _service.DeleteTemplateAsync(1, "admin");

        Assert.True(result.IsSuccess);
        _repoMock.Verify(r => r.DeleteTemplateAsync(1, It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task DeleteTemplate_ReferencedByInstances_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Instance> { new Instance("Pump1") { Id = 1, TemplateId = 1, SiteId = 1 } });
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var result = await _service.DeleteTemplateAsync(1, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("instance(s) reference it", result.Error);
    }

    [Fact]
    public async Task DeleteTemplate_HasChildren_Fails()
    {
        var parent = new Template("Base") { Id = 1 };
        var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(parent);
        _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Instance>());
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parent, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parent, child });

        var result = await _service.DeleteTemplateAsync(1, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("child template(s) inherit from it", result.Error);
    }

    [Fact]
    public async Task DeleteTemplate_ComposedByOther_Fails()
    {
        var moduleTemplate = new Template("Module") { Id = 1 };
        var composingTemplate = new Template("Composing") { Id = 2 };
        composingTemplate.Compositions.Add(new TemplateComposition("mod1") { Id = 1, TemplateId = 2, ComposedTemplateId = 1 });

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
        _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Instance>());
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { moduleTemplate, composingTemplate });

        var result = await _service.DeleteTemplateAsync(1, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("template(s) compose it", result.Error);
    }

    [Fact]
    public async Task DeleteTemplate_MultipleConstraints_ReportsAllNotJustFirst()
    {
        // TemplateEngine-014: DeleteTemplateAsync delegates its constraint check
        // to the single TemplateDeletionService implementation, which accumulates
        // every blocking reason instead of returning on the first failing category.
        var template = new Template("Busy") { Id = 1 };
        var composer = new Template("Composer") { Id = 3 };
        composer.Compositions.Add(new TemplateComposition("Module") { Id = 1, TemplateId = 3, ComposedTemplateId = 1 });

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(1, It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Instance> { new Instance("Inst1") { Id = 1, TemplateId = 1, SiteId = 1 } });
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template>
            {
                template,
                new Template("Child") { Id = 2, ParentTemplateId = 1 },
                composer
            });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template>
            {
                template,
                new Template("Child") { Id = 2, ParentTemplateId = 1 },
                composer
            });

        var result = await _service.DeleteTemplateAsync(1, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("instance(s) reference it", result.Error);
        Assert.Contains("child template(s) inherit from it", result.Error);
        Assert.Contains("template(s) compose it", result.Error);
    }

    // ========================================================================
    // WP-2: Attribute Definitions with Lock Flags
    // ========================================================================

    [Fact]
    public async Task AddAttribute_Success()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("Temperature", result.Value.Name);
        Assert.Equal(1, result.Value.TemplateId);
    }

    [Fact]
    public async Task AddAttribute_ToDerivedTemplateWithInheritedPlaceholders_Succeeds()
    {
        // Follow-up #3 (end-to-end). Adding a member to a derived template used to
        // fail: CloneTemplateWithNewAttribute carries the child's IsInherited
        // placeholder rows into DetectCollisions, which counted each one twice (child
        // origin + the parent the inheritance walk re-adds) and reported a spurious
        // "Naming collision" for every inherited attribute — there was no supported
        // API path to extend a derived template. The new attribute must now save.
        var parent = new Template("ReactorSide") { Id = 7 };
        parent.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 70, TemplateId = 7, DataType = DataType.Float });
        parent.Attributes.Add(new TemplateAttribute("ResultType") { Id = 71, TemplateId = 7, DataType = DataType.String });

        var child = new Template("LeftReactorSide") { Id = 8, ParentTemplateId = 7 };
        child.Attributes.Add(new TemplateAttribute("DeltaVac") { Id = 80, TemplateId = 8, DataType = DataType.Float, IsInherited = true });
        child.Attributes.Add(new TemplateAttribute("ResultType") { Id = 81, TemplateId = 8, DataType = DataType.String, IsInherited = true });

        _repoMock.Setup(r => r.GetTemplateByIdAsync(8, It.IsAny<CancellationToken>())).ReturnsAsync(child);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parent, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parent, child });

        var attr = new TemplateAttribute("MoveInType") { DataType = DataType.String, Value = "" };
        var result = await _service.AddAttributeAsync(8, attr, "admin");

        // Guard the message: Result.Error throws on a success result, so only read
        // it when the add actually failed (which is the case this test regresses).
        Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
        Assert.Equal("MoveInType", result.Value.Name);
        Assert.Equal(8, result.Value.TemplateId);
    }

    [Fact]
    public async Task AddAttribute_DuplicateName_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        template.Attributes.Add(new TemplateAttribute("Temperature") { Id = 1, TemplateId = 1, DataType = DataType.Float });
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("already exists", result.Error);
    }

    // ── #92: (DataType, ElementDataType, Value) validation hoisted into the
    //         service so the Central UI's direct write path shares the same
    //         server-side guard as ManagementActor.ValidateAttributeTypes. ──

    [Fact]
    public async Task AddAttribute_InvalidDataType_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        // An out-of-range enum value is the in-service equivalent of an
        // unrecognised data-type token (the CLI/API path rejects the token before
        // the enum is parsed).
        var attr = new TemplateAttribute("Temperature") { DataType = (DataType)999, Value = "0" };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("data type", result.Error, StringComparison.OrdinalIgnoreCase);
        // The attribute must never reach the repository.
        _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task AddAttribute_ListMissingElementType_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var attr = new TemplateAttribute("SetPoints") { DataType = DataType.List, ElementDataType = null };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("element type", result.Error, StringComparison.OrdinalIgnoreCase);
        _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task AddAttribute_ListInvalidElementType_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        // Binary is a real DataType but not a valid List element scalar.
        var attr = new TemplateAttribute("SetPoints") { DataType = DataType.List, ElementDataType = DataType.Binary };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("element type", result.Error, StringComparison.OrdinalIgnoreCase);
        _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task AddAttribute_ScalarWithElementType_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, ElementDataType = DataType.Int32, Value = "0" };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("ElementDataType", result.Error);
        _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task AddAttribute_ListValueDoesNotDecode_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        // A valid typed List, but the value carries an element that is not an Int32.
        var attr = new TemplateAttribute("SetPoints")
        {
            DataType = DataType.List, ElementDataType = DataType.Int32, Value = """["abc"]"""
        };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("invalid list value", result.Error, StringComparison.OrdinalIgnoreCase);
        _repoMock.Verify(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task AddAttribute_ScalarValueDoesNotParse_Fails()
    {
        // Mirrors what AttributeValueCodec.Decode would accept for the declared
        // (DataType, ElementDataType). Scalars decode as-is in the codec, so a
        // free-form scalar value is accepted by both the CLI/API guard and the
        // service guard — there is no scalar-string parse here. Documenting the
        // accepted shape: a non-numeric scalar Value is allowed (validated at deploy).
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var attr = new TemplateAttribute("Temperature") { DataType = DataType.Int32, Value = "not-a-number" };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        // Behaviour-preserving: the codec leaves scalars untouched, so this is
        // accepted (the deploy-time SemanticValidator remains the scalar backstop).
        Assert.True(result.IsSuccess);
    }

    [Fact]
    public async Task AddAttribute_ValidTypedList_Succeeds()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var attr = new TemplateAttribute("SetPoints")
        {
            DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[10, 20, 30]"""
        };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("SetPoints", result.Value.Name);
        Assert.Equal(DataType.List, result.Value.DataType);
        Assert.Equal(DataType.Int32, result.Value.ElementDataType);
    }

    [Fact]
    public async Task UpdateAttribute_InvalidListValue_Fails()
    {
        // DataType/ElementDataType are fixed on the existing row; the proposed
        // value must still decode against that fixed element type.
        var existing = new TemplateAttribute("SetPoints")
        {
            Id = 1, TemplateId = 1, DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[1, 2]"""
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAttribute("SetPoints")
        {
            DataType = DataType.List, ElementDataType = DataType.Int32, Value = """["abc"]"""
        };
        var result = await _service.UpdateAttributeAsync(1, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("invalid list value", result.Error, StringComparison.OrdinalIgnoreCase);
        // The fixed value must not have been mutated.
        Assert.Equal("""[1, 2]""", existing.Value);
        _repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task UpdateAttribute_ValidTypedListValue_Succeeds()
    {
        var existing = new TemplateAttribute("SetPoints")
        {
            Id = 1, TemplateId = 1, DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[1, 2]"""
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAttribute("SetPoints")
        {
            DataType = DataType.List, ElementDataType = DataType.Int32, Value = """[10, 20, 30]"""
        };
        var result = await _service.UpdateAttributeAsync(1, proposed, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("""[10, 20, 30]""", result.Value.Value);
    }

    // ========================================================================
    // WP-3: Alarm Definitions
    // ========================================================================

    [Fact]
    public async Task AddAlarm_Success()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var alarm = new TemplateAlarm("HighTemp")
        {
            PriorityLevel = 500,
            TriggerType = AlarmTriggerType.RangeViolation,
            TriggerConfiguration = """{"Max": 100}"""
        };
        var result = await _service.AddAlarmAsync(1, alarm, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("HighTemp", result.Value.Name);
    }

    [Fact]
    public async Task AddAlarm_InvalidPriority_Fails()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var alarm = new TemplateAlarm("HighTemp") { PriorityLevel = 1001, TriggerType = AlarmTriggerType.ValueMatch };
        var result = await _service.AddAlarmAsync(1, alarm, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("priority", result.Error, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public async Task UpdateAlarm_TriggerTypeFixed_Fails()
    {
        var existing = new TemplateAlarm("HighTemp")
        {
            Id = 1,
            TemplateId = 1,
            TriggerType = AlarmTriggerType.ValueMatch,
            PriorityLevel = 500
        };
        _repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);

        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAlarm("HighTemp")
        {
            TriggerType = AlarmTriggerType.RangeViolation, // Changed!
            PriorityLevel = 600
        };
        var result = await _service.UpdateAlarmAsync(1, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("TriggerType", result.Error);
    }

    // ========================================================================
    // WP-4: Script Definitions
    // ========================================================================

    [Fact]
    public async Task AddScript_Success()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
        var result = await _service.AddScriptAsync(1, script, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("OnStart", result.Value.Name);
    }

    [Fact]
    public async Task UpdateScript_NameFixed_Fails()
    {
        var existing = new TemplateScript("OnStart", "return true;") { Id = 1, TemplateId = 1 };
        _repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateScript("OnStop", "return false;"); // Name changed!
        var result = await _service.UpdateScriptAsync(1, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("Name", result.Error);
    }

    [Fact]
    public async Task UpdateScript_UiEditPath_PreservesExistingExecutionTimeoutSeconds()
    {
        // #54: the UI now has an ExecutionTimeoutSeconds input, but it still loads
        // (round-trips) the existing value into that input on edit, so leaving it
        // untouched re-sends the same value rather than null. This test proves the
        // service preserves the timeout end-to-end when the loaded value is re-sent
        // — guarding the UI edit path against silently blanking a configured timeout.
        var existing = new TemplateScript("OnStart", "return true;")
        {
            Id = 1,
            TemplateId = 1,
            ExecutionTimeoutSeconds = 30 // set via Transport import
        };
        _repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        // Simulate what the UI now does: round-trip the loaded ExecutionTimeoutSeconds.
        var proposed = new TemplateScript("OnStart", "return false;")
        {
            ExecutionTimeoutSeconds = existing.ExecutionTimeoutSeconds // round-trip
        };

        var result = await _service.UpdateScriptAsync(1, proposed, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(30, result.Value.ExecutionTimeoutSeconds);
    }

    [Fact]
    public async Task UpdateScript_ExplicitNullTimeout_ClearsExecutionTimeoutSeconds()
    {
        // M2.5 (#9): a deliberate clear (e.g. via Transport or CLI setting null
        // explicitly) must still work — the service must not guard against null.
        var existing = new TemplateScript("OnStart", "return true;")
        {
            Id = 1,
            TemplateId = 1,
            ExecutionTimeoutSeconds = 30
        };
        _repoMock.Setup(r => r.GetTemplateScriptByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        // Explicit null — caller intentionally clears the timeout.
        var proposed = new TemplateScript("OnStart", "return false;")
        {
            ExecutionTimeoutSeconds = null
        };

        var result = await _service.UpdateScriptAsync(1, proposed, "admin");

        Assert.True(result.IsSuccess);
        Assert.Null(result.Value.ExecutionTimeoutSeconds);
    }

    // ========================================================================
    // WP-5: Shared Script CRUD (see SharedScriptServiceTests)
    // ========================================================================

    // ========================================================================
    // WP-6: Composition with Recursive Nesting
    // ========================================================================

    [Fact]
    public async Task AddComposition_Success_DerivesTemplate()
    {
        var moduleTemplate = new Template("Module") { Id = 2 };
        moduleTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, Value = "75", DataType = DataType.Float });
        moduleTemplate.Scripts.Add(new TemplateScript("Compute", "return 1;") { Id = 20, TemplateId = 2 });
        var template = new Template("Parent") { Id = 1 };

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template, moduleTemplate });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template, moduleTemplate });

        Template? captured = null;
        _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
            .Callback<Template, CancellationToken>((t, _) => captured = t)
            .Returns(Task.CompletedTask);

        var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("myModule", result.Value.InstanceName);
        Assert.NotNull(captured);
        Assert.True(captured!.IsDerived);
        Assert.Equal("myModule", captured.Name); // contained (slot) name, not the qualified path
        Assert.Equal(2, captured.ParentTemplateId);
        Assert.Single(captured.Attributes);
        Assert.True(captured.Attributes.First().IsInherited);
        Assert.Equal("SetPoint", captured.Attributes.First().Name);
        Assert.Single(captured.Scripts);
        Assert.True(captured.Scripts.First().IsInherited);
        _repoMock.Verify(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task AddComposition_CascadesChildCompositions()
    {
        // $Probe (base) → $Sensor's "Probe1" derived ← $Sensor composes "Probe1"
        // Composing $Sensor into $Pump as "TempSensor" should produce two derived
        // templates whose stored names are their *contained* names (the slot
        // names) — "TempSensor" and "Probe1" — plus a composition row on the
        // TempSensor derived pointing at the new inner derived.
        var probe = new Template("Probe") { Id = 10 };
        var sensorProbe1 = new Template("Probe1") { Id = 11, IsDerived = true, ParentTemplateId = 10, OwnerCompositionId = 1 };
        var sensor = new Template("Sensor") { Id = 2 };
        sensor.Compositions.Add(new TemplateComposition("Probe1") { Id = 1, TemplateId = 2, ComposedTemplateId = 11 });
        var pump = new Template("Pump") { Id = 1 };

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(pump);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(sensor);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(11, It.IsAny<CancellationToken>())).ReturnsAsync(sensorProbe1);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { pump, sensor, probe, sensorProbe1 });

        var captured = new List<Template>();
        _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
            .Callback<Template, CancellationToken>((t, _) => captured.Add(t))
            .Returns(Task.CompletedTask);

        var capturedCompositions = new List<TemplateComposition>();
        _repoMock.Setup(r => r.AddTemplateCompositionAsync(It.IsAny<TemplateComposition>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateComposition, CancellationToken>((c, _) => capturedCompositions.Add(c))
            .Returns(Task.CompletedTask);

        var result = await _service.AddCompositionAsync(1, 2, "TempSensor", "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(2, captured.Count);
        Assert.Equal("TempSensor", captured[0].Name);
        Assert.Equal(2, captured[0].ParentTemplateId);
        Assert.Equal("Probe1", captured[1].Name);
        Assert.Equal(11, captured[1].ParentTemplateId);
        Assert.Equal(2, capturedCompositions.Count);
        Assert.Equal("TempSensor", capturedCompositions[0].InstanceName);
        Assert.Equal("Probe1", capturedCompositions[1].InstanceName);
    }

    [Fact]
    public async Task AddComposition_BaseIsDerived_Fails()
    {
        var derivedBase = new Template("Sensor") { Id = 2, IsDerived = true };
        var template = new Template("Parent") { Id = 1 };

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(derivedBase);

        var result = await _service.AddCompositionAsync(1, 2, "slot", "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("derived template", result.Error);
    }

    [Fact]
    public async Task AddComposition_DerivedContainedName_NeedNotBeGloballyUnique()
    {
        // A derived template's stored name is its contained (slot) name, which
        // is only unique within its owner — it may freely coincide with an
        // unrelated base template's name. Composing a module as the slot
        // "myModule" succeeds even though a base template "myModule" exists.
        var existing = new Template("myModule") { Id = 99 };
        var moduleTemplate = new Template("Module") { Id = 2 };
        var template = new Template("Parent") { Id = 1 };

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template, moduleTemplate, existing });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template, moduleTemplate, existing });

        var captured = new List<Template>();
        _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
            .Callback<Template, CancellationToken>((t, _) => captured.Add(t))
            .Returns(Task.CompletedTask);

        var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");

        Assert.True(result.IsSuccess);
        Assert.Single(captured);
        Assert.Equal("myModule", captured[0].Name);
    }

    [Fact]
    public async Task RenameComposition_RenamesSlotAndDerivedTemplate()
    {
        // The derived template's stored name is the slot's contained name, so
        // renaming the slot renames the derived template to the new short name.
        var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
        var owner = new Template("Pump") { Id = 1 };
        owner.Compositions.Add(composition);
        var derived = new Template("OldSlot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };

        _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);

        var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("NewSlot", result.Value.InstanceName);
        Assert.Equal("NewSlot", derived.Name);
    }

    [Fact]
    public async Task RenameComposition_RenamesOnlyTheSlotsOwnDerivedTemplate()
    {
        // With contained names, a nested derived template's name is its own
        // slot name and is unaffected by renaming an ancestor slot. Renaming
        // the TempSensor slot to MainSensor renames only that derived template;
        // the inner "Probe1" derived is left untouched.
        var innerComp = new TemplateComposition("Probe1") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
        var innerDerived = new Template("Probe1") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 11 };
        var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
        var owner = new Template("Pump") { Id = 1 };
        owner.Compositions.Add(composition);
        var derived = new Template("TempSensor") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
        derived.Compositions.Add(innerComp);

        _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(innerDerived);

        var result = await _service.RenameCompositionAsync(50, "MainSensor", "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("MainSensor", result.Value.InstanceName);
        Assert.Equal("MainSensor", derived.Name);
        Assert.Equal("Probe1", innerDerived.Name);
        _repoMock.Verify(r => r.UpdateTemplateAsync(
            It.Is<Template>(t => t.Id == 78), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task RenameComposition_DuplicateName_Fails()
    {
        var composition = new TemplateComposition("OldSlot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
        var sibling = new TemplateComposition("NewSlot") { Id = 51, TemplateId = 1, ComposedTemplateId = 78 };
        var owner = new Template("Pump") { Id = 1 };
        owner.Compositions.Add(composition);
        owner.Compositions.Add(sibling);

        _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(owner);

        var result = await _service.RenameCompositionAsync(50, "NewSlot", "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("already exists", result.Error);
    }

    [Fact]
    public async Task DeleteComposition_CascadesNestedDerivedTemplates()
    {
        // Pump.TempSensor is a cascaded derived under Pump (outer derived) that
        // is owned by composition 50. Deleting composition 50 must drop:
        //   - the outer derived (Pump)
        //   - its nested composition (TempSensor on Pump)
        //   - the nested derived (Pump.TempSensor)
        var nestedComp = new TemplateComposition("TempSensor") { Id = 51, TemplateId = 77, ComposedTemplateId = 78 };
        var nestedDerived = new Template("Tank.Pump.TempSensor") { Id = 78, IsDerived = true, OwnerCompositionId = 51, ParentTemplateId = 3 };
        var outerComposition = new TemplateComposition("Pump") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
        var outerDerived = new Template("Tank.Pump") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };
        outerDerived.Compositions.Add(nestedComp);

        _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(outerComposition);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(outerDerived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(78, It.IsAny<CancellationToken>())).ReturnsAsync(nestedDerived);

        var result = await _service.DeleteCompositionAsync(50, "admin");

        Assert.True(result.IsSuccess);
        _repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
        _repoMock.Verify(r => r.DeleteTemplateCompositionAsync(51, It.IsAny<CancellationToken>()), Times.Once);
        _repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
        _repoMock.Verify(r => r.DeleteTemplateAsync(78, It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task DeleteComposition_CascadesDerivedTemplate()
    {
        var composition = new TemplateComposition("slot") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
        var derived = new Template("Parent.slot") { Id = 77, IsDerived = true, OwnerCompositionId = 50, ParentTemplateId = 2 };

        _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(50, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);

        var result = await _service.DeleteCompositionAsync(50, "admin");

        Assert.True(result.IsSuccess);
        _repoMock.Verify(r => r.DeleteTemplateCompositionAsync(50, It.IsAny<CancellationToken>()), Times.Once);
        _repoMock.Verify(r => r.DeleteTemplateAsync(77, It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task DeleteComposition_LegacyDirectBase_DoesNotCascade()
    {
        var composition = new TemplateComposition("slot") { Id = 60, TemplateId = 1, ComposedTemplateId = 5 };
        var baseTemplate = new Template("Module") { Id = 5, IsDerived = false };

        _repoMock.Setup(r => r.GetTemplateCompositionByIdAsync(60, It.IsAny<CancellationToken>())).ReturnsAsync(composition);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);

        var result = await _service.DeleteCompositionAsync(60, "admin");

        Assert.True(result.IsSuccess);
        _repoMock.Verify(r => r.DeleteTemplateCompositionAsync(60, It.IsAny<CancellationToken>()), Times.Once);
        _repoMock.Verify(r => r.DeleteTemplateAsync(5, It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task DeleteTemplate_DerivedTemplate_DirectDeleteBlocked()
    {
        var derived = new Template("Parent.slot") { Id = 77, IsDerived = true };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);

        var result = await _service.DeleteTemplateAsync(77, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("derived template", result.Error);
    }

    [Fact]
    public async Task UpdateAttribute_LockedInDerivedBase_RejectsOnDerived()
    {
        var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
        var baseTemplate = new Template("Sensor") { Id = 2 };
        baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2, LockedInDerived = true });
        var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };

        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });

        var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
        var result = await _service.UpdateAttributeAsync(100, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("locked by base template 'Sensor'", result.Error);
    }

    [Fact]
    public async Task UpdateScript_LockedInDerivedBase_RejectsOnDerived()
    {
        var existing = new TemplateScript("Sample", "stale") { Id = 200, TemplateId = 77, IsInherited = true };
        var baseTemplate = new Template("Sensor") { Id = 2 };
        baseTemplate.Scripts.Add(new TemplateScript("Sample", "return 1;") { Id = 20, TemplateId = 2, LockedInDerived = true });
        var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };

        _repoMock.Setup(r => r.GetTemplateScriptByIdAsync(200, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });

        var proposed = new TemplateScript("Sample", "return 2;") { IsInherited = false };
        var result = await _service.UpdateScriptAsync(200, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("locked by base template 'Sensor'", result.Error);
    }

    [Fact]
    public async Task AddComposition_CopiesAlarmsAsInherited()
    {
        var moduleTemplate = new Template("Module") { Id = 2 };
        moduleTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
        {
            Id = 30,
            TemplateId = 2,
            TriggerType = AlarmTriggerType.RangeViolation,
            TriggerConfiguration = "{\"attributeName\":\"Temp\",\"high\":100}",
            PriorityLevel = 5,
            Description = "Too hot"
        });
        var template = new Template("Parent") { Id = 1 };

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template, moduleTemplate });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template, moduleTemplate });

        Template? captured = null;
        _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
            .Callback<Template, CancellationToken>((t, _) => captured = t)
            .Returns(Task.CompletedTask);

        var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");

        Assert.True(result.IsSuccess);
        Assert.NotNull(captured);
        Assert.Single(captured!.Alarms);
        var copied = captured.Alarms.First();
        Assert.Equal("HighTemp", copied.Name);
        Assert.True(copied.IsInherited);
        Assert.False(copied.LockedInDerived);
        Assert.Equal(AlarmTriggerType.RangeViolation, copied.TriggerType);
        Assert.Equal(5, copied.PriorityLevel);
        Assert.Equal("Too hot", copied.Description);
    }

    [Fact]
    public async Task UpdateAlarm_LockedInDerivedBase_RejectsOnDerived()
    {
        var existing = new TemplateAlarm("HighTemp")
        {
            Id = 300,
            TemplateId = 77,
            TriggerType = AlarmTriggerType.RangeViolation,
            PriorityLevel = 5,
            IsInherited = true
        };
        var baseTemplate = new Template("Sensor") { Id = 2 };
        baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
        {
            Id = 30,
            TemplateId = 2,
            TriggerType = AlarmTriggerType.RangeViolation,
            LockedInDerived = true
        });
        var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };

        _repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });

        var proposed = new TemplateAlarm("HighTemp")
        {
            TriggerType = AlarmTriggerType.RangeViolation,
            PriorityLevel = 99,
            IsInherited = false
        };
        var result = await _service.UpdateAlarmAsync(300, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("locked by base template 'Sensor'", result.Error);
    }

    [Fact]
    public async Task UpdateAlarm_DerivedOverride_PersistsIsInheritedFalse()
    {
        var existing = new TemplateAlarm("HighTemp")
        {
            Id = 300,
            TemplateId = 77,
            TriggerType = AlarmTriggerType.RangeViolation,
            PriorityLevel = 5,
            IsInherited = true
        };
        var baseTemplate = new Template("Sensor") { Id = 2 };
        baseTemplate.Alarms.Add(new TemplateAlarm("HighTemp")
        {
            Id = 30,
            TemplateId = 2,
            TriggerType = AlarmTriggerType.RangeViolation
        });
        var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };

        _repoMock.Setup(r => r.GetTemplateAlarmByIdAsync(300, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });

        var proposed = new TemplateAlarm("HighTemp")
        {
            TriggerType = AlarmTriggerType.RangeViolation,
            PriorityLevel = 99,
            IsInherited = false
        };
        var result = await _service.UpdateAlarmAsync(300, proposed, "admin");

        Assert.True(result.IsSuccess);
        Assert.False(result.Value.IsInherited);
        Assert.Equal(99, result.Value.PriorityLevel);
    }

    [Fact]
    public async Task UpdateAttribute_DerivedOverride_PersistsIsInheritedFalse()
    {
        var existing = new TemplateAttribute("SetPoint") { Id = 100, TemplateId = 77, DataType = DataType.Float, IsInherited = true };
        var baseTemplate = new Template("Sensor") { Id = 2 };
        baseTemplate.Attributes.Add(new TemplateAttribute("SetPoint") { Id = 10, TemplateId = 2 });
        var derived = new Template("Parent.slot") { Id = 77, ParentTemplateId = 2, IsDerived = true };

        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(77, It.IsAny<CancellationToken>())).ReturnsAsync(derived);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, derived });

        var proposed = new TemplateAttribute("SetPoint") { Value = "99", DataType = DataType.Float, IsInherited = false };
        var result = await _service.UpdateAttributeAsync(100, proposed, "admin");

        Assert.True(result.IsSuccess);
        Assert.False(result.Value.IsInherited);
        Assert.Equal("99", result.Value.Value);
    }

    [Fact]
    public async Task DeleteTemplate_BaseWithDerivatives_BlockedWithSlotNames()
    {
        var baseTemplate = new Template("Sensor") { Id = 5 };
        var parent = new Template("Pump") { Id = 1 };
        var composition = new TemplateComposition("TempSensor") { Id = 50, TemplateId = 1, ComposedTemplateId = 77 };
        parent.Compositions.Add(composition);
        var derived = new Template("Pump.TempSensor") { Id = 77, ParentTemplateId = 5, IsDerived = true, OwnerCompositionId = 50 };

        _repoMock.Setup(r => r.GetTemplateByIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);
        _repoMock.Setup(r => r.GetInstancesByTemplateIdAsync(5, It.IsAny<CancellationToken>())).ReturnsAsync(new List<Instance>());
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, parent, derived });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseTemplate, parent, derived });

        var result = await _service.DeleteTemplateAsync(5, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("derived", result.Error);
        Assert.Contains("'Pump' (as 'TempSensor')", result.Error);
    }

    [Fact]
    public async Task AddComposition_DuplicateInstanceName_Fails()
    {
        var moduleTemplate = new Template("Module") { Id = 2 };
        var template = new Template("Parent") { Id = 1 };
        template.Compositions.Add(new TemplateComposition("myModule") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });

        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(moduleTemplate);

        var result = await _service.AddCompositionAsync(1, 2, "myModule", "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("already exists", result.Error);
    }

    [Fact]
    public async Task AddComposition_SelfComposition_Fails()
    {
        var template = new Template("Self") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        var result = await _service.AddCompositionAsync(1, 1, "self", "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("compose itself", result.Error);
    }

    // ========================================================================
    // WP-9: Locking Rules
    // ========================================================================

    [Fact]
    public async Task UpdateAttribute_LockedMember_CannotUnlock()
    {
        var existing = new TemplateAttribute("Temperature")
        {
            Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = true
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAttribute("Temperature")
        {
            DataType = DataType.Float, IsLocked = false, Value = "42"
        };
        var result = await _service.UpdateAttributeAsync(1, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("cannot be unlocked", result.Error);
    }

    [Fact]
    public async Task UpdateAttribute_LockUnlockedMember_Succeeds()
    {
        var existing = new TemplateAttribute("Temperature")
        {
            Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAttribute("Temperature")
        {
            DataType = DataType.Float, IsLocked = true, Value = "42"
        };
        var result = await _service.UpdateAttributeAsync(1, proposed, "admin");

        Assert.True(result.IsSuccess);
        Assert.True(result.Value.IsLocked);
    }

    [Fact]
    public async Task UpdateAttribute_UnlockedAttribute_DataTypeChangeRejected()
    {
        // An unlocked attribute must still not be able to change its fixed DataType.
        var existing = new TemplateAttribute("Temperature")
        {
            Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAttribute("Temperature")
        {
            DataType = DataType.Int32, IsLocked = false, Value = "42"
        };
        var result = await _service.UpdateAttributeAsync(1, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("DataType", result.Error);
        // The fixed field must not have been mutated.
        Assert.Equal(DataType.Float, existing.DataType);
    }

    [Fact]
    public async Task UpdateAttribute_UnlockedAttribute_DataSourceReferenceChangeRejected()
    {
        var existing = new TemplateAttribute("Temperature")
        {
            Id = 1, TemplateId = 1, DataType = DataType.Float, IsLocked = false,
            DataSourceReference = "/Motor/Temp"
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var proposed = new TemplateAttribute("Temperature")
        {
            DataType = DataType.Float, IsLocked = false, Value = "42",
            DataSourceReference = "/Motor/Other"
        };
        var result = await _service.UpdateAttributeAsync(1, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("DataSourceReference", result.Error);
        Assert.Equal("/Motor/Temp", existing.DataSourceReference);
    }

    [Fact]
    public async Task UpdateAttribute_ParentLocked_CannotOverride()
    {
        // Parent template with locked attribute
        var parentTemplate = new Template("Base") { Id = 1 };
        parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
        {
            Id = 10, TemplateId = 1, DataType = DataType.Float, IsLocked = true
        });

        // Child template overriding same attribute
        var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
        var childAttr = new TemplateAttribute("Speed")
        {
            Id = 20, TemplateId = 2, DataType = DataType.Float, IsLocked = false
        };
        childTemplate.Attributes.Add(childAttr);

        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parentTemplate, childTemplate });

        var proposed = new TemplateAttribute("Speed")
        {
            DataType = DataType.Float, IsLocked = false, Value = "100"
        };
        var result = await _service.UpdateAttributeAsync(20, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("locked in parent", result.Error);
    }

    // ========================================================================
    // WP-10: Inheritance Override Scope — Cannot remove parent members
    // ========================================================================

    [Fact]
    public async Task DeleteAttribute_InheritedFromParent_Fails()
    {
        var parentTemplate = new Template("Base") { Id = 1 };
        parentTemplate.Attributes.Add(new TemplateAttribute("Speed")
        {
            Id = 10, TemplateId = 1, DataType = DataType.Float
        });

        var childTemplate = new Template("Child") { Id = 2, ParentTemplateId = 1 };
        var childAttr = new TemplateAttribute("Speed")
        {
            Id = 20, TemplateId = 2, DataType = DataType.Float
        };
        childTemplate.Attributes.Add(childAttr);

        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(20, It.IsAny<CancellationToken>())).ReturnsAsync(childAttr);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(childTemplate);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parentTemplate, childTemplate });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { parentTemplate, childTemplate });

        var result = await _service.DeleteAttributeAsync(20, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("inherited from parent", result.Error);
    }

    [Fact]
    public async Task DeleteAttribute_OwnMember_Succeeds()
    {
        var template = new Template("Pump") { Id = 1 };
        var attr = new TemplateAttribute("CustomAttr")
        {
            Id = 1, TemplateId = 1, DataType = DataType.String
        };
        template.Attributes.Add(attr);

        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(attr);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);

        var result = await _service.DeleteAttributeAsync(1, "admin");

        Assert.True(result.IsSuccess);
    }

    // ========================================================================
    // WP-13: Graph Acyclicity
    // ========================================================================

    [Fact]
    public async Task UpdateTemplate_ChangeParent_Fails()
    {
        var parentA = new Template("A") { Id = 1 };
        var child = new Template("Child") { Id = 2, ParentTemplateId = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);

        // Attempt to re-parent Child from A (id=1) to B (id=3).
        var result = await _service.UpdateTemplateAsync(2, "Child", null, 3, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("cannot be changed", result.Error, StringComparison.OrdinalIgnoreCase);
        _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task UpdateTemplate_OmittedParentAndDescription_LeavesUnchanged()
    {
        // Followup #5: a null parent/description means "leave unchanged" (an omitted
        // CLI/API field), not an attempt to clear. Renaming without re-passing the
        // description must not wipe it, and must not trip the parent-immutability guard.
        var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "keep me" };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { child });

        var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", null, null, "admin");

        Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
        Assert.Equal("ChildRenamed", result.Value.Name);
        Assert.Equal("keep me", result.Value.Description); // null description left unchanged
        Assert.Equal(1, result.Value.ParentTemplateId);    // parent preserved
        _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task UpdateTemplate_EmptyDescription_ClearsIt()
    {
        // An explicit empty string (distinct from omitted/null) clears the description.
        var t = new Template("T") { Id = 1, Description = "old" };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { t });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { t });

        var result = await _service.UpdateTemplateAsync(1, "T", "", null, "admin");

        Assert.True(result.IsSuccess, result.IsFailure ? result.Error : null);
        Assert.Equal("", result.Value.Description);
        _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task UpdateTemplate_SameParent_Succeeds()
    {
        var child = new Template("Child") { Id = 2, ParentTemplateId = 1, Description = "old" };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(child);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { child });

        // Idempotent pass — same parent value sent on update should succeed and apply name/description changes.
        var result = await _service.UpdateTemplateAsync(2, "ChildRenamed", "new", 1, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("ChildRenamed", result.Value.Name);
        Assert.Equal("new", result.Value.Description);
        Assert.Equal(1, result.Value.ParentTemplateId);
        _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Once);
    }

    [Fact]
    public async Task AddComposition_CircularChain_Fails()
    {
        // A composes B, B composes C, try to make C compose A => cycle
        var templateC = new Template("C") { Id = 3 };
        var templateB = new Template("B") { Id = 2 };
        templateB.Compositions.Add(new TemplateComposition("c1") { Id = 2, TemplateId = 2, ComposedTemplateId = 3 });
        var templateA = new Template("A") { Id = 1 };
        templateA.Compositions.Add(new TemplateComposition("b1") { Id = 1, TemplateId = 1, ComposedTemplateId = 2 });

        _repoMock.Setup(r => r.GetTemplateByIdAsync(3, It.IsAny<CancellationToken>())).ReturnsAsync(templateC);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(templateA);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { templateA, templateB, templateC });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { templateA, templateB, templateC });

        var result = await _service.AddCompositionAsync(3, 1, "a1", "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("cycle", result.Error, StringComparison.OrdinalIgnoreCase);
    }

    // ========================================================================
    // Move template between folders
    // ========================================================================

    [Fact]
    public async Task MoveTemplate_ToFolder_ReturnsSuccess()
    {
        var t = new Template("X") { Id = 1, FolderId = null };
        var folder = new TemplateFolder("Dev") { Id = 7 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
        _repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { t });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { t });

        var result = await _service.MoveTemplateAsync(1, 7, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(7, result.Value.FolderId);
    }

    [Fact]
    public async Task MoveTemplate_ToRoot_ReturnsSuccess()
    {
        var t = new Template("X") { Id = 1, FolderId = 7 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { t });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { t });

        var result = await _service.MoveTemplateAsync(1, null, "admin");

        Assert.True(result.IsSuccess);
        Assert.Null(result.Value.FolderId);
    }

    [Fact]
    public async Task MoveTemplate_TargetFolderMissing_ReturnsFailure()
    {
        var t = new Template("X") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(t);
        _repoMock.Setup(r => r.GetFolderByIdAsync(99, It.IsAny<CancellationToken>())).ReturnsAsync((TemplateFolder?)null);

        var result = await _service.MoveTemplateAsync(1, 99, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("not found", result.Error);
    }

    [Fact]
    public async Task MoveTemplate_NameCollisionAtDestination_Fails()
    {
        // TemplateEngine-021: moving "Pump" into a folder that already contains a
        // template named "Pump" (case-insensitive) must be rejected before the
        // FolderId is persisted. Mirrors TemplateFolderService.MoveFolderAsync
        // sibling-name uniqueness, and pins the design rule that naming
        // collisions are design-time errors.
        var moving = new Template("Pump") { Id = 1, FolderId = null };
        var existing = new Template("pump") { Id = 2, FolderId = 7 }; // case-insensitive clash
        var folder = new TemplateFolder("Dev") { Id = 7 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
        _repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { moving, existing });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { moving, existing });

        var result = await _service.MoveTemplateAsync(1, 7, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("already exists", result.Error);
        // The destination FolderId must NOT have been written when the move is rejected.
        Assert.Null(moving.FolderId);
        _repoMock.Verify(r => r.UpdateTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task MoveTemplate_NoCollisionAtDestination_Succeeds()
    {
        // TemplateEngine-021 companion: a sibling with the same name in a
        // *different* folder is not a collision. The move must succeed.
        var moving = new Template("Pump") { Id = 1, FolderId = null };
        var unrelated = new Template("Pump") { Id = 2, FolderId = 8 }; // different folder
        var folder = new TemplateFolder("Dev") { Id = 7 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(moving);
        _repoMock.Setup(r => r.GetFolderByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(folder);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { moving, unrelated });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { moving, unrelated });

        var result = await _service.MoveTemplateAsync(1, 7, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(7, result.Value.FolderId);
    }

    [Fact]
    public async Task UpdateAttribute_LockedInDerivedDowngrade_OnBase_Rejected()
    {
        // TemplateEngine-022: LockedInDerived is a one-way ratchet on base
        // templates. Once true, it cannot be cleared — otherwise existing
        // derived overrides that were previously blocked would become
        // retroactively legal.
        var baseTemplate = new Template("Sensor") { Id = 2 }; // base — not derived
        var existing = new TemplateAttribute("SetPoint")
        {
            Id = 100,
            TemplateId = 2,
            DataType = DataType.Float,
            LockedInDerived = true
        };
        _repoMock.Setup(r => r.GetTemplateAttributeByIdAsync(100, It.IsAny<CancellationToken>())).ReturnsAsync(existing);
        _repoMock.Setup(r => r.GetTemplateByIdAsync(2, It.IsAny<CancellationToken>())).ReturnsAsync(baseTemplate);

        var proposed = new TemplateAttribute("SetPoint")
        {
            DataType = DataType.Float,
            LockedInDerived = false // attempt to clear the lock
        };
        var result = await _service.UpdateAttributeAsync(100, proposed, "admin");

        Assert.True(result.IsFailure);
        Assert.Contains("locked-in-derived", result.Error);
        Assert.True(existing.LockedInDerived); // unchanged
    }

    // ========================================================================
    // TemplateEngine-020: Create* audit rows must carry the real entity Id,
    // not the literal "0" that EF Core leaves on the entity until
    // SaveChangesAsync populates the auto-generated key.
    // ========================================================================

    [Fact]
    public async Task CreateTemplate_AuditRowCarriesRealTemplateIdNotLiteralZero()
    {
        // Simulate EF Core's identity-key population: the repository's
        // AddTemplateAsync stamps Id during SaveChangesAsync. The pre-020
        // order (Add → LogAsync → Save) queued the audit row before the Id
        // existed, so every Create audit entry persisted with EntityId = "0"
        // and the audit trail lost its link back to the created template.
        // Post-020: save first, then log with the real Id, then save the
        // audit row.
        Template? added = null;
        _repoMock.Setup(r => r.AddTemplateAsync(It.IsAny<Template>(), It.IsAny<CancellationToken>()))
            .Callback<Template, CancellationToken>((t, _) => added = t)
            .Returns(Task.CompletedTask);
        _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .Callback<CancellationToken>(_ =>
            {
                // First SaveChangesAsync after Add: assign the identity key.
                if (added != null && added.Id == 0) added.Id = 42;
            })
            .ReturnsAsync(1);

        string? auditedEntityId = null;
        _auditMock.Setup(a => a.LogAsync(
                It.IsAny<string>(),
                "Create",
                "Template",
                It.IsAny<string>(),
                It.IsAny<string>(),
                It.IsAny<object?>(),
                It.IsAny<CancellationToken>()))
            .Callback<string, string, string, string, string, object?, CancellationToken>(
                (_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
            .Returns(Task.CompletedTask);

        var result = await _service.CreateTemplateAsync("Pump", null, null, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("42", auditedEntityId);
        Assert.NotEqual("0", auditedEntityId);
    }

    [Fact]
    public async Task AddAttribute_AuditRowCarriesRealAttributeIdNotLiteralZero()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        TemplateAttribute? added = null;
        _repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAttribute, CancellationToken>((a, _) => added = a)
            .Returns(Task.CompletedTask);
        _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .Callback<CancellationToken>(_ =>
            {
                if (added != null && added.Id == 0) added.Id = 77;
            })
            .ReturnsAsync(1);

        string? auditedEntityId = null;
        _auditMock.Setup(a => a.LogAsync(
                It.IsAny<string>(),
                "Create",
                "TemplateAttribute",
                It.IsAny<string>(),
                It.IsAny<string>(),
                It.IsAny<object?>(),
                It.IsAny<CancellationToken>()))
            .Callback<string, string, string, string, string, object?, CancellationToken>(
                (_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
            .Returns(Task.CompletedTask);

        var attr = new TemplateAttribute("Temperature") { DataType = DataType.Float, Value = "0.0" };
        var result = await _service.AddAttributeAsync(1, attr, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("77", auditedEntityId);
        Assert.NotEqual("0", auditedEntityId);
    }

    [Fact]
    public async Task AddAlarm_AuditRowCarriesRealAlarmIdNotLiteralZero()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        TemplateAlarm? added = null;
        _repoMock.Setup(r => r.AddTemplateAlarmAsync(It.IsAny<TemplateAlarm>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAlarm, CancellationToken>((a, _) => added = a)
            .Returns(Task.CompletedTask);
        _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .Callback<CancellationToken>(_ =>
            {
                if (added != null && added.Id == 0) added.Id = 99;
            })
            .ReturnsAsync(1);

        string? auditedEntityId = null;
        _auditMock.Setup(a => a.LogAsync(
                It.IsAny<string>(),
                "Create",
                "TemplateAlarm",
                It.IsAny<string>(),
                It.IsAny<string>(),
                It.IsAny<object?>(),
                It.IsAny<CancellationToken>()))
            .Callback<string, string, string, string, string, object?, CancellationToken>(
                (_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
            .Returns(Task.CompletedTask);

        var alarm = new TemplateAlarm("HighTemp")
        {
            PriorityLevel = 500,
            TriggerType = AlarmTriggerType.RangeViolation,
            TriggerConfiguration = """{"Max": 100}"""
        };
        var result = await _service.AddAlarmAsync(1, alarm, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("99", auditedEntityId);
        Assert.NotEqual("0", auditedEntityId);
    }

    [Fact]
    public async Task AddScript_AuditRowCarriesRealScriptIdNotLiteralZero()
    {
        var template = new Template("Pump") { Id = 1 };
        _repoMock.Setup(r => r.GetTemplateByIdAsync(1, It.IsAny<CancellationToken>())).ReturnsAsync(template);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { template });

        TemplateScript? added = null;
        _repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateScript, CancellationToken>((s, _) => added = s)
            .Returns(Task.CompletedTask);
        _repoMock.Setup(r => r.SaveChangesAsync(It.IsAny<CancellationToken>()))
            .Callback<CancellationToken>(_ =>
            {
                if (added != null && added.Id == 0) added.Id = 123;
            })
            .ReturnsAsync(1);

        string? auditedEntityId = null;
        _auditMock.Setup(a => a.LogAsync(
                It.IsAny<string>(),
                "Create",
                "TemplateScript",
                It.IsAny<string>(),
                It.IsAny<string>(),
                It.IsAny<object?>(),
                It.IsAny<CancellationToken>()))
            .Callback<string, string, string, string, string, object?, CancellationToken>(
                (_, _, _, entityId, _, _, _) => auditedEntityId = entityId)
            .Returns(Task.CompletedTask);

        var script = new TemplateScript("OnStart", "return true;") { TriggerType = "Startup" };
        var result = await _service.AddScriptAsync(1, script, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal("123", auditedEntityId);
        Assert.NotEqual("0", auditedEntityId);
    }

    // ========================================================================
    // Follow-up #1/#2: inherited-member propagation & resync
    // ========================================================================

    private static Template BaseTemplate(int id, string name) => new(name) { Id = id };
    private static Template DerivedTemplate(int id, string name, int parentId) =>
        new(name) { Id = id, ParentTemplateId = parentId, IsDerived = true };

    [Fact]
    public async Task Resync_MaterializesMissingInheritedAttribute()
    {
        // The reported #1/#2 shape: a base member added after the child was derived
        // never reached the child's stored rows. Resync materializes it.
        var baseT = BaseTemplate(7, "Base");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
        baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });

        var child = DerivedTemplate(8, "Child", 7);
        child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        var added = new List<TemplateAttribute>();
        _repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
            .Returns(Task.CompletedTask);

        var result = await _service.ResyncInheritedMembersAsync(8, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(1, result.Value.MembersAdded);
        Assert.Equal(1, result.Value.TemplatesChanged);
        var b = Assert.Single(added);
        Assert.Equal("B", b.Name);
        Assert.Equal(8, b.TemplateId);
        Assert.True(b.IsInherited);
        Assert.Equal("x", b.Value);
        Assert.Equal(DataType.String, b.DataType);
    }

    [Fact]
    public async Task Resync_RemovesOrphanedInheritedRow()
    {
        var baseT = BaseTemplate(7, "Base");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });

        var child = DerivedTemplate(8, "Child", 7);
        child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
        // Orphan: an inherited placeholder whose base member was removed.
        child.Attributes.Add(new TemplateAttribute("Gone") { Id = 81, TemplateId = 8, DataType = DataType.String, Value = "old", IsInherited = true });

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        var deleted = new List<int>();
        _repoMock.Setup(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()))
            .Callback<int, CancellationToken>((id, _) => deleted.Add(id))
            .Returns(Task.CompletedTask);

        var result = await _service.ResyncInheritedMembersAsync(8, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(1, result.Value.MembersRemoved);
        Assert.Equal(81, Assert.Single(deleted));
    }

    [Fact]
    public async Task Resync_UpdatesDriftedInheritedRow()
    {
        var baseT = BaseTemplate(7, "Base");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "NEW" });

        var child = DerivedTemplate(8, "Child", 7);
        var childA = new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "OLD", IsInherited = true };
        child.Attributes.Add(childA);

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        var updated = new List<TemplateAttribute>();
        _repoMock.Setup(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAttribute, CancellationToken>((a, _) => updated.Add(a))
            .Returns(Task.CompletedTask);

        var result = await _service.ResyncInheritedMembersAsync(8, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(1, result.Value.MembersUpdated);
        Assert.Same(childA, Assert.Single(updated));
        Assert.Equal("NEW", childA.Value); // re-synced in place to the live base value
    }

    [Fact]
    public async Task Resync_LeavesAuthoredOverridesUntouched()
    {
        var baseT = BaseTemplate(7, "Base");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.String, Value = "base" });

        var child = DerivedTemplate(8, "Child", 7);
        // Authored override (IsInherited == false) with a divergent value.
        child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.String, Value = "override", IsInherited = false });

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });

        var result = await _service.ResyncInheritedMembersAsync(8, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(0, result.Value.MembersAdded);
        Assert.Equal(0, result.Value.MembersUpdated);
        Assert.Equal(0, result.Value.MembersRemoved);
        _repoMock.Verify(r => r.UpdateTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()), Times.Never);
        _repoMock.Verify(r => r.DeleteTemplateAttributeAsync(It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
    }

    [Fact]
    public async Task Resync_OnBase_CascadesToAllDerivedChildren()
    {
        var baseT = BaseTemplate(7, "ReactorSide");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
        baseT.Attributes.Add(new TemplateAttribute("B") { Id = 71, TemplateId = 7, DataType = DataType.String, Value = "x" });

        var left = DerivedTemplate(8, "Left", 7);
        left.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });
        var right = DerivedTemplate(9, "Right", 7);
        right.Attributes.Add(new TemplateAttribute("A") { Id = 90, TemplateId = 9, DataType = DataType.Float, Value = "1", IsInherited = true });

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, left, right });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, left, right });
        var added = new List<TemplateAttribute>();
        _repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a))
            .Returns(Task.CompletedTask);

        // Resyncing the base repairs the whole subtree.
        var result = await _service.ResyncInheritedMembersAsync(7, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(2, result.Value.MembersAdded);     // B materialized on Left and Right
        Assert.Equal(2, result.Value.TemplatesChanged);
        Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "B" && a.IsInherited);
        Assert.Contains(added, a => a.TemplateId == 9 && a.Name == "B" && a.IsInherited);
    }

    [Fact]
    public async Task Resync_MaterializesMissingInheritedScriptAndNativeSource()
    {
        // Scripts and native alarm sources use the same inherit model and are
        // counted by the staleness summary, so reconcile must materialize them too.
        var baseT = BaseTemplate(7, "Base");
        baseT.Scripts.Add(new TemplateScript("OnStart", "return 1;") { Id = 70, TemplateId = 7, TriggerType = "Startup" });
        baseT.NativeAlarmSources.Add(new TemplateNativeAlarmSource("Src") { Id = 75, TemplateId = 7, ConnectionName = "opc", SourceReference = "ns=2;s=X" });

        var child = DerivedTemplate(8, "Child", 7); // no inherited rows yet

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        var addedScripts = new List<TemplateScript>();
        var addedSources = new List<TemplateNativeAlarmSource>();
        _repoMock.Setup(r => r.AddTemplateScriptAsync(It.IsAny<TemplateScript>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateScript, CancellationToken>((s, _) => addedScripts.Add(s)).Returns(Task.CompletedTask);
        _repoMock.Setup(r => r.AddTemplateNativeAlarmSourceAsync(It.IsAny<TemplateNativeAlarmSource>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateNativeAlarmSource, CancellationToken>((s, _) => addedSources.Add(s)).Returns(Task.CompletedTask);

        var result = await _service.ResyncInheritedMembersAsync(8, "admin");

        Assert.True(result.IsSuccess);
        Assert.Equal(2, result.Value.MembersAdded);
        var script = Assert.Single(addedScripts);
        Assert.Equal("OnStart", script.Name);
        Assert.Equal("return 1;", script.Code);
        Assert.True(script.IsInherited);
        Assert.Equal(8, script.TemplateId);
        var src = Assert.Single(addedSources);
        Assert.Equal("Src", src.Name);
        Assert.Equal("ns=2;s=X", src.SourceReference);
        Assert.True(src.IsInherited);
        Assert.Equal(8, src.TemplateId);
    }

    [Fact]
    public async Task ReconcileDescendants_PropagatesNewBaseMemberToChild()
    {
        // Base already carries the new member C; reconciling its descendants
        // materializes C onto the child (the engine behind auto-propagation).
        var baseT = BaseTemplate(7, "Base");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
        baseT.Attributes.Add(new TemplateAttribute("C") { Id = 72, TemplateId = 7, DataType = DataType.String, Value = "c" });

        var child = DerivedTemplate(8, "Child", 7);
        child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });

        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        var added = new List<TemplateAttribute>();
        _repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAttribute, CancellationToken>((a, _) => added.Add(a)).Returns(Task.CompletedTask);

        await _service.ReconcileDescendantsAsync(7, "admin");

        var c = Assert.Single(added);
        Assert.Equal("C", c.Name);
        Assert.Equal(8, c.TemplateId);
        Assert.True(c.IsInherited);
    }

    [Fact]
    public async Task AddAttribute_PropagatesNewMemberToDerivedChild()
    {
        // End-to-end: adding a member to a base auto-propagates an inherited
        // placeholder to its derived children (#1/#2).
        var baseT = BaseTemplate(7, "Base");
        baseT.Attributes.Add(new TemplateAttribute("A") { Id = 70, TemplateId = 7, DataType = DataType.Float, Value = "1" });
        var child = DerivedTemplate(8, "Child", 7);
        child.Attributes.Add(new TemplateAttribute("A") { Id = 80, TemplateId = 8, DataType = DataType.Float, Value = "1", IsInherited = true });

        _repoMock.Setup(r => r.GetTemplateByIdAsync(7, It.IsAny<CancellationToken>())).ReturnsAsync(baseT);
        _repoMock.Setup(r => r.GetAllTemplatesAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });
        _repoMock.Setup(r => r.GetAllTemplatesForAnalysisAsync(It.IsAny<CancellationToken>()))
            .ReturnsAsync(new List<Template> { baseT, child });

        var added = new List<TemplateAttribute>();
        _repoMock.Setup(r => r.AddTemplateAttributeAsync(It.IsAny<TemplateAttribute>(), It.IsAny<CancellationToken>()))
            .Callback<TemplateAttribute, CancellationToken>((a, _) =>
            {
                added.Add(a);
                // Simulate persistence so the post-add reconcile re-fetch sees the
                // base's new member and propagates it down.
                if (a.TemplateId == 7) baseT.Attributes.Add(a);
            })
            .Returns(Task.CompletedTask);

        var newAttr = new TemplateAttribute("C") { DataType = DataType.String, Value = "c" };
        var result = await _service.AddAttributeAsync(7, newAttr, "admin");

        Assert.True(result.IsSuccess);
        Assert.Contains(added, a => a.TemplateId == 7 && a.Name == "C" && !a.IsInherited); // base's own
        Assert.Contains(added, a => a.TemplateId == 8 && a.Name == "C" && a.IsInherited);  // child placeholder
    }
}
