using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Repositories;
using ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Services;

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

// Regression coverage for ConfigurationDatabase-010 (repositories / InstanceLocator lacked
// direct tests) and ConfigurationDatabase-011 (inconsistent constructor null-guarding).

public class ExternalSystemRepositoryTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;
    private readonly ExternalSystemRepository _repository;

    public ExternalSystemRepositoryTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
        _repository = new ExternalSystemRepository(_context);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public async Task AddExternalSystem_AndGetById_RoundTrips()
    {
        var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
        await _repository.AddExternalSystemAsync(def);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetExternalSystemByIdAsync(def.Id);
        Assert.NotNull(loaded);
        Assert.Equal("Sys", loaded!.Name);
    }

    [Fact]
    public async Task AddExternalSystem_TimeoutSeconds_RoundTrips()
    {
        var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey")
        {
            TimeoutSeconds = 15,
        };
        await _repository.AddExternalSystemAsync(def);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetExternalSystemByIdAsync(def.Id);
        Assert.NotNull(loaded);
        Assert.Equal(15, loaded!.TimeoutSeconds);
    }

    [Fact]
    public async Task GetMethodsByExternalSystemId_FiltersByParent()
    {
        var def = new ExternalSystemDefinition("Sys", "https://example.test", "ApiKey");
        await _repository.AddExternalSystemAsync(def);
        await _repository.SaveChangesAsync();

        await _repository.AddExternalSystemMethodAsync(
            new ExternalSystemMethod("M1", "GET", "/m1") { ExternalSystemDefinitionId = def.Id });
        await _repository.SaveChangesAsync();

        var methods = await _repository.GetMethodsByExternalSystemIdAsync(def.Id);
        Assert.Single(methods);
    }

    [Fact]
    public async Task DeleteDatabaseConnection_RemovesEntity()
    {
        var conn = new DatabaseConnectionDefinition("Db", "Server=x;Database=y;");
        await _repository.AddDatabaseConnectionAsync(conn);
        await _repository.SaveChangesAsync();

        await _repository.DeleteDatabaseConnectionAsync(conn.Id);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetDatabaseConnectionByIdAsync(conn.Id));
    }

    [Fact]
    public void Constructor_NullContext_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new ExternalSystemRepository(null!));
    }

    // ── ExternalSystemGateway-011: name-keyed repository lookups ──

    [Fact]
    public async Task GetExternalSystemByName_ReturnsMatchingRow()
    {
        await _repository.AddExternalSystemAsync(
            new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey"));
        await _repository.AddExternalSystemAsync(
            new ExternalSystemDefinition("Beta", "https://beta.test", "Basic"));
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetExternalSystemByNameAsync("Beta");

        Assert.NotNull(loaded);
        Assert.Equal("Beta", loaded!.Name);
        Assert.Equal("https://beta.test", loaded.EndpointUrl);
    }

    [Fact]
    public async Task GetExternalSystemByName_MissingName_ReturnsNull()
    {
        await _repository.AddExternalSystemAsync(
            new ExternalSystemDefinition("Alpha", "https://alpha.test", "ApiKey"));
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetExternalSystemByNameAsync("DoesNotExist"));
    }

    [Fact]
    public async Task GetMethodByName_ReturnsMethodScopedToParentSystem()
    {
        var sysA = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey");
        var sysB = new ExternalSystemDefinition("SysB", "https://b.test", "ApiKey");
        await _repository.AddExternalSystemAsync(sysA);
        await _repository.AddExternalSystemAsync(sysB);
        await _repository.SaveChangesAsync();

        // Same method name on two different systems — the lookup must be scoped.
        await _repository.AddExternalSystemMethodAsync(
            new ExternalSystemMethod("getData", "GET", "/a") { ExternalSystemDefinitionId = sysA.Id });
        await _repository.AddExternalSystemMethodAsync(
            new ExternalSystemMethod("getData", "POST", "/b") { ExternalSystemDefinitionId = sysB.Id });
        await _repository.SaveChangesAsync();

        var method = await _repository.GetMethodByNameAsync(sysB.Id, "getData");

        Assert.NotNull(method);
        Assert.Equal(sysB.Id, method!.ExternalSystemDefinitionId);
        Assert.Equal("POST", method.HttpMethod);
        Assert.Equal("/b", method.Path);
    }

    [Fact]
    public async Task GetMethodByName_MissingMethod_ReturnsNull()
    {
        var sys = new ExternalSystemDefinition("SysA", "https://a.test", "ApiKey");
        await _repository.AddExternalSystemAsync(sys);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetMethodByNameAsync(sys.Id, "noSuchMethod"));
    }

    [Fact]
    public async Task GetDatabaseConnectionByName_ReturnsMatchingRow()
    {
        await _repository.AddDatabaseConnectionAsync(
            new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;"));
        await _repository.AddDatabaseConnectionAsync(
            new DatabaseConnectionDefinition("Historian", "Server=hist;Database=h;"));
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetDatabaseConnectionByNameAsync("Historian");

        Assert.NotNull(loaded);
        Assert.Equal("Historian", loaded!.Name);
        Assert.Equal("Server=hist;Database=h;", loaded.ConnectionString);
    }

    [Fact]
    public async Task GetDatabaseConnectionByName_MissingName_ReturnsNull()
    {
        await _repository.AddDatabaseConnectionAsync(
            new DatabaseConnectionDefinition("Plant", "Server=plant;Database=p;"));
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetDatabaseConnectionByNameAsync("DoesNotExist"));
    }
}

