using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;

namespace ZB.MOM.WW.ScadaBridge.AuditLog.Site;

/// <summary>
/// Site-side hosted service that
/// periodically pulls a backlog snapshot from <see cref="ISiteAuditQueue"/>
/// and pushes it into <see cref="ISiteHealthCollector"/> so the next
/// <see cref="ISiteHealthCollector.CollectReport"/> emits a fresh
/// <c>SiteAuditBacklog</c> field on the site health report.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a hosted service, not the report sender.</b> Querying SQLite for the
/// backlog requires the queue's write lock; doing it inline in
/// <see cref="ISiteHealthCollector.CollectReport"/> would couple the collector
/// to <see cref="ISiteAuditQueue"/> and turn an in-memory snapshot read into
/// a synchronous I/O call on the report path. The hosted-service pattern keeps
/// the report path pure and the SQL probe off the report timing budget.
/// </para>
/// <para>
/// <b>Cadence.</b> 30 s by default — coarse enough to amortise the SQL probe
/// across many reports, fine enough that the central dashboard never lags by
/// more than one health-report interval. Tunable via
/// <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Site.SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/>
/// (a non-positive value falls back to the 30 s default).
/// </para>
/// <para>
/// <b>Failure containment.</b> The probe call is wrapped in a try/catch so a
/// transient SQLite error never tears down the hosted service — the next tick
/// retries. Mirrors <see cref="ZB.MOM.WW.ScadaBridge.AuditLog.Central.AuditLogPartitionMaintenanceService"/>'s
/// "exception logged, not propagated" contract.
/// </para>
/// </remarks>
public sealed class SiteAuditBacklogReporter : IHostedService, IDisposable
{
    /// <summary>
    /// Default poll cadence. Half a typical 60 s health-report interval keeps
    /// the snapshot fresh without spinning the SQL probe more often than
    /// necessary.
    /// </summary>
    internal static readonly TimeSpan DefaultRefreshInterval = TimeSpan.FromSeconds(30);

    /// <summary>
    /// Age at which the oldest still-Pending row is called out in the log. A Pending row is
    /// one central has not acknowledged through EITHER path (telemetry push or reconciliation
    /// pull), and <c>PurgeExpiredAsync</c> deliberately never purges Pending — so the site
    /// store's floor depends on reconciliation actually running. Rows served by a pull but
    /// never covered by a later cursor sit in exactly this state, which is why the age is
    /// worth a signal rather than only a dashboard number. One day is comfortably longer than
    /// any normal drain/reconcile outage and well inside the ~7-day site retention window.
    /// </summary>
    internal static readonly TimeSpan StalePendingThreshold = TimeSpan.FromHours(24);

    /// <summary>How often the stale-pending warning may repeat (it is a standing condition).</summary>
    internal static readonly TimeSpan StalePendingWarnInterval = TimeSpan.FromHours(1);

    private readonly ISiteAuditQueue _queue;
    private readonly ISiteHealthCollector _collector;
    private readonly ILogger<SiteAuditBacklogReporter> _logger;
    private readonly TimeSpan _refreshInterval;
    private CancellationTokenSource? _cts;
    private Task? _loop;
    private DateTime _lastStalePendingWarnUtc = DateTime.MinValue;

    /// <summary>Initializes a new instance of <see cref="SiteAuditBacklogReporter"/>.</summary>
    /// <param name="queue">The site audit queue used to probe the backlog count.</param>
    /// <param name="collector">The site health collector that receives the backlog snapshot.</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="refreshInterval">
    /// Explicit poll-interval override (used by tests). When null, the cadence comes from
    /// <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/>, falling back to
    /// <see cref="DefaultRefreshInterval"/> (30 s) when that is unset or non-positive.
    /// </param>
    /// <param name="options">
    /// SQLite audit-writer options supplying the configurable poll cadence
    /// (<see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/>). Optional so
    /// direct construction in tests need not build the options graph.
    /// </param>
    public SiteAuditBacklogReporter(
        ISiteAuditQueue queue,
        ISiteHealthCollector collector,
        ILogger<SiteAuditBacklogReporter> logger,
        TimeSpan? refreshInterval = null,
        IOptions<SqliteAuditWriterOptions>? options = null)
    {
        _queue = queue ?? throw new ArgumentNullException(nameof(queue));
        _collector = collector ?? throw new ArgumentNullException(nameof(collector));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        // Precedence: explicit override (tests) → configured cadence → 30 s default.
        _refreshInterval = refreshInterval ?? ResolveInterval(options) ?? DefaultRefreshInterval;
    }

    /// <summary>The resolved poll cadence (exposed for tests).</summary>
    internal TimeSpan RefreshInterval => _refreshInterval;

    /// <summary>
    /// Maps <see cref="SqliteAuditWriterOptions.BacklogPollIntervalSeconds"/> to a
    /// <see cref="TimeSpan"/>, or null when unconfigured / non-positive (caller then
    /// falls back to <see cref="DefaultRefreshInterval"/>).
    /// </summary>
    private static TimeSpan? ResolveInterval(IOptions<SqliteAuditWriterOptions>? options)
    {
        var seconds = options?.Value.BacklogPollIntervalSeconds ?? 0;
        return seconds > 0 ? TimeSpan.FromSeconds(seconds) : null;
    }

