using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;

namespace ZB.MOM.WW.ScadaBridge.CentralUI.Services;

/// <summary>
/// Default <see cref="IAuditLogQueryService"/> implementation — a thin pass-through
/// to <see cref="IAuditLogRepository.QueryAsync"/>. Default page size is 100 (the
/// AuditResultsGrid default).
/// </summary>
/// <remarks>
/// <para>
/// Each query opens its OWN DI scope and resolves a
/// fresh <see cref="IAuditLogRepository"/> — and therefore a fresh
/// <c>ScadaBridgeDbContext</c> — rather than sharing the scoped Blazor-circuit
/// context. Without this, the Audit Log page's query-string auto-load
/// (<c>/audit/log?correlationId=…</c>) races <c>AuditFilterBar.GetAllSitesAsync()</c>
/// on the single circuit-scoped <c>ScadaBridgeDbContext</c>, producing EF Core's
/// "A second operation was started on this context instance" error. Scope-per-query
/// removes the shared state so the two initial loads can run concurrently. This
/// mirrors the scope-per-message pattern used by <c>AuditLogIngestActor</c>.
/// </para>
/// <para>
/// A second constructor takes an <see cref="IAuditLogRepository"/> directly — a
/// test seam (mirroring <c>AuditLogIngestActor</c>'s dual ctor) so unit tests can
/// substitute a stub without standing up a DI container.
/// </para>
/// </remarks>
public sealed class AuditLogQueryService : IAuditLogQueryService
{
    // Trailing window for the Health dashboard's Audit KPI tiles.
    // Hard-coded here rather than configurable because the requirement
    // (Component-AuditLog.md §"Health & KPIs") fixes "rows/min over the last hour"
    // and "% errors over the last hour" as the KPI definition.
    private static readonly TimeSpan KpiWindow = TimeSpan.FromHours(1);

    // Audit Log Node filter: the distinct-source-nodes lookup powers
    // the filter dropdown population. A 60s cache keeps rendering the filter
    // bar cheap — node membership changes (failover, scaling) surface within
    // a minute, which is acceptable for a filter affordance.
    private static readonly TimeSpan DistinctSourceNodesTtl = TimeSpan.FromSeconds(60);

    // Production path: open a fresh scope per operation. Null in the test-seam ctor.
    private readonly IServiceScopeFactory? _scopeFactory;

    // Test seam: a directly-injected repository whose lifetime the test owns.
    // Null in the production ctor.
    private readonly IAuditLogRepository? _injectedRepository;

    private readonly ICentralHealthAggregator _healthAggregator;

    // Distinct-source-nodes cache. Lock guards the (snapshot, expiry) pair so
    // a stampede of concurrent filter-bar renders does not turn into N DB hits.
    private readonly object _sourceNodesLock = new();
    private IReadOnlyList<string>? _cachedSourceNodes;
    private DateTime _cachedSourceNodesExpiryUtc = DateTime.MinValue;

