using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace ZB.MOM.WW.OtOpcUa.Configuration.Migrations;

/// <summary>
///     Stored procedures per <c>config-db-schema.md §"Stored Procedures"</c>. All node + admin DB
///     access funnels through these — direct table writes are revoked in the AuthorizationGrants
///     migration that follows. CREATE OR ALTER style so procs version with the schema.
/// </summary>
public partial class StoredProcedures : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.Sql(Procs.GetCurrentGenerationForCluster);
        migrationBuilder.Sql(Procs.GetGenerationContent);
        migrationBuilder.Sql(Procs.RegisterNodeGenerationApplied);
        migrationBuilder.Sql(Procs.ValidateDraft);
        migrationBuilder.Sql(Procs.PublishGeneration);
        migrationBuilder.Sql(Procs.RollbackToGeneration);
        migrationBuilder.Sql(Procs.ComputeGenerationDiff);
        migrationBuilder.Sql(Procs.ReleaseExternalIdReservation);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        foreach (var name in new[]
                 {
                     "sp_ReleaseExternalIdReservation", "sp_ComputeGenerationDiff", "sp_RollbackToGeneration",
                     "sp_PublishGeneration", "sp_ValidateDraft", "sp_RegisterNodeGenerationApplied",
                     "sp_GetGenerationContent", "sp_GetCurrentGenerationForCluster",
                 })
        {
            migrationBuilder.Sql($"IF OBJECT_ID(N'dbo.{name}', N'P') IS NOT NULL DROP PROCEDURE dbo.{name};");
        }
    }

    private static class Procs
    {
        public const string GetCurrentGenerationForCluster = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetCurrentGenerationForCluster
    @NodeId    nvarchar(64),
    @ClusterId nvarchar(64)
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @Caller nvarchar(128) = SUSER_SNAME();

    IF NOT EXISTS (
        SELECT 1 FROM dbo.ClusterNodeCredential
        WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
    BEGIN
        RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
        RETURN;
    END

    IF NOT EXISTS (
        SELECT 1 FROM dbo.ClusterNode
        WHERE NodeId = @NodeId AND ClusterId = @ClusterId AND Enabled = 1)
    BEGIN
        RAISERROR('Forbidden: NodeId %s does not belong to ClusterId %s', 16, 1, @NodeId, @ClusterId);
        RETURN;
    END

    SELECT TOP 1 GenerationId, ClusterId, Status, PublishedAt, PublishedBy, Notes
    FROM   dbo.ConfigGeneration
    WHERE  ClusterId = @ClusterId AND Status = 'Published'
    ORDER BY GenerationId DESC;
END
";

        public const string GetGenerationContent = @"
CREATE OR ALTER PROCEDURE dbo.sp_GetGenerationContent
    @NodeId       nvarchar(64),
    @GenerationId bigint
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @Caller    nvarchar(128) = SUSER_SNAME();
    DECLARE @ClusterId nvarchar(64);

    SELECT @ClusterId = ClusterId FROM dbo.ConfigGeneration WHERE GenerationId = @GenerationId;

    IF @ClusterId IS NULL
    BEGIN
        RAISERROR('GenerationId %I64d not found', 16, 1, @GenerationId);
        RETURN;
    END

    IF NOT EXISTS (
        SELECT 1
        FROM   dbo.ClusterNodeCredential c
        JOIN   dbo.ClusterNode n ON n.NodeId = c.NodeId
        WHERE  c.NodeId = @NodeId AND c.Value = @Caller AND c.Enabled = 1
          AND  n.ClusterId = @ClusterId AND n.Enabled = 1)
    BEGIN
        RAISERROR('Forbidden: caller %s not bound to a node in ClusterId %s', 16, 1, @Caller, @ClusterId);
        RETURN;
    END

    SELECT * FROM dbo.Namespace       WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.UnsArea         WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.UnsLine         WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.DriverInstance  WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.Device          WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.Equipment       WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.PollGroup       WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.Tag             WHERE GenerationId = @GenerationId;
    SELECT * FROM dbo.NodeAcl         WHERE GenerationId = @GenerationId;
END
";

        public const string RegisterNodeGenerationApplied = @"
CREATE OR ALTER PROCEDURE dbo.sp_RegisterNodeGenerationApplied
    @NodeId          nvarchar(64),
    @GenerationId    bigint,
    @Status          nvarchar(16),
    @Error           nvarchar(max) = NULL
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @Caller nvarchar(128) = SUSER_SNAME();

    IF NOT EXISTS (
        SELECT 1 FROM dbo.ClusterNodeCredential
        WHERE NodeId = @NodeId AND Value = @Caller AND Enabled = 1)
    BEGIN
        RAISERROR('Unauthorized: caller %s is not bound to NodeId %s', 16, 1, @Caller, @NodeId);
        RETURN;
    END

    MERGE dbo.ClusterNodeGenerationState AS tgt
    USING (SELECT @NodeId AS NodeId) AS src ON tgt.NodeId = src.NodeId
    WHEN MATCHED THEN UPDATE SET
        CurrentGenerationId = @GenerationId,
        LastAppliedAt       = SYSUTCDATETIME(),
        LastAppliedStatus   = @Status,
        LastAppliedError    = @Error,
        LastSeenAt          = SYSUTCDATETIME()
    WHEN NOT MATCHED THEN INSERT
        (NodeId, CurrentGenerationId, LastAppliedAt, LastAppliedStatus, LastAppliedError, LastSeenAt)
        VALUES (@NodeId, @GenerationId, SYSUTCDATETIME(), @Status, @Error, SYSUTCDATETIME());

    -- Build DetailsJson via STRING_ESCAPE so a @Status containing a double-quote/backslash cannot
    -- produce malformed JSON (which would fail CK_ConfigAuditLog_DetailsJson_IsJson and abort the
    -- transaction) or inject extra JSON structure into the audit record.
    INSERT dbo.ConfigAuditLog (Principal, EventType, NodeId, GenerationId, DetailsJson)
    VALUES (@Caller, 'NodeApplied', @NodeId, @GenerationId,
            CONCAT('{""status"":""', STRING_ESCAPE(@Status, 'json'), '""}'));
END
";

        public const string ValidateDraft = @"
CREATE OR ALTER PROCEDURE dbo.sp_ValidateDraft
    @DraftGenerationId bigint
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @ClusterId nvarchar(64);
    DECLARE @Status    nvarchar(16);

    SELECT @ClusterId = ClusterId, @Status = Status
    FROM   dbo.ConfigGeneration WHERE GenerationId = @DraftGenerationId;

    IF @ClusterId IS NULL
    BEGIN
        RAISERROR('GenerationId %I64d not found', 16, 1, @DraftGenerationId);
        RETURN;
    END

    IF @Status <> 'Draft'
    BEGIN
        RAISERROR('GenerationId %I64d is not in Draft status (current=%s)', 16, 1, @DraftGenerationId, @Status);
        RETURN;
    END

    IF EXISTS (
        SELECT 1 FROM dbo.Tag t
        LEFT JOIN dbo.DriverInstance d ON d.GenerationId = t.GenerationId AND d.DriverInstanceId = t.DriverInstanceId
        WHERE t.GenerationId = @DraftGenerationId AND d.DriverInstanceId IS NULL)
    BEGIN
        RAISERROR('Draft has tags with unresolved DriverInstanceId', 16, 1);
        RETURN;
    END

    IF EXISTS (
        SELECT 1 FROM dbo.Tag t
        LEFT JOIN dbo.Device dv ON dv.GenerationId = t.GenerationId AND dv.DeviceId = t.DeviceId
        WHERE t.GenerationId = @DraftGenerationId AND t.DeviceId IS NOT NULL AND dv.DeviceId IS NULL)
    BEGIN
        RAISERROR('Draft has tags with unresolved DeviceId', 16, 1);
        RETURN;
    END

    IF EXISTS (
        SELECT 1 FROM dbo.Tag t
        LEFT JOIN dbo.PollGroup pg ON pg.GenerationId = t.GenerationId AND pg.PollGroupId = t.PollGroupId
        WHERE t.GenerationId = @DraftGenerationId AND t.PollGroupId IS NOT NULL AND pg.PollGroupId IS NULL)
    BEGIN
        RAISERROR('Draft has tags with unresolved PollGroupId', 16, 1);
        RETURN;
    END

    IF EXISTS (
        SELECT 1
        FROM   dbo.DriverInstance di
        JOIN   dbo.Namespace ns ON ns.GenerationId = di.GenerationId AND ns.NamespaceId = di.NamespaceId
        WHERE  di.GenerationId = @DraftGenerationId
          AND  ns.ClusterId <> di.ClusterId)
    BEGIN
        INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
        VALUES (SUSER_SNAME(), 'CrossClusterNamespaceAttempt', @ClusterId, @DraftGenerationId);
        RAISERROR('BadCrossClusterNamespaceBinding: namespace and driver must belong to the same cluster', 16, 1);
        RETURN;
    END

    IF EXISTS (
        SELECT 1
        FROM   dbo.Equipment draft
        JOIN   dbo.Equipment prior
            ON prior.EquipmentId = draft.EquipmentId
           AND prior.EquipmentUuid <> draft.EquipmentUuid
           AND prior.GenerationId <> draft.GenerationId
        JOIN   dbo.ConfigGeneration pg ON pg.GenerationId = prior.GenerationId
        WHERE  draft.GenerationId = @DraftGenerationId
          AND  pg.ClusterId = @ClusterId)
    BEGIN
        RAISERROR('EquipmentUuid immutability violated for an EquipmentId that existed in a prior generation', 16, 1);
        RETURN;
    END

    IF EXISTS (
        SELECT 1
        FROM   dbo.Equipment draft
        JOIN   dbo.ExternalIdReservation r
            ON r.Kind = 'ZTag' AND r.Value = draft.ZTag AND r.ReleasedAt IS NULL
           AND r.EquipmentUuid <> draft.EquipmentUuid
        WHERE  draft.GenerationId = @DraftGenerationId AND draft.ZTag IS NOT NULL)
    BEGIN
        RAISERROR('BadDuplicateExternalIdentifier: a ZTag in the draft is reserved by a different EquipmentUuid', 16, 1);
        RETURN;
    END

    IF EXISTS (
        SELECT 1
        FROM   dbo.Equipment draft
        JOIN   dbo.ExternalIdReservation r
            ON r.Kind = 'SAPID' AND r.Value = draft.SAPID AND r.ReleasedAt IS NULL
           AND r.EquipmentUuid <> draft.EquipmentUuid
        WHERE  draft.GenerationId = @DraftGenerationId AND draft.SAPID IS NOT NULL)
    BEGIN
        RAISERROR('BadDuplicateExternalIdentifier: a SAPID in the draft is reserved by a different EquipmentUuid', 16, 1);
        RETURN;
    END
END
";

        public const string PublishGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_PublishGeneration
    @ClusterId          nvarchar(64),
    @DraftGenerationId  bigint,
    @Notes              nvarchar(1024) = NULL
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    -- Transaction-nesting awareness: if a caller (e.g. sp_RollbackToGeneration) already
    -- holds a transaction, we use SAVE TRANSACTION so our failure path rolls back only to
    -- the savepoint instead of issuing a bare ROLLBACK that wipes the caller's transaction
    -- (which sets @@TRANCOUNT = 0 and causes error 3902 on the caller's subsequent COMMIT).
    DECLARE @OwnsTxn  bit = 0;
    DECLARE @SaveName nvarchar(32) = N'sp_PublishGeneration';

    IF @@TRANCOUNT = 0
    BEGIN
        BEGIN TRANSACTION;
        SET @OwnsTxn = 1;
    END
    ELSE
    BEGIN
        SAVE TRANSACTION sp_PublishGeneration;
    END

    DECLARE @Lock nvarchar(255) = N'OtOpcUa_Publish_' + @ClusterId;
    DECLARE @LockResult int;
    EXEC @LockResult = sp_getapplock @Resource = @Lock, @LockMode = 'Exclusive', @LockTimeout = 0;
    IF @LockResult < 0
    BEGIN
        RAISERROR('PublishConflict: another publish is in progress for cluster %s', 16, 1, @ClusterId);
        IF @OwnsTxn = 1 ROLLBACK;
        ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
        RETURN;
    END

    -- sp_ValidateDraft signals every rejection with RAISERROR(..., 16, 1) — a severity-16 error is
    -- NOT batch-aborting and SET XACT_ABORT ON does not abort the transaction for it, so without a
    -- TRY/CATCH control would return here and the draft would publish despite failed validation.
    -- Catch the validation error, roll back the publish transaction (only to our savepoint when a
    -- caller owns the outer transaction), and re-raise so the caller sees the real validation failure.
    BEGIN TRY
        EXEC dbo.sp_ValidateDraft @DraftGenerationId = @DraftGenerationId;
    END TRY
    BEGIN CATCH
        IF @OwnsTxn = 1 ROLLBACK;
        ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
        THROW;
    END CATCH

    MERGE dbo.ExternalIdReservation AS tgt
    USING (
        SELECT 'ZTag' AS Kind, ZTag AS Value, EquipmentUuid
        FROM   dbo.Equipment
        WHERE  GenerationId = @DraftGenerationId AND ZTag IS NOT NULL
        UNION ALL
        SELECT 'SAPID', SAPID, EquipmentUuid
        FROM   dbo.Equipment
        WHERE  GenerationId = @DraftGenerationId AND SAPID IS NOT NULL
    ) AS src
        ON  tgt.Kind = src.Kind AND tgt.Value = src.Value AND tgt.EquipmentUuid = src.EquipmentUuid
    WHEN MATCHED THEN UPDATE SET LastPublishedAt = SYSUTCDATETIME()
    WHEN NOT MATCHED BY TARGET THEN
        INSERT (Kind, Value, EquipmentUuid, ClusterId, FirstPublishedBy, LastPublishedAt)
        VALUES (src.Kind, src.Value, src.EquipmentUuid, @ClusterId, SUSER_SNAME(), SYSUTCDATETIME());

    UPDATE dbo.ConfigGeneration
    SET    Status = 'Superseded'
    WHERE  ClusterId = @ClusterId AND Status = 'Published';

    UPDATE dbo.ConfigGeneration
    SET    Status      = 'Published',
           PublishedAt = SYSUTCDATETIME(),
           PublishedBy = SUSER_SNAME(),
           Notes       = ISNULL(@Notes, Notes)
    WHERE  GenerationId = @DraftGenerationId AND ClusterId = @ClusterId AND Status = 'Draft';

    IF @@ROWCOUNT = 0
    BEGIN
        RAISERROR('Draft %I64d for cluster %s not in Draft status (was it already published?)', 16, 1, @DraftGenerationId, @ClusterId);
        IF @OwnsTxn = 1 ROLLBACK;
        ELSE ROLLBACK TRANSACTION sp_PublishGeneration;
        RETURN;
    END

    INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId)
    VALUES (SUSER_SNAME(), 'Published', @ClusterId, @DraftGenerationId);

    IF @OwnsTxn = 1 COMMIT;
END
";

        public const string RollbackToGeneration = @"
CREATE OR ALTER PROCEDURE dbo.sp_RollbackToGeneration
    @ClusterId            nvarchar(64),
    @TargetGenerationId   bigint,
    @Notes                nvarchar(1024) = NULL
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;
    BEGIN TRANSACTION;

    IF NOT EXISTS (
        SELECT 1 FROM dbo.ConfigGeneration
        WHERE GenerationId = @TargetGenerationId AND ClusterId = @ClusterId
          AND Status IN ('Published', 'Superseded'))
    BEGIN
        RAISERROR('Target generation %I64d not found or not rollback-eligible', 16, 1, @TargetGenerationId);
        ROLLBACK; RETURN;
    END

    DECLARE @NewGenId bigint;
    INSERT dbo.ConfigGeneration (ClusterId, Status, CreatedAt, CreatedBy, PublishedAt, PublishedBy, Notes)
    VALUES (@ClusterId, 'Draft', SYSUTCDATETIME(), SUSER_SNAME(), NULL, NULL,
            ISNULL(@Notes, CONCAT('Rollback clone of generation ', @TargetGenerationId)));
    SET @NewGenId = SCOPE_IDENTITY();

    INSERT dbo.Namespace      (GenerationId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes)
        SELECT @NewGenId, NamespaceId, ClusterId, Kind, NamespaceUri, Enabled, Notes FROM dbo.Namespace WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.UnsArea        (GenerationId, UnsAreaId, ClusterId, Name, Notes)
        SELECT @NewGenId, UnsAreaId, ClusterId, Name, Notes FROM dbo.UnsArea WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.UnsLine        (GenerationId, UnsLineId, UnsAreaId, Name, Notes)
        SELECT @NewGenId, UnsLineId, UnsAreaId, Name, Notes FROM dbo.UnsLine WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.DriverInstance (GenerationId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig)
        SELECT @NewGenId, DriverInstanceId, ClusterId, NamespaceId, Name, DriverType, Enabled, DriverConfig FROM dbo.DriverInstance WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.Device         (GenerationId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig)
        SELECT @NewGenId, DeviceId, DriverInstanceId, Name, Enabled, DeviceConfig FROM dbo.Device WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.Equipment      (GenerationId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled)
        SELECT @NewGenId, EquipmentId, EquipmentUuid, DriverInstanceId, DeviceId, UnsLineId, Name, MachineCode, ZTag, SAPID, Manufacturer, Model, SerialNumber, HardwareRevision, SoftwareRevision, YearOfConstruction, AssetLocation, ManufacturerUri, DeviceManualUri, EquipmentClassRef, Enabled FROM dbo.Equipment WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.PollGroup      (GenerationId, PollGroupId, DriverInstanceId, Name, IntervalMs)
        SELECT @NewGenId, PollGroupId, DriverInstanceId, Name, IntervalMs FROM dbo.PollGroup WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.Tag            (GenerationId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig)
        SELECT @NewGenId, TagId, DriverInstanceId, DeviceId, EquipmentId, Name, FolderPath, DataType, AccessLevel, WriteIdempotent, PollGroupId, TagConfig FROM dbo.Tag WHERE GenerationId = @TargetGenerationId;
    INSERT dbo.NodeAcl        (GenerationId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes)
        SELECT @NewGenId, NodeAclId, ClusterId, LdapGroup, ScopeKind, ScopeId, PermissionFlags, Notes FROM dbo.NodeAcl WHERE GenerationId = @TargetGenerationId;

    EXEC dbo.sp_PublishGeneration @ClusterId = @ClusterId, @DraftGenerationId = @NewGenId, @Notes = @Notes;

    -- @TargetGenerationId is a bigint, but build the JSON value via an explicit numeric CONVERT so
    -- the emitted token is always a bare JSON number — never reliant on implicit string coercion.
    INSERT dbo.ConfigAuditLog (Principal, EventType, ClusterId, GenerationId, DetailsJson)
    VALUES (SUSER_SNAME(), 'RolledBack', @ClusterId, @NewGenId,
            CONCAT('{""rolledBackTo"":', CONVERT(nvarchar(20), CONVERT(bigint, @TargetGenerationId)), '}'));

    COMMIT;
END
";

        public const string ComputeGenerationDiff = @"
CREATE OR ALTER PROCEDURE dbo.sp_ComputeGenerationDiff
    @FromGenerationId bigint,
    @ToGenerationId   bigint
AS
BEGIN
    SET NOCOUNT ON;

    CREATE TABLE #diff (TableName nvarchar(32), LogicalId nvarchar(64), ChangeKind nvarchar(16));

    WITH f AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @FromGenerationId),
         t AS (SELECT NamespaceId AS LogicalId, CHECKSUM(NamespaceUri, Kind, Enabled, Notes) AS Sig FROM dbo.Namespace WHERE GenerationId = @ToGenerationId)
    INSERT #diff
    SELECT 'Namespace', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
           CASE WHEN f.LogicalId IS NULL THEN 'Added'
                WHEN t.LogicalId IS NULL THEN 'Removed'
                WHEN f.Sig <> t.Sig       THEN 'Modified'
                ELSE 'Unchanged' END
    FROM   f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
    WHERE  f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;

    WITH f AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @FromGenerationId),
         t AS (SELECT DriverInstanceId AS LogicalId, CHECKSUM(ClusterId, NamespaceId, Name, DriverType, Enabled, CONVERT(varchar(max), DriverConfig)) AS Sig FROM dbo.DriverInstance WHERE GenerationId = @ToGenerationId)
    INSERT #diff
    SELECT 'DriverInstance', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
           CASE WHEN f.LogicalId IS NULL THEN 'Added'
                WHEN t.LogicalId IS NULL THEN 'Removed'
                WHEN f.Sig <> t.Sig       THEN 'Modified'
                ELSE 'Unchanged' END
    FROM   f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
    WHERE  f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;

    WITH f AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @FromGenerationId),
         t AS (SELECT EquipmentId AS LogicalId, CHECKSUM(EquipmentUuid, DriverInstanceId, UnsLineId, Name, MachineCode, ZTag, SAPID, EquipmentClassRef, Manufacturer, Model, SerialNumber) AS Sig FROM dbo.Equipment WHERE GenerationId = @ToGenerationId)
    INSERT #diff
    SELECT 'Equipment', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
           CASE WHEN f.LogicalId IS NULL THEN 'Added'
                WHEN t.LogicalId IS NULL THEN 'Removed'
                WHEN f.Sig <> t.Sig       THEN 'Modified'
                ELSE 'Unchanged' END
    FROM   f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
    WHERE  f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;

    WITH f AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @FromGenerationId),
         t AS (SELECT TagId AS LogicalId, CHECKSUM(DriverInstanceId, DeviceId, EquipmentId, PollGroupId, FolderPath, Name, DataType, AccessLevel, WriteIdempotent, CONVERT(varchar(max), TagConfig)) AS Sig FROM dbo.Tag WHERE GenerationId = @ToGenerationId)
    INSERT #diff
    SELECT 'Tag', CONVERT(nvarchar(64), COALESCE(f.LogicalId, t.LogicalId)),
           CASE WHEN f.LogicalId IS NULL THEN 'Added'
                WHEN t.LogicalId IS NULL THEN 'Removed'
                WHEN f.Sig <> t.Sig       THEN 'Modified'
                ELSE 'Unchanged' END
    FROM   f FULL OUTER JOIN t ON f.LogicalId = t.LogicalId
    WHERE  f.LogicalId IS NULL OR t.LogicalId IS NULL OR f.Sig <> t.Sig;

    SELECT TableName, LogicalId, ChangeKind FROM #diff;
    DROP TABLE #diff;
