using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Notifications;

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

/// <summary>
/// EF Core data access for the central notification outbox. See
/// <see cref="INotificationOutboxRepository"/> for the behaviour contract.
/// </summary>
public class NotificationOutboxRepository : INotificationOutboxRepository
{
    // SQL Server duplicate-key error numbers, matching the AuditLogRepository
    // and SiteCallAuditRepository race-fixes. 2601 is a unique-index violation;
    // 2627 is a primary-key/unique-constraint violation. The IF NOT EXISTS …
    // INSERT pattern has a check-then-act race window — two sessions can both
    // pass the EXISTS check and then both attempt the INSERT — and the loser
    // surfaces as one of these. The site→central handoff is documented
    // at-least-once with insert-if-not-exists, so the collision IS the expected
    // contention mode; idempotency demands we swallow them rather than let the
    // site retry the same NotificationId forever.
    private const int SqlErrorUniqueIndexViolation = 2601;
    private const int SqlErrorPrimaryKeyViolation = 2627;

    private readonly ScadaBridgeDbContext _context;
    private readonly ILogger<NotificationOutboxRepository> _logger;

    // Statuses that represent a finished notification lifecycle. Non-terminal is the complement.
    private static readonly NotificationStatus[] TerminalStatuses =
    {
        NotificationStatus.Delivered,
        NotificationStatus.Parked,
        NotificationStatus.Discarded,
    };