public class NotificationRepositoryTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;
    private readonly NotificationRepository _repository;

    public NotificationRepositoryTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
        _repository = new NotificationRepository(_context);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public async Task AddNotificationList_WithRecipients_RoundTrips()
    {
        var list = new NotificationList("Ops");
        list.Recipients.Add(new NotificationRecipient("Ops Team", "ops@example.test"));
        await _repository.AddNotificationListAsync(list);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetListByNameAsync("Ops");
        Assert.NotNull(loaded);

        var all = await _repository.GetAllNotificationListsAsync();
        Assert.Single(all);
        Assert.Single(all[0].Recipients);
    }

    [Fact]
    public async Task AddSmtpConfiguration_AndGetById_RoundTrips()
    {
        var smtp = new SmtpConfiguration("smtp.example.test", "Basic", "from@example.test");
        await _repository.AddSmtpConfigurationAsync(smtp);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetSmtpConfigurationByIdAsync(smtp.Id);
        Assert.NotNull(loaded);
        Assert.Equal("smtp.example.test", loaded!.Host);
    }

    [Fact]
    public async Task AddSmsConfiguration_AndGetById_RoundTrips()
    {
        var sms = new SmsConfiguration("ACbyid123", "+14155550111");
        await _repository.AddSmsConfigurationAsync(sms);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetSmsConfigurationByIdAsync(sms.Id);
        Assert.NotNull(loaded);
        Assert.Equal("ACbyid123", loaded!.AccountSid);
    }

    [Fact]
    public async Task DeleteNotificationList_RemovesEntity()
    {
        var list = new NotificationList("ToDelete");
        await _repository.AddNotificationListAsync(list);
        await _repository.SaveChangesAsync();

        await _repository.DeleteNotificationListAsync(list.Id);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetNotificationListByIdAsync(list.Id));
    }

    [Fact]
    public async Task AddSmsConfiguration_RoundTrips()
    {
        var sms = new SmsConfiguration("ACtest123", "+14155550100");
        await _repository.AddSmsConfigurationAsync(sms);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetSmsConfigurationAsync();
        Assert.NotNull(loaded);
        Assert.Equal("ACtest123", loaded!.AccountSid);
        Assert.Equal("+14155550100", loaded.FromNumber);

        // Update a field and verify GetAllSmsConfigurationsAsync reflects it.
        loaded.FromNumber = "+14155550199";
        await _repository.UpdateSmsConfigurationAsync(loaded);
        await _repository.SaveChangesAsync();

        var all = await _repository.GetAllSmsConfigurationsAsync();
        Assert.Single(all);
        Assert.Equal("+14155550199", all[0].FromNumber);

        // Delete and verify removal.
        await _repository.DeleteSmsConfigurationAsync(loaded.Id);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetSmsConfigurationAsync());
    }

    [Fact]
    public async Task NotificationList_PersistsType()
    {
        var list = new NotificationList("ops") { Type = NotificationType.Email };
        await _repository.AddNotificationListAsync(list);
        await _repository.SaveChangesAsync();
        _context.ChangeTracker.Clear();
        var loaded = await _repository.GetListByNameAsync("ops");
        Assert.Equal(NotificationType.Email, loaded!.Type);
    }

    [Fact]
    public void Constructor_NullContext_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new NotificationRepository(null!));
    }
}

public class NotificationOutboxConfigurationTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;

    public NotificationOutboxConfigurationTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public async Task Notification_FullyPopulated_RoundTrips()
    {
        var id = Guid.NewGuid().ToString();
        var siteEnqueuedAt = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
        var createdAt = new DateTimeOffset(2026, 5, 19, 8, 0, 5, TimeSpan.Zero);
        var lastAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 1, 0, TimeSpan.Zero);
        var nextAttemptAt = new DateTimeOffset(2026, 5, 19, 8, 2, 0, TimeSpan.Zero);
        var deliveredAt = new DateTimeOffset(2026, 5, 19, 8, 3, 0, TimeSpan.Zero);
        var originExecutionId = Guid.NewGuid();
        var originParentExecutionId = Guid.NewGuid();

        var notification = new Notification(id, NotificationType.Email, "Ops List",
            "High Tank Level", "Tank 4 exceeded the high level threshold.", "site-north")
        {
            TypeData = "{\"channel\":\"email\"}",
            Status = NotificationStatus.Retrying,
            RetryCount = 3,
            LastError = "SMTP timeout",
            ResolvedTargets = "ops@example.test;duty@example.test",
            SourceInstanceId = "instance-42",
            SourceScript = "TankLevelAlarm",
            OriginExecutionId = originExecutionId,
            OriginParentExecutionId = originParentExecutionId,
            SiteEnqueuedAt = siteEnqueuedAt,
            CreatedAt = createdAt,
            LastAttemptAt = lastAttemptAt,
            NextAttemptAt = nextAttemptAt,
            DeliveredAt = deliveredAt,
        };

        _context.Notifications.Add(notification);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _context.Notifications.FindAsync(id);

        Assert.NotNull(loaded);
        Assert.Equal(id, loaded!.NotificationId);
        Assert.Equal(NotificationType.Email, loaded.Type);
        Assert.Equal(NotificationStatus.Retrying, loaded.Status);
        Assert.Equal("Ops List", loaded.ListName);
        Assert.Equal("High Tank Level", loaded.Subject);
        Assert.Equal("Tank 4 exceeded the high level threshold.", loaded.Body);
        Assert.Equal("{\"channel\":\"email\"}", loaded.TypeData);
        Assert.Equal(3, loaded.RetryCount);
        Assert.Equal("SMTP timeout", loaded.LastError);
        Assert.Equal("ops@example.test;duty@example.test", loaded.ResolvedTargets);
        Assert.Equal("site-north", loaded.SourceSiteId);
        Assert.Equal("instance-42", loaded.SourceInstanceId);
        Assert.Equal("TankLevelAlarm", loaded.SourceScript);
        Assert.Equal(siteEnqueuedAt, loaded.SiteEnqueuedAt);
        Assert.Equal(createdAt, loaded.CreatedAt);
        Assert.Equal(lastAttemptAt, loaded.LastAttemptAt);
        Assert.Equal(nextAttemptAt, loaded.NextAttemptAt);
        Assert.Equal(deliveredAt, loaded.DeliveredAt);
        Assert.Equal(originExecutionId, loaded.OriginExecutionId);
        Assert.Equal(originParentExecutionId, loaded.OriginParentExecutionId);
    }

    [Fact]
    public async Task Notification_NullOriginExecutionId_RoundTripsAsNull()
    {
        // Audit Log #23: OriginExecutionId is an additive nullable column —
        // notifications raised outside a script execution (or submitted before
        // the column existed) persist and reload it as null.
        var id = Guid.NewGuid().ToString();
        var notification = new Notification(id, NotificationType.Email, "Ops List",
            "Subject", "Body", "site-north");

        _context.Notifications.Add(notification);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _context.Notifications.FindAsync(id);

        Assert.NotNull(loaded);
        Assert.Null(loaded!.OriginExecutionId);
    }

    [Fact]
    public async Task Notification_NullOriginParentExecutionId_RoundTripsAsNull()
    {
        // Audit Log ParentExecutionId: OriginParentExecutionId is an additive
        // nullable column — notifications from non-routed runs (or submitted
        // before the column existed) persist and reload it as null.
        var id = Guid.NewGuid().ToString();
        var notification = new Notification(id, NotificationType.Email, "Ops List",
            "Subject", "Body", "site-north");

        _context.Notifications.Add(notification);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _context.Notifications.FindAsync(id);

        Assert.NotNull(loaded);
        Assert.Null(loaded!.OriginParentExecutionId);
    }

    [Fact]
    public async Task Notification_StatusPersistsAsString()
    {
        var id = Guid.NewGuid().ToString();
        var notification = new Notification(id, NotificationType.Email, "Ops List",
            "Subject", "Body", "site-north")
        {
            Status = NotificationStatus.Parked,
        };

        _context.Notifications.Add(notification);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var statusText = await _context.Database
            .SqlQuery<string>($"SELECT Status AS Value FROM Notifications WHERE NotificationId = {id}")
            .SingleAsync();

        Assert.Equal("Parked", statusText);
    }
}

