using Microsoft.Extensions.Options;
using ZB.MOM.WW.Overview.Observability;
using ZB.MOM.WW.Overview.Registry;

namespace ZB.MOM.WW.Overview.Polling;

/// <summary>
/// The single writer: sweeps every registered instance on a timer and publishes each result set to
/// <see cref="OverviewSnapshotStore"/>.
/// </summary>
/// <remarks>
/// No retries. A failed probe is simply reported as failed and the next tick is the retry, which
/// keeps the sweep bounded in time and keeps the two-strike damping in
/// <see cref="StatusDerivation.Damp"/> the only place that decides whether a failure is worth
/// showing. Retrying inside a sweep would silently double the effective strike count.
/// </remarks>
public sealed class OverviewPollerService : BackgroundService
{
    /// <summary>Name of the pooled <see cref="HttpClient"/> used for probes.</summary>
    public const string HttpClientName = "overview-health";

    /// <summary>
    /// Slack allowed when deciding whether an instance is due. A timer tick can arrive a hair early
    /// relative to the recorded sweep time, and without the tolerance such an instance would be
    /// skipped and end up polling at half its configured rate.
    /// </summary>
    private static readonly TimeSpan DueTolerance = TimeSpan.FromMilliseconds(250);

    private readonly IHttpClientFactory _httpClientFactory;
    private readonly OverviewSnapshotStore _store;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<OverviewPollerService> _logger;
    private readonly OverviewMetrics _metrics;
    private readonly PollCycleHeartbeat _heartbeat;
    private readonly List<PollTarget> _targets;

