using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;

namespace ZB.MOM.WW.OtOpcUa.Configuration;

/// <summary>
///     Central config DB context. Schema matches <c>docs/v2/config-db-schema.md</c> exactly —
///     any divergence is a defect caught by the SchemaComplianceTests introspection check.
/// </summary>
public sealed class OtOpcUaConfigDbContext(DbContextOptions<OtOpcUaConfigDbContext> options)
    : DbContext(options), IDataProtectionKeyContext
{
    /// <summary>Gets the DbSet of server clusters.</summary>
    public DbSet<ServerCluster> ServerClusters => Set<ServerCluster>();
    /// <summary>Gets the DbSet of cluster nodes.</summary>
    public DbSet<ClusterNode> ClusterNodes => Set<ClusterNode>();
    /// <summary>Gets the DbSet of cluster node credentials.</summary>
    public DbSet<ClusterNodeCredential> ClusterNodeCredentials => Set<ClusterNodeCredential>();
    /// <summary>Gets the DbSet of v3 Raw-tree driver-organizing folders.</summary>
    public DbSet<RawFolder> RawFolders => Set<RawFolder>();
    /// <summary>Gets the DbSet of v3 Raw-tree tag groups (nestable under a device).</summary>
    public DbSet<TagGroup> TagGroups => Set<TagGroup>();
    /// <summary>Gets the DbSet of v3 UNS tag references (equipment → raw tag projection).</summary>
    public DbSet<UnsTagReference> UnsTagReferences => Set<UnsTagReference>();
    /// <summary>Gets the DbSet of UNS areas.</summary>
    public DbSet<UnsArea> UnsAreas => Set<UnsArea>();
    /// <summary>Gets the DbSet of UNS lines.</summary>
    public DbSet<UnsLine> UnsLines => Set<UnsLine>();
    /// <summary>Gets the DbSet of driver instances.</summary>
    public DbSet<DriverInstance> DriverInstances => Set<DriverInstance>();
    /// <summary>Gets the DbSet of devices.</summary>
    public DbSet<Device> Devices => Set<Device>();
    /// <summary>Gets the DbSet of equipment.</summary>
    public DbSet<Equipment> Equipment => Set<Equipment>();
    /// <summary>Gets the DbSet of tags.</summary>
    public DbSet<Tag> Tags => Set<Tag>();
    /// <summary>Gets the DbSet of poll groups.</summary>
    public DbSet<PollGroup> PollGroups => Set<PollGroup>();
    /// <summary>Gets the DbSet of node ACLs.</summary>
    public DbSet<NodeAcl> NodeAcls => Set<NodeAcl>();
    /// <summary>Gets the DbSet of configuration audit logs.</summary>
    public DbSet<ConfigAuditLog> ConfigAuditLogs => Set<ConfigAuditLog>();
    /// <summary>Gets the DbSet of external ID reservations.</summary>
    public DbSet<ExternalIdReservation> ExternalIdReservations => Set<ExternalIdReservation>();
    /// <summary>Gets the DbSet of driver host statuses.</summary>
    public DbSet<DriverHostStatus> DriverHostStatuses => Set<DriverHostStatus>();
    /// <summary>Gets the DbSet of driver instance resilience statuses.</summary>
    public DbSet<DriverInstanceResilienceStatus> DriverInstanceResilienceStatuses => Set<DriverInstanceResilienceStatus>();
    /// <summary>Gets the DbSet of LDAP group role mappings.</summary>
    public DbSet<LdapGroupRoleMapping> LdapGroupRoleMappings => Set<LdapGroupRoleMapping>();
    /// <summary>Gets the DbSet of scripts.</summary>
    public DbSet<Script> Scripts => Set<Script>();
    /// <summary>Gets the DbSet of virtual tags.</summary>
    public DbSet<VirtualTag> VirtualTags => Set<VirtualTag>();
    /// <summary>Gets the DbSet of scripted alarms.</summary>
    public DbSet<ScriptedAlarm> ScriptedAlarms => Set<ScriptedAlarm>();

    // v2 deploy-model tables (Phase 1 of the Akka + fused-hosting alignment).
    /// <summary>Gets the DbSet of deployments.</summary>
    public DbSet<Deployment> Deployments => Set<Deployment>();
    /// <summary>Gets the DbSet of node deployment states.</summary>
    public DbSet<NodeDeploymentState> NodeDeploymentStates => Set<NodeDeploymentState>();
    /// <summary>Gets the DbSet of configuration edits.</summary>
    public DbSet<ConfigEdit> ConfigEdits => Set<ConfigEdit>();

    // ASP.NET DataProtection key ring storage (decision: keys persisted in ConfigDb so every
    // admin-role node decrypts the same cookies without sharing a filesystem).
    /// <summary>Gets the DbSet of data protection keys.</summary>
    public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();

    /// <inheritdoc />
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);
        ConfigureServerCluster(modelBuilder);
        ConfigureClusterNode(modelBuilder);
        ConfigureClusterNodeCredential(modelBuilder);
        ConfigureRawFolder(modelBuilder);
        ConfigureUnsArea(modelBuilder);
        ConfigureUnsLine(modelBuilder);
        ConfigureDriverInstance(modelBuilder);
        ConfigureDevice(modelBuilder);
        ConfigureTagGroup(modelBuilder);
        ConfigureEquipment(modelBuilder);
        ConfigureTag(modelBuilder);
        ConfigureUnsTagReference(modelBuilder);
        ConfigurePollGroup(modelBuilder);
        ConfigureNodeAcl(modelBuilder);
        ConfigureConfigAuditLog(modelBuilder);
        ConfigureExternalIdReservation(modelBuilder);
        ConfigureDriverHostStatus(modelBuilder);
        ConfigureDriverInstanceResilienceStatus(modelBuilder);
        ConfigureLdapGroupRoleMapping(modelBuilder);
        ConfigureScript(modelBuilder);
        ConfigureVirtualTag(modelBuilder);
        ConfigureScriptedAlarm(modelBuilder);
        ConfigureDeployment(modelBuilder);
        ConfigureNodeDeploymentState(modelBuilder);
        ConfigureConfigEdit(modelBuilder);
        ConfigureDataProtectionKey(modelBuilder);
    }

    private static void ConfigureServerCluster(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ServerCluster>(e =>
        {
            e.ToTable("ServerCluster", t =>
            {
                t.HasCheckConstraint("CK_ServerCluster_RedundancyMode_NodeCount",
                    "((NodeCount = 1 AND RedundancyMode = 'None') " +
                    "OR (NodeCount = 2 AND RedundancyMode IN ('Warm', 'Hot')))");
            });
            e.HasKey(x => x.ClusterId);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.Enterprise).HasMaxLength(32);
            e.Property(x => x.Site).HasMaxLength(32);
            e.Property(x => x.RedundancyMode).HasConversion<string>().HasMaxLength(16);
            e.Property(x => x.Notes).HasMaxLength(1024);
            e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.CreatedBy).HasMaxLength(128);
            e.Property(x => x.ModifiedAt).HasColumnType("datetime2(3)");
            e.Property(x => x.ModifiedBy).HasMaxLength(128);
            e.HasIndex(x => x.Name).IsUnique().HasDatabaseName("UX_ServerCluster_Name");
            e.HasIndex(x => x.Site).HasDatabaseName("IX_ServerCluster_Site");
        });
    }

    private static void ConfigureClusterNode(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ClusterNode>(e =>
        {
            e.ToTable("ClusterNode");
            e.HasKey(x => x.NodeId);
            e.Property(x => x.NodeId).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.Host).HasMaxLength(255);
            e.Property(x => x.ApplicationUri).HasMaxLength(256);
            e.Property(x => x.DriverConfigOverridesJson).HasColumnType("nvarchar(max)");
            // Central's dial targets (per-cluster mesh Phase 1). AkkaPort carries a DB-side default so
            // rows migrated from before the column existed come out at 4053 rather than 0 — every node
            // in the fleet does listen on a remoting port, so 0 is never a truthful value for it.
            // GrpcPort has no default: nothing listens on it until Phase 5.
            e.Property(x => x.AkkaPort).HasDefaultValue(4053);
            e.Property(x => x.LastSeenAt).HasColumnType("datetime2(3)");
            e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.CreatedBy).HasMaxLength(128);

            e.HasOne(x => x.Cluster).WithMany(c => c.Nodes)
                .HasForeignKey(x => x.ClusterId)
                .OnDelete(DeleteBehavior.Restrict);

            // Fleet-wide unique.
            e.HasIndex(x => x.ApplicationUri).IsUnique().HasDatabaseName("UX_ClusterNode_ApplicationUri");
            e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_ClusterNode_ClusterId");
            // v2: the "one Primary per cluster" filtered unique index (and the RedundancyRole
            // column it filtered on) are gone. Akka cluster leader-of-driver-role is the
            // authoritative primary signal (see RedundancyStateActor + ServiceLevelCalculator).
        });
    }

    private static void ConfigureClusterNodeCredential(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ClusterNodeCredential>(e =>
        {
            e.ToTable("ClusterNodeCredential");
            e.HasKey(x => x.CredentialId);
            e.Property(x => x.CredentialId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.NodeId).HasMaxLength(64);
            e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(32);
            e.Property(x => x.Value).HasMaxLength(512);
            e.Property(x => x.RotatedAt).HasColumnType("datetime2(3)");
            e.Property(x => x.CreatedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.CreatedBy).HasMaxLength(128);

            e.HasOne(x => x.Node).WithMany(n => n.Credentials)
                .HasForeignKey(x => x.NodeId)
                .OnDelete(DeleteBehavior.Restrict);

            e.HasIndex(x => new { x.NodeId, x.Enabled }).HasDatabaseName("IX_ClusterNodeCredential_NodeId");
            e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
                .HasFilter("[Enabled] = 1")
                .HasDatabaseName("UX_ClusterNodeCredential_Value");
        });
    }

    private static void ConfigureRawFolder(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<RawFolder>(e =>
        {
            e.ToTable("RawFolder");
            e.HasKey(x => x.RawFolderRowId);
            e.Property(x => x.RawFolderRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.RawFolderId).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.ParentRawFolderId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.RowVersion).IsRowVersion();

            // Cluster-rooted. Restrict so a cluster with folders cannot be deleted out from under
            // them (matches the OnDelete(Restrict) convention across the config schema). Child FKs
            // (DriverInstance.RawFolderId, RawFolder.ParentRawFolderId) are logical-only, so
            // non-empty-folder-delete is enforced at the service layer in later batches.
            e.HasOne<ServerCluster>().WithMany(c => c.RawFolders)
                .HasForeignKey(x => x.ClusterId)
                .OnDelete(DeleteBehavior.Restrict);

            e.HasIndex(x => x.RawFolderId).IsUnique().HasDatabaseName("UX_RawFolder_LogicalId");
            e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_RawFolder_Cluster");

            // Sibling-name uniqueness. Two filtered indexes cover the nullable ParentRawFolderId:
            // one for nested folders (Parent NOT NULL), one for cluster-root folders (Parent NULL),
            // mirroring the Tag filtered-index convention.
            e.HasIndex(x => new { x.ClusterId, x.ParentRawFolderId, x.Name }).IsUnique()
                .HasFilter("[ParentRawFolderId] IS NOT NULL")
                .HasDatabaseName("UX_RawFolder_Parent_Name");
            e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
                .HasFilter("[ParentRawFolderId] IS NULL")
                .HasDatabaseName("UX_RawFolder_ClusterRoot_Name");
        });
    }

    private static void ConfigureUnsArea(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<UnsArea>(e =>
        {
            e.ToTable("UnsArea");
            e.HasKey(x => x.UnsAreaRowId);
            e.Property(x => x.UnsAreaRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.UnsAreaId).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(32);
            e.Property(x => x.Notes).HasMaxLength(512);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);

            e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_UnsArea_Cluster");
            e.HasIndex(x => x.UnsAreaId).IsUnique().HasDatabaseName("UX_UnsArea_LogicalId");
            e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique().HasDatabaseName("UX_UnsArea_ClusterName");
        });
    }

    private static void ConfigureUnsLine(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<UnsLine>(e =>
        {
            e.ToTable("UnsLine");
            e.HasKey(x => x.UnsLineRowId);
            e.Property(x => x.UnsLineRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.UnsLineId).HasMaxLength(64);
            e.Property(x => x.UnsAreaId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(32);
            e.Property(x => x.Notes).HasMaxLength(512);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.UnsAreaId).HasDatabaseName("IX_UnsLine_Area");
            e.HasIndex(x => x.UnsLineId).IsUnique().HasDatabaseName("UX_UnsLine_LogicalId");
            e.HasIndex(x => new { x.UnsAreaId, x.Name }).IsUnique().HasDatabaseName("UX_UnsLine_AreaName");
        });
    }

    private static void ConfigureDriverInstance(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DriverInstance>(e =>
        {
            e.ToTable("DriverInstance", t =>
            {
                t.HasCheckConstraint("CK_DriverInstance_DriverConfig_IsJson",
                    "ISJSON(DriverConfig) = 1");
                t.HasCheckConstraint("CK_DriverInstance_ResilienceConfig_IsJson",
                    "ResilienceConfig IS NULL OR ISJSON(ResilienceConfig) = 1");
            });
            e.HasKey(x => x.DriverInstanceRowId);
            e.Property(x => x.DriverInstanceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.DriverInstanceId).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.RawFolderId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.DriverType).HasMaxLength(32);
            e.Property(x => x.DriverConfig).HasColumnType("nvarchar(max)");
            e.Property(x => x.ResilienceConfig).HasColumnType("nvarchar(max)");
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasOne(x => x.Cluster).WithMany().HasForeignKey(x => x.ClusterId).OnDelete(DeleteBehavior.Restrict);

            e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_DriverInstance_Cluster");
            e.HasIndex(x => x.RawFolderId).HasDatabaseName("IX_DriverInstance_RawFolder");
            e.HasIndex(x => x.DriverInstanceId).IsUnique().HasDatabaseName("UX_DriverInstance_LogicalId");

            // v3 sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
            // RawFolderId: nested drivers keyed by (ClusterId, RawFolderId, Name), cluster-root drivers
            // by (ClusterId, Name).
            e.HasIndex(x => new { x.ClusterId, x.RawFolderId, x.Name }).IsUnique()
                .HasFilter("[RawFolderId] IS NOT NULL")
                .HasDatabaseName("UX_DriverInstance_Folder_Name");
            e.HasIndex(x => new { x.ClusterId, x.Name }).IsUnique()
                .HasFilter("[RawFolderId] IS NULL")
                .HasDatabaseName("UX_DriverInstance_ClusterRoot_Name");
        });
    }

    private static void ConfigureDevice(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Device>(e =>
        {
            e.ToTable("Device", t =>
            {
                t.HasCheckConstraint("CK_Device_DeviceConfig_IsJson", "ISJSON(DeviceConfig) = 1");
            });
            e.HasKey(x => x.DeviceRowId);
            e.Property(x => x.DeviceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.DeviceId).HasMaxLength(64);
            e.Property(x => x.DriverInstanceId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.DeviceConfig).HasColumnType("nvarchar(max)");
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_Device_Driver");
            e.HasIndex(x => x.DeviceId).IsUnique().HasDatabaseName("UX_Device_LogicalId");
            // v3 sibling-name uniqueness within a driver (a RawPath segment).
            e.HasIndex(x => new { x.DriverInstanceId, x.Name }).IsUnique()
                .HasDatabaseName("UX_Device_Driver_Name");
        });
    }

    private static void ConfigureTagGroup(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<TagGroup>(e =>
        {
            e.ToTable("TagGroup");
            e.HasKey(x => x.TagGroupRowId);
            e.Property(x => x.TagGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.TagGroupId).HasMaxLength(64);
            e.Property(x => x.DeviceId).HasMaxLength(64);
            e.Property(x => x.ParentTagGroupId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.TagGroupId).IsUnique().HasDatabaseName("UX_TagGroup_LogicalId");
            e.HasIndex(x => x.DeviceId).HasDatabaseName("IX_TagGroup_Device");

            // Sibling-name uniqueness. Two filtered indexes cover the nullable ParentTagGroupId:
            // nested groups keyed by (DeviceId, ParentTagGroupId, Name), device-root groups by
            // (DeviceId, Name). DeviceId/ParentTagGroupId are logical FKs (no navigation); non-empty
            // group deletes are blocked at the service layer in later batches.
            e.HasIndex(x => new { x.DeviceId, x.ParentTagGroupId, x.Name }).IsUnique()
                .HasFilter("[ParentTagGroupId] IS NOT NULL")
                .HasDatabaseName("UX_TagGroup_Parent_Name");
            e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
                .HasFilter("[ParentTagGroupId] IS NULL")
                .HasDatabaseName("UX_TagGroup_DeviceRoot_Name");
        });
    }

    private static void ConfigureEquipment(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Equipment>(e =>
        {
            e.ToTable("Equipment");
            e.HasKey(x => x.EquipmentRowId);
            e.Property(x => x.EquipmentRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.EquipmentId).HasMaxLength(64);
            e.Property(x => x.UnsLineId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(32);
            e.Property(x => x.MachineCode).HasMaxLength(64);
            e.Property(x => x.ZTag).HasMaxLength(64);
            e.Property(x => x.SAPID).HasMaxLength(64);
            e.Property(x => x.Manufacturer).HasMaxLength(64);
            e.Property(x => x.Model).HasMaxLength(64);
            e.Property(x => x.SerialNumber).HasMaxLength(64);
            e.Property(x => x.HardwareRevision).HasMaxLength(32);
            e.Property(x => x.SoftwareRevision).HasMaxLength(32);
            e.Property(x => x.AssetLocation).HasMaxLength(256);
            e.Property(x => x.ManufacturerUri).HasMaxLength(512);
            e.Property(x => x.DeviceManualUri).HasMaxLength(512);
            e.Property(x => x.EquipmentClassRef).HasMaxLength(128);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.UnsLineId).HasDatabaseName("IX_Equipment_Line");
            e.HasIndex(x => x.EquipmentId).IsUnique().HasDatabaseName("UX_Equipment_LogicalId");
            e.HasIndex(x => new { x.UnsLineId, x.Name }).IsUnique().HasDatabaseName("UX_Equipment_LinePath");
            e.HasIndex(x => x.EquipmentUuid).IsUnique().HasDatabaseName("UX_Equipment_Uuid");
            e.HasIndex(x => x.ZTag).HasFilter("[ZTag] IS NOT NULL").HasDatabaseName("IX_Equipment_ZTag");
            e.HasIndex(x => x.SAPID).HasFilter("[SAPID] IS NOT NULL").HasDatabaseName("IX_Equipment_SAPID");
            e.HasIndex(x => x.MachineCode).HasDatabaseName("IX_Equipment_MachineCode");
        });
    }

    private static void ConfigureTag(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Tag>(e =>
        {
            e.ToTable("Tag", t =>
            {
                t.HasCheckConstraint("CK_Tag_TagConfig_IsJson", "ISJSON(TagConfig) = 1");
            });
            e.HasKey(x => x.TagRowId);
            e.Property(x => x.TagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.TagId).HasMaxLength(64);
            // v3: raw-only tag. Required DeviceId FK; nullable TagGroupId. EquipmentId/FolderPath/
            // DriverInstanceId are retired — identity is the RawPath (device + optional group + name).
            e.Property(x => x.DeviceId).HasMaxLength(64).IsRequired();
            e.Property(x => x.TagGroupId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.DataType).HasMaxLength(32);
            e.Property(x => x.AccessLevel).HasConversion<string>().HasMaxLength(16);
            e.Property(x => x.PollGroupId).HasMaxLength(64);
            e.Property(x => x.TagConfig).HasColumnType("nvarchar(max)");
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => new { x.DeviceId, x.TagGroupId }).HasDatabaseName("IX_Tag_Device");
            e.HasIndex(x => x.TagId).IsUnique().HasDatabaseName("UX_Tag_LogicalId");

            // Sibling-name uniqueness (a RawPath segment). Two filtered indexes cover the nullable
            // TagGroupId: grouped tags keyed by (DeviceId, TagGroupId, Name), device-root tags by
            // (DeviceId, Name).
            e.HasIndex(x => new { x.DeviceId, x.TagGroupId, x.Name }).IsUnique()
                .HasFilter("[TagGroupId] IS NOT NULL")
                .HasDatabaseName("UX_Tag_Group_Name");
            e.HasIndex(x => new { x.DeviceId, x.Name }).IsUnique()
                .HasFilter("[TagGroupId] IS NULL")
                .HasDatabaseName("UX_Tag_Device_Name");
        });
    }

    private static void ConfigurePollGroup(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<PollGroup>(e =>
        {
            e.ToTable("PollGroup", t =>
            {
                t.HasCheckConstraint("CK_PollGroup_IntervalMs_Min", "IntervalMs >= 50");
            });
            e.HasKey(x => x.PollGroupRowId);
            e.Property(x => x.PollGroupRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.PollGroupId).HasMaxLength(64);
            e.Property(x => x.DriverInstanceId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.DriverInstanceId).HasDatabaseName("IX_PollGroup_Driver");
            e.HasIndex(x => x.PollGroupId).IsUnique().HasDatabaseName("UX_PollGroup_LogicalId");
        });
    }

    private static void ConfigureNodeAcl(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<NodeAcl>(e =>
        {
            e.ToTable("NodeAcl");
            e.HasKey(x => x.NodeAclRowId);
            e.Property(x => x.NodeAclRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.NodeAclId).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.LdapGroup).HasMaxLength(256);
            e.Property(x => x.ScopeKind).HasConversion<string>().HasMaxLength(16);
            e.Property(x => x.ScopeId).HasMaxLength(64);
            e.Property(x => x.PermissionFlags).HasConversion<int>();
            e.Property(x => x.Notes).HasMaxLength(512);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.ClusterId).HasDatabaseName("IX_NodeAcl_Cluster");
            e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_NodeAcl_Group");
            e.HasIndex(x => new { x.ScopeKind, x.ScopeId })
                .HasFilter("[ScopeId] IS NOT NULL")
                .HasDatabaseName("IX_NodeAcl_Scope");
            e.HasIndex(x => x.NodeAclId).IsUnique().HasDatabaseName("UX_NodeAcl_LogicalId");
            e.HasIndex(x => new { x.ClusterId, x.LdapGroup, x.ScopeKind, x.ScopeId }).IsUnique()
                .HasDatabaseName("UX_NodeAcl_GroupScope");
        });
    }

    private static void ConfigureConfigAuditLog(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ConfigAuditLog>(e =>
        {
            e.ToTable("ConfigAuditLog", t =>
            {
                t.HasCheckConstraint("CK_ConfigAuditLog_DetailsJson_IsJson",
                    "DetailsJson IS NULL OR ISJSON(DetailsJson) = 1");
            });
            e.HasKey(x => x.AuditId);
            e.Property(x => x.AuditId).UseIdentityColumn(seed: 1, increment: 1);
            e.Property(x => x.Timestamp).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.Principal).HasMaxLength(128);
            e.Property(x => x.EventType).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.NodeId).HasMaxLength(64);
            e.Property(x => x.DetailsJson).HasColumnType("nvarchar(max)");
            e.Property(x => x.EventId);
            e.Property(x => x.CorrelationId);
            // Stored as the AuditOutcome enum member name (mirrors AdminRole's string storage):
            // "Success" | "Failure" | "Denied" all fit nvarchar(16). Nullable for legacy + SP-path rows.
            e.Property(x => x.Outcome).HasMaxLength(16);

            e.HasIndex(x => new { x.ClusterId, x.Timestamp })
                .IsDescending(false, true)
                .HasDatabaseName("IX_ConfigAuditLog_Cluster_Time");
            e.HasIndex(x => x.GenerationId)
                .HasFilter("[GenerationId] IS NOT NULL")
                .HasDatabaseName("IX_ConfigAuditLog_Generation");
            // Filtered unique index gives cross-restart idempotency for the AuditWriterActor:
            // a retry of an already-flushed batch will hit this constraint and the catch in
            // FlushBuffer drops the duplicate insert. Nullable + filter so legacy backfill rows
            // (EventId=NULL) don't collide.
            e.HasIndex(x => x.EventId)
                .IsUnique()
                .HasFilter("[EventId] IS NOT NULL")
                .HasDatabaseName("UX_ConfigAuditLog_EventId");
        });
    }

    private static void ConfigureExternalIdReservation(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ExternalIdReservation>(e =>
        {
            e.ToTable("ExternalIdReservation");
            e.HasKey(x => x.ReservationId);
            e.Property(x => x.ReservationId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.Kind).HasConversion<string>().HasMaxLength(16);
            e.Property(x => x.Value).HasMaxLength(64);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.FirstPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.FirstPublishedBy).HasMaxLength(128);
            e.Property(x => x.LastPublishedAt).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.ReleasedAt).HasColumnType("datetime2(3)");
            e.Property(x => x.ReleasedBy).HasMaxLength(128);
            e.Property(x => x.ReleaseReason).HasMaxLength(512);

            // Active reservations unique per (Kind, Value) — filtered index lets released rows coexist with a new reservation of the same value.
            // The UX_ filtered index covers active-reservation lookups; history queries over released rows
            // fall back to the table scan (released rows are rare + small). No separate non-unique (Kind, Value)
            // index is declared because EF Core merges duplicate column sets into a single index, which would
            // clobber the filtered-unique name.
            e.HasIndex(x => new { x.Kind, x.Value }).IsUnique()
                .HasFilter("[ReleasedAt] IS NULL")
                .HasDatabaseName("UX_ExternalIdReservation_KindValue_Active");
            e.HasIndex(x => x.EquipmentUuid).HasDatabaseName("IX_ExternalIdReservation_Equipment");
        });
    }

    private static void ConfigureDriverHostStatus(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DriverHostStatus>(e =>
        {
            e.ToTable("DriverHostStatus");
            // Composite key — one row per (server node, driver instance, probe-reported host).
            // A redundant 2-node cluster with one Galaxy driver reporting 3 platforms produces
            // 6 rows because each server node owns its own runtime view; the composite key is
            // what lets both views coexist without shadowing each other.
            e.HasKey(x => new { x.NodeId, x.DriverInstanceId, x.HostName });
            e.Property(x => x.NodeId).HasMaxLength(64);
            e.Property(x => x.DriverInstanceId).HasMaxLength(64);
            e.Property(x => x.HostName).HasMaxLength(256);
            e.Property(x => x.State).HasConversion<string>().HasMaxLength(16);
            e.Property(x => x.StateChangedUtc).HasColumnType("datetime2(3)");
            e.Property(x => x.LastSeenUtc).HasColumnType("datetime2(3)");
            e.Property(x => x.Detail).HasMaxLength(1024);

            // NodeId-only index drives the Admin UI's per-cluster drill-down (select all host
            // statuses for the nodes of a specific cluster via join on ClusterNode.ClusterId).
            e.HasIndex(x => x.NodeId).HasDatabaseName("IX_DriverHostStatus_Node");
            // LastSeenUtc index powers the Admin UI's stale-row query (now - LastSeen > N).
            e.HasIndex(x => x.LastSeenUtc).HasDatabaseName("IX_DriverHostStatus_LastSeen");
        });
    }

    private static void ConfigureDriverInstanceResilienceStatus(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<DriverInstanceResilienceStatus>(e =>
        {
            e.ToTable("DriverInstanceResilienceStatus");
            e.HasKey(x => new { x.DriverInstanceId, x.HostName });
            e.Property(x => x.DriverInstanceId).HasMaxLength(64);
            e.Property(x => x.HostName).HasMaxLength(256);
            e.Property(x => x.LastCircuitBreakerOpenUtc).HasColumnType("datetime2(3)");
            e.Property(x => x.LastRecycleUtc).HasColumnType("datetime2(3)");
            e.Property(x => x.LastSampledUtc).HasColumnType("datetime2(3)");
            // LastSampledUtc drives the Admin UI's stale-sample filter same way DriverHostStatus's
            // LastSeenUtc index does for connectivity rows.
            e.HasIndex(x => x.LastSampledUtc).HasDatabaseName("IX_DriverResilience_LastSampled");
        });
    }

    private static void ConfigureLdapGroupRoleMapping(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<LdapGroupRoleMapping>(e =>
        {
            e.ToTable("LdapGroupRoleMapping");
            e.HasKey(x => x.Id);
            e.Property(x => x.LdapGroup).HasMaxLength(512).IsRequired();
            e.Property(x => x.Role).HasConversion<string>().HasMaxLength(32);
            e.Property(x => x.ClusterId).HasMaxLength(64);
            e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)");
            e.Property(x => x.Notes).HasMaxLength(512);

            // FK to ServerCluster when cluster-scoped; null for system-wide grants.
            e.HasOne(x => x.Cluster)
                .WithMany()
                .HasForeignKey(x => x.ClusterId)
                .OnDelete(DeleteBehavior.Cascade);

            // Uniqueness: one row per (LdapGroup, ClusterId). Null ClusterId is treated as its own
            // "bucket" so a system-wide row coexists with cluster-scoped rows for the same group.
            // SQL Server treats NULL as a distinct value in unique-index comparisons by default
            // since 2008 SP1 onwards under the session setting we use — tested in SchemaCompliance.
            e.HasIndex(x => new { x.LdapGroup, x.ClusterId })
                .IsUnique()
                .HasDatabaseName("UX_LdapGroupRoleMapping_Group_Cluster");

            // Hot-path lookup during cookie auth: "what grants does this user's set of LDAP
            // groups carry?". Fires on every sign-in so the index earns its keep.
            e.HasIndex(x => x.LdapGroup).HasDatabaseName("IX_LdapGroupRoleMapping_Group");
        });
    }

    private static void ConfigureUnsTagReference(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<UnsTagReference>(e =>
        {
            e.ToTable("UnsTagReference");
            e.HasKey(x => x.UnsTagReferenceRowId);
            e.Property(x => x.UnsTagReferenceRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.UnsTagReferenceId).HasMaxLength(64);
            e.Property(x => x.EquipmentId).HasMaxLength(64);
            e.Property(x => x.TagId).HasMaxLength(64);
            e.Property(x => x.DisplayNameOverride).HasMaxLength(128);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.UnsTagReferenceId).IsUnique().HasDatabaseName("UX_UnsTagReference_LogicalId");
            e.HasIndex(x => x.EquipmentId).HasDatabaseName("IX_UnsTagReference_Equipment");
            e.HasIndex(x => x.TagId).HasDatabaseName("IX_UnsTagReference_Tag");

            // One reference per (equipment, raw tag). EquipmentId/TagId are logical FKs (no
            // navigation); the "raw-tag delete blocked while referenced" rule is enforced at the
            // service layer in later batches.
            e.HasIndex(x => new { x.EquipmentId, x.TagId }).IsUnique()
                .HasDatabaseName("UX_UnsTagReference_Equip_Tag");
        });
    }

    private static void ConfigureScript(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Script>(e =>
        {
            e.ToTable("Script");
            e.HasKey(x => x.ScriptRowId);
            e.Property(x => x.ScriptRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.ScriptId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.SourceCode).HasColumnType("nvarchar(max)");
            e.Property(x => x.SourceHash).HasMaxLength(64);
            e.Property(x => x.Language).HasMaxLength(16);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.ScriptId).IsUnique().HasDatabaseName("UX_Script_LogicalId");
            e.HasIndex(x => x.SourceHash).HasDatabaseName("IX_Script_SourceHash");
        });
    }

    private static void ConfigureVirtualTag(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<VirtualTag>(e =>
        {
            e.ToTable("VirtualTag", t =>
            {
                t.HasCheckConstraint("CK_VirtualTag_Trigger_AtLeastOne",
                    "ChangeTriggered = 1 OR TimerIntervalMs IS NOT NULL");
                t.HasCheckConstraint("CK_VirtualTag_TimerInterval_Min",
                    "TimerIntervalMs IS NULL OR TimerIntervalMs >= 50");
            });
            e.HasKey(x => x.VirtualTagRowId);
            e.Property(x => x.VirtualTagRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.VirtualTagId).HasMaxLength(64);
            e.Property(x => x.EquipmentId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.DataType).HasMaxLength(32);
            e.Property(x => x.ScriptId).HasMaxLength(64);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.VirtualTagId).IsUnique().HasDatabaseName("UX_VirtualTag_LogicalId");
            e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_VirtualTag_EquipmentPath");
            e.HasIndex(x => x.ScriptId).HasDatabaseName("IX_VirtualTag_Script");
        });
    }

    private static void ConfigureScriptedAlarm(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ScriptedAlarm>(e =>
        {
            e.ToTable("ScriptedAlarm", t =>
            {
                t.HasCheckConstraint("CK_ScriptedAlarm_Severity_Range", "Severity BETWEEN 1 AND 1000");
                t.HasCheckConstraint("CK_ScriptedAlarm_AlarmType",
                    "AlarmType IN ('AlarmCondition','LimitAlarm','OffNormalAlarm','DiscreteAlarm')");
            });
            e.HasKey(x => x.ScriptedAlarmRowId);
            e.Property(x => x.ScriptedAlarmRowId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.ScriptedAlarmId).HasMaxLength(64);
            e.Property(x => x.EquipmentId).HasMaxLength(64);
            e.Property(x => x.Name).HasMaxLength(128);
            e.Property(x => x.AlarmType).HasMaxLength(32);
            e.Property(x => x.MessageTemplate).HasMaxLength(1024);
            e.Property(x => x.PredicateScriptId).HasMaxLength(64);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasIndex(x => x.ScriptedAlarmId).IsUnique().HasDatabaseName("UX_ScriptedAlarm_LogicalId");
            e.HasIndex(x => new { x.EquipmentId, x.Name }).IsUnique().HasDatabaseName("UX_ScriptedAlarm_EquipmentPath");
            e.HasIndex(x => x.PredicateScriptId).HasDatabaseName("IX_ScriptedAlarm_Script");
        });
    }

    private static void ConfigureDeployment(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Deployment>(e =>
        {
            e.ToTable("Deployment");
            e.HasKey(x => x.DeploymentId);
            e.Property(x => x.DeploymentId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.RevisionHash).HasMaxLength(64).IsRequired();
            e.Property(x => x.Status).HasConversion<int>();
            e.Property(x => x.CreatedBy).HasMaxLength(128).IsRequired();
            e.Property(x => x.CreatedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.ArtifactBlob).HasColumnType("varbinary(max)");
            e.Property(x => x.RowVersion).IsRowVersion();
            e.Property(x => x.FailureReason).HasMaxLength(2048);
            e.Property(x => x.SealedAtUtc).HasColumnType("datetime2(3)");

            e.HasIndex(x => x.Status).HasDatabaseName("IX_Deployment_Status");
            e.HasIndex(x => x.CreatedAtUtc).HasDatabaseName("IX_Deployment_CreatedAt");
        });
    }

    private static void ConfigureNodeDeploymentState(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<NodeDeploymentState>(e =>
        {
            e.ToTable("NodeDeploymentState");
            e.HasKey(x => new { x.NodeId, x.DeploymentId });
            e.Property(x => x.NodeId).HasMaxLength(64);
            e.Property(x => x.Status).HasConversion<int>();
            e.Property(x => x.StartedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.AppliedAtUtc).HasColumnType("datetime2(3)");
            e.Property(x => x.FailureReason).HasMaxLength(2048);
            e.Property(x => x.RowVersion).IsRowVersion();

            e.HasOne(x => x.Node).WithMany().HasForeignKey(x => x.NodeId).OnDelete(DeleteBehavior.Restrict);
            e.HasOne(x => x.Deployment).WithMany().HasForeignKey(x => x.DeploymentId).OnDelete(DeleteBehavior.Cascade);

            e.HasIndex(x => x.DeploymentId).HasDatabaseName("IX_NodeDeploymentState_Deployment");
            e.HasIndex(x => x.Status).HasDatabaseName("IX_NodeDeploymentState_Status");
        });
    }

    private static void ConfigureConfigEdit(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<ConfigEdit>(e =>
        {
            e.ToTable("ConfigEdit", t =>
            {
                t.HasCheckConstraint("CK_ConfigEdit_FieldsJson_IsJson", "ISJSON(FieldsJson) = 1");
            });
            e.HasKey(x => x.EditId);
            e.Property(x => x.EditId).HasDefaultValueSql("NEWSEQUENTIALID()");
            e.Property(x => x.EntityType).HasMaxLength(64).IsRequired();
            e.Property(x => x.FieldsJson).HasColumnType("nvarchar(max)").IsRequired();
            e.Property(x => x.EditedBy).HasMaxLength(128).IsRequired();
            e.Property(x => x.EditedAtUtc).HasColumnType("datetime2(3)").HasDefaultValueSql("SYSUTCDATETIME()");
            e.Property(x => x.SourceNode).HasMaxLength(64).IsRequired();

            // Replays of admin operations group rows by ExecutionId, then by time.
            e.HasIndex(x => new { x.EntityType, x.EntityId }).HasDatabaseName("IX_ConfigEdit_Entity");
            e.HasIndex(x => x.ExecutionId).HasFilter("[ExecutionId] IS NOT NULL").HasDatabaseName("IX_ConfigEdit_Execution");
            e.HasIndex(x => x.EditedAtUtc).HasDatabaseName("IX_ConfigEdit_EditedAt");
        });
    }

    private static void ConfigureDataProtectionKey(ModelBuilder modelBuilder)
    {
        // ASP.NET DataProtection ships its own EF mapping; override only the table name so it
        // matches the rest of the schema's PascalCase convention.
        modelBuilder.Entity<DataProtectionKey>(e =>
        {
            e.ToTable("DataProtectionKeys");
        });
    }
}
