using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Options;

namespace Mbproxy.Admin;

/// <summary>
/// Background loop that drives the admin dashboard's live feed. Every
/// <see cref="MbproxyOptions.AdminPushIntervalMs"/> it builds a status snapshot and
/// pushes it through an <see cref="IStatusPushSink"/>:
/// <list type="bullet">
///   <item>the fleet snapshot to every fleet-dashboard subscriber;</item>
///   <item>a per-PLC detail payload (status row + tag-value capture) to each PLC that
///         currently has a detail-page subscriber — PLCs with no viewer are skipped.</item>
/// </list>
///
/// <para>Owned by <see cref="AdminEndpointHost"/>: <see cref="Start"/> is called once
/// the Kestrel app is up, <see cref="StopAsync"/> before it stops. <see cref="StopAsync"/>
/// disarms every tag-value capture, so an AdminPort hot-reload — which tears down the
/// SignalR host and all connections without firing per-connection disconnect cleanup
/// deterministically — never leaves a capture armed with no viewer.</para>
/// </summary>
internal sealed partial class StatusBroadcaster : IAsyncDisposable
{
    private readonly IStatusPushSink _sink;
    private readonly StatusSnapshotBuilder _builder;
    private readonly PlcSubscriptionTracker _tracker;
    private readonly TagCaptureRegistry _captureRegistry;
    private readonly IOptionsMonitor<MbproxyOptions> _options;
    private readonly ILogger _logger;

    private readonly CancellationTokenSource _cts = new();
    private Task _loop = Task.CompletedTask;

    // Guards StopAsync against a double-stop (DisposeAsync also calls StopAsync, and the
    // owner may call StopAsync explicitly first) — symmetry with AdminEndpointHost's
    // _disposed flag, and defends a future caller from touching the disposed CTS.
    private bool _stopped;

    public StatusBroadcaster(
        IStatusPushSink sink,
        StatusSnapshotBuilder builder,
        PlcSubscriptionTracker tracker,
        TagCaptureRegistry captureRegistry,
        IOptionsMonitor<MbproxyOptions> options,
        ILogger logger)
    {
        _sink            = sink;
        _builder         = builder;
        _tracker         = tracker;
        _captureRegistry = captureRegistry;
        _options         = options;
        _logger          = logger;
    }

    /// <summary>Starts the push loop. Idempotent only in the sense that it is called once.</summary>
    public void Start() => _loop = Task.Run(() => LoopAsync(_cts.Token));

    /// <summary>
    /// Stops the push loop and disarms every tag-value capture.
    /// </summary>
    public async Task StopAsync()
    {
        if (_stopped) return;
        _stopped = true;

        if (!_cts.IsCancellationRequested)
            await _cts.CancelAsync().ConfigureAwait(false);

        try
        {
            await _loop.ConfigureAwait(false);
        }
        catch (OperationCanceledException)
        {
            // Expected on cancellation.
        }

        _captureRegistry.DisarmAll();
    }

    /// <summary>One push cycle. Exposed internally so unit tests can drive it deterministically.</summary>
    internal async Task PushOnceAsync(CancellationToken ct)
    {
        StatusResponse snapshot;
        try
        {
            snapshot = _builder.Build();
        }
        catch (Exception ex)
        {
            LogSnapshotFailed(_logger, ex);
            return;
        }

        try
        {
            await _sink.PushFleetAsync(snapshot, ct).ConfigureAwait(false);
        }
        catch (Exception ex) when (ex is not OperationCanceledException)
        {
            LogFleetPushFailed(_logger, ex);
        }

        // Reconcile capture arm state from the live viewer set. This is the single
        // arm/disarm authority — doing it here (one thread, every cycle) means a SignalR
        // reconnect or a hot-reload capture rebuild can never strand a capture armed.
        var activePlcs = _tracker.ActivePlcs();
        _captureRegistry.ReconcileArmed(activePlcs);

        // Index the snapshot's PLC rows once per cycle — a per-active-PLC FirstOrDefault
        // would be O(active × fleet).
        Dictionary<string, PlcStatus>? plcsByName = activePlcs.Count > 0
            ? snapshot.Plcs.ToDictionary(p => p.Name, StringComparer.Ordinal)
            : null;

        foreach (var plcName in activePlcs)
        {
            try
            {
                var plc    = plcsByName!.GetValueOrDefault(plcName);
                var debug  = _builder.BuildDebug(plcName);
                var detail = new PlcDetailResponse(plc, debug);
                await _sink.PushPlcAsync(plcName, detail, ct).ConfigureAwait(false);
            }
            catch (Exception ex) when (ex is not OperationCanceledException)
            {
                LogDetailPushFailed(_logger, plcName, ex);
            }
        }
    }

    private async Task LoopAsync(CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                // Push first, delay second — so a dashboard that connects right after the
                // loop starts gets a snapshot immediately instead of waiting one interval.
                await PushOnceAsync(ct).ConfigureAwait(false);

                // Re-read the interval each cycle so an AdminPushIntervalMs hot-reload
                // takes effect without restarting the loop. Floored at 100 ms to avoid a
                // pathologically tight loop if a bad value slips past validation.
                int interval = Math.Max(100, _options.CurrentValue.AdminPushIntervalMs);
                await Task.Delay(interval, ct).ConfigureAwait(false);
            }
        }
        catch (OperationCanceledException)
        {
            // Normal shutdown.
        }
        catch (Exception ex)
        {
            LogLoopTerminated(_logger, ex);
        }
    }

    public async ValueTask DisposeAsync()
    {
        await StopAsync().ConfigureAwait(false);
        _cts.Dispose();
    }

    // ── Logging ──────────────────────────────────────────────────────────────
    // Stable event names in the mbproxy.admin.broadcast.* family — see
    // docs/Reference/LogEvents.md. EventIds continue the admin block (70/71 in
    // AdminEndpointHost).

    [LoggerMessage(EventId = 72, EventName = "mbproxy.admin.broadcast.snapshot.failed",
        Level = LogLevel.Error,
        Message = "Status broadcaster failed to build a status snapshot — this push cycle is skipped")]
    private static partial void LogSnapshotFailed(ILogger logger, Exception ex);

    [LoggerMessage(EventId = 73, EventName = "mbproxy.admin.broadcast.fleet.failed",
        Level = LogLevel.Error,
        Message = "Status broadcaster failed to push the fleet snapshot to dashboard subscribers")]
    private static partial void LogFleetPushFailed(ILogger logger, Exception ex);

    [LoggerMessage(EventId = 74, EventName = "mbproxy.admin.broadcast.detail.failed",
        Level = LogLevel.Error,
        Message = "Status broadcaster failed to push the detail snapshot for PLC {Plc}")]
    private static partial void LogDetailPushFailed(ILogger logger, string plc, Exception ex);

    [LoggerMessage(EventId = 75, EventName = "mbproxy.admin.broadcast.loop.terminated",
        Level = LogLevel.Error,
        Message = "Status broadcaster push loop terminated unexpectedly — the live dashboard feed has stopped")]
    private static partial void LogLoopTerminated(ILogger logger, Exception ex);
}
