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

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

/// <summary>
/// EF Core implementation of <see cref="IKpiHistoryRepository"/> over the central
/// <c>KpiSample</c> table ("KPI History &amp; Trends"). See the interface for the
/// contract; this class adds notes on the data-access strategy per method.
/// </summary>
public sealed class KpiHistoryRepository : IKpiHistoryRepository
{
    private readonly ScadaBridgeDbContext _context;
    private readonly ILogger<KpiHistoryRepository> _logger;

    /// <summary>
    /// Initializes a new instance of the <see cref="KpiHistoryRepository"/> class.
    /// </summary>
    /// <param name="context">The EF Core database context.</param>
    /// <param name="logger">
    /// Optional logger; defaults to <see cref="NullLogger{T}"/> (mirrors
    /// <c>SiteCallAuditRepository</c> — MS.DI resolves <see cref="ILogger{T}"/> automatically,
    /// so no registration churn). Used to classify the self-healing failover fold-race.
    /// </param>
    public KpiHistoryRepository(
        ScadaBridgeDbContext context, ILogger<KpiHistoryRepository>? logger = null)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        _logger = logger ?? NullLogger<KpiHistoryRepository>.Instance;
    }

    /// <inheritdoc />
    public async Task RecordSamplesAsync(
        IReadOnlyCollection<KpiSample> samples, CancellationToken cancellationToken = default)
    {
        ArgumentNullException.ThrowIfNull(samples);

        // Avoid a no-op SaveChanges round-trip on quiet sampling ticks.
        if (samples.Count == 0)
        {
            return;
        }

        // Bulk-insert one sampling pass. AddRange + a single SaveChanges keeps the
        // whole batch in one round-trip; the store assigns each row's identity.
        _context.KpiSamples.AddRange(samples);
        await _context.SaveChangesAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<KpiSeriesPoint>> GetRawSeriesAsync(
        string source, string metric, string scope, string? scopeKey,
        DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default)
    {
        // The ScopeKey == scopeKey comparison is intentional: when scopeKey is null
        // EF translates it to "ScopeKey IS NULL", which matches the Global-scope rows
        // (null key) and excludes the site/node-scoped rows that carry a non-null key.
        return await _context.KpiSamples
            .Where(s => s.Source == source
                && s.Metric == metric
                && s.Scope == scope
                && s.ScopeKey == scopeKey
                && s.CapturedAtUtc >= fromUtc
                && s.CapturedAtUtc <= toUtc)
            .OrderBy(s => s.CapturedAtUtc)
            .Select(s => new KpiSeriesPoint(s.CapturedAtUtc, s.Value))
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<int> PurgeOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
    {
        // Time-sliced batches: each DELETE covers at most one hour of samples, capping the
        // lock/log footprint per statement (arch-review 04, P4 — steady state is ~1 day of
        // rows/day; after an outage the catch-up would otherwise be one giant transaction).
        var total = 0;
        var floor = await _context.KpiSamples.Where(s => s.CapturedAtUtc < before)
            .MinAsync(s => (DateTime?)s.CapturedAtUtc, cancellationToken);
        while (floor is not null && floor < before)
        {
            var ceiling = floor.Value.AddHours(1) < before ? floor.Value.AddHours(1) : before;
            total += await _context.KpiSamples
                .Where(s => s.CapturedAtUtc < ceiling)
                .ExecuteDeleteAsync(cancellationToken);
            floor = await _context.KpiSamples.Where(s => s.CapturedAtUtc < before)
                .MinAsync(s => (DateTime?)s.CapturedAtUtc, cancellationToken);
        }

        return total;
    }

    /// <inheritdoc />
    public async Task FoldHourlyRollupsAsync(
        DateTime fromHourUtc, DateTime toHourUtc, CancellationToken cancellationToken = default)
    {
        // Truncate the window to whole-hour boundaries. The fold covers the complete hours
        // in [truncate(from), truncate(to)) — the upper bound is EXCLUSIVE, so a caller that
        // passes the current hour's start as toHourUtc never folds the in-progress hour.
        var from = TruncateToHour(fromHourUtc);
        var to = TruncateToHour(toHourUtc);
        if (to <= from)
        {
            return;
        }

        // The caller passes a small trailing lookback (e.g. 3 h), so the window is bounded:
        // fetch it and group IN MEMORY. This deliberately avoids provider-specific
        // DATEADD/DATEPART hour-truncation translation (SQLite in tests, SQL Server in prod),
        // keeping the hour-bucket arithmetic identical across providers and trivially correct.
        // Projected, untracked fetch: the fold only reads these six fields and never
        // mutates a KpiSample. A tracked ToListAsync registered ~5k-90k read-only
        // entities in the change tracker per healthy 3h fold — all re-scanned by
        // DetectChanges on the final SaveChanges (arch-review 04 round 2, R2). A
        // projection is inherently untracked and materializes no entity at all.
        var samples = await _context.KpiSamples
            .Where(s => s.CapturedAtUtc >= from && s.CapturedAtUtc < to)
            .Select(s => new FoldSample(s.Source, s.Metric, s.Scope, s.ScopeKey, s.CapturedAtUtc, s.Value))
            .ToListAsync(cancellationToken);
        if (samples.Count == 0)
        {
            return;
        }

        var groups = samples.GroupBy(s => new SeriesHourKey(
            s.Source, s.Metric, s.Scope, s.ScopeKey, TruncateToHour(s.CapturedAtUtc)));

        // Preload every rollup row already covering this window in ONE query and
        // index it by series+hour (WP2.2). The predecessor issued a
        // FirstOrDefaultAsync existence probe PER (series, hour) group — an N+1
        // that scaled with the metric catalogue times the lookback: a 3 h re-fold
        // over ~40 series cost ~120 sequential round trips before a single row was
        // written. The window is bounded by the caller's small trailing lookback
        // and the rollup table holds exactly one row per series-hour, so the
        // preload is a narrow range seek on IX_KpiRollupHourly_Series.
        //
        // Deliberately TRACKED (not a projection): the re-fold path mutates the
        // existing entity in place and relies on the change tracker to emit the
        // UPDATE. The dictionary's ScopeKey comparison is ordinal where the
        // previous SQL predicate used the database collation; both sides are
        // written from the same KpiSample.ScopeKey values, so they are
        // byte-identical in practice and a residual mismatch degrades to the
        // already-handled upsert-race path rather than a wrong aggregate.
        var existingRollups = await _context.KpiRollupHourly
            .Where(r => r.HourStartUtc >= from && r.HourStartUtc < to)
            .ToListAsync(cancellationToken);

        var existingByKey = new Dictionary<SeriesHourKey, KpiRollupHourly>(existingRollups.Count);
        foreach (var row in existingRollups)
        {
            existingByKey[new SeriesHourKey(
                row.Source,
                row.Metric,
                row.Scope,
                row.ScopeKey,
                DateTime.SpecifyKind(row.HourStartUtc, DateTimeKind.Utc))] = row;
        }

        foreach (var group in groups)
        {
            var key = group.Key;

            // Per-metric aggregation intent: Rate metrics sum the hour (last-value would
            // discard 59 of 60 intra-hour deltas); Gauge metrics keep the last (latest
            // timestamp) value in the hour. Min/Max/Count preserve the fold's fidelity.
            var aggregation = KpiMetricAggregationCatalog.Resolve(key.Source, key.Metric);
            var value = aggregation == KpiRollupAggregation.Rate
                ? group.Sum(s => s.Value)
                : group.OrderByDescending(s => s.CapturedAtUtc).First().Value;
            var minValue = group.Min(s => s.Value);
            var maxValue = group.Max(s => s.Value);
            var sampleCount = group.Count();

            // Idempotent upsert on the unique series+hour key, resolved against the
            // preloaded dictionary. A null ScopeKey keys the Global-scope rows exactly
            // as the UNIQUE IX_KpiRollupHourly_Series index treats a null key as
            // participating.
            if (!existingByKey.TryGetValue(key, out var existing))
            {
                _context.KpiRollupHourly.Add(new KpiRollupHourly
                {
                    Source = key.Source,
                    Metric = key.Metric,
                    Scope = key.Scope,
                    ScopeKey = key.ScopeKey,
                    HourStartUtc = key.HourStartUtc,
                    Value = value,
                    MinValue = minValue,
                    MaxValue = maxValue,
                    SampleCount = sampleCount,
                });
            }
            else
            {
                // Re-fold overwrites the aggregate in place — a re-run over the same window
                // produces identical values (no double-count), so a missed tick self-heals.
                existing.Value = value;
                existing.MinValue = minValue;
                existing.MaxValue = maxValue;
                existing.SampleCount = sampleCount;
            }
        }

        try
        {
            await _context.SaveChangesAsync(cancellationToken);
        }
        catch (DbUpdateException ex)
        {
            // Failover-overlap race: the old singleton incarnation's in-flight fold and the
            // new node's first fold can both Add the same (series, hour) row; the unique
            // IX_KpiRollupHourly_Series then faults the loser's ENTIRE SaveChanges — the
            // failure grain is the PASS, not the row (arch-review 04 round 2, R5). The fold
            // only ever writes KpiRollupHourly rows, so any save fault here is a lost upsert
            // race or a transient the next idempotent re-fold repairs identically; classify
            // at Information instead of surfacing a scary error for a self-healing no-op.
            _logger.LogInformation(ex,
                "KPI rollup fold lost a failover-overlap upsert race; pass discarded, next fold self-heals.");
        }
    }

    /// <inheritdoc />
    public async Task<IReadOnlyList<KpiSeriesPoint>> GetHourlySeriesAsync(
        string source, string metric, string scope, string? scopeKey,
        DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default)
    {
        // Same null-ScopeKey semantics as GetRawSeriesAsync: scopeKey == null translates to
        // "ScopeKey IS NULL", matching Global-scope rollups and excluding site/node-keyed ones.
        return await _context.KpiRollupHourly
            .Where(r => r.Source == source
                && r.Metric == metric
                && r.Scope == scope
                && r.ScopeKey == scopeKey
                && r.HourStartUtc >= fromUtc
                && r.HourStartUtc <= toUtc)
            .OrderBy(r => r.HourStartUtc)
            .Select(r => new KpiSeriesPoint(r.HourStartUtc, r.Value))
            .ToListAsync(cancellationToken);
    }

    /// <inheritdoc />
    public async Task<DateTime?> GetLatestRollupHourAsync(CancellationToken cancellationToken = default)
    {
        // Single MAX over the unique IX_KpiRollupHourly_Series population (the rollup
        // table is small — one row per series-hour). Null when no rollups exist.
        return await _context.KpiRollupHourly
            .MaxAsync(r => (DateTime?)r.HourStartUtc, cancellationToken);
    }

    /// <inheritdoc />
    public async Task PurgeRollupsOlderThanAsync(DateTime before, CancellationToken cancellationToken = default)
    {
        // Mirror PurgeOlderThanAsync: one-hour-sliced batched DELETEs cap the lock/log
        // footprint per statement (arch-review 04, P4). Rollups are one-per-hour, so each
        // slice deletes a single hour bucket.
        var floor = await _context.KpiRollupHourly.Where(r => r.HourStartUtc < before)
            .MinAsync(r => (DateTime?)r.HourStartUtc, cancellationToken);
        while (floor is not null && floor < before)
        {
            var ceiling = floor.Value.AddHours(1) < before ? floor.Value.AddHours(1) : before;
            await _context.KpiRollupHourly
                .Where(r => r.HourStartUtc < ceiling)
                .ExecuteDeleteAsync(cancellationToken);
            floor = await _context.KpiRollupHourly.Where(r => r.HourStartUtc < before)
                .MinAsync(r => (DateTime?)r.HourStartUtc, cancellationToken);
        }
    }

    /// <summary>Truncates a UTC timestamp to the start of its hour (minutes/seconds/ticks zeroed).</summary>
    private static DateTime TruncateToHour(DateTime utc) =>
        new(utc.Year, utc.Month, utc.Day, utc.Hour, 0, 0, DateTimeKind.Utc);

    /// <summary>In-memory grouping key: one KPI series (four-tuple) within one UTC hour.</summary>
    private readonly record struct SeriesHourKey(
        string Source, string Metric, string Scope, string? ScopeKey, DateTime HourStartUtc);

    /// <summary>Narrow, untracked projection of one KpiSample row for the fold (R2).</summary>
    private readonly record struct FoldSample(
        string Source, string Metric, string Scope, string? ScopeKey, DateTime CapturedAtUtc, double Value);
}
