using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Migrations
{
    /// <summary>
    /// Collapses the central
    /// <c>dbo.AuditLog</c> table from the transitional 24 typed columns to the 10
    /// canonical <c>ZB.MOM.WW.Audit.AuditEvent</c> columns plus six read-only,
    /// server-side <b>persisted computed columns</b> derived from <c>DetailsJson</c>
    /// (<c>JSON_VALUE</c> … <c>PERSISTED</c>) that back the indexed reporting queries.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Why new-table + copy (not in-place ALTER).</b> An in-place collapse is
    /// infeasible: the partition-aligned indexes are keyed on columns that are being
    /// dropped; the purge path (<c>SwitchOutPartitionAsync</c>) hard-codes a
    /// byte-identical staging column list; and dropping <c>nvarchar(max)</c> columns
    /// per partition is expensive. Instead this migration builds a fresh
    /// <c>dbo.AuditLog_v2</c> on the SAME preserved partition scheme
    /// (<c>ps_AuditLog_Month(OccurredAtUtc)</c>), copies every row with a one-way
    /// projection of the old typed columns into canonical columns + <c>DetailsJson</c>,
    /// drops the old table, renames v2 into place, and re-grants the append-only roles.
    /// </para>
    /// <para>
    /// <b>Down() is a documented ONE-WAY.</b> The projection of ~17 typed columns into
    /// a single JSON bag is lossy to reverse byte-for-byte (e.g. the codec's
    /// null-omission + key order, the Action/Category/Outcome derivation), so the
    /// reverse is NOT implemented. <see cref="Down"/> throws
    /// <see cref="System.NotSupportedException"/> with guidance to restore from backup.
    /// </para>
    /// <para>
    /// <b>Partition / SWITCH / computed-column interaction.</b> The new table and its
    /// non-clustered indexes are created directly on <c>ps_AuditLog_Month</c> so the
    /// partition-switch purge keeps working; the non-aligned <c>UX_AuditLog_EventId</c>
    /// stays on <c>[PRIMARY]</c> exactly as before. The persisted computed columns are
    /// part of the table's storage, so the staging table used by
    /// <c>SwitchOutPartitionAsync</c> MUST declare them with identical expressions —
    /// see that method (kept in sync with the v2 DDL below).
    /// </para>
    /// </remarks>
    public partial class CollapseAuditLogToCanonical : Migration
    {
        /// <inheritdoc />
        protected override void Up(MigrationBuilder migrationBuilder)
        {
            // 1) Create dbo.AuditLog_v2 on the PRESERVED partition scheme. Ten
            //    canonical columns first (ordinal-stable), then the six computed
            //    columns (five PERSISTED + IngestedAtUtc non-persisted). The clustered PK is composite {EventId,
            //    OccurredAtUtc} (partition-aligned). The computed-column expressions
            //    here are the single source of truth that AuditLogEntityTypeConfiguration
            //    and SwitchOutPartitionAsync's staging table must mirror exactly.
            migrationBuilder.Sql(@"
CREATE TABLE dbo.AuditLog_v2 (
    EventId            uniqueidentifier NOT NULL,
    OccurredAtUtc      datetime2(7)     NOT NULL,
    Actor              nvarchar(256)    NULL,
    Action             varchar(64)      NOT NULL,
    Outcome            varchar(16)      NOT NULL,
    Category           varchar(32)      NOT NULL,
    Target             nvarchar(256)    NULL,
    SourceNode         varchar(64)      NULL,
    CorrelationId      uniqueidentifier NULL,
    DetailsJson        nvarchar(max)    NULL,
    Kind               AS JSON_VALUE(DetailsJson,'$.kind') PERSISTED,
    Status             AS JSON_VALUE(DetailsJson,'$.status') PERSISTED,
    SourceSiteId       AS JSON_VALUE(DetailsJson,'$.sourceSiteId') PERSISTED,
    ExecutionId        AS CAST(JSON_VALUE(DetailsJson,'$.executionId') AS uniqueidentifier) PERSISTED,
    ParentExecutionId  AS CAST(JSON_VALUE(DetailsJson,'$.parentExecutionId') AS uniqueidentifier) PERSISTED,
    -- IngestedAtUtc is NOT persisted: the datetimeoffset cast / SWITCHOFFSET is
    -- non-deterministic and SQL Server rejects a PERSISTED non-deterministic
    -- computed column. It is not indexed, so non-persistence costs nothing.
    IngestedAtUtc      AS CAST(SWITCHOFFSET(CAST(JSON_VALUE(DetailsJson,'$.ingestedAtUtc') AS datetimeoffset), 0) AS datetime2(7)),
    CONSTRAINT PK_AuditLog_v2 PRIMARY KEY CLUSTERED (EventId, OccurredAtUtc)
        ON ps_AuditLog_Month(OccurredAtUtc)
) ON ps_AuditLog_Month(OccurredAtUtc);");

            // 2) Data copy: project the old 24-column rows into the canonical shape.
            //    - Action  = Channel + '.' + Kind (matches AuditFieldBuilders.BuildAction)
            //    - Category = Channel              (matches AuditFieldBuilders.BuildCategory)
            //    - Outcome  = AuditOutcomeProjector.Project(Status, Kind):
            //        Kind='InboundAuthFailure' -> 'Denied' (wins over any status);
            //        Status in ('Failed','Parked','Discarded') -> 'Failure'; else 'Success'.
            //    - Actor    : empty string maps to NULL (canonical Actor is non-null,
            //                 but the old column stored NULL for system/anon — keep NULL).
            //    - DetailsJson: every domain field re-serialised as a single JSON object
            //                 with camelCase keys matching AuditDetailsCodec. FOR JSON
            //                 PATH, WITHOUT_ARRAY_WRAPPER OMITS null keys by default
            //                 (no INCLUDE_NULL_VALUES), matching the codec's
            //                 JsonIgnoreCondition.WhenWritingNull; channel/kind/status
            //                 are NOT NULL in the legacy table so they are always present
            //                 and emit the enum-name strings the computed Kind/Status
            //                 (and Channel-as-Category) derive from. payloadTruncated is a
            //                 non-null bit so it is always written, matching the codec
            //                 (which always writes the bool).
            //    The six computed columns auto-derive from DetailsJson on INSERT.
            migrationBuilder.Sql(@"
INSERT INTO dbo.AuditLog_v2
    (EventId, OccurredAtUtc, Actor, Action, Outcome, Category, Target, SourceNode, CorrelationId, DetailsJson)
SELECT
    a.EventId,
    a.OccurredAtUtc,
    NULLIF(a.Actor, '')                                   AS Actor,
    a.Channel + '.' + a.Kind                              AS Action,
    CASE
        WHEN a.Kind = 'InboundAuthFailure'                THEN 'Denied'
        WHEN a.Status IN ('Failed','Parked','Discarded')  THEN 'Failure'
        ELSE 'Success'
    END                                                   AS Outcome,
    a.Channel                                             AS Category,
    a.Target,
    a.SourceNode,
    a.CorrelationId,
    (
        SELECT
            a.Channel                                                                AS channel,
            a.Kind                                                                   AS kind,
            a.Status                                                                 AS status,
            a.ExecutionId                                                            AS executionId,
            a.ParentExecutionId                                                      AS parentExecutionId,
            a.SourceSiteId                                                           AS sourceSiteId,
            a.SourceInstanceId                                                       AS sourceInstanceId,
            a.SourceScript                                                           AS sourceScript,
            a.HttpStatus                                                             AS httpStatus,
            a.DurationMs                                                             AS durationMs,
            a.ErrorMessage                                                           AS errorMessage,
            a.ErrorDetail                                                            AS errorDetail,
            a.RequestSummary                                                         AS requestSummary,
            a.ResponseSummary                                                        AS responseSummary,
            CAST(a.PayloadTruncated AS bit)                                          AS payloadTruncated,
            a.Extra                                                                  AS extra,
            CASE WHEN a.IngestedAtUtc IS NULL THEN NULL
                 ELSE CONVERT(varchar(33),
                              CAST(a.IngestedAtUtc AS datetimeoffset(7)) AT TIME ZONE 'UTC',
                              126)
            END                                                                      AS ingestedAtUtc
        FOR JSON PATH, WITHOUT_ARRAY_WRAPPER
    )                                                     AS DetailsJson
FROM dbo.AuditLog a;");

            // 3) Drop the old table and rename v2 into place. The table rename uses
            //    the 'schema.table' form; the PK constraint rename uses the BARE
            //    constraint name with @objtype='OBJECT' (a constraint is a schema-
            //    scoped object, NOT table-qualified — a 'dbo.AuditLog.PK_…' form is
            //    rejected as ambiguous). The non-clustered indexes were dropped with
            //    the old table; they are recreated by name in step 4 (no index rename
            //    needed because the v2 table was created without them).
            migrationBuilder.Sql(@"
DROP TABLE dbo.AuditLog;
EXEC sp_rename 'dbo.AuditLog_v2', 'AuditLog';
EXEC sp_rename 'PK_AuditLog_v2', 'PK_AuditLog', 'OBJECT';");

            // 4) Recreate the reconciliation/query indexes on the new shape, names
            //    preserved (alog.md §4 semantics): Channel→Category, Site/Node read
            //    off the canonical/computed columns. All non-clustered indexes are
            //    partition-aligned on ps_AuditLog_Month(OccurredAtUtc) so the
            //    partition-switch purge keeps touching a single partition. The
            //    UX_AuditLog_EventId unique index is INTENTIONALLY non-aligned (on
            //    [PRIMARY]) to give single-column EventId uniqueness for
            //    InsertIfNotExistsAsync idempotency.
            migrationBuilder.Sql(@"
CREATE NONCLUSTERED INDEX IX_AuditLog_OccurredAtUtc
ON dbo.AuditLog (OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE NONCLUSTERED INDEX IX_AuditLog_Site_Occurred
ON dbo.AuditLog (SourceSiteId ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE NONCLUSTERED INDEX IX_AuditLog_CorrelationId
ON dbo.AuditLog (CorrelationId)
WHERE CorrelationId IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE NONCLUSTERED INDEX IX_AuditLog_Channel_Status_Occurred
ON dbo.AuditLog (Category ASC, Status ASC, OccurredAtUtc DESC)
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE NONCLUSTERED INDEX IX_AuditLog_Target_Occurred
ON dbo.AuditLog (Target ASC, OccurredAtUtc DESC)
WHERE Target IS NOT NULL
ON ps_AuditLog_Month(OccurredAtUtc);

-- IX_AuditLog_Execution / IX_AuditLog_ParentExecution are NOT filtered: SQL
-- Server forbids a filtered-index WHERE predicate from referencing a computed
-- column (ExecutionId / ParentExecutionId are persisted computed columns). An
-- unfiltered index still backs the equality lookups GetExecutionTreeAsync uses;
-- it just also indexes the NULL rows. (The pre-C5 typed columns allowed the
-- IS NOT NULL filter; the computed-column constraint does not.)
CREATE NONCLUSTERED INDEX IX_AuditLog_Execution
ON dbo.AuditLog (ExecutionId)
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE NONCLUSTERED INDEX IX_AuditLog_ParentExecution
ON dbo.AuditLog (ParentExecutionId)
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE NONCLUSTERED INDEX IX_AuditLog_Node_Occurred
ON dbo.AuditLog (SourceNode ASC, OccurredAtUtc ASC)
ON ps_AuditLog_Month(OccurredAtUtc);

CREATE UNIQUE NONCLUSTERED INDEX UX_AuditLog_EventId
ON dbo.AuditLog (EventId)
ON [PRIMARY];");

            // 5) Re-grant the append-only roles on the renamed table. The grants were
            //    object-scoped to the old (now-dropped) table, so they must be re-issued
            //    against the new one. Idempotent role creation guards a fresh DB. The
            //    DENY UPDATE / DENY DELETE on the writer role is deliberate — a future
            //    db_datawriter membership cannot quietly re-enable mutation (DENY > GRANT).
            migrationBuilder.Sql(@"
IF DATABASE_PRINCIPAL_ID('scadabridge_audit_writer') IS NULL
    EXEC sp_executesql N'CREATE ROLE scadabridge_audit_writer';
GRANT INSERT ON dbo.AuditLog TO scadabridge_audit_writer;
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_writer;
DENY UPDATE ON dbo.AuditLog TO scadabridge_audit_writer;
DENY DELETE ON dbo.AuditLog TO scadabridge_audit_writer;

IF DATABASE_PRINCIPAL_ID('scadabridge_audit_purger') IS NULL
    EXEC sp_executesql N'CREATE ROLE scadabridge_audit_purger';
GRANT SELECT ON dbo.AuditLog TO scadabridge_audit_purger;
GRANT ALTER ON SCHEMA::dbo TO scadabridge_audit_purger;");
        }

        /// <inheritdoc />
        protected override void Down(MigrationBuilder migrationBuilder)
        {
            // ONE-WAY MIGRATION. Collapsing the 24 typed columns into the canonical
            // shape + a single DetailsJson bag is lossy to reverse byte-for-byte: the
            // Action/Category/Outcome derivation discards the original Channel/Kind/
            // Status split in a way only DetailsJson can reconstruct, and the codec's
            // null-omission + key-order contract cannot be reproduced by a generic
            // reverse projection. Reversing in place would also have to rebuild the
            // partition-aligned indexes on the dropped typed columns. If a rollback is
            // required, restore the central database from a pre-migration backup.
            throw new System.NotSupportedException(
                "CollapseAuditLogToCanonical is a one-way migration (Task 2.5 C5): the " +
                "central dbo.AuditLog collapse projects the legacy typed columns into a " +
                "lossy canonical/DetailsJson shape that cannot be reversed automatically. " +
                "Restore the database from a pre-migration backup to roll back.");
        }
    }
}
