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

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

/// <summary>
/// EF Core implementation of <see cref="ISiteCallAuditRepository"/>. See the
/// interface for the monotonic-upsert contract; this class adds notes on the
/// data-access strategy used by each method.
/// </summary>
public class SiteCallAuditRepository : ISiteCallAuditRepository
{
    // SQL Server duplicate-key error numbers, identical to the AuditLogRepository
    // race-fix: 2601 = unique-index violation, 2627 = PK/unique-constraint
    // violation. The IF NOT EXISTS … INSERT pattern has a check-then-act window
    // and the loser surfaces as one of these; monotonic-upsert semantics demand
    // we swallow them.
    private const int SqlErrorUniqueIndexViolation = 2601;
    private const int SqlErrorPrimaryKeyViolation = 2627;

    // Monotonic status ordering. Lower rank wins on tie (same-rank upserts are
    // no-ops, including terminal-over-terminal). Spec from Bundle B3 plan:
    // Submitted < Forwarded < Attempted == Skipped < Delivered == Failed == Parked == Discarded.
    private static readonly Dictionary<string, int> StatusRank = new(StringComparer.Ordinal)
    {
        ["Submitted"] = 0,
        ["Forwarded"] = 1,
        ["Attempted"] = 2,
        ["Skipped"] = 2,
        ["Delivered"] = 3,
        ["Failed"] = 3,
        ["Parked"] = 3,
        ["Discarded"] = 3,
    };

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

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

    /// <inheritdoc />
    public async Task UpsertAsync(SiteCall siteCall, CancellationToken ct = default)
    {
        if (siteCall is null)
        {
            throw new ArgumentNullException(nameof(siteCall));
        }

        var idText = siteCall.TrackedOperationId.Value.ToString("D");
        var incomingRank = GetRankOrThrow(siteCall.Status);

        // Step 1: insert-if-not-exists. Like AuditLogRepository.InsertIfNotExistsAsync
        // this is check-then-act so a duplicate-key violation may surface under
        // concurrent inserts on the same id — caught + logged at Debug.
        //
        // SourceNode-stamping (Task 14): the column is included in the INSERT
        // column list / VALUES so a fresh row carries the originating node
        // name (node-a/node-b for site rows). A null SourceNode (legacy hosts
        // / unstamped reconciled rows) writes NULL straight through.
        try
        {
            await _context.Database.ExecuteSqlInterpolatedAsync(
                $@"IF NOT EXISTS (SELECT 1 FROM dbo.SiteCalls WHERE TrackedOperationId = {idText})
INSERT INTO dbo.SiteCalls
    (TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
     LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc)
VALUES
    ({idText}, {siteCall.Channel}, {siteCall.Target}, {siteCall.SourceSite}, {siteCall.SourceNode}, {siteCall.Status}, {siteCall.RetryCount},
     {siteCall.LastError}, {siteCall.HttpStatus}, {siteCall.CreatedAtUtc}, {siteCall.UpdatedAtUtc}, {siteCall.TerminalAtUtc}, {siteCall.IngestedAtUtc});",
                ct);
        }
        catch (SqlException ex) when (
            ex.Number == SqlErrorUniqueIndexViolation
            || ex.Number == SqlErrorPrimaryKeyViolation)
        {
            _logger.LogDebug(
                ex,
                "SiteCallAuditRepository.UpsertAsync swallowed duplicate-key violation (error {SqlErrorNumber}) for TrackedOperationId {TrackedOperationId}; falling through to monotonic update.",
                ex.Number,
                idText);
        }

        // Step 2: monotonic update. The CASE expression maps the stored Status
        // string to the same rank table the caller uses; we only mutate if the
        // incoming rank is strictly greater. Same-rank (including
        // terminal-over-terminal) is a no-op — first-write-wins at each rank.
        //
        // SourceNode-stamping (Task 14): SourceNode is updated via
        // COALESCE(@SourceNode, SourceNode). The operator returns @SourceNode
        // when it is non-null, otherwise the stored value — so the column
        // behaves protectively: a later packet that carries a null
        // SourceNode (e.g. a reconciliation pull from an unstamped node)
        // NEVER blanks out a value the first stamping packet set. A later
        // packet that DOES carry a non-null SourceNode replaces the previous
        // value — combined with the monotonic-rank guard this is
        // "last-non-null-wins on rank advance", which lets a missing
        // SourceNode be filled in later if Submit happened to be unstamped
        // and an Attempt/Resolve carries the node identity. Within one
        // lifecycle every packet should carry the same SourceNode value (one
        // execution, one node) so the "overwrite" path is in practice
        // idempotent.
        await _context.Database.ExecuteSqlInterpolatedAsync(
            $@"UPDATE dbo.SiteCalls
SET Status = {siteCall.Status},
    RetryCount = {siteCall.RetryCount},
    LastError = {siteCall.LastError},
    HttpStatus = {siteCall.HttpStatus},
    UpdatedAtUtc = {siteCall.UpdatedAtUtc},
    TerminalAtUtc = {siteCall.TerminalAtUtc},
    IngestedAtUtc = {siteCall.IngestedAtUtc},
    SourceNode = COALESCE({siteCall.SourceNode}, SourceNode)
WHERE TrackedOperationId = {idText}
  AND {incomingRank} > (CASE Status
      WHEN 'Submitted' THEN 0
      WHEN 'Forwarded' THEN 1
      WHEN 'Attempted' THEN 2
      WHEN 'Skipped'   THEN 2
      WHEN 'Delivered' THEN 3
      WHEN 'Failed'    THEN 3
      WHEN 'Parked'    THEN 3
      WHEN 'Discarded' THEN 3
      ELSE -1
  END);",
            ct);
    }