// Coverage for the Notification Outbox repository (Task 5 of the notification-outbox feature).
public class NotificationOutboxRepositoryTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;
    private readonly NotificationOutboxRepository _repository;

    public NotificationOutboxRepositoryTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
        _repository = new NotificationOutboxRepository(_context);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    private static Notification MakeNotification(
        string id,
        NotificationStatus status = NotificationStatus.Pending,
        DateTimeOffset? createdAt = null,
        DateTimeOffset? nextAttemptAt = null,
        DateTimeOffset? deliveredAt = null,
        string listName = "Ops List",
        string subject = "Subject",
        string sourceSiteId = "site-north",
        NotificationType type = NotificationType.Email)
    {
        return new Notification(id, type, listName, subject, "Body", sourceSiteId)
        {
            Status = status,
            CreatedAt = createdAt ?? new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero),
            NextAttemptAt = nextAttemptAt,
            DeliveredAt = deliveredAt,
        };
    }

    // InsertIfNotExistsAsync coverage lives in
    // tests/ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests/Repositories/NotificationOutboxRepositoryIntegrationTests.cs
    // — the method is raw-SQL (IF NOT EXISTS … INSERT) so it must execute against
    // SQL Server, not the SQLite in-memory provider this class uses.

    [Fact]
    public async Task GetDueAsync_ReturnsPendingAndDueRetrying_OrderedByCreatedAt_CappedAtBatchSize()
    {
        var now = new DateTimeOffset(2026, 5, 19, 12, 0, 0, TimeSpan.Zero);

        var pendingOld = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: now.AddMinutes(-30));
        var pendingNew = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: now.AddMinutes(-10));
        var retryingDue = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
            createdAt: now.AddMinutes(-20), nextAttemptAt: now.AddMinutes(-1));
        var retryingNotDue = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
            createdAt: now.AddMinutes(-25), nextAttemptAt: now.AddMinutes(5));
        var delivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
            createdAt: now.AddMinutes(-40));
        var parked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
            createdAt: now.AddMinutes(-45));

        _context.Notifications.AddRange(pendingOld, pendingNew, retryingDue, retryingNotDue, delivered, parked);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var due = await _repository.GetDueAsync(now, batchSize: 10);

        // pendingOld, retryingDue, pendingNew are due; ordered by CreatedAt ascending.
        Assert.Equal(
            new[] { pendingOld.NotificationId, retryingDue.NotificationId, pendingNew.NotificationId },
            due.Select(n => n.NotificationId).ToArray());

        var capped = await _repository.GetDueAsync(now, batchSize: 2);
        Assert.Equal(2, capped.Count);
        Assert.Equal(pendingOld.NotificationId, capped[0].NotificationId);
        Assert.Equal(retryingDue.NotificationId, capped[1].NotificationId);
    }

    [Fact]
    public async Task GetByIdAsync_ReturnsRowOrNull()
    {
        var id = Guid.NewGuid().ToString();
        _context.Notifications.Add(MakeNotification(id));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        Assert.NotNull(await _repository.GetByIdAsync(id));
        Assert.Null(await _repository.GetByIdAsync("does-not-exist"));
    }

    [Fact]
    public async Task UpdateAsync_PersistsStatusTransition()
    {
        var id = Guid.NewGuid().ToString();
        _context.Notifications.Add(MakeNotification(id, NotificationStatus.Pending));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _repository.GetByIdAsync(id);
        loaded!.Status = NotificationStatus.Delivered;
        loaded.DeliveredAt = new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero);
        Assert.True(await _repository.UpdateAsync(loaded));

        _context.ChangeTracker.Clear();
        var reloaded = await _context.Notifications.FindAsync(id);
        Assert.Equal(NotificationStatus.Delivered, reloaded!.Status);
        Assert.Equal(new DateTimeOffset(2026, 5, 19, 9, 0, 0, TimeSpan.Zero), reloaded.DeliveredAt);
    }

    /// <summary>
    /// The write is a targeted UPDATE that bypasses the change tracker, so a row
    /// deleted between the read and the write simply matches nothing. The row
    /// count is the ONLY not-found signal available, and callers (the operator
    /// retry/discard one-shots) depend on it to avoid reporting success against a
    /// purged notification.
    /// </summary>
    [Fact]
    public async Task UpdateAsync_RowPurgedBeforeWrite_ReturnsFalse()
    {
        var id = Guid.NewGuid().ToString();
        _context.Notifications.Add(MakeNotification(id, NotificationStatus.Parked));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _repository.GetByIdAsync(id);
        Assert.NotNull(loaded);

        // The daily retention purge removes the row while the operator's request
        // is in flight.
        await _context.Notifications.Where(n => n.NotificationId == id).ExecuteDeleteAsync();
        _context.ChangeTracker.Clear();

        loaded!.Status = NotificationStatus.Pending;
        Assert.False(await _repository.UpdateAsync(loaded));
    }

    [Fact]
    public async Task QueryAsync_AppliesFilters_OrdersByCreatedAtDescending_AndPaginates()
    {
        var baseTime = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);

        // 5 matching rows for site-north / Ops List, plus noise.
        for (var i = 0; i < 5; i++)
        {
            _context.Notifications.Add(MakeNotification(
                Guid.NewGuid().ToString(), NotificationStatus.Pending,
                createdAt: baseTime.AddMinutes(i),
                subject: $"Tank Level {i}"));
        }
        _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
            sourceSiteId: "site-south", subject: "Other Site"));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var filter = new NotificationOutboxFilter(SourceSiteId: "site-north", ListName: "Ops List");
        var (rows, total) = await _repository.QueryAsync(filter, pageNumber: 1, pageSize: 3);

        Assert.Equal(5, total);
        Assert.Equal(3, rows.Count);
        // Descending by CreatedAt: Tank Level 4, 3, 2.
        Assert.Equal("Tank Level 4", rows[0].Subject);
        Assert.Equal("Tank Level 3", rows[1].Subject);
        Assert.Equal("Tank Level 2", rows[2].Subject);

        var (page2, _) = await _repository.QueryAsync(filter, pageNumber: 2, pageSize: 3);
        Assert.Equal(2, page2.Count);
        Assert.Equal("Tank Level 1", page2[0].Subject);
        Assert.Equal("Tank Level 0", page2[1].Subject);
    }

    [Fact]
    public async Task QueryAsync_SubjectKeyword_UsesContains()
    {
        _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), subject: "Tank Level High"));
        _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), subject: "Pump Failure"));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var (rows, total) = await _repository.QueryAsync(
            new NotificationOutboxFilter(SubjectKeyword: "Level"), pageNumber: 1, pageSize: 10);

        Assert.Equal(1, total);
        Assert.Equal("Tank Level High", rows[0].Subject);
    }

    [Fact]
    public async Task QueryAsync_StuckOnly_ReturnsNonTerminalRowsOlderThanCutoff()
    {
        var cutoff = new DateTimeOffset(2026, 5, 19, 10, 0, 0, TimeSpan.Zero);

        var stuckPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: cutoff.AddHours(-2), subject: "Stuck Pending");
        var stuckRetrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
            createdAt: cutoff.AddHours(-3), subject: "Stuck Retrying");
        var freshPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: cutoff.AddHours(1), subject: "Fresh");
        var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
            createdAt: cutoff.AddHours(-5), subject: "Old Delivered");

        _context.Notifications.AddRange(stuckPending, stuckRetrying, freshPending, oldDelivered);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var (rows, total) = await _repository.QueryAsync(
            new NotificationOutboxFilter(StuckOnly: true, StuckCutoff: cutoff),
            pageNumber: 1, pageSize: 10);

        Assert.Equal(2, total);
        Assert.Equal(
            new[] { "Stuck Pending", "Stuck Retrying" },
            rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
    }

    [Fact]
    public async Task QueryAsync_FromTo_FilterAgainstCreatedAt()
    {
        var baseTime = new DateTimeOffset(2026, 5, 19, 8, 0, 0, TimeSpan.Zero);
        for (var i = 0; i < 5; i++)
        {
            _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
                createdAt: baseTime.AddHours(i), subject: $"Row {i}"));
        }
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var (rows, total) = await _repository.QueryAsync(
            new NotificationOutboxFilter(From: baseTime.AddHours(1), To: baseTime.AddHours(3)),
            pageNumber: 1, pageSize: 10);

        Assert.Equal(3, total);
        Assert.Equal(
            new[] { "Row 1", "Row 2", "Row 3" },
            rows.Select(r => r.Subject).OrderBy(s => s).ToArray());
    }

    [Fact]
    public async Task QueryAsync_StatusAndTypeFilters()
    {
        _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
            subject: "Parked Row"));
        _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            subject: "Pending Row"));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var (rows, total) = await _repository.QueryAsync(
            new NotificationOutboxFilter(Status: NotificationStatus.Parked, Type: NotificationType.Email),
            pageNumber: 1, pageSize: 10);

        Assert.Equal(1, total);
        Assert.Equal("Parked Row", rows[0].Subject);
    }

    [Fact]
    public async Task DeleteTerminalOlderThanAsync_DeletesTerminalRowsOlderThanCutoff_LeavesOthers()
    {
        var cutoff = new DateTimeOffset(2026, 5, 19, 10, 0, 0, TimeSpan.Zero);

        var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
            createdAt: cutoff.AddHours(-1));
        var oldParked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
            createdAt: cutoff.AddHours(-2));
        var oldDiscarded = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Discarded,
            createdAt: cutoff.AddHours(-3));
        var recentDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
            createdAt: cutoff.AddHours(1));
        var oldPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: cutoff.AddHours(-4));

        _context.Notifications.AddRange(oldDelivered, oldParked, oldDiscarded, recentDelivered, oldPending);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var deleted = await _repository.DeleteTerminalOlderThanAsync(cutoff);

        Assert.Equal(3, deleted);
        var remaining = await _context.Notifications.Select(n => n.NotificationId).ToListAsync();
        Assert.Equal(2, remaining.Count);
        Assert.Contains(recentDelivered.NotificationId, remaining);
        Assert.Contains(oldPending.NotificationId, remaining);
    }

    [Fact]
    public async Task ComputeKpisAsync_ComputesSnapshotFields()
    {
        var now = DateTimeOffset.UtcNow;
        var stuckCutoff = now.AddMinutes(-30);
        var deliveredSince = now.AddHours(-1);

        var oldestPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: now.AddHours(-2));   // stuck
        var freshPending = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Pending,
            createdAt: now.AddMinutes(-5)); // not stuck
        var stuckRetrying = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Retrying,
            createdAt: now.AddMinutes(-45)); // stuck
        var parked = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Parked,
            createdAt: now.AddHours(-3));
        var recentlyDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
            createdAt: now.AddHours(-4), deliveredAt: now.AddMinutes(-10));
        var oldDelivered = MakeNotification(Guid.NewGuid().ToString(), NotificationStatus.Delivered,
            createdAt: now.AddHours(-5), deliveredAt: now.AddHours(-2));

        _context.Notifications.AddRange(oldestPending, freshPending, stuckRetrying, parked,
            recentlyDelivered, oldDelivered);
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var kpis = await _repository.ComputeKpisAsync(stuckCutoff, deliveredSince);

        Assert.Equal(3, kpis.QueueDepth);             // 2 pending + 1 retrying
        Assert.Equal(2, kpis.StuckCount);             // oldestPending + stuckRetrying
        Assert.Equal(1, kpis.ParkedCount);            // parked
        Assert.Equal(1, kpis.DeliveredLastInterval);  // recentlyDelivered only
        Assert.NotNull(kpis.OldestPendingAge);
        // Oldest non-terminal row is oldestPending (created ~2h ago).
        Assert.True(kpis.OldestPendingAge!.Value >= TimeSpan.FromMinutes(115));
    }

    [Fact]
    public async Task ComputeKpisAsync_NoNonTerminalRows_OldestPendingAgeIsNull()
    {
        var now = DateTimeOffset.UtcNow;
        _context.Notifications.Add(MakeNotification(Guid.NewGuid().ToString(),
            NotificationStatus.Delivered, createdAt: now.AddHours(-1), deliveredAt: now.AddMinutes(-5)));
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var kpis = await _repository.ComputeKpisAsync(now.AddMinutes(-30), now.AddHours(-1));

        Assert.Equal(0, kpis.QueueDepth);
        Assert.Equal(0, kpis.StuckCount);
        Assert.Null(kpis.OldestPendingAge);
    }

    [Fact]
    public void Constructor_NullContext_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new NotificationOutboxRepository(null!));
    }
}