END
";

        public const string ReleaseExternalIdReservation = @"
CREATE OR ALTER PROCEDURE dbo.sp_ReleaseExternalIdReservation
    @Kind           nvarchar(16),
    @Value          nvarchar(64),
    @ReleaseReason  nvarchar(512)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    IF @ReleaseReason IS NULL OR LEN(@ReleaseReason) = 0
    BEGIN
        RAISERROR('ReleaseReason is required', 16, 1);
        RETURN;
    END

    UPDATE dbo.ExternalIdReservation
    SET    ReleasedAt     = SYSUTCDATETIME(),
           ReleasedBy     = SUSER_SNAME(),
           ReleaseReason  = @ReleaseReason
    WHERE  Kind = @Kind AND Value = @Value AND ReleasedAt IS NULL;

    IF @@ROWCOUNT = 0
    BEGIN
        RAISERROR('No active reservation found for (%s, %s)', 16, 1, @Kind, @Value);
        RETURN;
    END

    -- Escape both caller-supplied values via STRING_ESCAPE so quotes/backslashes cannot break the
    -- JSON document or inject additional structure into the audit record.
    INSERT dbo.ConfigAuditLog (Principal, EventType, DetailsJson)
    VALUES (SUSER_SNAME(), 'ExternalIdReleased',
            CONCAT('{""kind"":""', STRING_ESCAPE(@Kind, 'json'),
                   '"",""value"":""', STRING_ESCAPE(@Value, 'json'), '""}'));
END
";
    }
}