    /// <inheritdoc />
    public async Task<SiteCall?> GetAsync(TrackedOperationId id, CancellationToken ct = default)
    {
        return await _context.Set<SiteCall>().FindAsync(new object?[] { id }, ct);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<SiteCall>> QueryAsync(
        SiteCallQueryFilter filter, SiteCallPaging paging, CancellationToken ct = default)
    {
        if (filter is null)
        {
            throw new ArgumentNullException(nameof(filter));
        }

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

        // FormattableString interpolation parameterises every value (no concatenation)
        // so this is injection-safe. EF Core resolves the parameter values, the
        // composed sql is shaped to SQL Server's grammar and projected into the
        // SiteCall entity via FromSqlInterpolated. The CASE expressions wrap each
        // optional predicate so a null filter field degrades to a no-op (matches
        // every row) instead of branching at C# level into N variants.
        var afterCreated = paging.AfterCreatedAtUtc;
        var afterIdString = paging.AfterId?.Value.ToString("D");
        var hasCursor = afterCreated is not null && afterIdString is not null;

        var fromUtc = filter.FromUtc;
        var toUtc = filter.ToUtc;
        var stuckCutoff = filter.StuckCutoffUtc;

        // The stuck predicate (TerminalAtUtc IS NULL AND CreatedAtUtc < cutoff)
        // is pushed into SQL here — both columns are plain (no value converter)
        // and compose with the keyset cursor, so a StuckOnly page is honest:
        // never under-filled with a non-null next cursor. Mirrors how
        // NotificationOutboxRepository.QueryAsync applies NotificationOutboxFilter.StuckCutoff.
        //
        // SELECT-list maintenance: EF Core's FromSqlInterpolated requires every
        // entity-tracked column to appear in the result set. Adding a new column
        // to the SiteCall entity means extending the list below too — otherwise
        // every read trips "The required column 'X' was not present" at runtime.
        FormattableString sql = $@"
SELECT TOP ({paging.PageSize})
    TrackedOperationId, Channel, Target, SourceSite, SourceNode, Status, RetryCount,
    LastError, HttpStatus, CreatedAtUtc, UpdatedAtUtc, TerminalAtUtc, IngestedAtUtc
FROM dbo.SiteCalls
WHERE ({filter.Channel} IS NULL OR Channel = {filter.Channel})
  AND ({filter.SourceSite} IS NULL OR SourceSite = {filter.SourceSite})
  AND ({filter.SourceNode} IS NULL OR SourceNode = {filter.SourceNode})
  AND ({filter.Status} IS NULL OR Status = {filter.Status})
  AND ({filter.Target} IS NULL OR Target = {filter.Target})
  AND ({fromUtc} IS NULL OR CreatedAtUtc >= {fromUtc})
  AND ({toUtc} IS NULL OR CreatedAtUtc <= {toUtc})
  AND ({stuckCutoff} IS NULL OR (TerminalAtUtc IS NULL AND CreatedAtUtc < {stuckCutoff}))
  AND ({(hasCursor ? 1 : 0)} = 0
       OR CreatedAtUtc < {afterCreated}
       OR (CreatedAtUtc = {afterCreated} AND TrackedOperationId < {afterIdString}))
ORDER BY CreatedAtUtc DESC, TrackedOperationId DESC;";

        var rows = await _context.Set<SiteCall>()
            .FromSqlInterpolated(sql)
            .AsNoTracking()
            .ToListAsync(ct);

        return rows;
    }

    /// <inheritdoc />
    public async Task<int> PurgeTerminalAsync(DateTime olderThanUtc, CancellationToken ct = default)
    {
        return await _context.Database.ExecuteSqlInterpolatedAsync(
            $"DELETE FROM dbo.SiteCalls WHERE TerminalAtUtc IS NOT NULL AND TerminalAtUtc < {olderThanUtc};",
            ct);
    }

    // Terminal status string literals for the interval-throughput KPIs. The
    // Status column is a plain varchar (no value converter), so these compare
    // directly in translated SQL.
    //
    // NOTE on the "buffered/non-terminal" definition: the SiteCalls operational
    // mirror stores AuditStatus-derived strings (Attempted/Delivered/Parked/
    // Failed/...), NOT the tracking-lifecycle Pending/Retrying names the spec's
    // KPI section uses. There is therefore no Status string that means
    // "buffered". The schema-honest predicate for "non-terminal / buffered" is
    // TerminalAtUtc IS NULL — consistent with PurgeTerminalAsync's terminal
    // predicate and with the SiteCall entity's own contract ("TerminalAtUtc ...
    // null while still active"). All buffered / stuck / oldest-pending counts
    // below key off TerminalAtUtc, not Status.
    private const string StatusParked = "Parked";
    private const string StatusDelivered = "Delivered";
    private const string StatusFailed = "Failed";

    /// <inheritdoc />
    public async Task<SiteCallKpiSnapshot> ComputeKpisAsync(
        DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;

        var bufferedCount = await _context.SiteCalls
            .CountAsync(s => s.TerminalAtUtc == null, ct);

        var parkedCount = await _context.SiteCalls
            .CountAsync(s => s.Status == StatusParked, ct);

        var failedLastInterval = await _context.SiteCalls
            .CountAsync(s => s.Status == StatusFailed
                && s.TerminalAtUtc != null
                && s.TerminalAtUtc >= intervalSince, ct);

        var deliveredLastInterval = await _context.SiteCalls
            .CountAsync(s => s.Status == StatusDelivered
                && s.TerminalAtUtc != null
                && s.TerminalAtUtc >= intervalSince, ct);

        var stuckCount = await _context.SiteCalls
            .CountAsync(s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);

        var nonTerminal = _context.SiteCalls.Where(s => s.TerminalAtUtc == null);

        TimeSpan? oldestPendingAge = null;
        if (await nonTerminal.AnyAsync(ct))
        {
            var oldestCreatedAt = await nonTerminal.MinAsync(s => s.CreatedAtUtc, ct);
            oldestPendingAge = now - oldestCreatedAt;
        }

        return new SiteCallKpiSnapshot(
            BufferedCount: bufferedCount,
            ParkedCount: parkedCount,
            FailedLastInterval: failedLastInterval,
            DeliveredLastInterval: deliveredLastInterval,
            OldestPendingAge: oldestPendingAge,
            StuckCount: stuckCount);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<SiteCallSiteKpiSnapshot>> ComputePerSiteKpisAsync(
        DateTime stuckCutoff, DateTime intervalSince, CancellationToken ct = default)
    {
        var now = DateTime.UtcNow;

        var buffered = await CountBySiteAsync(s => s.TerminalAtUtc == null, ct);

        var parked = await CountBySiteAsync(s => s.Status == StatusParked, ct);

        var failed = await CountBySiteAsync(
            s => s.Status == StatusFailed
                && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);

        var delivered = await CountBySiteAsync(
            s => s.Status == StatusDelivered
                && s.TerminalAtUtc != null && s.TerminalAtUtc >= intervalSince, ct);

        var stuck = await CountBySiteAsync(
            s => s.TerminalAtUtc == null && s.CreatedAtUtc < stuckCutoff, ct);

        // Oldest non-terminal CreatedAtUtc per site — a server-side GROUP BY MIN.
        var oldest = (await _context.SiteCalls
                .Where(s => s.TerminalAtUtc == null)
                .GroupBy(s => s.SourceSite)
                .Select(g => new { Site = g.Key, Oldest = g.Min(s => s.CreatedAtUtc) })
                .ToListAsync(ct))
            .ToDictionary(x => x.Site, x => x.Oldest);

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

        return siteIds.Select(site => new SiteCallSiteKpiSnapshot(
            SourceSite: site,
            BufferedCount: buffered.GetValueOrDefault(site),
            ParkedCount: parked.GetValueOrDefault(site),
            FailedLastInterval: failed.GetValueOrDefault(site),
            DeliveredLastInterval: delivered.GetValueOrDefault(site),
            OldestPendingAge: oldest.TryGetValue(site, out var createdAt)
                ? now - createdAt
                : null,
            StuckCount: stuck.GetValueOrDefault(site))).ToList();
    }

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

    private static int GetRankOrThrow(string status)
    {
        if (!StatusRank.TryGetValue(status, out var rank))
        {
            throw new ArgumentException(
                $"Unknown SiteCall status '{status}'. Expected one of: {string.Join(", ", StatusRank.Keys)}.",
                nameof(status));
        }
        return rank;
    }
}