    /// <summary>Initializes a new <see cref="OverviewPollerService"/>.</summary>
    /// <param name="options">The validated registry.</param>
    /// <param name="httpClientFactory">Factory for the pooled probe client.</param>
    /// <param name="store">The snapshot store this service writes to.</param>
    /// <param name="timeProvider">Clock and timer source.</param>
    /// <param name="metrics">The app's own instruments.</param>
    /// <param name="heartbeat">Records that a sweep completed, for the self health check.</param>
    /// <param name="logger">Logger.</param>
    public OverviewPollerService(
        IOptions<OverviewOptions> options,
        IHttpClientFactory httpClientFactory,
        OverviewSnapshotStore store,
        TimeProvider timeProvider,
        OverviewMetrics metrics,
        PollCycleHeartbeat heartbeat,
        ILogger<OverviewPollerService> logger)
    {
        ArgumentNullException.ThrowIfNull(options);

        _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory));
        _store = store ?? throw new ArgumentNullException(nameof(store));
        _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
        _metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
        _heartbeat = heartbeat ?? throw new ArgumentNullException(nameof(heartbeat));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _targets = BuildTargets(options.Value);
    }

    /// <inheritdoc/>
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        if (_targets.Count == 0)
        {
            _logger.LogWarning("Overview registry contains no instances; the poller has nothing to do.");
            return;
        }

        // Tick at the fastest configured cadence and let each target decide whether it is due.
        // A single timer honours per-instance overrides without spawning a timer per instance.
        var tick = _targets.Min(t => t.Timings.PollInterval);

        // Publish the registry before probing anything, so the page paints its full layout at once
        // instead of appearing empty for the duration of the first sweep.
        _store.Publish(BuildSnapshot(_timeProvider.GetUtcNow()));

        using var timer = new PeriodicTimer(tick, _timeProvider);

        try
        {
            do
            {
                await SweepAsync(stoppingToken).ConfigureAwait(false);
            }
            while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false));
        }
        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
        {
            // Ordinary shutdown.
        }
    }

    /// <summary>Runs one sweep: probes every due instance and publishes the resulting snapshot.</summary>
    /// <param name="cancellationToken">Cancelled on host shutdown.</param>
    /// <returns>The snapshot that was published.</returns>
    internal async Task<OverviewSnapshot> SweepAsync(CancellationToken cancellationToken = default)
    {
        // Checked here rather than left to the transport: whether an in-flight request observes the
        // token is a property of the handler stack, but "a sweep that started after shutdown must
        // not publish" has to hold unconditionally, or the store keeps a half-probed registry as
        // its final state.
        cancellationToken.ThrowIfCancellationRequested();

        var startedAt = _timeProvider.GetTimestamp();
        var now = _timeProvider.GetUtcNow();
        var due = _targets.Where(t => IsDue(t, now)).ToList();

        if (due.Count > 0)
        {
            // One client per sweep, not per probe: IHttpClientFactory pools the handler either way,
            // and a single instance keeps connection reuse across the fan-out.
            var client = _httpClientFactory.CreateClient(HttpClientName);
            var probe = new ZbHealthReportClient(client);

            var results = await Task
                .WhenAll(due.Select(target => ProbeAsync(probe, target, now, cancellationToken)))
                .ConfigureAwait(false);

            for (var i = 0; i < due.Count; i++)
                due[i].Last = results[i];
        }

        // Re-checked after the fan-out: a shutdown that lands mid-sweep leaves the probed targets
        // in an arbitrary mix of fresh and stale results, and publishing that mix would freeze a
        // misleading picture into the store for as long as the process takes to exit.
        cancellationToken.ThrowIfCancellationRequested();

        var snapshot = BuildSnapshot(now);
        _store.Publish(snapshot);

        _metrics.RecordSweep(_timeProvider.GetElapsedTime(startedAt));

        // Stamped after the publish, so the heartbeat can never claim a cycle the store did not
        // receive — the health check's whole job is to notice exactly that gap.
        _heartbeat.RecordCycleCompleted(_timeProvider.GetUtcNow());

        return snapshot;
    }

    private bool IsDue(PollTarget target, DateTimeOffset now) =>
        target.Last?.LastPolledUtc is not { } polled || now - polled + DueTolerance >= target.Timings.PollInterval;

    private static async Task<InstanceSnapshot> ProbeAsync(
        ZbHealthReportClient probe,
        PollTarget target,
        DateTimeOffset now,
        CancellationToken cancellationToken)
    {
        var timeout = target.Timings.Timeout;
        var ready = await probe.ProbeAsync(target.ReadyUrl, timeout, cancellationToken).ConfigureAwait(false);

        // Skip the active probe when nothing answered the ready probe: it would spend a second full
        // timeout to learn what we already know, and ObserveActive maps a missing probe to Unknown
        // anyway. A 503 from ready is NOT a skip — that instance is talking, and whether it is the
        // active half is exactly what an operator wants to know about a sick node.
        HealthProbeResult? active = null;
        if (target.Instance.HasActiveRole && ready.Reachable)
            active = await probe.ProbeStatusAsync(target.ActiveUrl, timeout, cancellationToken).ConfigureAwait(false);

        var observed = StatusDerivation.Observe(ready);
        var (status, failures) = StatusDerivation.Damp(
            target.Last?.Status,
            target.Last?.ConsecutiveFailures ?? 0,
            observed);

        return target.Template with
        {
            Status = status,
            Active = StatusDerivation.ObserveActive(target.Instance.HasActiveRole, active),
            ConsecutiveFailures = failures,
            LastPolledUtc = now,
            LastReachableUtc = ready.Reachable ? now : target.Last?.LastReachableUtc,
            Latency = ready.Latency,
            Error = ready.Error,
            Report = ready.Report,
        };
    }

    private OverviewSnapshot BuildSnapshot(DateTimeOffset now)
    {
        var applications = _targets
            .GroupBy(t => t.Application, StringComparer.Ordinal)
            .Select(group =>
            {
                var instances = group.Select(t => t.Last ?? t.Template).ToList();
                return new ApplicationSnapshot(group.Key, instances, LeaderResolver.Resolve(instances));
            })
            .ToList();

        return new OverviewSnapshot(now, applications);
    }

    private static List<PollTarget> BuildTargets(OverviewOptions options)
    {
        var targets = new List<PollTarget>();

        foreach (var application in options.Applications)
        {
            foreach (var instance in application.Instances)
            {
                var baseUrl = instance.BaseUrl.TrimEnd('/');

                targets.Add(new PollTarget
                {
                    Application = application.Name,
                    Instance = instance,
                    Timings = EffectiveTimings.Resolve(options, application, instance),
                    ReadyUrl = $"{baseUrl}/health/ready",
                    ActiveUrl = $"{baseUrl}/health/active",
                    Template = new InstanceSnapshot
                    {
                        Application = application.Name,
                        Instance = instance.Name,
                        Group = instance.Group,
                        BaseUrl = baseUrl,
                        ManagementUrl = instance.ManagementUrl,
                        ManagementLabel = application.ManagementLabel,
                        HasActiveRole = instance.HasActiveRole,
                        StaleAfter = EffectiveTimings.Resolve(options, application, instance).StaleAfter,
                    },
                });
            }
        }

        return targets;
    }

    /// <summary>One instance's fixed probe configuration plus its most recent result.</summary>
    private sealed class PollTarget
    {
        public required string Application { get; init; }

        public required InstanceEntry Instance { get; init; }

        public required EffectiveTimings Timings { get; init; }

        public required string ReadyUrl { get; init; }

        public required string ActiveUrl { get; init; }

        /// <summary>The registry-derived fields, reused as the base of every result.</summary>
        public required InstanceSnapshot Template { get; init; }

        /// <summary>
        /// The last published result, or null before the first probe. Written only by the sweep
        /// loop after its <c>Task.WhenAll</c> has completed, so it is never touched concurrently.
        /// </summary>
        public InstanceSnapshot? Last { get; set; }
    }
}