public class SiteRepositoryTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;
    private readonly SiteRepository _repository;

    public SiteRepositoryTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
        _repository = new SiteRepository(_context);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public async Task AddSite_AndGetByIdentifier_RoundTrips()
    {
        var site = new Site("Site1", "S-001");
        await _repository.AddSiteAsync(site);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetSiteByIdentifierAsync("S-001");
        Assert.NotNull(loaded);
        Assert.Equal("Site1", loaded!.Name);
    }

    [Fact]
    public async Task DeleteSite_ViaStubAttachPath_RemovesEntity()
    {
        // Exercises the stub-attach delete fallback: the entity is not tracked because the
        // ChangeTracker is cleared, forcing the Local-miss branch in DeleteSiteAsync.
        var site = new Site("Site1", "S-001");
        await _repository.AddSiteAsync(site);
        await _repository.SaveChangesAsync();
        var id = site.Id;
        _context.ChangeTracker.Clear();

        await _repository.DeleteSiteAsync(id);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetSiteByIdAsync(id));
    }

    [Fact]
    public async Task DeleteDataConnection_ViaStubAttachPath_RemovesEntity()
    {
        var site = new Site("Site1", "S-001");
        await _repository.AddSiteAsync(site);
        await _repository.SaveChangesAsync();

        var conn = new DataConnection("Conn1", "OpcUa", site.Id);
        await _repository.AddDataConnectionAsync(conn);
        await _repository.SaveChangesAsync();
        var id = conn.Id;
        _context.ChangeTracker.Clear();

        await _repository.DeleteDataConnectionAsync(id);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetDataConnectionByIdAsync(id));
    }

    [Fact]
    public async Task GetInstancesBySiteId_FiltersBySite()
    {
        var site = new Site("Site1", "S-001");
        var template = new Template("T1");
        _context.Sites.Add(site);
        _context.Templates.Add(template);
        await _context.SaveChangesAsync();

        _context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
        await _context.SaveChangesAsync();

        var instances = await _repository.GetInstancesBySiteIdAsync(site.Id);
        Assert.Single(instances);
    }

    [Fact]
    public async Task GetInstancesReferencingDataConnection_NoBindings_ReturnsEmpty()
    {
        var site = new Site("Site1", "S-001");
        var template = new Template("T1");
        _context.Sites.Add(site);
        _context.Templates.Add(template);
        await _context.SaveChangesAsync();

        var conn = new DataConnection("Conn1", "OpcUa", site.Id);
        _context.DataConnections.Add(conn);
        _context.Instances.Add(new Instance("I1") { SiteId = site.Id, TemplateId = template.Id });
        await _context.SaveChangesAsync();

        var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
        Assert.Empty(result);
    }

    [Fact]
    public async Task GetInstancesReferencingDataConnection_OneBinding_ReturnsThatInstance()
    {
        var site = new Site("Site1", "S-001");
        var template = new Template("T1");
        _context.Sites.Add(site);
        _context.Templates.Add(template);
        await _context.SaveChangesAsync();

        var conn = new DataConnection("Conn1", "OpcUa", site.Id);
        _context.DataConnections.Add(conn);
        var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
        _context.Instances.Add(instance);
        await _context.SaveChangesAsync();

        _context.InstanceConnectionBindings.Add(
            new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
        await _context.SaveChangesAsync();

        var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
        Assert.Single(result);
        Assert.Equal(instance.Id, result[0].Id);
    }

    [Fact]
    public async Task GetInstancesReferencingDataConnection_TwoBindingsSameInstance_ReturnsOne()
    {
        var site = new Site("Site1", "S-001");
        var template = new Template("T1");
        _context.Sites.Add(site);
        _context.Templates.Add(template);
        await _context.SaveChangesAsync();

        var conn = new DataConnection("Conn1", "OpcUa", site.Id);
        _context.DataConnections.Add(conn);
        var instance = new Instance("I1") { SiteId = site.Id, TemplateId = template.Id };
        _context.Instances.Add(instance);
        await _context.SaveChangesAsync();

        // Two distinct bindings (different attributes) on the SAME instance both
        // reference the connection -> the result must be deduped to one instance.
        _context.InstanceConnectionBindings.Add(
            new InstanceConnectionBinding("Attr1") { InstanceId = instance.Id, DataConnectionId = conn.Id });
        _context.InstanceConnectionBindings.Add(
            new InstanceConnectionBinding("Attr2") { InstanceId = instance.Id, DataConnectionId = conn.Id });
        await _context.SaveChangesAsync();

        var result = await _repository.GetInstancesReferencingDataConnectionAsync(conn.Id);
        Assert.Single(result);
        Assert.Equal(instance.Id, result[0].Id);
    }

    [Fact]
    public void Constructor_NullContext_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new SiteRepository(null!));
    }

    /// <summary>
    /// A data connection's protocol and primary/backup configuration are flattening
    /// inputs — <c>FlatteningService</c> packages them into the flattened config's
    /// <c>Connections</c> map and <c>RevisionHashService</c> folds them into the
    /// revision hash. Without a watermark bump on save, the process-wide
    /// <c>StaleInstanceProbe</c> memo and the flatten-session caches keep serving the
    /// pre-edit hash, so an instance whose deployed config genuinely drifted reads as
    /// up to date. (The bump is unattributed — a connection has no owning template —
    /// so both the global and structure counters must move.)
    /// </summary>
    [Fact]
    public async Task SaveChanges_DataConnectionEdit_BumpsWatermark()
    {
        var watermark = new TemplateGraphWatermark();
        var repository = new SiteRepository(_context, watermark);

        var site = new Site("Site1", "S-001");
        await repository.AddSiteAsync(site);
        await repository.SaveChangesAsync();

        var afterSiteOnly = (watermark.Global, watermark.StructureVersion);

        var conn = new DataConnection("Conn1", "OpcUa", site.Id);
        await repository.AddDataConnectionAsync(conn);
        await repository.SaveChangesAsync();

        Assert.True(watermark.Global > afterSiteOnly.Global, "adding a data connection did not bump the global watermark");
        Assert.True(watermark.StructureVersion > afterSiteOnly.StructureVersion,
            "adding a data connection did not bump the structure watermark");

        var afterAdd = (watermark.Global, watermark.StructureVersion);

        conn.Protocol = "MxGateway";
        await repository.UpdateDataConnectionAsync(conn);
        await repository.SaveChangesAsync();

        Assert.True(watermark.Global > afterAdd.Global, "editing a data connection did not bump the watermark");

        var afterUpdate = watermark.Global;

        await repository.DeleteDataConnectionAsync(conn.Id);
        await repository.SaveChangesAsync();

        Assert.True(watermark.Global > afterUpdate, "deleting a data connection did not bump the watermark");
    }

    /// <summary>
    /// The bump is scoped to the entity that actually feeds the flattener: a save
    /// that touches no data connection must leave the watermark alone, or every
    /// site/area edit would needlessly invalidate the whole flatten cache.
    /// </summary>
    [Fact]
    public async Task SaveChanges_WithoutDataConnectionChange_DoesNotBumpWatermark()
    {
        var watermark = new TemplateGraphWatermark();
        var repository = new SiteRepository(_context, watermark);

        var site = new Site("Site1", "S-001");
        await repository.AddSiteAsync(site);
        await repository.SaveChangesAsync();

        Assert.Equal(0, watermark.Global);
        Assert.Equal(0, watermark.StructureVersion);
    }
}

public class DeploymentManagerRepositoryTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;
    private readonly DeploymentManagerRepository _repository;

    public DeploymentManagerRepositoryTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
        _repository = new DeploymentManagerRepository(_context);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    private async Task<Instance> SeedInstanceAsync()
    {
        var site = new Site("Site1", "S-001");
        var template = new Template("T1");
        _context.Sites.Add(site);
        _context.Templates.Add(template);
        await _context.SaveChangesAsync();

        var instance = new Instance("Inst1") { SiteId = site.Id, TemplateId = template.Id };
        _context.Instances.Add(instance);
        await _context.SaveChangesAsync();
        return instance;
    }

    [Fact]
    public async Task AddDeploymentRecord_AndGetCurrentStatus_ReturnsMostRecent()
    {
        var instance = await SeedInstanceAsync();

        await _repository.AddDeploymentRecordAsync(
            new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow.AddHours(-1) });
        await _repository.AddDeploymentRecordAsync(
            new DeploymentRecord("d-002", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
        await _repository.SaveChangesAsync();

        var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
        Assert.NotNull(current);
        Assert.Equal("d-002", current!.DeploymentId);
    }

    [Fact]
    public async Task GetCurrentDeploymentStatus_SameTickRecords_DeterministicTiebreakerPicksHighestId()
    {
        // DeploymentManager-026: deployments are insert-only, so two records for the
        // same instance can tie on DeployedAt when created within the same clock tick
        // (rapid redeploy, or redeploy right after a timed-out attempt). Without a
        // secondary sort key the "current" read is non-deterministic. The repository
        // now orders by DeployedAt DESC, THEN Id DESC, so the most recently inserted
        // (highest Id) row wins the tie deterministically.
        var instance = await SeedInstanceAsync();
        var sameInstant = DateTimeOffset.UtcNow;

        await _repository.AddDeploymentRecordAsync(
            new DeploymentRecord("d-old", "admin") { InstanceId = instance.Id, DeployedAt = sameInstant });
        await _repository.SaveChangesAsync();
        await _repository.AddDeploymentRecordAsync(
            new DeploymentRecord("d-new", "admin") { InstanceId = instance.Id, DeployedAt = sameInstant });
        await _repository.SaveChangesAsync();

        var current = await _repository.GetCurrentDeploymentStatusAsync(instance.Id);
        Assert.NotNull(current);
        // d-new was inserted second → it has the higher Id and must win the tie.
        Assert.Equal("d-new", current!.DeploymentId);
    }

    [Fact]
    public async Task DeleteDeploymentRecord_ViaStubAttachPath_RemovesEntity()
    {
        var instance = await SeedInstanceAsync();
        var record = new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow };
        await _repository.AddDeploymentRecordAsync(record);
        await _repository.SaveChangesAsync();
        var id = record.Id;
        var rowVersion = record.RowVersion ?? Array.Empty<byte>();
        _context.ChangeTracker.Clear();

        await _repository.DeleteDeploymentRecordAsync(id, rowVersion);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
    }

    /// <summary>
    /// ConfigurationDatabase-024: CD-017 added optimistic-concurrency to
    /// <c>DeleteDeploymentRecordAsync(int id, byte[] expectedRowVersion)</c> — the stub-attach
    /// path now seeds <c>OriginalValues["RowVersion"]</c> from the caller's last-observed
    /// value so the generated SQL becomes <c>DELETE … WHERE Id = @id AND RowVersion = @prior</c>.
    /// This test pins the production-shape happy path: caller holds the entity's CURRENT
    /// RowVersion, clears the change-tracker (i.e. no tracked instance — exactly the M&amp;V
    /// admin / handler shape), calls Delete with that token, and the delete completes
    /// without throwing <see cref="Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException"/>.
    /// </summary>
    [Fact]
    public async Task DeleteDeploymentRecord_CurrentRowVersion_StubAttachPath_DeleteSucceeds()
    {
        // STM: CD-024-RowVersionDeleteHappyPath marker.
        var instance = await SeedInstanceAsync();
        var record = new DeploymentRecord("d-rv-001", "admin")
        {
            InstanceId = instance.Id,
            DeployedAt = DateTimeOffset.UtcNow,
        };
        await _repository.AddDeploymentRecordAsync(record);
        await _repository.SaveChangesAsync();

        // Capture the entity's CURRENT RowVersion (the one the caller would have
        // read from a prior GetDeploymentRecordByIdAsync), then detach so the
        // delete travels through the stub-attach branch (no tracked entity).
        var id = record.Id;
        var currentRowVersion = record.RowVersion ?? Array.Empty<byte>();
        _context.ChangeTracker.Clear();

        // No NotSupported/Concurrency exception should fire on this code path.
        await _repository.DeleteDeploymentRecordAsync(id, currentRowVersion);
        var affected = await _repository.SaveChangesAsync();

        Assert.Equal(1, affected);
        Assert.Null(await _repository.GetDeploymentRecordByIdAsync(id));
    }

    [Fact]
    public async Task DeleteInstance_RemovesRestrictFkDeploymentRecordsFirst()
    {
        // DeploymentRecord has a Restrict FK to Instance; DeleteInstanceAsync must remove
        // the dependent deployment records explicitly or the delete would fail.
        var instance = await SeedInstanceAsync();
        await _repository.AddDeploymentRecordAsync(
            new DeploymentRecord("d-001", "admin") { InstanceId = instance.Id, DeployedAt = DateTimeOffset.UtcNow });
        await _repository.SaveChangesAsync();

        await _repository.DeleteInstanceAsync(instance.Id);
        await _repository.SaveChangesAsync();

        Assert.Null(await _repository.GetInstanceByIdAsync(instance.Id));
        Assert.Empty(await _repository.GetDeploymentsByInstanceIdAsync(instance.Id));
    }

    [Fact]
    public async Task GetInstanceByIdAsync_IncludesNativeAlarmSourceOverrides()
    {
        // Regression for #226: NativeAlarmSourceOverrides was omitted from the Include chain.
        var instance = await SeedInstanceAsync();
        instance.NativeAlarmSourceOverrides.Add(
            new InstanceNativeAlarmSourceOverride("src/AlarmA") { InstanceId = instance.Id });
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _repository.GetInstanceByIdAsync(instance.Id);

        Assert.NotNull(loaded);
        Assert.Single(loaded!.NativeAlarmSourceOverrides);
        Assert.Equal("src/AlarmA", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName);
    }

    [Fact]
    public async Task GetInstanceByUniqueNameAsync_IncludesNativeAlarmSourceOverrides()
    {
        // Regression for #226: NativeAlarmSourceOverrides was omitted from the Include chain.
        var instance = await SeedInstanceAsync();
        instance.NativeAlarmSourceOverrides.Add(
            new InstanceNativeAlarmSourceOverride("src/AlarmB") { InstanceId = instance.Id });
        await _context.SaveChangesAsync();
        _context.ChangeTracker.Clear();

        var loaded = await _repository.GetInstanceByUniqueNameAsync(instance.UniqueName);

        Assert.NotNull(loaded);
        Assert.Single(loaded!.NativeAlarmSourceOverrides);
        Assert.Equal("src/AlarmB", loaded.NativeAlarmSourceOverrides.First().SourceCanonicalName);
    }

    [Fact]
    public void Constructor_NullContext_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new DeploymentManagerRepository(null!));
    }
}