    /// <summary>Starts the background polling loop, running an immediate first probe before entering the timed cycle.</summary>
    /// <param name="ct">Cancellation token signalling host shutdown.</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public Task StartAsync(CancellationToken ct)
    {
        // Linked CTS lets StopAsync's cancellation AND the host's shutdown
        // token both terminate the loop; either side firing aborts the
        // pending Task.Delay.
        var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        _cts = cts;

        // Read Token on the caller's thread, not inside the lambda: the lambda runs
        // whenever the thread pool gets to it, so a Dispose landing first would make
        // the deferred _cts.Token read throw and fault the loop task the host awaits.
        var token = cts.Token;
        _loop = Task.Run(() => RunLoopAsync(token), CancellationToken.None);
        return Task.CompletedTask;
    }

    private async Task RunLoopAsync(CancellationToken ct)
    {
        try
        {
            // First tick runs immediately so the very first health report after
            // process start carries a real backlog snapshot — without this the
            // dashboard would show null for the first 30 s after a deploy.
            await SafeProbeAsync(ct).ConfigureAwait(false);

            while (!ct.IsCancellationRequested)
            {
                try
                {
                    await Task.Delay(_refreshInterval, ct).ConfigureAwait(false);
                }
                catch (OperationCanceledException)
                {
                    break;
                }

                await SafeProbeAsync(ct).ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException)
        {
            // Shutdown landed mid-probe: SafeProbeAsync rethrows OCE by design so the probe
            // aborts promptly, but the loop task must complete CLEANLY — StopAsync hands
            // _loop straight to the host, and a canceled task there is shutdown-log noise
            // (arch-review 04 round 2, R7). Cancellation here IS the clean exit.
        }
    }

    private async Task SafeProbeAsync(CancellationToken ct)
    {
        try
        {
            var snapshot = await _queue.GetBacklogStatsAsync(ct).ConfigureAwait(false);
            _collector.UpdateSiteAuditBacklog(snapshot);
            WarnIfPendingIsStale(snapshot.OldestPendingUtc, snapshot.PendingCount);
        }
        catch (OperationCanceledException)
        {
            // Shutdown — let the outer loop exit cleanly.
            throw;
        }
        catch (Exception ex)
        {
            // Catch-all is deliberate: the hosted service must survive every
            // class of probe failure (transient SQLite lock contention, disk
            // I/O hiccup, …) so the next tick gets a chance.
            _logger.LogWarning(ex, "SiteAuditBacklogReporter probe failed; next tick will retry.");
        }
    }

    /// <summary>
    /// Logs a rate-limited warning when the oldest still-Pending audit row is older than
    /// <see cref="StalePendingThreshold"/>. Pending rows are exempt from the retention purge
    /// by design, so a standing Pending backlog is the one site-store condition that does not
    /// self-heal on age — it clears only when central acknowledges the rows (telemetry ack or
    /// a reconciliation cursor that covers them). Internal so tests can drive it directly.
    /// </summary>
    /// <param name="oldestPendingUtc">Oldest pending row's occurrence instant, or null when none.</param>
    /// <param name="pendingCount">Number of rows currently pending.</param>
    internal void WarnIfPendingIsStale(DateTime? oldestPendingUtc, int pendingCount)
    {
        if (oldestPendingUtc is null) return;

        var age = DateTime.UtcNow - DateTime.SpecifyKind(oldestPendingUtc.Value, DateTimeKind.Utc);
        if (age < StalePendingThreshold) return;

        var now = DateTime.UtcNow;
        if (now - _lastStalePendingWarnUtc < StalePendingWarnInterval) return;
        _lastStalePendingWarnUtc = now;

        _logger.LogWarning(
            "Site audit backlog: oldest pending row is {AgeHours:F1}h old ({PendingCount} pending). " +
            "Pending rows are never purged on age, so this backlog only clears when central " +
            "acknowledges them — check the site→central audit telemetry drain and the central " +
            "reconciliation pull.",
            age.TotalHours, pendingCount);
    }

    /// <summary>Signals the polling loop to stop and waits for it to complete.</summary>
    /// <param name="ct">Cancellation token (not used; the internal CTS governs shutdown).</param>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public Task StopAsync(CancellationToken ct)
    {
        try
        {
            _cts?.Cancel();
        }
        catch (ObjectDisposedException)
        {
            // Stop-after-Dispose is a legal ordering; Dispose already cancelled the
            // loop. Letting this escape would abort the host's shutdown sequence.
        }

        return _loop ?? Task.CompletedTask;
    }

    /// <summary>Releases the internal <see cref="CancellationTokenSource"/> used to stop the polling loop.</summary>
    public void Dispose()
    {
        // Cancel before disposing so the loop is always signalled even when the host
        // disposes the container without having driven StopAsync first, and so the
        // loop's pending Task.Delay(interval, token) sees an already-cancelled token
        // rather than registering against a dead source.
        try
        {
            _cts?.Cancel();
        }
        catch (ObjectDisposedException)
        {
            // Already disposed — Dispose is idempotent.
        }

        _cts?.Dispose();
    }
}
