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();

        // 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)
    {
        return await _context.Notifications
            .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<Notification?> GetByIdAsync(string notificationId, CancellationToken cancellationToken = default)
        => await _context.Notifications.FindAsync(new object[] { notificationId }, cancellationToken);

    /// <inheritdoc />
    public async Task UpdateAsync(Notification n, CancellationToken cancellationToken = default)
    {
        _context.Notifications.Update(n);
        await _context.SaveChangesAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<(IReadOnlyList<Notification> Rows, int TotalCount)> QueryAsync(
        NotificationOutboxFilter filter, int pageNumber, int pageSize, CancellationToken cancellationToken = default)
    {
        var query = _context.Notifications.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);
        }

        // Task 16: 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);

        var rows = await query
            .OrderByDescending(n => n.CreatedAt)
            .Skip((pageNumber - 1) * pageSize)
            .Take(pageSize)
            .ToListAsync(cancellationToken);

        return (rows, totalCount);
    }

    /// <inheritdoc />
    public async Task<int> DeleteTerminalOlderThanAsync(DateTimeOffset cutoff, CancellationToken cancellationToken = default)
    {
        return await _context.Notifications
            .Where(n => TerminalStatuses.Contains(n.Status) && n.CreatedAt < cutoff)
            .ExecuteDeleteAsync(cancellationToken);
    }

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

        var queueDepth = await _context.Notifications
            .CountAsync(n => n.Status == NotificationStatus.Pending
                || n.Status == NotificationStatus.Retrying, cancellationToken);

        var stuckCount = await _context.Notifications
            .CountAsync(n => (n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying)
                && n.CreatedAt < stuckCutoff, cancellationToken);

        var parkedCount = await _context.Notifications
            .CountAsync(n => n.Status == NotificationStatus.Parked, cancellationToken);

        var deliveredLastInterval = await _context.Notifications
            .CountAsync(n => n.Status == NotificationStatus.Delivered
                && n.DeliveredAt != null
                && n.DeliveredAt >= deliveredSince, cancellationToken);

        // Oldest non-terminal CreatedAt. The DateTimeOffset value converter makes a SQL
        // Min aggregate awkward, so order ascending and take the first instead.
        var nonTerminal = _context.Notifications
            .Where(n => n.Status == NotificationStatus.Pending
                || n.Status == NotificationStatus.Retrying);

        TimeSpan? oldestPendingAge = null;
        if (await nonTerminal.AnyAsync(cancellationToken))
        {
            var oldestCreatedAt = await nonTerminal
                .OrderBy(n => n.CreatedAt)
                .Select(n => n.CreatedAt)
                .FirstAsync(cancellationToken);
            oldestPendingAge = now - oldestCreatedAt;
        }

        return new NotificationKpiSnapshot(
            QueueDepth: queueDepth,
            StuckCount: stuckCount,
            ParkedCount: parkedCount,
            DeliveredLastInterval: deliveredLastInterval,
            OldestPendingAge: oldestPendingAge);
    }

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

        var queueDepth = await CountBySiteAsync(
            n => n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying,
            cancellationToken);

        var stuck = await CountBySiteAsync(
            n => (n.Status == NotificationStatus.Pending || n.Status == NotificationStatus.Retrying)
                && n.CreatedAt < stuckCutoff,
            cancellationToken);

        var parked = await CountBySiteAsync(
            n => n.Status == NotificationStatus.Parked, cancellationToken);

        var delivered = await CountBySiteAsync(
            n => n.Status == NotificationStatus.Delivered
                && n.DeliveredAt != null && n.DeliveredAt >= deliveredSince,
            cancellationToken);

        // Oldest non-terminal CreatedAt per site. A SQL Min over the DateTimeOffset
        // converter is awkward (see ComputeKpisAsync), so project the non-terminal
        // (site, created) pairs — the live queue, which stays bounded — and reduce
        // in memory.
        var oldest = (await _context.Notifications
                .Where(n => n.Status == NotificationStatus.Pending
                    || n.Status == NotificationStatus.Retrying)
                .Select(n => new { n.SourceSiteId, n.CreatedAt })
                .ToListAsync(cancellationToken))
            .GroupBy(x => x.SourceSiteId)
            .ToDictionary(g => g.Key, g => g.Min(x => x.CreatedAt));

        var siteIds = queueDepth.Keys
            .Concat(stuck.Keys).Concat(parked.Keys).Concat(delivered.Keys)
            .Distinct()
            .OrderBy(s => s, StringComparer.Ordinal);

        return siteIds.Select(site => new SiteNotificationKpiSnapshot(
            SourceSiteId: site,
            QueueDepth: queueDepth.GetValueOrDefault(site),
            StuckCount: stuck.GetValueOrDefault(site),
            ParkedCount: parked.GetValueOrDefault(site),
            DeliveredLastInterval: delivered.GetValueOrDefault(site),
            OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
                ? now - createdAt
                : null)).ToList();
    }

    /// <summary>Counts notification rows matching <paramref name="predicate"/>, grouped by source site.</summary>
    private async Task<Dictionary<string, int>> CountBySiteAsync(
        System.Linq.Expressions.Expression<Func<Notification, bool>> predicate,
        CancellationToken cancellationToken)
    {
        return await _context.Notifications
            .Where(predicate)
            .GroupBy(n => n.SourceSiteId)
            .Select(g => new { Site = g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.Site, x => x.Count, cancellationToken);
    }

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