-- usp_SyncApply: set-based apply of pending SyncQueue rows.
-- Per distinct (TargetSchema, TargetTable) it builds column lists from sys.columns
-- (excluding the ID key, computed, identity and rowversion columns) and runs a
-- dynamic-SQL MERGE (last-write-wins by SourceLogID DESC) for Insert/Update and a
-- DELETE for the last op = Delete. SET IDENTITY_INSERT ON preserves Access PKs.
--
-- CORRECTNESS NOTE: a SINGLE ranked CTE (rn=1 per RecordID, over ALL pending ops
-- regardless of OperateType) is read by BOTH the upsert and delete branches.
-- Earlier this was two independent ranked CTEs (one over Insert/Update, one over
-- Delete), which let a stale Delete outrank a newer Insert for the same RecordID
-- and silently drop a row whose true last op was an Insert. Routing off the one
-- winning row's OperateType guarantees only the genuine last op wins.
--
-- AUDIT NOTE: when ProductionDataBaseSync.SyncApplyRunLog exists (see
-- sql/04_sync_apply_runlog.sql), one audit row is written per target table and
-- per invocation: pending/distinct counts going in, the MERGE/DELETE rowcounts,
-- how many queue rows were flipped to applied (or to error/dead on failure),
-- the error message, the duration, and the caller-supplied @CycleID that ties
-- the row to the Python service log's [cyc:...] lines. The audit insert is
-- best-effort (wrapped in its own TRY/CATCH, outside the data transaction) and
-- can never fail the apply itself. @CycleID defaults to NULL so the legacy
-- single-parameter EXEC keeps working.

CREATE OR ALTER PROCEDURE ProductionDataBaseSync.usp_SyncApply
    @MaxRetries INT = 5,
    @CycleID    NVARCHAR(40) = NULL