    /// <summary>
    /// Production constructor — resolves <see cref="IAuditLogRepository"/> from a
    /// fresh DI scope on every call so each query gets its own
    /// <c>ScadaBridgeDbContext</c> and never contends with the circuit-scoped
    /// context the filter bar uses.
    /// </summary>
    /// <param name="scopeFactory">Factory used to open a fresh DI scope per query.</param>
    /// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
    public AuditLogQueryService(
        IServiceScopeFactory scopeFactory,
        ICentralHealthAggregator healthAggregator)
    {
        _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory));
        _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
    }

    /// <summary>
    /// Test-seam constructor — injects a repository instance whose lifetime the
    /// caller owns. Used by unit tests that substitute a stub repository.
    /// </summary>
    /// <param name="repository">The audit log repository instance to use directly.</param>
    /// <param name="healthAggregator">Central health aggregator for KPI backlog data.</param>
    public AuditLogQueryService(
        IAuditLogRepository repository,
        ICentralHealthAggregator healthAggregator)
    {
        _injectedRepository = repository ?? throw new ArgumentNullException(nameof(repository));
        _healthAggregator = healthAggregator ?? throw new ArgumentNullException(nameof(healthAggregator));
    }

    /// <inheritdoc />
    public int DefaultPageSize => 100;

    /// <inheritdoc />
    public async Task<IReadOnlyList<AuditEventView>> QueryAsync(
        AuditLogQueryFilter filter,
        AuditLogPaging? paging = null,
        CancellationToken ct = default)
    {
        ArgumentNullException.ThrowIfNull(filter);
        var effective = paging ?? new AuditLogPaging(DefaultPageSize);

        // The repository seam returns canonical records; decompose
        // each into a flat AuditEventView so the audit pages keep binding to typed
        // properties.
        // Test-seam ctor: use the injected repository directly.
        if (_injectedRepository is not null)
        {
            var rows = await _injectedRepository.QueryAsync(filter, effective, ct);
            return rows.Select(AuditEventView.From).ToList();
        }

        // Production: a fresh scope (and thus a fresh DbContext) per query so the
        // page's auto-load never shares the circuit-scoped context.
        await using var scope = _scopeFactory!.CreateAsyncScope();
        var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
        var result = await repository.QueryAsync(filter, effective, ct);
        return result.Select(AuditEventView.From).ToList();
    }

    /// <inheritdoc/>
    public async Task<AuditLogKpiSnapshot> GetKpiSnapshotAsync(CancellationToken ct = default)
    {
        // 1. Volume + error counts: aggregate over the trailing 1h window.
        //    BacklogTotal is left at 0 by the repository — we fill it from the
        //    in-memory health aggregator below. Resolved via a per-call scope on
        //    the production path for the same context-isolation reason as
        //    QueryAsync; the test-seam ctor uses the injected repository.
        AuditLogKpiSnapshot repoSnapshot;
        if (_injectedRepository is not null)
        {
            repoSnapshot = await _injectedRepository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
        }
        else
        {
            await using var scope = _scopeFactory!.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
            repoSnapshot = await repository.GetKpiSnapshotAsync(KpiWindow, nowUtc: null, ct);
        }

        // 2. Backlog: sum PendingCount across every site's latest report.
        //    Sites that have not yet reported or whose reporter is disabled
        //    leave SiteAuditBacklog null — those contribute zero (a Missing
        //    snapshot is "unknown", not "zero", but the tile is best-effort).
        long backlog = 0;
        foreach (var state in _healthAggregator.GetAllSiteStates().Values)
        {
            var pending = state.LatestReport?.SiteAuditBacklog?.PendingCount;
            if (pending is > 0)
            {
                backlog += pending.Value;
            }
        }

        return repoSnapshot with { BacklogTotal = backlog };
    }

    /// <inheritdoc/>
    public async Task<IReadOnlyList<ExecutionTreeNode>> GetExecutionTreeAsync(
        Guid executionId,
        CancellationToken ct = default)
    {
        // Test-seam ctor: use the injected repository directly.
        if (_injectedRepository is not null)
        {
            return await _injectedRepository.GetExecutionTreeAsync(executionId, ct);
        }

        // Production: a fresh scope (and thus a fresh DbContext) per call — the
        // same context-isolation contract QueryAsync upholds, so the tree
        // page's auto-load never shares the circuit-scoped context.
        await using var scope = _scopeFactory!.CreateAsyncScope();
        var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
        return await repository.GetExecutionTreeAsync(executionId, ct);
    }

    /// <inheritdoc/>
    public async Task<IReadOnlyList<string>> GetDistinctSourceNodesAsync(CancellationToken ct = default)
    {
        // Fast path: a fresh cache entry served entirely from memory.
        lock (_sourceNodesLock)
        {
            if (_cachedSourceNodes is not null && DateTime.UtcNow < _cachedSourceNodesExpiryUtc)
            {
                return _cachedSourceNodes;
            }
        }

        IReadOnlyList<string> snapshot;
        if (_injectedRepository is not null)
        {
            snapshot = await _injectedRepository.GetDistinctSourceNodesAsync(ct);
        }
        else
        {
            await using var scope = _scopeFactory!.CreateAsyncScope();
            var repository = scope.ServiceProvider.GetRequiredService<IAuditLogRepository>();
            snapshot = await repository.GetDistinctSourceNodesAsync(ct);
        }

        // Slow path: replace the cache entry. Concurrent slow paths can race
        // here — every winner stores a valid snapshot, so the last write wins
        // and no caller sees stale data. The double-check inside the lock is
        // deliberately omitted: redundant DB hits during a stampede are rare
        // (60s TTL) and cheaper than holding the lock across the await.
        lock (_sourceNodesLock)
        {
            _cachedSourceNodes = snapshot;
            _cachedSourceNodesExpiryUtc = DateTime.UtcNow + DistinctSourceNodesTtl;
        }

        return snapshot;
    }
}