public class InstanceLocatorTests : IDisposable
{
    private readonly ScadaBridgeDbContext _context;
    private readonly InstanceLocator _locator;

    public InstanceLocatorTests()
    {
        _context = SqliteTestHelper.CreateInMemoryContext();
        _locator = new InstanceLocator(_context);
    }

    public void Dispose()
    {
        _context.Database.CloseConnection();
        _context.Dispose();
    }

    [Fact]
    public async Task GetSiteIdForInstance_WhenFound_ReturnsSiteIdentifier()
    {
        var site = new Site("Site1", "SITE-001");
        var template = new Template("T1");
        _context.Sites.Add(site);
        _context.Templates.Add(template);
        await _context.SaveChangesAsync();

        _context.Instances.Add(new Instance("Pump1") { SiteId = site.Id, TemplateId = template.Id });
        await _context.SaveChangesAsync();

        var result = await _locator.GetSiteIdForInstanceAsync("Pump1");
        Assert.Equal("SITE-001", result);
    }

    [Fact]
    public async Task GetSiteIdForInstance_WhenInstanceNotFound_ReturnsNull()
    {
        var result = await _locator.GetSiteIdForInstanceAsync("DoesNotExist");
        Assert.Null(result);
    }

    [Fact]
    public void Constructor_NullContext_Throws()
    {
        Assert.Throws<ArgumentNullException>(() => new InstanceLocator(null!));
    }
}