AS
BEGIN
    SET NOCOUNT ON;

    DECLARE @sch NVARCHAR(128), @tbl NVARCHAR(255), @FullName NVARCHAR(514);
    DECLARE @sql NVARCHAR(MAX), @cols NVARCHAR(MAX), @upd NVARCHAR(MAX), @ins NVARCHAR(MAX);
    DECLARE @hasRunLog bit =
        CASE WHEN OBJECT_ID('ProductionDataBaseSync.SyncApplyRunLog', 'U') IS NOT NULL
             THEN 1 ELSE 0 END;
    DECLARE @t0 DATETIME2, @pending INT, @distinctRecs INT,
            @merged INT, @deleted INT, @applied INT,
            @errCnt INT, @deadCnt INT,
            @errMsg NVARCHAR(MAX), @outcome VARCHAR(10);

    -- Re-queue rows still under the retry budget.
    UPDATE ProductionDataBaseSync.SyncQueue
       SET Status = 'pending'
     WHERE Status = 'error' AND RetryCount < @MaxRetries;

    DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
        SELECT DISTINCT TargetSchema, TargetTable
          FROM ProductionDataBaseSync.SyncQueue
         WHERE Status = 'pending';

    OPEN cur;
    FETCH NEXT FROM cur INTO @sch, @tbl;

    WHILE @@FETCH_STATUS = 0
    BEGIN
        SET @FullName = QUOTENAME(@sch) + N'.' + QUOTENAME(@tbl);

        -- Per-table audit state. Explicit reset every iteration: local
        -- variables keep their previous value across cursor loops.
        SELECT @t0 = SYSDATETIME(),
               @merged = NULL, @deleted = NULL, @applied = NULL,
               @errCnt = NULL, @deadCnt = NULL,
               @errMsg = NULL, @outcome = 'ok',
               @cols = NULL, @upd = NULL, @ins = NULL;

        SELECT @pending      = COUNT(*),
               @distinctRecs = COUNT(DISTINCT RecordID)
          FROM ProductionDataBaseSync.SyncQueue
         WHERE TargetSchema = @sch AND TargetTable = @tbl AND Status = 'pending';

        BEGIN TRY
            BEGIN TRAN;
            -- @cols: comma-quoted column names (for INSERT target list)
            -- @upd:  tgt.col = JSON_VALUE(src.RowData,'$.col')
            -- @ins:  JSON_VALUE(src.RowData,'$.col')  (for INSERT VALUES)
            -- NOTE: every JSON path key is wrapped in double quotes ('$."col"').
            -- This is mandatory for non-ASCII column names (e.g. Chinese names
            -- like 名字) and harmless for ASCII names, so we quote unconditionally.
            SELECT
                @cols = STRING_AGG(QUOTENAME(c.name), N',')
                            WITHIN GROUP (ORDER BY c.column_id),
                @upd  = STRING_AGG(QUOTENAME(c.name) + N'=JSON_VALUE(src.RowData,''$."' + c.name + N'"'')', N',')
                            WITHIN GROUP (ORDER BY c.column_id),
                @ins  = STRING_AGG(N'JSON_VALUE(src.RowData,''$."' + c.name + N'"'')', N',')
                            WITHIN GROUP (ORDER BY c.column_id)
              FROM sys.columns c
             WHERE c.object_id = OBJECT_ID(@FullName)
               AND c.is_computed = 0
               AND c.is_identity = 0
               AND TYPE_NAME(c.system_type_id) <> 'timestamp'
               AND c.name <> 'ID';

            IF @cols IS NOT NULL
            BEGIN
                -- Upsert: winners (rn=1) whose winning OperateType is
                -- Insert/Update and carry RowData. The ranked CTE covers ALL
                -- pending ops so the rn=1 row is the true last op for that
                -- RecordID; an earlier Delete can no longer outrank a newer
                -- Insert for the same RecordID.
                -- AUDIT: @@ROWCOUNT is captured into @MergedOut IMMEDIATELY
                -- after the MERGE -- SET IDENTITY_INSERT (like any SET option)
                -- resets @@ROWCOUNT, so the order below is load-bearing.
                SET @sql = N'SET IDENTITY_INSERT ' + @FullName + N' ON;'
                         + N';WITH ranked AS ('
                         + N'  SELECT RecordID, OperateType, RowData,'
                         + N'         ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn'
                         + N'    FROM ProductionDataBaseSync.SyncQueue'
                         + N'   WHERE TargetSchema=@sch AND TargetTable=@tbl AND Status=''pending'''
                         + N')'
                         + N'MERGE ' + @FullName + N' WITH (HOLDLOCK) AS tgt'
                         + N' USING ('
                         + N'  SELECT RecordID, RowData FROM ranked'
                         + N'   WHERE rn=1 AND OperateType IN (''Insert'',''Update'') AND RowData IS NOT NULL'
                         + N') AS src ON tgt.ID = TRY_CAST(src.RecordID AS int)'
                         + N' WHEN MATCHED THEN UPDATE SET ' + @upd
                         + N' WHEN NOT MATCHED THEN INSERT (ID,' + @cols + N')'
                         + N'  VALUES (TRY_CAST(src.RecordID AS int),' + @ins + N');'
                         + N'SET @MergedOut = @@ROWCOUNT;'
                         + N'SET IDENTITY_INSERT ' + @FullName + N' OFF;';
                EXEC sp_executesql @sql,
                     N'@sch NVARCHAR(128),@tbl NVARCHAR(255),@MergedOut INT OUTPUT',
                     @sch, @tbl, @MergedOut = @merged OUTPUT;

                -- Delete: winners (rn=1) whose winning OperateType is Delete.
                -- Same ranked CTE — only the genuine last op can be a delete.
                SET @sql = N';WITH ranked AS ('
                         + N'  SELECT RecordID, OperateType,'
                         + N'         ROW_NUMBER() OVER (PARTITION BY RecordID ORDER BY SourceLogID DESC) rn'
                         + N'    FROM ProductionDataBaseSync.SyncQueue'
                         + N'   WHERE TargetSchema=@sch AND TargetTable=@tbl AND Status=''pending'''
                         + N')'
                         + N'DELETE t FROM ' + @FullName + N' t'
                         + N' JOIN (SELECT RecordID FROM ranked WHERE rn=1 AND OperateType=''Delete'') d'
                         + N'   ON t.ID = TRY_CAST(d.RecordID AS int);'
                         + N'SET @DeletedOut = @@ROWCOUNT;';
                EXEC sp_executesql @sql,
                     N'@sch NVARCHAR(128),@tbl NVARCHAR(255),@DeletedOut INT OUTPUT',
                     @sch, @tbl, @DeletedOut = @deleted OUTPUT;
            END

            UPDATE ProductionDataBaseSync.SyncQueue
               SET Status = 'applied', AppliedAt = SYSDATETIME()
             WHERE TargetSchema = @sch AND TargetTable = @tbl AND Status = 'pending';
            SET @applied = @@ROWCOUNT;

            COMMIT;
        END TRY
        BEGIN CATCH
            -- ROLLBACK unwinds the per-table transaction (and, if the caller
            -- started one, the caller's too). A proc that errors under an outer
            -- transaction is expected to leave the txn uncommittable; the caller
            -- issues its own rollback. We roll back here so the partial per-table
            -- work is discarded before marking rows as 'error'/'dead'.
            IF @@TRANCOUNT > 0 ROLLBACK;
            SELECT @errMsg = ERROR_MESSAGE(), @outcome = 'error';

            -- Split of the previous single CASE update so the audit row can
            -- report exactly how many rows died vs. how many will be retried.
            -- Net effect on SyncQueue is identical.
            UPDATE ProductionDataBaseSync.SyncQueue
               SET Status = 'dead', RetryCount = RetryCount + 1, ErrorMsg = @errMsg
             WHERE TargetSchema = @sch AND TargetTable = @tbl AND Status = 'pending'
               AND RetryCount + 1 >= @MaxRetries;
            SET @deadCnt = @@ROWCOUNT;

            UPDATE ProductionDataBaseSync.SyncQueue
               SET Status = 'error', RetryCount = RetryCount + 1, ErrorMsg = @errMsg
             WHERE TargetSchema = @sch AND TargetTable = @tbl AND Status = 'pending';
            SET @errCnt = @@ROWCOUNT;
        END CATCH

        -- Best-effort audit row: sits outside the data transaction (after
        -- COMMIT/ROLLBACK) so it survives either outcome, and its own failure
        -- can never break the apply loop.
        IF @hasRunLog = 1
        BEGIN
            BEGIN TRY
                INSERT ProductionDataBaseSync.SyncApplyRunLog
                    (CycleID, TargetSchema, TargetTable,
                     PendingCount, DistinctRecords,
                     MergedCount, DeletedCount, AppliedCount,
                     ErrorCount, DeadCount,
                     Outcome, ErrorMsg, StartedAt, DurationMs)
                VALUES
                    (@CycleID, @sch, @tbl,
                     @pending, @distinctRecs,
                     @merged, @deleted, @applied,
                     @errCnt, @deadCnt,
                     @outcome, @errMsg, @t0,
                     DATEDIFF(millisecond, @t0, SYSDATETIME()));
            END TRY
            BEGIN CATCH
                PRINT 'SyncApplyRunLog insert failed: ' + ERROR_MESSAGE();
            END CATCH
        END

        FETCH NEXT FROM cur INTO @sch, @tbl;
    END

    CLOSE cur;
    DEALLOCATE cur;
END
GO