    /// <summary>Initializes a new instance of <see cref="NotificationOutboxRepository"/> with the given EF Core context.</summary>
    /// <param name="context">The EF Core database context.</param>
    /// <param name="logger">Optional logger instance.</param>
    public NotificationOutboxRepository(ScadaBridgeDbContext context, ILogger<NotificationOutboxRepository>? logger = null)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _logger = logger ?? NullLogger<NotificationOutboxRepository>.Instance;
    }

    /// <inheritdoc />
    public async Task<bool> InsertIfNotExistsAsync(Notification n, CancellationToken cancellationToken = default)
    {
        if (n is null)
        {
            throw new ArgumentNullException(nameof(n));
        }

        // Enum columns are stored as varchar(32) (HasConversion<string>()); convert
        // in C# rather than relying on parameter type inference (SqlClient would
        // otherwise bind enums as int by default and break the column conversion).
        var type = n.Type.ToString();
        var status = n.Status.ToString();

        // Provider-aware idempotent insert. The production path is SQL Server; the
        // NotificationOutbox integration tests run over an in-memory SQLite database,
        // and SQLite does not understand the T-SQL `IF NOT EXISTS … INSERT` form
        // (it raises 'near "IF": syntax error'). Detect SQLite by provider name so
        // ConfigurationDatabase needs no Sqlite package reference; the SQL Server
        // path below stays byte-identical.
        if (_context.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite")
        {
            // SQLite's idempotent insert: INSERT OR IGNORE skips the row when the
            // NotificationId primary key already exists (no schema prefix in SQLite).
            // Same parameterised columns/values as the SQL Server branch — the
            // FormattableString interpolation still parameterises every value
            // (no concatenation), so this is injection-safe.
            var sqliteRowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
                $@"INSERT OR IGNORE INTO Notifications
    (NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError,
     ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript,
     OriginExecutionId, OriginParentExecutionId,
     SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt)
VALUES
    ({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError},
     {n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript},
     {n.OriginExecutionId}, {n.OriginParentExecutionId},
     {n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});",
                cancellationToken);

            // rowsAffected == 1 -> we inserted; 0 -> the row was IGNOREd because the
            // NotificationId already existed. Preserves the true=inserted / false=already-existed
            // contract. No SqlException catch needed — INSERT OR IGNORE never raises a
            // unique-constraint violation.
            return sqliteRowsAffected == 1;
        }

        // FormattableString interpolation parameterises every value (no concatenation),
        // so this is safe against injection even for the string columns.
        try
        {
            var rowsAffected = await _context.Database.ExecuteSqlInterpolatedAsync(
                $@"IF NOT EXISTS (SELECT 1 FROM dbo.Notifications WHERE NotificationId = {n.NotificationId})
INSERT INTO dbo.Notifications
    (NotificationId, Type, ListName, Subject, Body, TypeData, Status, RetryCount, LastError,
     ResolvedTargets, SourceSiteId, SourceNode, SourceInstanceId, SourceScript,
     OriginExecutionId, OriginParentExecutionId,
     SiteEnqueuedAt, CreatedAt, LastAttemptAt, NextAttemptAt, DeliveredAt)
VALUES
    ({n.NotificationId}, {type}, {n.ListName}, {n.Subject}, {n.Body}, {n.TypeData}, {status}, {n.RetryCount}, {n.LastError},
     {n.ResolvedTargets}, {n.SourceSiteId}, {n.SourceNode}, {n.SourceInstanceId}, {n.SourceScript},
     {n.OriginExecutionId}, {n.OriginParentExecutionId},
     {n.SiteEnqueuedAt}, {n.CreatedAt}, {n.LastAttemptAt}, {n.NextAttemptAt}, {n.DeliveredAt});",
                cancellationToken);

            // rowsAffected == 1 -> we inserted; 0 -> a prior row was already there
            // (IF NOT EXISTS short-circuited the INSERT).
            return rowsAffected == 1;
        }
        catch (SqlException ex) when (
            ex.Number == SqlErrorUniqueIndexViolation
            || ex.Number == SqlErrorPrimaryKeyViolation)
        {
            // Two concurrent sessions both passed IF NOT EXISTS and both
            // attempted the INSERT — the loser raises 2601/2627 against the
            // NotificationId primary key. First-write-wins idempotency is the
            // documented contract (the site→central handoff is at-least-once,
            // and the actor discards the return value), so the race outcome is
            // semantically a no-op. Returning false here matches the
            // "row already existed" branch of the success path.
            _logger.LogDebug(
                ex,
                "InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for NotificationId {NotificationId}; treating as no-op.",
                ex.Number,
                n.NotificationId);
            return false;
        }
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<Notification>> GetDueAsync(
        DateTimeOffset now, int batchSize, CancellationToken cancellationToken = default)
    {
        // AsNoTracking (WP2.2): the dispatcher mutates each row's delivery state in
        // memory and persists it through UpdateAsync, which is now a targeted
        // server-side ExecuteUpdate and needs no change tracker. Tracking a batch
        // of notifications — each carrying an nvarchar(max) Body and TypeData —
        // paid for a full snapshot copy per row plus a DetectChanges scan of the
        // whole batch on every save.
        return await _context.Notifications
            .AsNoTracking()
            .Where(n => n.Status == NotificationStatus.Pending
                || (n.Status == NotificationStatus.Retrying
                    && n.NextAttemptAt != null
                    && n.NextAttemptAt <= now))
            .OrderBy(n => n.CreatedAt)
            .Take(batchSize)
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<bool> UpdateAsync(Notification n, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(n);

        // Targeted server-side UPDATE of the seven mutable delivery-state columns
        // (WP2.2). The predecessor called DbSet.Update(n) + SaveChanges, which
        // marks EVERY property modified and rewrites all 21 columns — including
        // the immutable nvarchar(max) Body/TypeData payloads — on every single
        // delivery attempt. ExecuteUpdate also bypasses the change tracker
        // entirely, so it composes with the untracked GetDueAsync read.
        //
        // Immutable-by-contract columns (NotificationId, Type, ListName, Subject,
        // Body, TypeData, Source*, Origin*, SiteEnqueuedAt, CreatedAt) are
        // deliberately absent — see the interface contract: nothing in the
        // notification lifecycle ever changes them, and omitting them is what
        // makes the write narrow.
        var status = n.Status;
        var retryCount = n.RetryCount;
        var lastError = n.LastError;
        var resolvedTargets = n.ResolvedTargets;
        var lastAttemptAt = n.LastAttemptAt;
        var nextAttemptAt = n.NextAttemptAt;
        var deliveredAt = n.DeliveredAt;

        var notificationId = n.NotificationId;

        // The row count is the not-found signal and MUST NOT be discarded.
        // DbSet.Update + SaveChanges used to throw DbUpdateConcurrencyException
        // when the row had vanished (the daily retention purge deletes terminal
        // rows); ExecuteUpdate just reports "0 rows" and returns, which made an
        // operator Retry/Discard of a purged notification answer "success".
        var rowsAffected = await _context.Notifications
            .Where(row => row.NotificationId == notificationId)
            .ExecuteUpdateAsync(
                setters => setters
                    .SetProperty(row => row.Status, status)
                    .SetProperty(row => row.RetryCount, retryCount)
                    .SetProperty(row => row.LastError, lastError)
                    .SetProperty(row => row.ResolvedTargets, resolvedTargets)
                    .SetProperty(row => row.LastAttemptAt, lastAttemptAt)
                    .SetProperty(row => row.NextAttemptAt, nextAttemptAt)
                    .SetProperty(row => row.DeliveredAt, deliveredAt),
                cancellationToken);

        return rowsAffected > 0;
    }

    /// <inheritdoc />
    public async Task<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
    {
        // AsNoTracking + a keyed predicate rather than FindAsync: the write that
        // follows this read is ExecuteUpdate, which bypasses the change tracker
        // entirely, so tracking the entity bought nothing but a snapshot copy of
        // the nvarchar(max) Body/TypeData columns — and left a stale tracked
        // instance in the context that a later read of the same id would return
        // in preference to the database. FindAsync cannot be made no-tracking.
        return await _context.Notifications
            .AsNoTracking()
            .FirstOrDefaultAsync(row => row.NotificationId == notificationId, cancellationToken);
    }

    /// <inheritdoc />
    public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
        NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
    {
        // AsNoTracking (WP2.2): this is the Central UI's read-only list page. The
        // rows are projected to the wire and never saved, so tracking them cost a
        // snapshot copy of every nvarchar(max) Body in the page for nothing.
        var query = _context.Notifications.AsNoTracking().AsQueryable();

        if (filter.Status is { } status)
        {
            query = query.Where(n => n.Status == status);
        }

        if (filter.Type is { } type)
        {
            query = query.Where(n => n.Type == type);
        }

        if (!string.IsNullOrEmpty(filter.SourceSiteId))
        {
            query = query.Where(n => n.SourceSiteId == filter.SourceSiteId);
        }

        // SourceNode is exact-match like SourceSiteId. Rows with NULL
        // SourceNode (legacy / unconfigured) are excluded when the filter is set.
        if (!string.IsNullOrEmpty(filter.SourceNode))
        {
            query = query.Where(n => n.SourceNode == filter.SourceNode);
        }

        if (!string.IsNullOrEmpty(filter.ListName))
        {
            query = query.Where(n => n.ListName == filter.ListName);
        }

        if (!string.IsNullOrEmpty(filter.SubjectKeyword))
        {
            query = query.Where(n => n.Subject.Contains(filter.SubjectKeyword));
        }

        if (filter.StuckOnly && filter.StuckCutoff is { } stuckCutoff)
        {
            query = query.Where(n =>
                (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
                && n.CreatedAt < stuckCutoff);
        }

        if (filter.From is { } from)
        {
            query = query.Where(n => n.CreatedAt >= from);
        }

        if (filter.To is { } to)
        {
            query = query.Where(n => n.CreatedAt <= to);
        }

        var totalCount = await query.CountAsync(cancellationToken);

        // NotificationId breaks CreatedAt ties so the OFFSET window is deterministic —
        // without it two rows sharing a CreatedAt could appear on both pages or on
        // neither. (This page keeps OFFSET paging rather than the sibling repos'
        // keyset cursor because its contract surfaces a page number and a total
        // count, neither of which a keyset cursor can express.)
        var rows = await query
            .OrderByDescending(n => n.CreatedAt)
            .ThenByDescending(n => n.NotificationId)
            .Skip((pageNumber - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        return (rows, totalCount);
    }

    /// <inheritdoc />
    public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
    {
        // Time-sliced batches: each DELETE covers at most one hour of terminal rows,
        // capping the lock/log footprint per statement — mirrors
        // KpiHistoryRepository.PurgeOlderThanAsync (arch-review WP1.4; an unbounded
        // single-statement ExecuteDeleteAsync would otherwise turn a long-idle catch-up
        // of the default 365-day window into one giant transaction against
        // dbo.Notifications). Pure EF LINQ (no raw SQL) so this stays portable across
        // the SQL Server production provider and the SQLite provider the repository
        // tests run against.
        var total = 0;
        var floor = await _context.Notifications
            .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
            .MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken);
        while (floor is not null && floor < cutoff)
        {
            var ceiling = floor.Value.AddHours(1) < cutoff ? floor.Value.AddHours(1) : cutoff;
            total += await _context.Notifications
                .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < ceiling)
                .ExecuteDeleteAsync(cancellationToken);
            floor = await _context.Notifications
                .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
                .MinAsync(n => (DateTimeOffset?)n.CreatedAt, cancellationToken);
        }

        return total;
    }

    /// <inheritdoc />
    public async Task<NotificationKpiSnapshot> ComputeKpisAsync(
        DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;

        // One conditional-aggregation pass replaces four sequential COUNT round trips:
        // each metric is a COUNT(CASE WHEN <predicate> THEN 1 END) over the same scan
        // (arch-review 04). GroupBy(_ => 1) yields a single group (no rows → no group).
        //
        // WP2.2 — the aggregation is now PREDICATE-RESTRICTED instead of scanning the
        // whole table. Every KPI here is about the live queue (Pending/Retrying), the
        // parked backlog, or the last delivery interval; the overwhelming bulk of the
        // Notifications table is historical Delivered and Discarded rows that
        // contribute to NONE of them. The pre-filter below is the union of the
        // metric-contributing predicates, which lets the optimizer seek the status
        // index for the live/parked legs and the filtered
        // (DeliveredAt) WHERE Status='Delivered' index (WP1.4) for the interval leg,
        // instead of paying a full scan whose cost grows with retained history.
        // Mirrors the pre-filter ComputePerSiteKpisAsync/ComputePerNodeKpisAsync
        // already use — the global snapshot was the odd one out.
        var counts = await _context.Notifications
            .Where(n => n.Status == NotificationStatus.Pending
                || n.Status == NotificationStatus.Retrying
                || n.Status == NotificationStatus.Parked
                || (n.Status == NotificationStatus.Delivered
                    && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince))
            .GroupBy(_ => 1)
            .Select(g => new
            {
                QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying),
                StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
                        || n.Status == NotificationStatus.Retrying)
                    && n.CreatedAt < stuckCutoff),
                ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
                DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
                    && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
            })
            .FirstOrDefaultAsync(cancellationToken);

        // Oldest non-terminal CreatedAt — TOP(1) via ordered FirstOrDefault, so the
        // live queue is never materialized. The nullable cast lets FirstOrDefault
        // return null (no non-terminal rows) instead of throwing.
        var oldestCreatedAt = await _context.Notifications
            .Where(n => n.Status == NotificationStatus.Pending
                || n.Status == NotificationStatus.Retrying)
            .OrderBy(n => n.CreatedAt)
            .Select(n => (DateTimeOffset?)n.CreatedAt)
            .FirstOrDefaultAsync(cancellationToken);

        return new NotificationKpiSnapshot(
            QueueDepth: counts?.QueueDepth ?? 0,
            StuckCount: counts?.StuckCount ?? 0,
            ParkedCount: counts?.ParkedCount ?? 0,
            DeliveredLastInterval: counts?.DeliveredLastInterval ?? 0,
            OldestPendingAge: oldestCreatedAt is null ? null : now - oldestCreatedAt.Value);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<SiteNotificationKpiSnapshot>> ComputePerSiteKpisAsync(
        DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;

        // One grouped conditional-aggregation query per scope: counts AND the oldest
        // non-terminal CreatedAt (server-side MIN over a CASE, so the live queue is
        // never materialized) computed in a single scan. Pre-filter to rows that
        // contribute to at least one metric so a site with only fully-uncounted rows
        // (e.g. Discarded, or out-of-window Delivered) does not surface as an
        // all-zero row — preserving the prior union-of-metric-keys semantics.
        var rows = await _context.Notifications
            .Where(n => n.Status == NotificationStatus.Pending
                || n.Status == NotificationStatus.Retrying
                || n.Status == NotificationStatus.Parked
                || (n.Status == NotificationStatus.Delivered
                    && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince))
            .GroupBy(n => n.SourceSiteId)
            .Select(g => new
            {
                Site = g.Key,
                QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying),
                StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
                        || n.Status == NotificationStatus.Retrying)
                    && n.CreatedAt < stuckCutoff),
                ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
                DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
                    && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
                OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending
                        || n.Status == NotificationStatus.Retrying)
                    ? (DateTimeOffset?)n.CreatedAt
                    : null),
            })
            .ToListAsync(cancellationToken);

        return rows
            .OrderBy(r => r.Site, StringComparer.Ordinal)
            .Select(r => new SiteNotificationKpiSnapshot(
                SourceSiteId: r.Site,
                QueueDepth: r.QueueDepth,
                StuckCount: r.StuckCount,
                ParkedCount: r.ParkedCount,
                DeliveredLastInterval: r.DeliveredLastInterval,
                OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value))
            .ToList();
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<NodeNotificationKpiSnapshot>> ComputePerNodeKpisAsync(
        DateTimeOffset stuckCutoff, DateTimeOffset deliveredSince, CancellationToken cancellationToken = default)
    {
        var now = DateTimeOffset.UtcNow;

        // Same single-scan grouped aggregation as ComputePerSiteKpisAsync, keyed by
        // SourceNode. Rows with a NULL SourceNode (legacy / unstamped) are excluded —
        // per-node KPIs are only meaningful when node identity is known.
        var rows = await _context.Notifications
            .Where(n => n.SourceNode != null
                && (n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying
                    || n.Status == NotificationStatus.Parked
                    || (n.Status == NotificationStatus.Delivered
                        && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince)))
            .GroupBy(n => n.SourceNode!)
            .Select(g => new
            {
                Node = g.Key,
                QueueDepth = g.Count(n => n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying),
                StuckCount = g.Count(n => (n.Status == NotificationStatus.Pending
                        || n.Status == NotificationStatus.Retrying)
                    && n.CreatedAt < stuckCutoff),
                ParkedCount = g.Count(n => n.Status == NotificationStatus.Parked),
                DeliveredLastInterval = g.Count(n => n.Status == NotificationStatus.Delivered
                    && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince),
                OldestCreatedAt = g.Min(n => (n.Status == NotificationStatus.Pending
                        || n.Status == NotificationStatus.Retrying)
                    ? (DateTimeOffset?)n.CreatedAt
                    : null),
            })
            .ToListAsync(cancellationToken);

        return rows
            .OrderBy(r => r.Node, StringComparer.Ordinal)
            .Select(r => new NodeNotificationKpiSnapshot(
                SourceNode: r.Node,
                QueueDepth: r.QueueDepth,
                StuckCount: r.StuckCount,
                ParkedCount: r.ParkedCount,
                DeliveredLastInterval: r.DeliveredLastInterval,
                OldestPendingAge: r.OldestCreatedAt is null ? null : now - r.OldestCreatedAt.Value))
            .ToList();
    }

    /// <inheritdoc />
    public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
        => await _context.SaveChangesAsync(cancellationToken);
}
