using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ScadaLink.Commons.Entities.Audit;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Types.Audit;

namespace ScadaLink.ConfigurationDatabase.Repositories;

/// <summary>
/// EF Core implementation of <see cref="IAuditLogRepository"/>. See the interface
/// for the append-only contract; this class only adds notes on the data-access
/// strategy used by each method.
/// </summary>
public class AuditLogRepository : IAuditLogRepository
{
    // SQL Server error numbers for duplicate-key violations on
    // UX_AuditLog_EventId. 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 errors. Idempotency demands we swallow them.
    private const int SqlErrorUniqueIndexViolation = 2601;
    private const int SqlErrorPrimaryKeyViolation = 2627;

    private readonly ScadaLinkDbContext _context;
    private readonly ILogger<AuditLogRepository> _logger;

    public AuditLogRepository(ScadaLinkDbContext context, ILogger<AuditLogRepository>? logger = null)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _logger = logger ?? NullLogger<AuditLogRepository>.Instance;
    }

    /// <summary>
    /// Issues a single <c>IF NOT EXISTS … INSERT INTO dbo.AuditLog (…) VALUES (…)</c>
    /// via <see cref="Microsoft.EntityFrameworkCore.RelationalDatabaseFacadeExtensions.ExecuteSqlInterpolatedAsync"/>.
    /// Bypasses the EF change tracker so the row never enters a tracked state and
    /// the enum-as-string conversion is done explicitly in C# (the columns are
    /// declared <c>varchar(32)</c> via <c>HasConversion&lt;string&gt;()</c> in
    /// <see cref="ScadaLink.ConfigurationDatabase.Configurations.AuditLogEntityTypeConfiguration"/>).
    /// </summary>
    public async Task InsertIfNotExistsAsync(AuditEvent evt, CancellationToken ct = default)
    {
        if (evt is null)
        {
            throw new ArgumentNullException(nameof(evt));
        }

        // Enum columns are stored as varchar(32) (HasConversion<string>()), so do
        // the conversion in C# rather than relying on parameter type inference —
        // SqlClient would otherwise bind enums as int by default.
        var channel = evt.Channel.ToString();
        var kind = evt.Kind.ToString();
        var status = evt.Status.ToString();
        var forwardState = evt.ForwardState?.ToString();

        // FormattableString interpolation parameterises every value (no concatenation),
        // so this is safe against injection even for the string columns.
        try
        {
            await _context.Database.ExecuteSqlInterpolatedAsync(
                $@"IF NOT EXISTS (SELECT 1 FROM dbo.AuditLog WHERE EventId = {evt.EventId})
INSERT INTO dbo.AuditLog
    (EventId, OccurredAtUtc, IngestedAtUtc, Channel, Kind, CorrelationId,
     SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status,
     HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary,
     ResponseSummary, PayloadTruncated, Extra, ForwardState)
VALUES
    ({evt.EventId}, {evt.OccurredAtUtc}, {evt.IngestedAtUtc}, {channel}, {kind}, {evt.CorrelationId},
     {evt.SourceSiteId}, {evt.SourceInstanceId}, {evt.SourceScript}, {evt.Actor}, {evt.Target}, {status},
     {evt.HttpStatus}, {evt.DurationMs}, {evt.ErrorMessage}, {evt.ErrorDetail}, {evt.RequestSummary},
     {evt.ResponseSummary}, {evt.PayloadTruncated}, {evt.Extra}, {forwardState});",
                ct);
        }
        catch (SqlException ex) when (
            ex.Number == SqlErrorUniqueIndexViolation
            || ex.Number == SqlErrorPrimaryKeyViolation)
        {
            // Two concurrent sessions both passed the IF NOT EXISTS check and
            // both attempted the INSERT — the loser raises 2601/2627 against
            // UX_AuditLog_EventId. First-write-wins idempotency is already the
            // documented contract for this method, so the race outcome is
            // semantically a no-op. Swallow at Debug; other SqlExceptions
            // bubble.
            _logger.LogDebug(
                ex,
                "InsertIfNotExistsAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for EventId {EventId}; treating as no-op.",
                ex.Number,
                evt.EventId);
        }
    }

    /// <summary>
    /// Builds an <c>AsNoTracking</c> queryable over <see cref="AuditEvent"/>, applies
    /// every non-null filter predicate, and pages by keyset on
    /// <c>(OccurredAtUtc DESC, EventId DESC)</c>. The keyset clause is expressed
    /// directly (<c>occurred &lt; after || (occurred == after &amp;&amp; eventId.CompareTo(afterId) &lt; 0)</c>)
    /// — EF Core 10 translates <see cref="Guid.CompareTo(Guid)"/> against SQL Server's
    /// <c>uniqueidentifier</c> sort order.
    /// </summary>
    public async Task<IReadOnlyList<AuditEvent>> QueryAsync(
        AuditLogQueryFilter filter, AuditLogPaging paging, CancellationToken ct = default)
    {
        if (filter is null)
        {
            throw new ArgumentNullException(nameof(filter));
        }

        if (paging is null)
        {
            throw new ArgumentNullException(nameof(paging));
        }

        var query = _context.Set<AuditEvent>().AsNoTracking();

        if (filter.Channel is { } channel)
        {
            query = query.Where(e => e.Channel == channel);
        }

        if (filter.Kind is { } kind)
        {
            query = query.Where(e => e.Kind == kind);
        }

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

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

        if (!string.IsNullOrEmpty(filter.Target))
        {
            var target = filter.Target;
            query = query.Where(e => e.Target == target);
        }

        if (!string.IsNullOrEmpty(filter.Actor))
        {
            var actor = filter.Actor;
            query = query.Where(e => e.Actor == actor);
        }

        if (filter.CorrelationId is { } correlationId)
        {
            query = query.Where(e => e.CorrelationId == correlationId);
        }

        if (filter.FromUtc is { } fromUtc)
        {
            query = query.Where(e => e.OccurredAtUtc >= fromUtc);
        }

        if (filter.ToUtc is { } toUtc)
        {
            query = query.Where(e => e.OccurredAtUtc <= toUtc);
        }

        // Keyset cursor on (OccurredAtUtc desc, EventId desc).
        if (paging.AfterOccurredAtUtc is { } afterOccurred && paging.AfterEventId is { } afterEventId)
        {
            query = query.Where(e =>
                e.OccurredAtUtc < afterOccurred
                || (e.OccurredAtUtc == afterOccurred && e.EventId.CompareTo(afterEventId) < 0));
        }

        return await query
            .OrderByDescending(e => e.OccurredAtUtc)
            .ThenByDescending(e => e.EventId)
            .Take(paging.PageSize)
            .ToListAsync(ct);
    }

    /// <summary>
    /// M1 honest contract: throws <see cref="NotSupportedException"/>. The
    /// <c>UX_AuditLog_EventId</c> unique index is non-aligned with
    /// <c>ps_AuditLog_Month</c> (it lives on <c>[PRIMARY]</c> to keep
    /// <see cref="InsertIfNotExistsAsync"/> cheap), and SQL Server rejects
    /// <c>ALTER TABLE … SWITCH PARTITION</c> when a non-aligned index is present.
    /// The drop-and-rebuild dance that makes the switch legal ships with the M6
    /// purge actor.
    /// </summary>
    public Task SwitchOutPartitionAsync(DateTime monthBoundary, CancellationToken ct = default)
    {
        throw new NotSupportedException(
            "AuditLog partition switch is blocked by the non-aligned UX_AuditLog_EventId " +
            "unique index; the drop-and-rebuild dance ships in M6 (purge actor).");
    }
}
